fix(android): bound archive extraction and decoder resources

This commit is contained in:
Momoko-Ayase committed 2026-11-04 13:33:38 +08:00
1 parent 22dd92982e
commit a380031567
21 files changed
+2685 -152

No files matched your search

+113 -4
View File
@@ -1,3 +1,4 @@
use senbei_engine::android::ResourceLimits;
use senbei_io::{job, pause, scan};
use std::path::Path;
@@ -11,6 +12,7 @@ fn main() -> std::process::ExitCode {
let mut verbose = false;
let mut scan_all = false;
let mut strict_integrity = false;
let mut limits = ResourceLimits::default();
while let Some(a) = args.next() {
match a.as_str() {
@@ -28,6 +30,13 @@ fn main() -> std::process::ExitCode {
"--no-log" => no_log = true,
"--scan-all" => scan_all = true,
"--strict-integrity" => strict_integrity = true,
flag @ ("--max-entry-mib" | "--max-total-mib" | "--max-entries" | "--max-depth") => {
if let Err(message) = set_resource_limit(&mut limits, flag, args.next().as_deref())
{
eprintln!("error: {message}");
return std::process::ExitCode::from(2);
}
}
"--out" => match args.next() {
Some(v) if !v.starts_with('-') => out = Some(v),
_ => {
@@ -62,16 +71,17 @@ fn main() -> std::process::ExitCode {
let p = Path::new(&p);
let out_path = out.as_deref().map(Path::new);
let result = if p.is_dir() {
job::run_folder_opts(
job::run_folder_with_limits(
p,
out_path,
quiet,
verbose,
no_log,
scan_all || scan::scan_all_env(),
&limits,
)
} else {
job::run_file_v(p, out_path, quiet, verbose, no_log)
job::run_file_with_limits(p, out_path, quiet, verbose, no_log, &limits)
};
match result {
Ok(summary) => {
@@ -92,7 +102,7 @@ fn main() -> std::process::ExitCode {
};
pause::maybe_pause(no_pause);
std::process::ExitCode::from(code as u8)
std::process::ExitCode::from(code)
}
/// Process status for a finished run.
@@ -108,9 +118,40 @@ fn exit_code(summary: &job::Summary, strict_integrity: bool) -> u8 {
}
}
fn set_resource_limit(
limits: &mut ResourceLimits,
flag: &str,
value: Option<&str>,
) -> Result<(), String> {
let invalid = || format!("{flag} requires an unsigned integer argument");
let text = value
.filter(|text| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()))
.ok_or_else(invalid)?;
let value: u64 = text.parse().map_err(|_| invalid())?;
if value == 0 && flag != "--max-depth" {
return Err(format!("{flag} must be greater than zero"));
}
match flag {
"--max-entry-mib" | "--max-total-mib" => {
let bytes = value
.checked_mul(1024 * 1024)
.ok_or_else(|| format!("{flag} exceeds the supported byte range"))?;
if flag == "--max-entry-mib" {
limits.max_entry_bytes = bytes;
} else {
limits.max_total_bytes = bytes;
}
}
"--max-entries" => limits.max_entries = usize::try_from(value).map_err(|_| invalid())?,
"--max-depth" => limits.max_depth = usize::try_from(value).map_err(|_| invalid())?,
_ => return Err(format!("unknown resource option '{flag}'")),
}
Ok(())
}
fn print_help() {
println!(
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--strict-integrity] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--strict-integrity] [--max-entry-mib N] [--max-total-mib N] [--max-entries N] [--max-depth N] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
);
println!(
" input a Crackproof PE (.exe/.dll), an il2cpp global-metadata.dat,\n\
@@ -129,6 +170,19 @@ fn print_help() {
\x20 suspect is still written and the process exits 0 unless\n\
\x20 a file errors. Quiet levels are unchanged."
);
let defaults = ResourceLimits::default();
println!(
" Android resource limits (MiB = 1048576 bytes):\n\
\x20 --max-entry-mib N single archive entry/decoded buffer/output (default {})\n\
\x20 --max-total-mib N cumulative archive bytes and per-engine task budget (default {})\n\
\x20 --max-entries N archive entries/stage-2 records (default {})\n\
\x20 --max-depth N nesting depth, root = 0 (default {})\n\
\x20 Limits are checked before extraction/allocation; they are not a peak-RSS cap.",
defaults.max_entry_bytes / (1024 * 1024),
defaults.max_total_bytes / (1024 * 1024),
defaults.max_entries,
defaults.max_depth,
);
}
#[cfg(test)]
@@ -172,3 +226,58 @@ mod strict_integrity {
assert_eq!(exit_code(&summary, true), 1);
}
}
#[cfg(test)]
mod resource_limits {
use super::*;
#[test]
fn flags_override_only_the_selected_limit() {
let mut limits = ResourceLimits::default();
set_resource_limit(&mut limits, "--max-entry-mib", Some("4096")).unwrap();
assert_eq!(limits.max_entry_bytes, 4096 * 1024 * 1024);
assert_eq!(
limits.max_total_bytes,
ResourceLimits::default().max_total_bytes
);
set_resource_limit(&mut limits, "--max-total-mib", Some("16384")).unwrap();
set_resource_limit(&mut limits, "--max-entries", Some("123")).unwrap();
set_resource_limit(&mut limits, "--max-depth", Some("0")).unwrap();
assert_eq!(limits.max_total_bytes, 16384 * 1024 * 1024);
assert_eq!(limits.max_entries, 123);
assert_eq!(limits.max_depth, 0);
}
#[test]
fn invalid_and_overflowing_limits_are_usage_errors() {
let mut limits = ResourceLimits::default();
for flag in [
"--max-entry-mib",
"--max-total-mib",
"--max-entries",
"--max-depth",
] {
for value in [
None,
Some(""),
Some("-1"),
Some("1.5"),
Some("--no-log"),
Some("18446744073709551616"),
] {
assert!(
set_resource_limit(&mut limits, flag, value).is_err(),
"{flag} {value:?}"
);
}
}
for flag in ["--max-entry-mib", "--max-total-mib", "--max-entries"] {
assert!(set_resource_limit(&mut limits, flag, Some("0")).is_err());
}
assert!(
set_resource_limit(&mut limits, "--max-entry-mib", Some("18446744073709551615"))
.is_err()
);
assert_eq!(limits, ResourceLimits::default());
}
}
+107
View File
@@ -0,0 +1,107 @@
use std::process::Command;
fn command() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_senbei"));
command.args(["--no-pause", "--no-log"]);
command
}
#[test]
fn help_lists_resource_limits_and_defaults() {
let output = command().arg("--help").output().unwrap();
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
for option in [
"--max-entry-mib",
"--max-total-mib",
"--max-entries",
"--max-depth",
] {
assert!(text.contains(option), "missing {option}");
}
for value in ["2048", "8192", "100000", "32", "not a peak-RSS cap"] {
assert!(text.contains(value), "missing {value}");
}
}
#[test]
fn invalid_limits_exit_with_usage_error() {
for option in [
"--max-entry-mib",
"--max-total-mib",
"--max-entries",
"--max-depth",
] {
for value in [
None,
Some("-1"),
Some("invalid"),
Some("18446744073709551616"),
] {
let mut command = command();
command.arg(option);
if let Some(value) = value {
command.arg(value);
}
let output = command.output().unwrap();
assert_eq!(output.status.code(), Some(2), "{option} {value:?}");
assert!(String::from_utf8_lossy(&output.stderr).contains(option));
}
}
}
#[test]
fn limits_compose_with_strict_and_quiet_modes() {
let input = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let output = command()
.arg(input.path())
.arg("--out")
.arg(out.path())
.args([
"--max-entry-mib",
"1",
"--max-total-mib",
"2",
"--max-entries",
"1",
"--max-depth",
"0",
"--strict-integrity",
"-q",
"-q",
])
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
#[test]
fn zip_entry_limit_rejects_before_central_directory_indexing() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("large.apk");
// A ZIP end record advertising two entries is enough to exercise the
// preflight. Its directory is intentionally absent: the count limit must
// reject it before the ZIP parser tries to read that directory.
let mut bytes = b"PK\x03\x04".to_vec();
let mut end = [0_u8; 22];
end[..4].copy_from_slice(b"PK\x05\x06");
end[8..10].copy_from_slice(&2_u16.to_le_bytes());
end[10..12].copy_from_slice(&2_u16.to_le_bytes());
bytes.extend_from_slice(&end);
std::fs::write(&package, bytes).unwrap();
let output = command()
.arg(&package)
.arg("--out")
.arg(temp.path().join("out"))
.args(["--max-entries", "1"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&output.stderr).contains("entry limit"),
"{output:?}"
);
}
+3 -2
View File
@@ -3,6 +3,7 @@
mod protector;
pub use protector::{
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor,
decode_container, gf32_mul_fixed, transform_segment,
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, OutputLimit,
ProtectedDescriptor, decode_container, decode_container_with_limit, gf32_mul_fixed,
transform_segment,
};
+358 -11
View File
@@ -14,6 +14,30 @@ pub enum Error {
type Result<T> = std::result::Result<T, Error>;
/// Ceiling for one decoded container or compressed writer.
///
/// `decode_container` stays unlimited so existing callers keep their behavior.
/// Extraction and restoration pass the shared engine entry ceiling here and
/// reject the declared size before the output buffer is reserved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputLimit {
pub max_output_bytes: u64,
}
impl OutputLimit {
fn check(self, bytes: usize, what: &str) -> Result<()> {
let requested = u64::try_from(bytes)
.map_err(|_| Error::Invalid(format!("{what} length exceeds u64")))?;
if requested > self.max_output_bytes {
return invalid(format!(
"{what} of {requested} bytes exceeds the {limit}-byte output ceiling",
limit = self.max_output_bytes
));
}
Ok(())
}
}
fn invalid<T>(message: impl Into<String>) -> Result<T> {
Err(Error::Invalid(message.into()))
}
@@ -417,7 +441,24 @@ impl HuffmanLzDecoder {
/// Decode one compressed writer payload to its exact expected size.
pub fn decode(&self, source: &[u8], output_size: usize) -> Result<Vec<u8>> {
let mut output = vec![0_u8; output_size];
self.decode_limited(source, output_size, None)
}
/// Decode one writer, rejecting `output_size` before its buffer is reserved.
pub fn decode_limited(
&self,
source: &[u8],
output_size: usize,
limit: Option<OutputLimit>,
) -> Result<Vec<u8>> {
if let Some(limit) = limit {
limit.check(output_size, "compressed writer output")?;
}
let mut output = Vec::new();
output.try_reserve_exact(output_size).map_err(|_| {
Error::Invalid("compressed writer output reservation failed".to_owned())
})?;
output.resize(output_size, 0);
let mut source_pos = 0_usize;
let mut bit_buffer = 0_u64;
let mut available = 0_u8;
@@ -548,7 +589,11 @@ pub fn transform_segment(
aes_key: &[u8; 32],
decrypt_aes: bool,
) -> Result<Vec<u8>> {
let mut transformed = data.to_vec();
let mut transformed = Vec::new();
transformed
.try_reserve_exact(data.len())
.map_err(|_| Error::Invalid("segment reservation failed".to_owned()))?;
transformed.extend_from_slice(data);
let mut state = seed;
let mut left = 0xe34e_ac63_u32;
let mut right = 0x07b4_8238_u32;
@@ -590,11 +635,31 @@ pub fn transform_segment(
/// Decode one complete protector container into its flat output buffer.
///
/// This is the static equivalent of the decoder entrypoint embedded in Stage
/// 2 and in each nested interpreter module.
/// 2 and in each nested interpreter module. The declared output size is not
/// capped; [`decode_container_with_limit`] applies the shared entry ceiling.
pub fn decode_container(
data: &[u8],
config: &Module9bConfig,
expected_size: usize,
) -> Result<Vec<u8>> {
decode_container_inner(data, config, expected_size, None)
}
/// [`decode_container`] with a ceiling checked before the output is reserved.
pub fn decode_container_with_limit(
data: &[u8],
config: &Module9bConfig,
expected_size: usize,
limit: OutputLimit,
) -> Result<Vec<u8>> {
decode_container_inner(data, config, expected_size, Some(limit))
}
fn decode_container_inner(
data: &[u8],
config: &Module9bConfig,
expected_size: usize,
limit: Option<OutputLimit>,
) -> Result<Vec<u8>> {
let header = ContainerHeader::parse(data, 0, config.container_seed)?;
let header_size = usize::try_from(header.output_size)
@@ -604,9 +669,20 @@ pub fn decode_container(
"container output size 0x{header_size:x} != expected 0x{expected_size:x}"
));
}
if let Some(limit) = limit {
// Both declared sizes are rejected before the flat output exists, so a
// hostile container header cannot force the allocation by disagreeing
// with the record that pointed at it.
limit.check(header_size, "container output")?;
limit.check(expected_size, "container output")?;
}
let decoder = HuffmanLzDecoder::new(&header.tree)?;
let decrypt_aes = !(config.skip_aes || header.skip_aes);
let mut output = vec![0_u8; expected_size];
let mut output = Vec::new();
output
.try_reserve_exact(expected_size)
.map_err(|_| Error::Invalid("container output reservation failed".to_owned()))?;
output.resize(expected_size, 0);
for (segment_index, encoded) in header.segments.iter().enumerate() {
let start = header
@@ -614,6 +690,9 @@ pub fn decode_container(
.checked_add(encoded.offset as usize)
.ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?;
let encoded_data = range(data, start, encoded.size as usize)?;
if let Some(limit) = limit {
limit.check(encoded.size as usize, "encoded segment")?;
}
let transformed = transform_segment(
encoded_data,
config.container_seed,
@@ -662,18 +741,28 @@ pub fn decode_container(
));
}
let source = &transformed[data_cursor..encoded_end];
let decoded = if encoded_size == output_size {
None
} else {
Some(decoder.decode(source, output_size)?)
};
let decoded = decoded.as_deref().unwrap_or(source);
if let Some(limit) = limit {
limit.check(encoded_size, "writer source")?;
limit.check(output_size, "writer destination")?;
}
let target = base_offset
.checked_add(output_offset)
.ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?;
let target_end = target
.checked_add(decoded.len())
.checked_add(output_size)
.ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?;
if target_end > output.len() {
return invalid(format!(
"segment {segment_index} writer {writer_index} target 0x{target:x}..0x{target_end:x} is outside the 0x{:x}-byte container",
output.len()
));
}
let decoded = if encoded_size == output_size {
None
} else {
Some(decoder.decode_limited(source, output_size, limit)?)
};
let decoded = decoded.as_deref().unwrap_or(source);
let destination = output.get_mut(target..target_end).ok_or_else(|| {
Error::Invalid(format!(
"segment {segment_index} writer {writer_index} target is out of range"
@@ -709,4 +798,262 @@ mod tests {
fn descriptor_rejects_truncated_input() {
assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err());
}
/// A container whose declared output is past the ceiling.
///
/// Parsing succeeds. Decoding must fail on that declared size before the
/// flat output is reserved, so the failure mentions the output ceiling and
/// not a later segment or writer error. The exact ceiling is allowed
/// through that check.
#[test]
fn oversized_container_is_rejected_before_its_output_is_reserved() {
let config = empty_config();
let over = synthetic_container(&config, 5, None);
let error = decode_container_with_limit(
&over,
&config,
5,
OutputLimit {
max_output_bytes: 4,
},
)
.expect_err("five-byte output is over the four-byte ceiling");
assert!(
error.to_string().contains("container output"),
"unexpected error: {error}"
);
let boundary = synthetic_container(&config, 4, None);
let error = decode_container_with_limit(
&boundary,
&config,
4,
OutputLimit {
max_output_bytes: 4,
},
)
.expect_err("the boundary container has no usable segment body");
assert!(
!error.to_string().contains("output ceiling"),
"the boundary size must pass the ceiling: {error}"
);
}
/// The container itself is within the ceiling. Its writer declares a
/// decoded length past that ceiling and must be rejected before Huffman
/// decoding reserves a buffer of that length.
#[test]
fn oversized_writer_is_rejected_before_decompression() {
let config = empty_config();
let data = synthetic_container(&config, 8, Some((0, 128)));
let error = decode_container_with_limit(
&data,
&config,
8,
OutputLimit {
// The segment body is 36 bytes, under this ceiling. The writer
// declares 128 decoded bytes, which is over it.
max_output_bytes: 64,
},
)
.expect_err("writer destination exceeds the container ceiling");
assert!(
error.to_string().contains("writer destination"),
"unexpected error: {error}"
);
}
/// The declared writer length is within the entry ceiling and would be a
/// legal allocation on its own. It still starts past the flat container,
/// so the range check rejects it before Huffman decoding.
#[test]
fn writer_inside_the_entry_cap_but_outside_the_container_is_rejected_first() {
let config = empty_config();
let data = synthetic_container(&config, 8, Some((8, 4)));
let error = decode_container_with_limit(
&data,
&config,
8,
OutputLimit {
max_output_bytes: 64,
},
)
.expect_err("target 8..12 is outside an 8-byte container");
assert!(
error.to_string().contains("outside"),
"unexpected error: {error}"
);
}
/// A one-byte writer that ends exactly at the container length is in range.
/// It is uncompressed, so success does not depend on a Huffman payload.
#[test]
fn writer_ending_at_the_container_length_is_accepted() {
let config = empty_config();
let data = synthetic_container(&config, 8, Some((7, 1)));
let decoded = decode_container_with_limit(
&data,
&config,
8,
OutputLimit {
max_output_bytes: 64,
},
)
.expect("a writer ending at the container length is in range");
assert_eq!(decoded.len(), 8);
}
fn empty_config() -> Module9bConfig {
Module9bConfig {
header_seed: 0,
container_seed: 0,
aes_key: [0; 32],
skip_aes: true,
schedule_offset: 0,
}
}
/// A container with one segment.
///
/// `writer_output` replaces the segment body with a transformed writer
/// record declaring that decoded length. `None` leaves a four-byte body
/// that is only there so the header parses.
fn synthetic_container(
config: &Module9bConfig,
output_size: u32,
writer_output: Option<(u32, u32)>,
) -> Vec<u8> {
let seed = config.container_seed;
let seed_square = seed.wrapping_mul(seed);
let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11);
let raw0 = gf32_unmultiply(
output_size
.wrapping_sub(0xa21d_fb3a_u32.wrapping_shl(state & 7))
.wrapping_sub(state.wrapping_mul(0xf87b_337c)),
);
// One segment, skip_aes = 1.
let flag_word = 1_u32 | (1_u32 << 8);
let raw1 = gf32_unmultiply(
flag_word
^ state
.wrapping_add(0xbd19_c63c)
.wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)),
);
let mut tree = vec![0_u8; 256 * 3];
tree[1] = 0x80;
tree[2] = 1;
let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state);
let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state));
// Stored tree bytes are decrypted a word at a time and then adjusted a
// byte at a time, so the byte adjustment has to be undone first.
for (index, byte) in tree.iter_mut().enumerate() {
let shift = (index & 0x1b) as u32;
let left = gf32_mul_fixed(tree_state.wrapping_shl(shift));
let right = tree_delta.wrapping_shr((index & 0x17) as u32);
let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32);
*byte = byte.wrapping_sub(adjustment as u8);
}
let raw2 = gf32_unmultiply(
(tree.len() as u32)
.wrapping_add(state ^ 0x3b2b_f538)
.wrapping_sub(0x643a_3a3b_u32.wrapping_shl(state & 0x0b)),
);
let mut header = Vec::new();
header.extend_from_slice(&raw0.to_le_bytes());
header.extend_from_slice(&raw1.to_le_bytes());
header.extend_from_slice(&raw2.to_le_bytes());
let mut stored_tree = tree;
for offset in (0..stored_tree.len() & !3).step_by(4) {
let value = u32::from_le_bytes(stored_tree[offset..offset + 4].try_into().unwrap());
stored_tree[offset..offset + 4].copy_from_slice(&gf32_unmultiply(value).to_le_bytes());
}
header.extend_from_slice(&stored_tree);
let body = match writer_output {
Some((writer_offset, writer_output)) => {
writer_body(config, writer_offset, writer_output)
}
None => vec![0_u8; 4],
};
let segment_offset = (header.len() + 8) as u32;
let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state);
let table_xor = table_state.wrapping_shl(3);
let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state));
for (index, plain) in [segment_offset, body.len() as u32].into_iter().enumerate() {
let adjustment = table_add.wrapping_shr((((index * 4) & 7) + 5) as u32);
// decode is `gf(encoded ^ xor) + adjustment`, so the stored word
// is `ungf(plain - adjustment) ^ xor`.
let encoded = gf32_unmultiply(plain.wrapping_sub(adjustment)) ^ table_xor;
header.extend_from_slice(&encoded.to_le_bytes());
}
header.extend_from_slice(&body);
header
}
/// Segment body that `transform_segment` turns into one writer record.
fn writer_body(config: &Module9bConfig, writer_offset: u32, writer_output: u32) -> Vec<u8> {
let mut plain = vec![0_u8; 36];
plain[..4].copy_from_slice(&0_u32.to_le_bytes());
plain[4..8].copy_from_slice(&1_u32.to_le_bytes());
plain[8..12].copy_from_slice(&16_u32.to_le_bytes());
plain[12..16].copy_from_slice(&32_u32.to_le_bytes());
plain[16..20].copy_from_slice(&writer_offset.to_le_bytes());
plain[20..24].copy_from_slice(&writer_output.to_le_bytes());
plain[24..28].copy_from_slice(&1_u32.to_le_bytes());
inverse_transform_segment(&plain, config.container_seed)
}
/// Invert [`transform_segment`] for `decrypt_aes = false`.
///
/// The forward routine adds and xors a state that does not depend on the
/// ciphertext, so replaying that state and undoing the arithmetic recovers
/// the input that decodes to `plain`.
fn inverse_transform_segment(plain: &[u8], seed: u32) -> Vec<u8> {
let mut encoded = plain.to_vec();
let mut state = seed;
let mut left = 0xe34e_ac63_u32;
let mut right = 0x07b4_8238_u32;
for (index, chunk) in encoded.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let index32 = index as u32;
left = state
.wrapping_add(0x72f6_fcbe)
.wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left))
.wrapping_shr(index32.wrapping_mul(index32) & 0x0f);
right = state
.wrapping_sub(0x71b6_a98d)
.wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right))
.wrapping_shl(index32 & 7);
state = left ^ right;
let mut value = u32::from_le_bytes(*chunk);
value ^= state;
value = value.wrapping_add(state);
value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3);
value = value.wrapping_sub(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d));
chunk.copy_from_slice(&value.to_le_bytes());
}
encoded
}
fn gf32_unmultiply(encoded: u32) -> u32 {
// Multiply by the inverse of the fixed element 0x94511dd2.
let mut multiplier = GF32_INVERSE;
let mut value = encoded;
let mut result = 0_u32;
while multiplier != 0 {
if multiplier & 1 != 0 {
result ^= value;
}
let carry = value >> 31;
value = value.wrapping_shl(1);
if carry != 0 {
value ^= 0x5793_57eb;
}
multiplier >>= 1;
}
result
}
/// `gf32_mul_fixed(GF32_INVERSE) == 1`, so this undoes that multiply.
const GF32_INVERSE: u32 = 0xdc49_cd06;
}
@@ -44,6 +44,8 @@ pub enum Error {
},
#[error("{0}")]
Invalid(String),
#[error(transparent)]
Limit(#[from] crate::android::LimitExceeded),
}
impl Error {
+2 -2
View File
@@ -6,7 +6,7 @@ mod stage1;
mod stream;
pub use error::Error;
pub use pipeline::{ExtractOptions, extract_stage2};
pub use probe::is_protected_libil2cpp;
pub use pipeline::{ExtractOptions, extract_stage2, extract_stage2_with_limits};
pub use probe::{is_protected_libil2cpp, probe_libil2cpp_with_limits};
pub use report::ExtractionReport;
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
+261 -9
View File
@@ -3,7 +3,9 @@ use std::fs::File;
use std::path::{Path, PathBuf};
use memmap2::MmapOptions;
use senbei_crypto::android::{Module9bConfig, decode_container};
use senbei_crypto::android::{Module9bConfig, decode_container_with_limit};
use crate::android::{ByteBudget, EntryBudget, ResourceLimits};
use serde_json::to_vec_pretty;
use super::super::common;
@@ -61,6 +63,13 @@ struct ArtifactSpec<'a> {
classification: &'a str,
}
struct DecodeBudget {
limits: ResourceLimits,
bytes: ByteBudget,
records: EntryBudget,
config: Module9bConfig,
}
struct Extractor {
output_dir: PathBuf,
streams: Vec<StreamReport>,
@@ -70,6 +79,20 @@ struct Extractor {
}
pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
extract_stage2_with_limits(options, &ResourceLimits::default())
}
/// [`extract_stage2`] with explicit ceilings for decoded records and nesting.
///
/// `limits.max_entries` counts decoded stage-2 records. `limits.max_depth`
/// counts nested interpreter streams, with the bootstrap stream at depth 0.
/// `limits.max_entry_bytes` bounds each decoded container and its writers, and
/// `limits.max_total_bytes` sums those decoded payloads for this call. Neither
/// budget is a peak resident-memory measurement.
pub fn extract_stage2_with_limits(
options: &ExtractOptions,
limits: &ResourceLimits,
) -> Result<ExtractionReport> {
let input_path = absolute(&options.input)?;
let output_dir = absolute(&options.output_dir)?;
if !input_path.is_file() {
@@ -98,6 +121,10 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
&input_path,
options.outer_size,
options.cipher_constant,
// Stage 1 plaintext is the first thing this task retains. Its copy
// is capped by both the single-entry ceiling and whatever remains of
// the task total, which is the whole total at this point.
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
)?;
if let Some(stage2_output) = &options.stage2_output {
write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?;
@@ -119,13 +146,22 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
registry: BTreeMap::new(),
seen_streams: HashSet::new(),
};
let mut budget = DecodeBudget {
limits: *limits,
bytes: ByteBudget::new(limits),
records: EntryBudget::new(limits),
config: core_config,
};
budget
.bytes
.charge(u64::try_from(stage1.plaintext.len()).unwrap_or(u64::MAX))?;
extractor.extract_stream(
bootstrap,
0xe2,
0,
None,
Some(stage1.remaining_file_offset),
core_config,
&mut budget,
)?;
let module_registry = extractor
@@ -170,8 +206,9 @@ impl Extractor {
depth: usize,
parent: Option<StreamParent>,
source_file_offset: Option<usize>,
config: Module9bConfig,
budget: &mut DecodeBudget,
) -> Result<()> {
budget.limits.check_depth(depth)?;
let digest = sha256(stream);
if !self.seen_streams.insert((stream_id, digest.clone())) {
return Ok(());
@@ -200,10 +237,15 @@ impl Extractor {
"decoded_interpreter"
},
(depth != 0).then_some(stream_id),
&config,
&budget.config,
),
records: Vec::with_capacity(records.len()),
records: Vec::new(),
};
budget.records.charge(records.len())?;
stream_report
.records
.try_reserve_exact(records.len())
.map_err(|_| Error::Invalid("stage-2 record report reservation failed".to_owned()))?;
let mut direct_records = Vec::new();
let mut modules_at_level = BTreeSet::new();
@@ -213,8 +255,16 @@ impl Extractor {
let mut metadata_data = None;
if !record.direct() && record.image_size != 0 {
// Charge the declared size before the decoder reserves it.
// A metadata-only record is charged below, on the same rule.
budget.bytes.charge(u64::from(record.image_size))?;
let image_source = record_tail(stream, record.image_offset)?;
let image = decode_container(image_source, &config, record.image_size as usize)
let image = decode_container_with_limit(
image_source,
&budget.config,
record.image_size as usize,
budget.limits.output_limit(),
)
.map_err(|source| Error::RecordDecode {
depth,
stream_id,
@@ -243,9 +293,14 @@ impl Extractor {
image_data = Some((image, artifact));
}
if record.metadata_size != 0 {
budget.bytes.charge(u64::from(record.metadata_size))?;
let metadata_source = record_tail(stream, record.metadata_offset)?;
let metadata =
decode_container(metadata_source, &config, record.metadata_size as usize)
let metadata = decode_container_with_limit(
metadata_source,
&budget.config,
record.metadata_size as usize,
budget.limits.output_limit(),
)
.map_err(|source| Error::RecordDecode {
depth,
stream_id,
@@ -339,14 +394,17 @@ impl Extractor {
source,
}
})?;
budget.limits.check_depth(depth + 1)?;
let parent_config = std::mem::replace(&mut budget.config, interpreter_config);
self.extract_stream(
child_data,
next_stream_id,
depth + 1,
Some(parent),
None,
interpreter_config,
budget,
)?;
budget.config = parent_config;
}
Ok(())
}
@@ -506,3 +564,197 @@ fn absolute(path: &Path) -> Result<PathBuf> {
fn sha256(data: &[u8]) -> String {
common::sha256(data)
}
#[cfg(test)]
mod limit_tests {
use super::*;
use crate::android::LimitExceeded;
fn config() -> Module9bConfig {
Module9bConfig {
header_seed: 0,
container_seed: 0,
aes_key: [0; 32],
skip_aes: true,
schedule_offset: 0,
}
}
fn tight(entries: usize, depth: usize) -> ResourceLimits {
ResourceLimits {
max_entry_bytes: 1024,
max_total_bytes: 1024,
max_entries: entries,
max_depth: depth,
}
}
/// One direct record whose payload is `child`.
///
/// The command is `stream_id + 0x10`, which is the id a nested stream uses
/// for its interpreter. The payload begins immediately after the single
/// descriptor, so parsing stops at that boundary.
fn direct_stream(stream_id: u32, child: &[u8]) -> Vec<u8> {
let key = stream_id.wrapping_mul(0x9d32_3cd7);
let shift = stream_id & 7;
let base = (key >> shift)
.wrapping_add(0x5e72_7d74)
.wrapping_add(key.wrapping_shl(stream_id & 0xb))
.wrapping_add(0xf71e_3005);
// record_state = plain1 + base, and plain1 is chosen so that state is
// zero. That keeps the record cipher's index mask zero as well.
let plain1 = 0_u32.wrapping_sub(base);
let cipher0 =
unmultiply(1_u32 ^ 0xebe8_1dba_u32.wrapping_add(base)).wrapping_sub(0xcbf0_c1d8);
let cipher1 = unmultiply(plain1 ^ 0xebe8_1dba_u32.wrapping_mul(5).wrapping_add(base))
.wrapping_sub(cipher0);
let mut stream = Vec::new();
stream.extend_from_slice(&cipher0.to_le_bytes());
stream.extend_from_slice(&cipher1.to_le_bytes());
// `command - 0x10` is the interpreter id a nested stream looks up.
// Using `stream_id + 0x10` makes a lone record direct, because that
// interpreter was not decoded from this same stream.
let command = stream_id.wrapping_add(0x10);
let words = [
command,
super::super::stream::DIRECT_FLAG,
(8 + super::super::stream::RECORD_SIZE) as u32,
child.len() as u32,
0,
0,
command,
0,
0,
];
stream.extend_from_slice(&encrypt_record(&words));
stream.extend_from_slice(child);
stream
}
fn encrypt_record(words: &[u32]) -> Vec<u8> {
// Inverse of `decrypt_record` at record state zero: replay its
// feedback from each ciphertext word, then undo the field multiply.
let mut feedback = 0xf02f_7685_u32;
let mut accumulator = 0x7993_4cf6_u32;
let mut raw = vec![0_u8; super::super::stream::RECORD_SIZE];
for (word_index, plain) in words.iter().copied().enumerate() {
feedback = feedback.wrapping_mul(feedback);
let mixed = plain
.wrapping_sub(accumulator)
.wrapping_add(0 >> ((word_index * 4 + 3) & 5));
let cipher = unmultiply(mixed) ^ (feedback >> 3);
raw[word_index * 4..word_index * 4 + 4].copy_from_slice(&cipher.to_le_bytes());
accumulator = accumulator.wrapping_add(0xe64d_33d8);
feedback = cipher;
}
raw
}
fn unmultiply(encoded: u32) -> u32 {
let mut multiplier = 0xdc49_cd06_u32;
let mut value = encoded;
let mut result = 0_u32;
while multiplier != 0 {
if multiplier & 1 != 0 {
result ^= value;
}
let carry = value >> 31;
value = value.wrapping_shl(1);
if carry != 0 {
value ^= 0x5793_57eb;
}
multiplier >>= 1;
}
result
}
fn extractor() -> Extractor {
Extractor {
output_dir: PathBuf::from("."),
streams: Vec::new(),
artifacts: Vec::new(),
registry: BTreeMap::new(),
seen_streams: HashSet::new(),
}
}
fn budget(entries: usize, depth: usize) -> DecodeBudget {
let limits = tight(entries, depth);
DecodeBudget {
limits,
bytes: ByteBudget::new(&limits),
records: EntryBudget::new(&limits),
config: config(),
}
}
#[test]
fn record_budget_rejects_one_past_the_ceiling_and_allows_the_boundary() {
let stream = direct_stream(0xe2, &[0]);
let error = extractor()
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(0, 32))
.expect_err("one record is already past a zero-record ceiling");
assert!(
matches!(
error,
Error::Limit(LimitExceeded::Entries {
requested: 1,
limit: 0
})
),
"unexpected error: {error}"
);
let directory = tempfile::tempdir().expect("temporary output directory");
let mut at_ceiling = extractor();
at_ceiling.output_dir = directory.path().to_path_buf();
at_ceiling
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(1, 0))
.expect("one record at depth zero is the boundary");
}
#[test]
fn nesting_one_past_the_ceiling_is_rejected_before_the_child_is_decoded() {
let stream = direct_stream(0xe2, &[0]);
let directory = tempfile::tempdir().expect("temporary output directory");
let mut at_ceiling = extractor();
at_ceiling.output_dir = directory.path().to_path_buf();
at_ceiling
.extract_stream(&stream, 0xe2, 32, None, None, &mut budget(1, 32))
.expect("depth 32 is the nesting boundary");
let error = extractor()
.extract_stream(&stream, 0xe2, 33, None, None, &mut budget(1, 32))
.expect_err("depth 33 is rejected before the stream is parsed");
match &error {
Error::Limit(limit) => assert_eq!(
limit,
&LimitExceeded::Depth {
requested: 33,
limit: 32
}
),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn decoded_payloads_share_one_task_budget() {
let stream = direct_stream(0xe2, &[0]);
let directory = tempfile::tempdir().expect("temporary output directory");
let mut budget = budget(1, 0);
budget.limits.max_total_bytes = 1;
budget.bytes = ByteBudget::new(&budget.limits);
// A direct record has no decoded container, so it does not charge the
// byte budget. Seed the only allowed byte to prove the budget is the
// one the caller passed in and is not reset per stream.
budget.bytes.charge(1).unwrap();
let mut extractor = extractor();
extractor.output_dir = directory.path().to_path_buf();
extractor
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget)
.unwrap();
assert_eq!(budget.bytes.used(), 1);
assert!(budget.bytes.charge(1024).is_err());
}
}
+118 -4
View File
@@ -2,18 +2,132 @@ use std::path::Path;
use senbei_crypto::android::Module9bConfig;
use super::error::Error;
use super::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
use crate::android::ResourceLimits;
/// Return whether `data` has a supported protected AArch64 IL2CPP layout.
///
/// This uses the default ceilings. A size past those ceilings is reported as
/// not protected, matching a scan that has not opted into a custom limit.
/// [`probe_libil2cpp_with_limits`] returns that limit error instead.
#[must_use]
pub fn is_protected_libil2cpp(data: &[u8]) -> bool {
let Ok(stage1) = stage1::inspect(
probe_libil2cpp_with_limits(data, &ResourceLimits::default()).unwrap_or(false)
}
/// Probe one image under explicit ceilings.
///
/// A parse or configuration mismatch is `Ok(false)`: the image is not a
/// supported target. [`Error::Limit`] is returned as-is, because the declared
/// Stage 1 payload was large enough that classifying it would require the
/// allocation the ceiling exists to prevent. The payload copy is capped by
/// `min(max_entry_bytes, max_total_bytes)` before it is reserved.
pub fn probe_libil2cpp_with_limits(data: &[u8], limits: &ResourceLimits) -> Result<bool, Error> {
let stage1 = match stage1::inspect(
data,
Path::new("<probe>"),
DEFAULT_OUTER_SIZE,
DEFAULT_CIPHER_CONSTANT,
) else {
return false;
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
) {
Ok(stage1) => stage1,
Err(Error::Limit(limit)) => return Err(Error::Limit(limit)),
Err(_) => return Ok(false),
};
Module9bConfig::parse_embedded(&stage1.plaintext).is_ok()
Ok(Module9bConfig::parse_embedded(&stage1.plaintext).is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
/// Stage 1 header words, encrypted with the default cipher constant.
fn encrypt_header(words: [u32; 8]) -> [u8; 0x20] {
let key = words[0];
let mut raw = [0_u8; 0x20];
raw[..4].copy_from_slice(&key.to_le_bytes());
for (index, value) in words.into_iter().enumerate().skip(1) {
let index = index as u32;
let word = (value ^ DEFAULT_CIPHER_CONSTANT.wrapping_mul(index + 1))
.wrapping_sub((index + 3).wrapping_mul(key));
raw[index as usize * 4..index as usize * 4 + 4].copy_from_slice(&word.to_le_bytes());
}
raw
}
fn put(data: &mut [u8], offset: usize, bytes: &[u8]) {
data[offset..offset + bytes.len()].copy_from_slice(bytes);
}
/// A minimal AArch64 ELF whose SHT_LOUSER payload declares `payload_size`.
///
/// The declared payload is not present in the file. Inspection rejects that
/// size before copying it when the ceiling is smaller.
fn elf_with_payload_size(payload_size: u32) -> Vec<u8> {
let outer = DEFAULT_OUTER_SIZE;
let section_file_size = outer + 0x1000;
let section_offset = 64_usize;
let shoff = section_offset + section_file_size;
// Offsets into `names`: dynsym 1, dynstr 9, gnu.version 17,
// gnu.version_r 30, hash 44, private 50, shstrtab 59.
let names =
b"\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_r\0.hash\0.private\0.shstrtab\0";
let strtab = shoff + 64 * 8;
let mut data = vec![0_u8; strtab + names.len()];
put(&mut data, 0, b"\x7fELF\x02\x01\x01");
put(&mut data, 16, &3_u16.to_le_bytes());
put(&mut data, 18, &183_u16.to_le_bytes());
put(&mut data, 20, &1_u32.to_le_bytes());
put(&mut data, 40, &(shoff as u64).to_le_bytes());
put(&mut data, 52, &64_u16.to_le_bytes());
put(&mut data, 58, &64_u16.to_le_bytes());
put(&mut data, 60, &8_u16.to_le_bytes());
put(&mut data, 62, &7_u16.to_le_bytes());
let header = encrypt_header([1, 0, 0x20, payload_size, 0, 0, payload_size, payload_size]);
put(&mut data, section_offset + outer, &header);
put(&mut data, strtab, names);
let sections: [(u32, u32, u64, u64); 8] = [
(0, 0, 0, 0),
(1, 11, 0, 0),
(9, 3, 0, 0),
(17, 0x6fff_fff0, 0, 0),
(30, 0x6fff_fffe, 0, 0),
(45, 5, 0, 0),
(
51,
0x8000_0000,
section_offset as u64,
(outer + 0x1000 + payload_size as usize) as u64,
),
(60, 3, strtab as u64, names.len() as u64),
];
for (index, (name, kind, offset, size)) in sections.into_iter().enumerate() {
let at = shoff + index * 64;
put(&mut data, at, &name.to_le_bytes());
put(&mut data, at + 4, &kind.to_le_bytes());
put(&mut data, at + 24, &offset.to_le_bytes());
put(&mut data, at + 32, &size.to_le_bytes());
}
data
}
#[test]
fn oversized_stage1_payload_is_a_limit_error_not_a_clean_miss() {
let image = elf_with_payload_size(8);
let limits = ResourceLimits {
max_entry_bytes: 4,
max_total_bytes: 4,
..ResourceLimits::default()
};
let error = probe_libil2cpp_with_limits(&image, &limits);
assert!(
matches!(error, Err(Error::Limit(_))),
"unexpected probe result: {error:?}"
);
assert!(
!probe_libil2cpp_with_limits(&[], &limits).expect("an empty image is not protected")
);
assert!(!is_protected_libil2cpp(&[]));
}
}
+15 -1
View File
@@ -38,6 +38,7 @@ pub(crate) fn inspect(
path: &Path,
outer_size: usize,
cipher_constant: u32,
max_payload_bytes: Option<u64>,
) -> Result<Stage1Result> {
let elf = parse(data).map_err(|source: ElfError| Error::Elf {
path: path.to_path_buf(),
@@ -133,6 +134,15 @@ pub(crate) fn inspect(
"Stage 1 payload size must be nonzero and word aligned: 0x{payload_size:x}"
));
}
if let Some(limit) = max_payload_bytes {
let declared = u64::try_from(payload_size)
.map_err(|_| Error::Invalid("Stage 1 payload size exceeds u64".to_owned()))?;
crate::android::ResourceLimits {
max_entry_bytes: limit,
..crate::android::ResourceLimits::default()
}
.check_entry_bytes(declared)?;
}
let entry_offset = usize::try_from(header.entry_offset)
.map_err(|_| Error::Invalid("Stage 1 entry offset exceeds usize".to_owned()))?;
if entry_offset >= payload_size {
@@ -190,7 +200,11 @@ fn decrypt_words(ciphertext: &[u8], key: u32, constant: u32) -> Result<Vec<u8>>
if !ciphertext.len().is_multiple_of(4) {
return invalid("Stage 1 word cipher input is not 4-byte aligned");
}
let mut plaintext = ciphertext.to_vec();
let mut plaintext = Vec::new();
plaintext
.try_reserve_exact(ciphertext.len())
.map_err(|_| Error::Invalid("Stage 1 plaintext reservation failed".to_owned()))?;
plaintext.extend_from_slice(ciphertext);
for (index, chunk) in plaintext.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let index = u32::try_from(index)
.map_err(|_| Error::Invalid("Stage 1 word index exceeds u32".to_owned()))?;
+273
View File
@@ -0,0 +1,273 @@
//! Shared Android resource ceilings for extraction, restoration, and archives.
//!
//! These bounds reject an attacker-controlled size before the corresponding
//! allocation or file growth. They are per-allocation and per-task accounting
//! caps, not a measurement of peak resident memory: decoder scratch, Huffman
//! tables, and mapped inputs can exist at the same time.
/// Default ceiling for one ZIP entry, one decoder output, one writer source or
/// destination, one artifact read, and the restored output image.
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 2 << 30;
/// Default ceiling for the sum of retained or temporary bytes charged by one
/// extraction, restoration, or archive task.
pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 8 << 30;
/// Default ceiling for ZIP entries and decoded stage-2 records in one task.
pub const DEFAULT_MAX_ENTRIES: usize = 100_000;
/// Default nesting ceiling. Depth `0` is the outermost container or stream, and
/// a depth equal to this value is still allowed.
pub const DEFAULT_MAX_DEPTH: usize = 32;
/// Overridable ceilings shared by Android extraction, restoration, and I/O.
///
/// Archive code uses the same type: `max_entry_bytes` bounds one streamed ZIP
/// entry, `max_total_bytes` bounds the sum of those entries, `max_entries`
/// bounds the entry count including nested archives, and `max_depth` bounds
/// archive nesting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceLimits {
pub max_entry_bytes: u64,
pub max_total_bytes: u64,
pub max_entries: usize,
pub max_depth: usize,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES,
max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
max_entries: DEFAULT_MAX_ENTRIES,
max_depth: DEFAULT_MAX_DEPTH,
}
}
}
impl ResourceLimits {
/// Numeric output ceiling passed into the crypto crate.
#[must_use]
pub fn output_limit(self) -> senbei_crypto::android::OutputLimit {
senbei_crypto::android::OutputLimit {
max_output_bytes: self.max_entry_bytes,
}
}
/// Reject one allocation, entry, or output before it is reserved.
pub fn check_entry_bytes(self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
if bytes > self.max_entry_bytes {
return Err(LimitExceeded::Entry {
requested: bytes,
limit: self.max_entry_bytes,
});
}
Ok(())
}
/// Reject a declared entry or record count before those records are stored.
pub fn check_entries(self, count: usize) -> std::result::Result<(), LimitExceeded> {
if count > self.max_entries {
return Err(LimitExceeded::Entries {
requested: count,
limit: self.max_entries,
});
}
Ok(())
}
/// Reject a nested container or stream whose depth exceeds the ceiling.
///
/// `depth` is zero for the outermost item. `max_depth` itself is accepted.
pub fn check_depth(self, depth: usize) -> std::result::Result<(), LimitExceeded> {
if depth > self.max_depth {
return Err(LimitExceeded::Depth {
requested: depth,
limit: self.max_depth,
});
}
Ok(())
}
}
/// A size or count that would exceed a [`ResourceLimits`] ceiling.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LimitExceeded {
/// One entry or allocation is larger than `max_entry_bytes`.
#[error("resource limit: entry of {requested} bytes exceeds the {limit}-byte ceiling")]
Entry { requested: u64, limit: u64 },
/// Charging more bytes would pass `max_total_bytes`.
#[error(
"resource limit: task total of {requested} bytes exceeds the {limit}-byte ceiling (adding {adding} bytes)"
)]
Total {
requested: u64,
adding: u64,
limit: u64,
},
/// An entry or record count passed `max_entries`.
#[error("resource limit: {requested} entries exceed the ceiling of {limit}")]
Entries { requested: usize, limit: usize },
/// Nesting passed `max_depth`.
#[error("resource limit: nesting depth {requested} exceeds the ceiling of {limit}")]
Depth { requested: usize, limit: usize },
}
/// Running sum of retained or temporary bytes for one task.
///
/// Each call starts its own budget at zero. Charging checks the single-entry
/// ceiling and the task total, but it does not account for buffers that were
/// never charged, so it is not a peak-RSS budget.
#[derive(Debug, Clone)]
pub struct ByteBudget {
limits: ResourceLimits,
used: u64,
}
impl ByteBudget {
#[must_use]
pub fn new(limits: &ResourceLimits) -> Self {
Self {
limits: *limits,
used: 0,
}
}
#[must_use]
pub fn used(&self) -> u64 {
self.used
}
/// Reject `bytes` against the single-entry ceiling without charging it.
pub fn check_entry(&self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
self.limits.check_entry_bytes(bytes)
}
/// Charge `bytes` against both the single-entry ceiling and the task total.
pub fn charge(&mut self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
self.check_entry(bytes)?;
self.charge_total(bytes)
}
/// Charge `bytes` against the task total only.
///
/// Use this for a sum of pieces that were each already checked against the
/// single-entry ceiling, such as several decoded records in one stream.
pub fn charge_total(&mut self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
let requested = self
.used
.checked_add(bytes)
.filter(|total| *total <= self.limits.max_total_bytes);
match requested {
Some(total) => {
self.used = total;
Ok(())
}
None => Err(LimitExceeded::Total {
requested: self.used.saturating_add(bytes),
adding: bytes,
limit: self.limits.max_total_bytes,
}),
}
}
}
/// Running count of entries or decoded records for one task.
#[derive(Debug, Clone)]
pub struct EntryBudget {
limits: ResourceLimits,
used: usize,
}
impl EntryBudget {
#[must_use]
pub fn new(limits: &ResourceLimits) -> Self {
Self {
limits: *limits,
used: 0,
}
}
#[must_use]
pub fn used(&self) -> usize {
self.used
}
/// Charge `count` additional entries against `max_entries`.
pub fn charge(&mut self, count: usize) -> std::result::Result<(), LimitExceeded> {
let requested = self
.used
.checked_add(count)
.filter(|total| *total <= self.limits.max_entries);
match requested {
Some(total) => {
self.used = total;
Ok(())
}
None => Err(LimitExceeded::Entries {
requested: self.used.saturating_add(count),
limit: self.limits.max_entries,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_documented_ceilings() {
let limits = ResourceLimits::default();
assert_eq!(limits.max_entry_bytes, 2 << 30);
assert_eq!(limits.max_total_bytes, 8 << 30);
assert_eq!(limits.max_entries, 100_000);
assert_eq!(limits.max_depth, 32);
assert!(limits.check_entry_bytes(limits.max_entry_bytes).is_ok());
assert!(limits.check_entries(limits.max_entries).is_ok());
assert!(limits.check_depth(limits.max_depth).is_ok());
assert!(
limits
.check_entry_bytes(limits.max_entry_bytes + 1)
.is_err()
);
assert!(limits.check_depth(limits.max_depth + 1).is_err());
}
#[test]
fn byte_budget_accepts_the_boundary_and_rejects_the_next_byte() {
let limits = ResourceLimits {
max_entry_bytes: 4,
max_total_bytes: 8,
max_entries: 2,
max_depth: 1,
};
let mut budget = ByteBudget::new(&limits);
budget.charge(4).unwrap();
budget.charge_total(4).unwrap();
assert_eq!(budget.used(), 8);
assert!(matches!(
budget.charge(5),
Err(LimitExceeded::Entry { requested: 5, .. })
));
assert!(matches!(
budget.charge_total(1),
Err(LimitExceeded::Total { .. })
));
assert_eq!(budget.used(), 8);
}
#[test]
fn entry_budget_rejects_the_count_past_the_ceiling() {
let limits = ResourceLimits {
max_entries: 2,
..ResourceLimits::default()
};
let mut budget = EntryBudget::new(&limits);
budget.charge(2).unwrap();
assert!(matches!(
budget.charge(1),
Err(LimitExceeded::Entries {
requested: 3,
limit: 2
})
));
}
}
+11 -2
View File
@@ -2,10 +2,19 @@
mod common;
mod extract;
mod limits;
mod restore;
pub use extract::{
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, Error as ExtractionError, ExtractOptions,
ExtractionReport, extract_stage2, is_protected_libil2cpp,
ExtractionReport, extract_stage2, extract_stage2_with_limits, is_protected_libil2cpp,
probe_libil2cpp_with_limits,
};
pub use limits::{
ByteBudget, DEFAULT_MAX_DEPTH, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
DEFAULT_MAX_TOTAL_BYTES, EntryBudget, LimitExceeded, ResourceLimits,
};
pub use restore::{
Error as RestoreError, RestoreOptions, RestoreReport, restore_libil2cpp,
restore_libil2cpp_with_limits,
};
pub use restore::{Error as RestoreError, RestoreOptions, RestoreReport, restore_libil2cpp};
@@ -18,6 +18,8 @@ pub enum Error {
Elf(#[from] senbei_elf::Error),
#[error("{0}")]
Invalid(String),
#[error(transparent)]
Limit(#[from] crate::android::LimitExceeded),
}
impl Error {
+3 -1
View File
@@ -4,4 +4,6 @@ mod error;
mod pipeline;
pub use error::Error;
pub use pipeline::{RestoreOptions, RestoreReport, restore_libil2cpp};
pub use pipeline::{
RestoreOptions, RestoreReport, restore_libil2cpp, restore_libil2cpp_with_limits,
};
+447 -25
View File
@@ -15,6 +15,7 @@ use tempfile::NamedTempFile;
use super::super::common;
use super::artifact::load_artifacts;
use super::error::{Error, Result, invalid};
use crate::android::{ByteBudget, ResourceLimits};
use senbei_elf::{
DT_GNU_HASH, DT_HASH, DT_JMPREL, DT_PLTRELSZ, DT_RELA, DT_RELACOUNT, DT_RELASZ, DT_STRSZ,
DT_STRTAB, DT_SYMTAB, DT_VERNEED, DT_VERSYM, ELF64_RELA_SIZE, ELF64_SYMBOL_SIZE, ElfLayout,
@@ -169,8 +170,68 @@ fn map_mut(file: &File, length: usize, path: &Path) -> Result<MmapMut> {
.map_err(|error| Error::io("map temporary output", path, error))
}
fn read_file(path: &Path) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|error| Error::io("read", path, error))
/// Reserve the flat auxiliary image.
///
/// `output_size` is the size declared by the auxiliary container header. It
/// is charged and rejected before `try_reserve_exact`, so an over-limit size
/// never becomes an allocation.
fn allocate_auxiliary(
output_size: u32,
limits: &ResourceLimits,
bytes: &mut ByteBudget,
) -> Result<Vec<u8>> {
let declared = u64::from(output_size);
limits.check_entry_bytes(declared)?;
// The auxiliary image is retained for the rest of the task, so it counts
// against the task total as well as the single-entry ceiling. The task
// total is also the cap for the restored output file below.
bytes.charge(declared)?;
let auxiliary_size = output_size as usize;
let mut decoded = Vec::new();
decoded
.try_reserve_exact(auxiliary_size)
.map_err(|_| Error::Invalid("auxiliary output reservation failed".to_owned()))?;
decoded.resize(auxiliary_size, 0);
Ok(decoded)
}
/// Reject one scratch reservation against the single-entry ceiling.
fn reserve_within(limits: &ResourceLimits, bytes: usize) -> Result<()> {
let declared = u64::try_from(bytes)
.map_err(|_| Error::Invalid("scratch reservation exceeds u64".to_owned()))?;
limits.check_entry_bytes(declared)?;
Ok(())
}
fn read_artifact(path: &Path, limits: &ResourceLimits, bytes: &mut ByteBudget) -> Result<Vec<u8>> {
let data = read_file_limited(path, limits, bytes)?;
Ok(data)
}
fn read_file_limited(
path: &Path,
limits: &ResourceLimits,
bytes: &mut ByteBudget,
) -> Result<Vec<u8>> {
let mut file = File::open(path).map_err(|error| Error::io("read", path, error))?;
let length = file
.metadata()
.map_err(|error| Error::io("inspect", path, error))?
.len();
// Charge the length the open file reports before reserving it. This is
// the file's own metadata, not a directory lookup that can be swapped
// before the open.
limits.check_entry_bytes(length)?;
bytes.charge(length)?;
let capacity = usize::try_from(length)
.map_err(|_| Error::Invalid("artifact length exceeds usize".to_owned()))?;
let mut data = Vec::new();
data.try_reserve_exact(capacity)
.map_err(|_| Error::Invalid("artifact reservation failed".to_owned()))?;
data.resize(capacity, 0);
file.read_exact(&mut data)
.map_err(|error| Error::io("read", path, error))?;
Ok(data)
}
fn sha256_bytes(data: &[u8]) -> String {
@@ -214,8 +275,9 @@ struct FileLayoutWriter<'a> {
}
impl FileLayoutWriter<'_> {
fn write(&mut self, virtual_address: u64, data: &[u8]) -> Result<usize> {
let data_len = u64::try_from(data.len())
/// Reject a declared write before its bytes have been decompressed.
fn validate(&self, virtual_address: u64, length: usize) -> Result<()> {
let data_len = u64::try_from(length)
.map_err(|_| Error::Invalid("decoded write length exceeds u64".to_owned()))?;
let end = virtual_address
.checked_add(data_len)
@@ -225,6 +287,16 @@ impl FileLayoutWriter<'_> {
"decoded write 0x{virtual_address:x}..0x{end:x} exceeds target load image"
));
}
Ok(())
}
fn write(&mut self, virtual_address: u64, data: &[u8]) -> Result<usize> {
self.validate(virtual_address, data.len())?;
let data_len = u64::try_from(data.len())
.map_err(|_| Error::Invalid("decoded write length exceeds u64".to_owned()))?;
let end = virtual_address
.checked_add(data_len)
.ok_or_else(|| Error::Invalid("decoded write range overflow".to_owned()))?;
let mut written = 0_u64;
let mut covered_memory = 0_u64;
for segment in &self.layout.program_headers {
@@ -275,21 +347,31 @@ impl FileLayoutWriter<'_> {
}
}
fn decode_container<F>(
struct DecodeContext<'a, V> {
limits: &'a ResourceLimits,
bytes: &'a mut ByteBudget,
records: &'a mut crate::android::EntryBudget,
validate: V,
}
fn decode_container<F, V>(
payload: &[u8],
header: &ContainerHeader,
config: &Module9bConfig,
verbose: bool,
context: &mut DecodeContext<'_, V>,
mut writer: F,
) -> Result<DecodeStatistics>
where
F: FnMut(u64, &[u8]) -> Result<usize>,
V: FnMut(u64, usize) -> Result<()>,
{
let decoder = HuffmanLzDecoder::new(&header.tree)?;
let mut statistics = DecodeStatistics {
segments: header.segments.len(),
..DecodeStatistics::default()
};
context.records.charge(header.segments.len())?;
let decrypt_aes = !(config.skip_aes || header.skip_aes);
for (segment_index, encoded) in header.segments.iter().enumerate() {
let start = checked_add(
@@ -298,6 +380,7 @@ where
"encoded segment start",
)?;
let encoded_data = slice(payload, start, encoded.size as usize)?;
context.limits.check_entry_bytes(u64::from(encoded.size))?;
let transformed = transform_segment(
encoded_data,
config.container_seed,
@@ -325,6 +408,7 @@ where
"decoded segment {segment_index} has invalid writer offsets"
));
}
context.records.charge(writer_count)?;
let mut data_cursor = data_offset;
for writer_index in 0..writer_count {
let record = table_offset + writer_index * 16;
@@ -341,16 +425,32 @@ where
));
}
let source = &transformed[data_cursor..encoded_end];
let encoded_bytes = u64::try_from(encoded_size)
.map_err(|_| Error::Invalid("writer source length exceeds u64".to_owned()))?;
let decoded_bytes = u64::try_from(output_size)
.map_err(|_| Error::Invalid("writer output length exceeds u64".to_owned()))?;
// The compressed source and the declared destination are both
// rejected before Huffman decoding reserves the output buffer.
// Only a writer that actually decompresses retains a new buffer,
// so only that length is charged to the task total.
context.limits.check_entry_bytes(encoded_bytes)?;
context.limits.check_entry_bytes(decoded_bytes)?;
let target = base_offset
.checked_add(output_offset)
.ok_or_else(|| Error::Invalid("writer target address overflow".to_owned()))?;
// The destination is checked against the real output before a
// compressed writer reserves its buffer. An in-range declared
// length that still falls past the image is a bounds error, not
// a decoder error.
(context.validate)(target, output_size)?;
let decoded = if encoded_size == output_size {
None
} else {
statistics.compressed_writers += 1;
context.bytes.charge(decoded_bytes)?;
Some(decoder.decode(source, output_size)?)
};
let decoded_slice = decoded.as_deref().unwrap_or(source);
let target = base_offset
.checked_add(output_offset)
.ok_or_else(|| Error::Invalid("writer target address overflow".to_owned()))?;
statistics.file_bytes_written += writer(target, decoded_slice)? as u64;
statistics.writers += 1;
statistics.encoded_bytes += encoded_size as u64;
@@ -1585,14 +1685,19 @@ fn table_end(tables: &[TablePayload], start: u64) -> Result<u64> {
table_placements(tables, start).map(|(_, end)| end)
}
struct MaterializeInput<'a> {
auxiliary_symbol_seed: Option<u32>,
constructor_module: Option<(&'a [u8], u32)>,
limits: &'a ResourceLimits,
}
fn materialize_static_elf_tables(
output: &mut [u8],
source: &[u8],
layout: &ElfLayout,
symbol_patch_data: &[u8],
auxiliary_data: &[u8],
auxiliary_symbol_seed: Option<u32>,
constructor_module: Option<(&[u8], u32)>,
input: MaterializeInput<'_>,
) -> Result<(ElfLayout, ElfMaterializationReport, u64)> {
let names = layout.section_names(source)?;
let indices = required_section_indices(&names)?;
@@ -1651,7 +1756,14 @@ fn materialize_static_elf_tables(
Error::Invalid("auxiliary symbol table has no null entry".to_owned())
})?)
.map_err(|_| Error::Invalid("auxiliary symbol count exceeds usize".to_owned()))?;
let mut appended_symbols = Vec::with_capacity(auxiliary_appended_count * ELF64_SYMBOL_SIZE);
let appended_symbol_bytes = auxiliary_appended_count
.checked_mul(ELF64_SYMBOL_SIZE)
.ok_or_else(|| Error::Invalid("appended symbol reservation overflow".to_owned()))?;
reserve_within(input.limits, appended_symbol_bytes)?;
let mut appended_symbols = Vec::new();
appended_symbols
.try_reserve_exact(appended_symbol_bytes)
.map_err(|_| Error::Invalid("appended symbol reservation failed".to_owned()))?;
for index in 1..auxiliary.dynsym_count as usize {
let offset = auxiliary.dynsym_offset as usize + index * ELF64_SYMBOL_SIZE;
let symbol = slice(auxiliary_data, offset, ELF64_SYMBOL_SIZE)?;
@@ -1670,10 +1782,27 @@ fn materialize_static_elf_tables(
appended_symbols.extend_from_slice(&merged_name_offset.to_le_bytes());
appended_symbols.extend_from_slice(&symbol[4..]);
}
let mut merged_symbols = Vec::with_capacity(old_symbols.len() + appended_symbols.len());
let merged_symbol_bytes = old_symbols
.len()
.checked_add(appended_symbols.len())
.ok_or_else(|| Error::Invalid("merged symbol reservation overflow".to_owned()))?;
reserve_within(input.limits, merged_symbol_bytes)?;
let mut merged_symbols = Vec::new();
merged_symbols
.try_reserve_exact(merged_symbol_bytes)
.map_err(|_| Error::Invalid("merged symbol reservation failed".to_owned()))?;
merged_symbols.extend_from_slice(&old_symbols);
merged_symbols.extend_from_slice(&appended_symbols);
let mut merged_strings = Vec::with_capacity(old_strings.len() + auxiliary_strings.len() + 64);
let merged_string_bytes = old_strings
.len()
.checked_add(auxiliary_strings.len())
.and_then(|len| len.checked_add(64))
.ok_or_else(|| Error::Invalid("merged string reservation overflow".to_owned()))?;
reserve_within(input.limits, merged_string_bytes)?;
let mut merged_strings = Vec::new();
merged_strings
.try_reserve_exact(merged_string_bytes)
.map_err(|_| Error::Invalid("merged string reservation failed".to_owned()))?;
merged_strings.extend_from_slice(&old_strings);
merged_strings.extend_from_slice(auxiliary_strings);
let mut merged_versions =
@@ -1708,7 +1837,7 @@ fn materialize_static_elf_tables(
remap: Some(RelocationRemap {
base_symbol_count: old_symbol_count,
auxiliary_symbol_count: auxiliary.dynsym_count,
permutation_seed: auxiliary_symbol_seed,
permutation_seed: input.auxiliary_symbol_seed,
}),
};
let auxiliary2 = RelocationTable {
@@ -1718,7 +1847,7 @@ fn materialize_static_elf_tables(
remap: Some(RelocationRemap {
base_symbol_count: old_symbol_count,
auxiliary_symbol_count: auxiliary.dynsym_count,
permutation_seed: auxiliary_symbol_seed,
permutation_seed: input.auxiliary_symbol_seed,
}),
};
old_dyn.validate(
@@ -1783,7 +1912,7 @@ fn materialize_static_elf_tables(
let new_symbol_count = merged_names.len();
let new_dynstr_size = merged_strings.len();
let constructors = match constructor_module {
let constructors = match input.constructor_module {
Some(module) => {
let init_index = names
.iter()
@@ -1951,11 +2080,27 @@ fn materialize_static_elf_tables(
.map_err(|_| Error::Invalid("fini RVA exceeds i64".to_owned()))?;
}
let mut merged_rela_dyn = Vec::with_capacity(dyn_relocations.len() * ELF64_RELA_SIZE);
let rela_dyn_bytes = dyn_relocations
.len()
.checked_mul(ELF64_RELA_SIZE)
.ok_or_else(|| Error::Invalid("merged relocation reservation overflow".to_owned()))?;
reserve_within(input.limits, rela_dyn_bytes)?;
let mut merged_rela_dyn = Vec::new();
merged_rela_dyn
.try_reserve_exact(rela_dyn_bytes)
.map_err(|_| Error::Invalid("merged relocation reservation failed".to_owned()))?;
for relocation in dyn_relocations {
relocation.encode(&mut merged_rela_dyn);
}
let mut merged_rela_plt = Vec::with_capacity(plt_relocations.len() * ELF64_RELA_SIZE);
let rela_plt_bytes = plt_relocations
.len()
.checked_mul(ELF64_RELA_SIZE)
.ok_or_else(|| Error::Invalid("merged PLT reservation overflow".to_owned()))?;
reserve_within(input.limits, rela_plt_bytes)?;
let mut merged_rela_plt = Vec::new();
merged_rela_plt
.try_reserve_exact(rela_plt_bytes)
.map_err(|_| Error::Invalid("merged PLT reservation failed".to_owned()))?;
for relocation in plt_relocations {
relocation.encode(&mut merged_rela_plt);
}
@@ -2358,6 +2503,20 @@ fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
/// Restore the current protected `libil2cpp.so` without executing protector code.
pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
restore_libil2cpp_with_limits(options, &ResourceLimits::default())
}
/// [`restore_libil2cpp`] with explicit ceilings.
///
/// `max_entry_bytes` bounds each artifact read, the auxiliary decoded image,
/// every writer source and destination, and growth of the temporary output.
/// `max_total_bytes` sums the stage-2 artifact reads and decompressed writer
/// outputs for this call. It is not a peak resident-memory budget: the mapped
/// input and the output image are checked individually and are not added in.
pub fn restore_libil2cpp_with_limits(
options: &RestoreOptions,
limits: &ResourceLimits,
) -> Result<RestoreReport> {
let started = Instant::now();
let input_path = absolute(&options.input)?;
let output_path = absolute(&options.output)?;
@@ -2369,11 +2528,13 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
return invalid("refusing to overwrite the protected input in place");
}
let artifacts = load_artifacts(&index_path)?;
let module = read_file(&artifacts[&0x9b].path)?;
let symbol_patch_data = read_file(&artifacts[&0x9e].path)?;
let mut bytes = ByteBudget::new(limits);
let mut records = crate::android::EntryBudget::new(limits);
let module = read_artifact(&artifacts[&0x9b].path, limits, &mut bytes)?;
let symbol_patch_data = read_artifact(&artifacts[&0x9e].path, limits, &mut bytes)?;
let auxiliary_symbol_seed = artifacts
.get(&0x96)
.map(|artifact| read_file(&artifact.path))
.map(|artifact| read_artifact(&artifact.path, limits, &mut bytes))
.transpose()?
.as_deref()
.map(discover_auxiliary_symbol_seed)
@@ -2389,7 +2550,7 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
"constructor module 0x98 lacks its descriptor entry offset".to_owned(),
)
})?;
Ok((read_file(&artifact.path)?, entry))
Ok((read_artifact(&artifact.path, limits, &mut bytes)?, entry))
})
.transpose()?;
let config = Module9bConfig::parse(&module)?;
@@ -2445,6 +2606,12 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
if auxiliary_header.output_size != descriptor.auxiliary_expected_size {
return invalid("auxiliary container output size does not match the 0x9D descriptor");
}
// The auxiliary image is one flat allocation. Reject its declared size
// before that buffer is reserved, even when outer-only mode will not
// decode it: a hostile size must not depend on a later branch to be safe.
// The same check repeats inside `allocate_auxiliary`, which is also what
// charges the buffer; this one only guards the outer-only path.
limits.check_entry_bytes(u64::from(auxiliary_header.output_size))?;
if outer.encoded_end()? != descriptor.auxiliary_offset as usize {
return invalid("primary and auxiliary 0x9D containers are not contiguous");
}
@@ -2456,6 +2623,19 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
.map_err(|error| Error::io("create temporary output", parent, error))?;
let temporary_path = temporary.path().to_path_buf();
let private_size = usize_from_u64(private.offset, "private section offset")?;
// The temporary output is one file. It is capped by the smaller of the
// single-entry ceiling and the task total, but it is not added to the
// decoder byte budget: that budget counts retained decoded bytes, and the
// output file is the image those bytes are written into.
limits.check_entry_bytes(private.offset.min(limits.max_total_bytes))?;
if private.offset > limits.max_total_bytes {
return Err(crate::android::LimitExceeded::Total {
requested: private.offset,
adding: private.offset,
limit: limits.max_total_bytes,
}
.into());
}
copy_range(
&source,
temporary.as_file_mut(),
@@ -2486,11 +2666,30 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
layout: &layout,
load_end,
};
let mut context = DecodeContext {
limits,
bytes: &mut bytes,
records: &mut records,
validate: |address: u64, length: usize| {
let data_len = u64::try_from(length)
.map_err(|_| Error::Invalid("decoded write length exceeds u64".to_owned()))?;
let end = address
.checked_add(data_len)
.ok_or_else(|| Error::Invalid("decoded write range overflow".to_owned()))?;
if end > load_end {
return invalid(format!(
"decoded write 0x{address:x}..0x{end:x} exceeds target load image"
));
}
Ok(())
},
};
primary_stats = decode_container(
&payload,
&outer,
&config,
options.verbose,
&mut context,
|address, data| writer.write(address, data),
)?;
output
@@ -2502,12 +2701,29 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
if options.verbose {
eprintln!("Decoding auxiliary 0x9D ELF materialization container...");
}
let mut decoded = vec![0_u8; auxiliary_header.output_size as usize];
let mut decoded = allocate_auxiliary(auxiliary_header.output_size, limits, &mut bytes)?;
let auxiliary_len = decoded.len();
let mut context = DecodeContext {
limits,
bytes: &mut bytes,
records: &mut records,
validate: |offset: u64, length: usize| {
let start = usize_from_u64(offset, "auxiliary write offset")?;
let end = start
.checked_add(length)
.ok_or_else(|| Error::Invalid("auxiliary decoded write overflow".to_owned()))?;
if end > auxiliary_len {
return invalid("auxiliary decoded write is out of range".to_owned());
}
Ok(())
},
};
let stats = decode_container(
&payload,
&auxiliary_header,
&config,
options.verbose,
&mut context,
|offset, data| {
let start = usize_from_u64(offset, "auxiliary write offset")?;
let end = start
@@ -2527,9 +2743,20 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
if mapping_length < private_size {
return invalid("dynamic-table mapping is shorter than the ELF image");
}
let mapping_bytes = u64::try_from(mapping_length)
.map_err(|_| Error::Invalid("dynamic-table mapping length exceeds u64".to_owned()))?;
limits.check_entry_bytes(mapping_bytes.min(limits.max_total_bytes))?;
if mapping_bytes > limits.max_total_bytes {
return Err(crate::android::LimitExceeded::Total {
requested: mapping_bytes,
adding: mapping_bytes,
limit: limits.max_total_bytes,
}
.into());
}
temporary
.as_file()
.set_len(mapping_length as u64)
.set_len(mapping_bytes)
.map_err(|error| Error::io("extend temporary output", &temporary_path, error))?;
if options.verbose {
eprintln!("Rebuilding static ELF dynamic-linker tables...");
@@ -2542,10 +2769,13 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
&layout,
&symbol_patch_data,
&decoded,
MaterializeInput {
auxiliary_symbol_seed,
constructor_module
constructor_module: constructor_module
.as_ref()
.map(|(image, entry)| (image.as_slice(), *entry)),
limits,
},
)?;
restored_layout = new_layout;
materialization = Some(report);
@@ -2616,6 +2846,198 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result<RestoreReport> {
mod tests {
use super::*;
/// `decode_container` rejects a writer whose declared length fits the entry
/// ceiling but whose target ends past the destination. The writer closure
/// counts calls and must stay at zero.
#[test]
fn decode_rejects_an_in_cap_writer_past_the_destination_before_writing() {
let limits = ResourceLimits {
max_entry_bytes: 64,
max_total_bytes: 64,
max_entries: 8,
..ResourceLimits::default()
};
let destination_len = 8_usize;
let mut writes = 0_usize;
let mut records = crate::android::EntryBudget::new(&limits);
let mut bytes = ByteBudget::new(&limits);
let container = past_end_container();
let header = senbei_crypto::android::ContainerHeader::parse(&container, 0, 0)
.expect("synthetic container header");
let config = senbei_crypto::android::Module9bConfig {
header_seed: 0,
container_seed: 0,
aes_key: [0; 32],
skip_aes: true,
schedule_offset: 0,
};
let mut context = DecodeContext {
limits: &limits,
bytes: &mut bytes,
records: &mut records,
validate: |offset: u64, length: usize| {
limits.check_entry_bytes(length as u64)?;
let start = usize_from_u64(offset, "auxiliary write offset")?;
let end = start
.checked_add(length)
.ok_or_else(|| Error::Invalid("auxiliary decoded write overflow".to_owned()))?;
if end > destination_len {
return invalid("auxiliary decoded write is out of range".to_owned());
}
Ok(())
},
};
let error = decode_container(
&container,
&header,
&config,
false,
&mut context,
|_offset, _data| {
writes += 1;
Ok(0)
},
)
.expect_err("the writer target ends past the destination");
assert!(error.to_string().contains("out of range"), "{error}");
assert_eq!(writes, 0, "the writer ran before the range check");
}
/// Container whose one writer declares 4 bytes at offset 8. The destination
/// used by the test is 8 bytes, so the range check fails before the writer.
fn past_end_container() -> Vec<u8> {
fn unmultiply(encoded: u32) -> u32 {
let mut multiplier = 0xdc49_cd06_u32;
let mut value = encoded;
let mut result = 0_u32;
while multiplier != 0 {
if multiplier & 1 != 0 {
result ^= value;
}
let carry = value >> 31;
value = value.wrapping_shl(1);
if carry != 0 {
value ^= 0x5793_57eb;
}
multiplier >>= 1;
}
result
}
let state = 0_u32;
let output_size = 8_u32;
let raw0 = unmultiply(
output_size
.wrapping_sub(0xa21d_fb3a_u32.wrapping_shl(state & 7))
.wrapping_sub(state.wrapping_mul(0xf87b_337c)),
);
let flag_word = 1_u32 | (1_u32 << 8);
let raw1 = unmultiply(
flag_word
^ state
.wrapping_add(0xbd19_c63c)
.wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)),
);
let mut tree = vec![0_u8; 256 * 3];
tree[1] = 0x80;
tree[2] = 1;
let raw2 = unmultiply(
(tree.len() as u32)
.wrapping_add(state ^ 0x3b2b_f538)
.wrapping_sub(0x643a_3a3b_u32.wrapping_shl(state & 0x0b)),
);
let mut header = Vec::new();
header.extend_from_slice(&raw0.to_le_bytes());
header.extend_from_slice(&raw1.to_le_bytes());
header.extend_from_slice(&raw2.to_le_bytes());
for offset in (0..tree.len() & !3).step_by(4) {
let value = u32::from_le_bytes(tree[offset..offset + 4].try_into().unwrap());
tree[offset..offset + 4].copy_from_slice(&unmultiply(value).to_le_bytes());
}
header.extend_from_slice(&tree);
let mut plain = vec![0_u8; 36];
plain[4..8].copy_from_slice(&1_u32.to_le_bytes());
plain[8..12].copy_from_slice(&16_u32.to_le_bytes());
plain[12..16].copy_from_slice(&32_u32.to_le_bytes());
plain[16..20].copy_from_slice(&8_u32.to_le_bytes());
plain[20..24].copy_from_slice(&4_u32.to_le_bytes());
plain[24..28].copy_from_slice(&4_u32.to_le_bytes());
let mut state_word = 0_u32;
let mut left = 0xe34e_ac63_u32;
let mut right = 0x07b4_8238_u32;
for (index, chunk) in plain.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let index32 = index as u32;
left = state_word
.wrapping_add(0x72f6_fcbe)
.wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left))
.wrapping_shr(index32.wrapping_mul(index32) & 0x0f);
right = state_word
.wrapping_sub(0x71b6_a98d)
.wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right))
.wrapping_shl(index32 & 7);
state_word = left ^ right;
let mut value = u32::from_le_bytes(*chunk);
value ^= state_word;
value = value.wrapping_add(state_word);
value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3);
value = value.wrapping_sub(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d));
chunk.copy_from_slice(&value.to_le_bytes());
}
let segment_offset = (header.len() + 8) as u32;
let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state);
let table_xor = table_state.wrapping_shl(3);
let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state));
for (index, word) in [segment_offset, plain.len() as u32].into_iter().enumerate() {
let adjustment = table_add.wrapping_shr((((index * 4) & 7) + 5) as u32);
let encoded = unmultiply(word.wrapping_sub(adjustment)) ^ table_xor;
header.extend_from_slice(&encoded.to_le_bytes());
}
header.extend_from_slice(&plain);
header
}
#[test]
fn auxiliary_allocation_rejects_one_byte_over_the_ceiling() {
let limits = ResourceLimits {
max_entry_bytes: 4,
max_total_bytes: 8,
..ResourceLimits::default()
};
let mut budget = ByteBudget::new(&limits);
let error = allocate_auxiliary(5, &limits, &mut budget)
.expect_err("five bytes is over the four-byte ceiling");
assert!(matches!(
error,
Error::Limit(crate::android::LimitExceeded::Entry {
requested: 5,
limit: 4
})
));
assert_eq!(budget.used(), 0, "a rejected size is not charged");
let decoded = allocate_auxiliary(4, &limits, &mut budget).expect("the ceiling is allowed");
assert_eq!(decoded.len(), 4);
assert_eq!(budget.used(), 4);
}
#[test]
fn auxiliary_allocations_share_the_task_budget() {
let limits = ResourceLimits {
max_entry_bytes: 4,
max_total_bytes: 8,
..ResourceLimits::default()
};
let mut budget = ByteBudget::new(&limits);
allocate_auxiliary(4, &limits, &mut budget).unwrap();
allocate_auxiliary(4, &limits, &mut budget).expect("eight bytes is the total ceiling");
let error = allocate_auxiliary(1, &limits, &mut budget)
.expect_err("the next byte is over the total");
assert!(matches!(
error,
Error::Limit(crate::android::LimitExceeded::Total { .. })
));
assert_eq!(budget.used(), 8);
}
fn auxiliary_image(symbol_count: u32) -> Vec<u8> {
let mut data = vec![0_u8; 0x279];
let words = [
+7 -5
View File
@@ -52,7 +52,7 @@ where
line: details.line,
column: details.column,
})
},
}
}
}
@@ -215,12 +215,12 @@ where
{
match validate_dll_layout(dll_layout()) {
Ok(output) => Ok(output),
Err(dll) => validate_dll_layout(exe_layout()).map_err(|exe| {
UnpackError::PipelineFallbackFailed {
Err(dll) => {
validate_dll_layout(exe_layout()).map_err(|exe| UnpackError::PipelineFallbackFailed {
dll: Box::new(dll),
exe: Box::new(exe),
})
}
}),
}
}
@@ -228,7 +228,9 @@ fn validate_dll_layout(output: Result<Vec<u8>, UnpackError>) -> Result<Vec<u8>,
let output = output?;
if let Some(report) = dll_layout_invalid(&output) {
return Err(UnpackError::InvalidLayoutCandidate {
issues: report.findings.into_iter()
issues: report
.findings
.into_iter()
.filter(|finding| finding.severity == integrity::IntegritySeverity::Fatal)
.map(|finding| finding.message)
.collect(),
+575
View File
@@ -0,0 +1,575 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use senbei_engine::android::ResourceLimits;
use zip::ZipArchive;
use anyhow::{Context, Result, bail};
#[derive(Clone, Copy)]
struct ArchiveInfo {
entries: usize,
directory_start: u64,
directory_bytes: u64,
end_record: u64,
zip64_record: Option<u64>,
}
/// The ZIP library may retry an older end record when metadata is malformed.
/// During index construction, permit only the end records we preflighted and
/// keep central-header reads inside their declared region. Once indexing ends,
/// normal entry reads regain unrestricted seeking on the same file handle.
pub(super) struct ArchiveReader {
file: File,
info: ArchiveInfo,
indexing: Arc<AtomicBool>,
directory_started: bool,
}
fn invalid_metadata(message: &'static str) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, message)
}
impl Seek for ArchiveReader {
fn seek(&mut self, from: SeekFrom) -> std::io::Result<u64> {
let position = self.file.seek(from)?;
if !self.indexing.load(Ordering::Relaxed) {
return Ok(position);
}
if matches!(from, SeekFrom::Start(_)) {
let mut signature = [0_u8; 4];
let read = self.file.read(&mut signature)?;
self.file.seek(SeekFrom::Start(position))?;
if read == 4
&& ((signature == *b"PK\x05\x06" && position != self.info.end_record)
|| (signature == *b"PK\x06\x06" && Some(position) != self.info.zip64_record))
{
return Err(invalid_metadata(
"ZIP parser attempted an unverified end record",
));
}
}
if position == self.info.directory_start {
self.directory_started = true;
}
Ok(position)
}
}
impl Read for ArchiveReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if buffer.is_empty() {
return Ok(0);
}
if !self.indexing.load(Ordering::Relaxed) || !self.directory_started {
return self.file.read(buffer);
}
let position = self.file.stream_position()?;
let directory_end = self.info.directory_start + self.info.directory_bytes;
if position >= self.info.directory_start && position < directory_end {
let count = (directory_end - position).min(buffer.len() as u64) as usize;
return self.file.read(&mut buffer[..count]);
}
let trailer = self.info.zip64_record.unwrap_or(self.info.end_record);
if position >= trailer {
return self.file.read(buffer);
}
Err(invalid_metadata(
"ZIP indexing crossed the verified metadata region",
))
}
}
/// Cumulative extraction work for a run, including nested APKs. The budget is
/// charged for actual bytes read, even if a later write or restore fails.
#[derive(Debug)]
pub(super) struct ArchiveBudget {
max_entry_bytes: u64,
max_total_bytes: u64,
max_entries: usize,
bytes: u64,
entries: usize,
archives: HashMap<PathBuf, usize>,
}
impl Default for ArchiveBudget {
fn default() -> Self {
Self::from_limits(&ResourceLimits::default())
}
}
impl ArchiveBudget {
pub(super) fn from_limits(limits: &ResourceLimits) -> Self {
Self::new(
limits.max_entry_bytes,
limits.max_total_bytes,
limits.max_entries,
)
}
fn new(max_entry_bytes: u64, max_total_bytes: u64, max_entries: usize) -> Self {
Self {
max_entry_bytes,
max_total_bytes,
max_entries,
bytes: 0,
entries: 0,
archives: HashMap::new(),
}
}
pub(super) fn open_archive(&mut self, path: &Path) -> Result<ZipArchive<ArchiveReader>> {
let mut file = File::open(path).with_context(|| format!("open `{}`", path.display()))?;
let info = inspect_archive(
&mut file,
self.max_entries,
self.max_entry_bytes
.min(self.max_total_bytes.saturating_sub(self.bytes)),
)
.with_context(|| format!("check package `{}`", path.display()))?;
let count = info.entries;
let trailer = info.zip64_record.unwrap_or(info.end_record);
if info
.directory_start
.checked_add(info.directory_bytes)
.is_none_or(|end| end > trailer)
|| (count as u64)
.checked_mul(46)
.is_none_or(|minimum| minimum > info.directory_bytes)
{
bail!("ZIP central directory range is inconsistent with its end record");
}
let previous = self.archives.get(path).copied().unwrap_or(0);
if count > previous {
self.register_entries(count - previous)?;
}
self.archives
.insert(path.to_path_buf(), count.max(previous));
let indexing = Arc::new(AtomicBool::new(true));
let reader = ArchiveReader {
file,
info,
indexing: indexing.clone(),
directory_started: false,
};
let archive = ZipArchive::with_config(
zip::read::Config {
archive_offset: zip::read::ArchiveOffset::Known(0),
},
reader,
)
.with_context(|| format!("read package `{}`", path.display()))?;
indexing.store(false, Ordering::Relaxed);
if archive.len() > count {
bail!("package central directory contains more entries than its end record");
}
Ok(archive)
}
pub(super) fn read_probe(
&mut self,
input: &mut impl Read,
size: u64,
maximum: usize,
label: &str,
) -> Result<Vec<u8>> {
if size > self.max_entry_bytes {
bail!(
"entry `{label}` exceeds extraction limit {}",
self.max_entry_bytes
);
}
let length = size.min(maximum as u64);
if length > self.max_total_bytes.saturating_sub(self.bytes) {
bail!("manifest `{label}` exceeds the remaining extraction byte limit");
}
let mut bytes = Vec::new();
bytes
.try_reserve_exact(length as usize)
.context("reserve manifest probe")?;
self.copy_entry(&mut input.take(length), &mut bytes, length, label)?;
Ok(bytes)
}
pub(super) fn register_entries(&mut self, entries: usize) -> Result<()> {
let total = self
.entries
.checked_add(entries)
.context("archive entry count overflow")?;
if total > self.max_entries {
bail!(
"archive entry limit exceeded: {total} > {}",
self.max_entries
);
}
self.entries = total;
Ok(())
}
pub(super) fn copy_entry(
&mut self,
input: &mut impl Read,
output: &mut impl Write,
declared_size: u64,
label: &str,
) -> Result<u64> {
let remaining = self.max_total_bytes.saturating_sub(self.bytes);
let allowance = self.max_entry_bytes.min(remaining);
if declared_size > allowance {
bail!(
"entry `{label}` declares {declared_size} bytes, exceeding extraction limit {allowance}"
);
}
// The advertised size is a stricter ceiling when it is smaller than
// the budget. A dishonest header must not buy extra decompression work.
let limit = declared_size;
let mut buffer = [0_u8; 64 * 1024];
let mut written = 0_u64;
loop {
// Read at most one byte beyond the allowance to detect a dishonest
// size header without decompressing the rest of the entry.
let read_limit = (limit - written).saturating_add(1).min(buffer.len() as u64) as usize;
let read = input.read(&mut buffer[..read_limit])?;
if read == 0 {
break;
}
let next = written
.checked_add(read as u64)
.context("decompressed entry size overflow")?;
if next > limit {
bail!("entry `{label}` exceeds extraction limit {limit} while decompressing");
}
self.bytes = self
.bytes
.checked_add(read as u64)
.context("extraction byte count overflow")?;
output.write_all(&buffer[..read])?;
written = next;
}
if written != declared_size {
bail!("entry `{label}` decompressed to {written} bytes, expected {declared_size}");
}
Ok(written)
}
}
fn word(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap())
}
fn dword(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
}
fn qword(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap())
}
/// Read only the bounded end records before asking the ZIP library to allocate
/// its central-directory index. Both ordinary ZIP and ZIP64 are supported.
#[cfg(test)]
fn preflight_archive(
file: &mut File,
max_entries: usize,
max_directory_bytes: u64,
) -> Result<usize> {
Ok(inspect_archive(file, max_entries, max_directory_bytes)?.entries)
}
fn inspect_archive(
file: &mut File,
max_entries: usize,
max_directory_bytes: u64,
) -> Result<ArchiveInfo> {
let length = file.seek(SeekFrom::End(0))?;
let tail_length = length.min(22 + u16::MAX as u64) as usize;
let mut tail = vec![0_u8; tail_length];
file.seek(SeekFrom::End(-(tail_length as i64)))?;
file.read_exact(&mut tail)?;
let end = (0..=tail.len().saturating_sub(22))
.rev()
.find(|&offset| {
tail.get(offset..offset + 4) == Some(b"PK\x05\x06")
&& offset + 22 <= tail.len()
&& offset + 22 + word(&tail, offset + 20) as usize == tail.len()
})
.context("ZIP end record is missing or truncated")?;
let record = &tail[end..end + 22];
if word(record, 4) != 0 || word(record, 6) != 0 {
bail!("multi-disk ZIP packages are unsupported");
}
let end_record = length - tail_length as u64 + end as u64;
let mut zip64_record = None;
let mut directory_start = u64::from(dword(record, 16));
let mut entries = u64::from(word(record, 10));
let mut directory_bytes = u64::from(dword(record, 12));
if entries == u16::MAX as u64
|| directory_bytes == u32::MAX as u64
|| dword(record, 16) == u32::MAX
{
let absolute_end = length - tail_length as u64 + end as u64;
let locator_offset = absolute_end
.checked_sub(20)
.context("ZIP64 locator is missing")?;
file.seek(SeekFrom::Start(locator_offset))?;
let mut locator = [0_u8; 20];
file.read_exact(&mut locator)?;
if &locator[..4] != b"PK\x06\x07" || dword(&locator, 4) != 0 || dword(&locator, 16) != 1 {
bail!("invalid ZIP64 locator");
}
let record_offset = qword(&locator, 8);
if record_offset
.checked_add(56)
.is_none_or(|end| end > locator_offset)
{
bail!("ZIP64 end record is outside the package");
}
file.seek(SeekFrom::Start(record_offset))?;
let mut record = [0_u8; 56];
file.read_exact(&mut record)?;
if &record[..4] != b"PK\x06\x06"
|| qword(&record, 4) < 44
|| record_offset
.checked_add(12)
.and_then(|start| start.checked_add(qword(&record, 4)))
.is_none_or(|end| end > locator_offset)
|| dword(&record, 16) != 0
|| dword(&record, 20) != 0
|| qword(&record, 24) != qword(&record, 32)
{
bail!("invalid ZIP64 end record");
}
if qword(&record, 4) > max_directory_bytes {
bail!("ZIP64 metadata exceeds the directory memory limit");
}
zip64_record = Some(record_offset);
directory_start = qword(&record, 48);
entries = qword(&record, 32);
directory_bytes = qword(&record, 40);
} else if word(record, 8) != word(record, 10) {
bail!("inconsistent ZIP entry counts");
}
if entries > max_entries as u64 {
bail!("archive entry limit exceeded: {entries} > {max_entries}");
}
if directory_bytes > max_directory_bytes {
bail!("ZIP directory size {directory_bytes} exceeds memory limit {max_directory_bytes}");
}
file.seek(SeekFrom::Start(0))?;
Ok(ArchiveInfo {
entries: usize::try_from(entries).context("ZIP entry count exceeds address space")?,
directory_start,
directory_bytes,
end_record,
zip64_record,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn entry_and_aggregate_limits_allow_exact_boundaries() {
let mut budget = ArchiveBudget::new(4, 6, 3);
budget.register_entries(3).unwrap();
assert!(budget.register_entries(1).is_err());
let mut output = Vec::new();
budget
.copy_entry(&mut Cursor::new(b"abcd"), &mut output, 4, "first")
.unwrap();
budget
.copy_entry(&mut Cursor::new(b"ef"), &mut output, 2, "second")
.unwrap();
assert_eq!(output, b"abcdef");
assert!(
budget
.copy_entry(&mut Cursor::new(b"g"), &mut output, 1, "third")
.is_err()
);
assert_eq!(output, b"abcdef");
}
#[test]
fn declared_and_actual_sizes_are_both_limited() {
let mut budget = ArchiveBudget::new(4, 10, 10);
let mut output = Vec::new();
assert!(
budget
.copy_entry(&mut Cursor::new(b"abcde"), &mut output, 5, "large header")
.is_err()
);
assert!(output.is_empty());
assert!(
budget
.copy_entry(&mut Cursor::new(b"abcde"), &mut output, 4, "lying header")
.is_err()
);
assert!(output.is_empty());
assert!(
budget
.copy_entry(&mut Cursor::new(b"abc"), &mut output, 4, "short entry")
.is_err()
);
}
#[test]
fn failed_writes_still_consume_the_read_budget() {
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("blocked"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut budget = ArchiveBudget::new(4, 4, 10);
assert!(
budget
.copy_entry(&mut Cursor::new(b"abcd"), &mut FailingWriter, 4, "first")
.is_err()
);
assert!(
budget
.copy_entry(&mut Cursor::new(b"x"), &mut Vec::new(), 1, "retry")
.is_err()
);
}
fn end_record(entries: u16, directory_bytes: u32) -> [u8; 22] {
let mut record = [0_u8; 22];
record[..4].copy_from_slice(b"PK\x05\x06");
record[8..10].copy_from_slice(&entries.to_le_bytes());
record[10..12].copy_from_slice(&entries.to_le_bytes());
record[12..16].copy_from_slice(&directory_bytes.to_le_bytes());
record
}
#[test]
fn preflight_rejects_large_counts_and_directory_before_indexing() {
let mut file = tempfile::tempfile().unwrap();
file.write_all(&end_record(50, 1000)).unwrap();
assert!(preflight_archive(&mut file, 49, 1000).is_err());
assert!(preflight_archive(&mut file, 50, 999).is_err());
assert_eq!(preflight_archive(&mut file, 50, 1000).unwrap(), 50);
}
#[test]
fn preflight_bounds_zip64_counts() {
let mut bytes = [0_u8; 76];
bytes[..4].copy_from_slice(b"PK\x06\x06");
bytes[4..12].copy_from_slice(&44_u64.to_le_bytes());
bytes[24..32].copy_from_slice(&100_001_u64.to_le_bytes());
bytes[32..40].copy_from_slice(&100_001_u64.to_le_bytes());
bytes[56..60].copy_from_slice(b"PK\x06\x07");
bytes[72..76].copy_from_slice(&1_u32.to_le_bytes());
let mut file = tempfile::tempfile().unwrap();
file.write_all(&bytes).unwrap();
file.write_all(&end_record(u16::MAX, u32::MAX)).unwrap();
assert!(preflight_archive(&mut file, 100_000, 1024).is_err());
assert_eq!(
preflight_archive(&mut file, 100_001, 1024).unwrap(),
100_001
);
}
#[test]
fn reopening_an_archive_does_not_charge_its_entries_twice() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first.apk");
let second = temp.path().join("second.apk");
for path in [&first, &second] {
let mut writer = zip::ZipWriter::new(File::create(path).unwrap());
writer
.start_file("lib/test.so", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"payload").unwrap();
writer.finish().unwrap();
}
let mut budget = ArchiveBudget::new(1024, 4096, 1);
assert_eq!(budget.open_archive(&first).unwrap().len(), 1);
assert_eq!(budget.open_archive(&first).unwrap().len(), 1);
assert!(budget.open_archive(&second).is_err());
}
#[test]
fn parser_cannot_retry_an_unverified_older_end_record() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("misleading.apk");
let mut writer = zip::ZipWriter::new(File::create(&path).unwrap());
for name in ["one.so", "two.so"] {
writer
.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"payload").unwrap();
}
let mut file = writer.finish().unwrap();
// An invalid last record advertises a small archive. Without guarding
// fallback, the ZIP parser retries the older, larger directory.
file.seek(SeekFrom::End(0)).unwrap();
file.write_all(&end_record(1, 46)).unwrap();
drop(file);
let mut budget = ArchiveBudget::new(1024, 4096, 1);
assert!(budget.open_archive(&path).is_err());
}
#[test]
fn directory_indexing_respects_the_remaining_shared_budget() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("app.apk");
let mut writer = zip::ZipWriter::new(File::create(&path).unwrap());
writer
.start_file("lib/test.so", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"payload").unwrap();
writer.finish().unwrap();
let mut budget = ArchiveBudget::new(1024, 1024, 10);
budget
.copy_entry(
&mut Cursor::new(vec![0; 1024]),
&mut std::io::sink(),
1024,
"earlier",
)
.unwrap();
assert!(budget.open_archive(&path).is_err());
}
#[test]
fn manifest_probe_shares_the_extraction_budget() {
let mut budget = ArchiveBudget::new(10, 6, 10);
assert_eq!(
budget
.read_probe(&mut Cursor::new(b"abcdefghi"), 9, 4, "manifest")
.unwrap(),
b"abcd"
);
assert!(
budget
.copy_entry(&mut Cursor::new(b"xyz"), &mut Vec::new(), 3, "library")
.is_err()
);
assert!(
budget
.read_probe(&mut Cursor::new(b"abc"), 3, 3, "next manifest")
.is_err()
);
}
#[test]
fn preflight_rejects_truncated_records() {
for bytes in [&b""[..], &b"PK\x05\x06"[..], &b"not an archive"[..]] {
let mut file = tempfile::tempfile().unwrap();
file.write_all(bytes).unwrap();
assert!(preflight_archive(&mut file, 100, 1024).is_err());
}
}
}
+25 -20
View File
@@ -72,10 +72,12 @@ pub(crate) enum ContextAmbiguity {
/// Run-scoped cache of restored libraries and application module contexts.
///
/// The public package API still takes a content-identity set. This cache is
/// internal: a library hit reuses the restored image, while a metadata hit is
/// keyed by the recovery context as well as the encrypted bytes.
/// internal: a library hit reuses only its recovered module context, while a
/// metadata hit is keyed by recovery context as well as the encrypted bytes.
#[derive(Debug, Default)]
pub(crate) struct AndroidRunCache {
pub(super) limits: senbei_engine::android::ResourceLimits,
pub(super) archive: super::archive_limits::ArchiveBudget,
apps: HashMap<AppKey, ApplicationModules>,
/// Successfully restored protected libraries, keyed by encrypted content.
libraries: HashMap<String, CachedLibrary>,
@@ -111,6 +113,14 @@ pub(crate) enum CachedMetadata {
}
impl AndroidRunCache {
pub(super) fn with_limits(limits: &senbei_engine::android::ResourceLimits) -> Self {
Self {
limits: *limits,
archive: super::archive_limits::ArchiveBudget::from_limits(limits),
..Self::default()
}
}
pub(crate) fn modules_mut(&mut self, key: &AppKey) -> &mut ApplicationModules {
self.apps.entry(key.clone()).or_default()
}
@@ -305,7 +315,7 @@ fn component_is(component: Component<'_>, expected: &str) -> bool {
/// One ZIP whose central directory was listed while naming an application.
///
/// `reopen_key` is the on-disk package (or extracted nested APK) to open again
/// when entries are restored. A later resource budget must charge this archive's
/// when entries are restored. The resource budget charges this archive's
/// physical entry count on the first inventory pass only; reopening it for
/// entry extraction is not a second package.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -333,10 +343,10 @@ pub(crate) struct PackageInventory {
/// Manifest-first application identity for one ZIP package.
///
/// Reads only the root `AndroidManifest.xml` entry, and only the leading window
/// needed for the plain-XML `package` and version attributes. A missing or
/// binary manifest does not fall back to the ZIP file name; the caller isolates
/// that package. `package_set` is the bundle or directory that owns this set of
/// Reads only a bounded root `AndroidManifest.xml` entry, supporting plain and
/// compiled XML. A missing, truncated, or unrecognized manifest isolates the
/// package instead of guessing from the ZIP filename. `package_set` is the
/// bundle or directory that owns this set of
/// splits — the same package name outside that boundary is a different app.
///
/// The returned [`InventoriedArchive`] is the reopen handle. Callers restore
@@ -347,6 +357,7 @@ pub(crate) fn inventory_manifest_package<R: std::io::Read + std::io::Seek>(
reopen_key: &Path,
package_set: &Path,
isolate_as: &str,
budget: &mut super::archive_limits::ArchiveBudget,
) -> Result<PackageInventory> {
let entry_count = archive.len();
let mut manifest_index = None;
@@ -379,20 +390,13 @@ pub(crate) fn inventory_manifest_package<R: std::io::Read + std::io::Seek>(
};
let mut entry = archive.by_index(index)?;
// The package and version attributes are in the root `<manifest` start tag.
// Bound the read. A later shared ZIP budget should charge this probe; it
// Bound and charge this probe against the shared extraction budget. It
// never opens assets or other entries. A manifest larger than the bound is
// truncated and produces no identity rather than a partial guess.
const MANIFEST_PROBE: usize = 4 * 1024 * 1024;
let mut buffer = vec![0_u8; MANIFEST_PROBE];
let mut filled = 0_usize;
while filled < buffer.len() {
match std::io::Read::read(&mut entry, &mut buffer[filled..])? {
0 => break,
read => filled += read,
}
}
buffer.truncate(filled);
let truncated = entry.size() > MANIFEST_PROBE as u64;
let size = entry.size();
let buffer = budget.read_probe(&mut entry, size, MANIFEST_PROBE, "AndroidManifest.xml")?;
let truncated = size > MANIFEST_PROBE as u64;
let named = super::manifest::identity(&buffer, truncated);
Ok(PackageInventory {
key: match &named {
@@ -503,10 +507,11 @@ pub(crate) trait LibraryRestorer {
}
/// Production restorer: stage-2 extract, ELF rebuild, embedded metadata unwrap.
pub(crate) struct EngineRestorer;
#[derive(Default)]
pub(crate) struct EngineRestorer(pub(super) senbei_engine::android::ResourceLimits);
impl LibraryRestorer for EngineRestorer {
fn restore(&mut self, input: &Path, dest: &Path, verbose: bool) -> Result<RestoredLibrary> {
crate::android::restore_so_with_engine(input, dest, verbose)
crate::android::restore_so_with_engine_limits(input, dest, verbose, &self.0)
}
}
+159 -55
View File
@@ -15,9 +15,12 @@
//! All functions in this module are native filesystem orchestration; the web
//! app (wasm) never touches them.
#[cfg(test)]
mod resource_tests;
#[cfg(test)]
mod tests;
mod archive_limits;
mod context;
mod manifest;
@@ -31,8 +34,8 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail, ensure};
use memmap2::{Mmap, MmapOptions};
use senbei_crypto::hex_digest;
use senbei_engine::android::{ExtractOptions, extract_stage2, is_protected_libil2cpp};
use senbei_engine::android::{RestoreOptions, restore_libil2cpp};
use senbei_engine::android::{ExtractOptions, extract_stage2_with_limits, is_protected_libil2cpp};
use senbei_engine::android::{ResourceLimits, RestoreOptions, restore_libil2cpp_with_limits};
use sha2::{Digest, Sha256};
use zip::ZipArchive;
@@ -94,6 +97,18 @@ pub fn is_protected_so_file(path: &Path) -> bool {
is_elf64_aarch64(&bytes) && is_protected_libil2cpp(&bytes)
}
pub(crate) fn probe_so_file_with_limits(
path: &Path,
limits: &senbei_engine::android::ResourceLimits,
) -> Result<bool> {
let file = File::open(path).with_context(|| format!("open `{}`", path.display()))?;
let bytes = map_read_only(&file, path)?;
if !is_elf64_aarch64(&bytes) {
return Ok(false);
}
senbei_engine::android::probe_libil2cpp_with_limits(&bytes, limits).map_err(Into::into)
}
pub fn file_content_identity(path: &Path) -> std::io::Result<String> {
let file = File::open(path)?;
// SAFETY: the file remains open for the mapping lifetime and the mapping
@@ -113,17 +128,18 @@ pub fn file_content_identity(path: &Path) -> std::io::Result<String> {
/// that restore more than one application must keep those modules apart; this
/// single-file entry point has only one application, so it keeps the module
/// only long enough to unwrap embedded metadata (which does not consume it).
pub(crate) fn restore_so_with_engine(
pub(crate) fn restore_so_with_engine_limits(
input: &Path,
dest: &Path,
verbose: bool,
limits: &ResourceLimits,
) -> Result<context::RestoredLibrary> {
let temporary = tempfile::tempdir().context("create stage-2 workspace")?;
let stage2_dir = temporary.path().join("stage2");
let extraction = extract_stage2(&ExtractOptions::with_defaults(
input.to_path_buf(),
stage2_dir.clone(),
))
let extraction = extract_stage2_with_limits(
&ExtractOptions::with_defaults(input.to_path_buf(), stage2_dir.clone()),
limits,
)
.context("extract stage-1/stage-2 payload")?;
// Every module 0x0C is collected. Two distinct images in one library are a
// conflict for that library's application, not a last-writer-wins update.
@@ -152,7 +168,8 @@ pub(crate) fn restore_so_with_engine(
.pop()
.map(|(image, bytes)| (Some(image), Some(bytes)))
.unwrap_or((None, None));
restore_libil2cpp(&RestoreOptions {
restore_libil2cpp_with_limits(
&RestoreOptions {
input: input.to_path_buf(),
output: dest.to_path_buf(),
index: stage2_dir.join("index.json"),
@@ -160,7 +177,9 @@ pub(crate) fn restore_so_with_engine(
outer_only: false,
preserve_entrypoint: false,
verbose,
})
},
limits,
)
.context("restore protected library")?;
let restored_file =
File::open(dest).with_context(|| format!("read restored `{}`", dest.display()))?;
@@ -173,17 +192,32 @@ pub(crate) fn restore_so_with_engine(
}
pub fn restore_so_file(input: &Path, dest: &Path, verbose: bool) -> Result<Option<Vec<u8>>> {
Ok(context::EngineRestorer
restore_so_file_with_limits(input, dest, verbose, &ResourceLimits::default())
}
/// Restore a loose Android library with explicit resource ceilings.
pub fn restore_so_file_with_limits(
input: &Path,
dest: &Path,
verbose: bool,
limits: &ResourceLimits,
) -> Result<Option<Vec<u8>>> {
Ok(context::EngineRestorer(*limits)
.restore(input, dest, verbose)?
.embedded_metadata)
}
/// Fresh run-scoped cache. Library hits reuse a restored image; metadata hits
/// Fresh run-scoped cache. Library hits reuse their module context; metadata hits
/// are keyed by encrypted bytes and the module that recovered them.
#[cfg(test)]
pub(crate) fn run_cache() -> context::AndroidRunCache {
context::AndroidRunCache::default()
}
pub(crate) fn run_cache_with_limits(limits: &ResourceLimits) -> context::AndroidRunCache {
context::AndroidRunCache::with_limits(limits)
}
/// Assign loose libraries and metadata to application contexts.
pub(crate) fn loose_scopes(
libraries: &[PathBuf],
@@ -210,6 +244,7 @@ pub(crate) fn restore_loose_library(
verbose: bool,
cache: &mut context::AndroidRunCache,
) -> Result<LooseLibraryOutcome> {
let limits = cache.limits;
restore_loose_library_with(
input,
dest,
@@ -217,7 +252,7 @@ pub(crate) fn restore_loose_library(
app,
verbose,
cache,
&mut context::EngineRestorer,
&mut context::EngineRestorer(limits),
)
}
@@ -243,8 +278,13 @@ fn restore_loose_library_with<R: context::LibraryRestorer>(
// A prior content-only success still needs context extraction. Restore it
// in a private workspace, never over a destination that must stay untouched.
let prior = cache.is_prior_success(&identity);
let workspace = prior.then(tempfile::tempdir).transpose().context("create duplicate context workspace")?;
let scratch = workspace.as_ref().map(|directory| directory.path().join("restored.so"));
let workspace = prior
.then(tempfile::tempdir)
.transpose()
.context("create duplicate context workspace")?;
let scratch = workspace
.as_ref()
.map(|directory| directory.path().join("restored.so"));
let restore_dest = scratch.as_deref().unwrap_or(dest);
match restorer.restore(input, restore_dest, verbose) {
Ok(restored) => {
@@ -268,7 +308,11 @@ fn restore_loose_library_with<R: context::LibraryRestorer>(
record_module(cache, app, &cached, label);
cache.insert_library(identity, cached);
Ok(LooseLibraryOutcome {
status: if prior { EntryStatus::Duplicate } else { EntryStatus::Restored },
status: if prior {
EntryStatus::Duplicate
} else {
EntryStatus::Restored
},
embedded,
})
}
@@ -323,7 +367,9 @@ pub(crate) fn restore_loose_metadata(
restore_metadata_bytes_with_module(&data, module).with_context(|| label.to_owned())?;
if report.remapped > 0 {
write_metadata_blob(dest, &out)?;
if verbose { println!("Write to {}", dest.display()); }
if verbose {
println!("Write to {}", dest.display());
}
}
Ok(LooseMetadataOutcome {
version: report.version,
@@ -336,7 +382,15 @@ pub(crate) fn restore_loose_metadata(
/// the same manifest package name plus version. Bundles are never grouped
/// with anything. Order inside a group does not matter; the caller restores
/// every library in the group before any of its metadata.
#[cfg(test)]
pub(crate) fn loose_apk_groups(packages: &[PathBuf]) -> Vec<Vec<PathBuf>> {
loose_apk_groups_cached(packages, &mut context::AndroidRunCache::default())
}
pub(crate) fn loose_apk_groups_cached(
packages: &[PathBuf],
cache: &mut context::AndroidRunCache,
) -> Vec<Vec<PathBuf>> {
let mut groups: Vec<(context::AppKey, Vec<PathBuf>)> = Vec::new();
let mut alone = Vec::new();
for package in packages {
@@ -346,7 +400,7 @@ pub(crate) fn loose_apk_groups(packages: &[PathBuf]) -> Vec<Vec<PathBuf>> {
alone.push(vec![package.clone()]);
continue;
}
let key = apk_manifest_key(package);
let key = apk_manifest_key(package, cache);
if let Some(key) = key {
if let Some((_, members)) = groups.iter_mut().find(|(existing, _)| existing == &key) {
members.push(package.clone());
@@ -362,9 +416,11 @@ pub(crate) fn loose_apk_groups(packages: &[PathBuf]) -> Vec<Vec<PathBuf>> {
grouped
}
fn apk_manifest_key(package: &Path) -> Option<context::AppKey> {
let file = std::fs::File::open(package).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
fn apk_manifest_key(
package: &Path,
cache: &mut context::AndroidRunCache,
) -> Option<context::AppKey> {
let mut archive = cache.archive.open_archive(package).ok()?;
let parent = package
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
@@ -374,6 +430,7 @@ fn apk_manifest_key(package: &Path) -> Option<context::AppKey> {
package,
parent,
&package.display().to_string(),
&mut cache.archive,
)
.ok()?;
match inventory.key {
@@ -392,13 +449,14 @@ pub(crate) fn restore_package_cached(
cache: &mut context::AndroidRunCache,
verbose: bool,
) -> Result<Vec<EntryOutcome>> {
let limits = cache.limits;
restore_package_with_restorer(
package,
rel,
out_root,
cache,
verbose,
&mut context::EngineRestorer,
&mut context::EngineRestorer(limits),
)
}
@@ -412,6 +470,7 @@ pub(crate) fn restore_apk_group(
cache: &mut context::AndroidRunCache,
verbose: bool,
) -> Result<Vec<EntryOutcome>> {
let limits = cache.limits;
let mut outcomes = Vec::new();
for package in packages {
let rel = package.strip_prefix(root).unwrap_or(package);
@@ -421,7 +480,7 @@ pub(crate) fn restore_apk_group(
out_root,
cache,
verbose,
&mut context::EngineRestorer,
&mut context::EngineRestorer(limits),
PackagePass::Libraries,
)?);
}
@@ -433,7 +492,7 @@ pub(crate) fn restore_apk_group(
out_root,
cache,
verbose,
&mut context::EngineRestorer,
&mut context::EngineRestorer(limits),
PackagePass::Metadata,
)?);
}
@@ -591,6 +650,26 @@ pub fn restore_package(
seen: &mut HashSet<String>,
verbose: bool,
) -> Result<Vec<EntryOutcome>> {
restore_package_with_limits(
package,
rel,
out_root,
seen,
verbose,
&ResourceLimits::default(),
)
}
/// Restore one Android package with explicit archive and decoder ceilings.
pub fn restore_package_with_limits(
package: &Path,
rel: &Path,
out_root: &Path,
seen: &mut HashSet<String>,
verbose: bool,
limits: &ResourceLimits,
) -> Result<Vec<EntryOutcome>> {
let limits = *limits;
// `seen` is the public successful-content set shared with loose libraries.
// Metadata recovery context does not fit in that set, so this wrapper keeps
// a run-scoped cache and only publishes content identities of entries that
@@ -598,7 +677,7 @@ pub fn restore_package(
// package still skips those bytes; module context for metadata is rebuilt
// inside this package (one package, or one manifest-scoped bundle, is one
// application boundary).
let mut cache = context::AndroidRunCache::default();
let mut cache = context::AndroidRunCache::with_limits(&limits);
for identity in seen.iter().cloned() {
cache.insert_prior_success(identity);
}
@@ -608,7 +687,7 @@ pub fn restore_package(
out_root,
&mut cache,
verbose,
&mut context::EngineRestorer,
&mut context::EngineRestorer(limits),
);
// Publish successes even when the package returns `Err`. The caller's set
// is only replaced after that, so a panic still leaves the old successes.
@@ -652,7 +731,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
.is_some_and(|value| {
value.eq_ignore_ascii_case("apks") || value.eq_ignore_ascii_case("xapk")
});
let mut archive = open_package(package)?;
let mut archive = cache.archive.open_archive(package)?;
let temporary = tempfile::tempdir().context("create package workspace")?;
let mut outcomes = Vec::new();
let mut state = PackageRestoreState {
@@ -687,7 +766,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
}
}
// One direct package is one application inside its own package set (the
// package file). A plain manifest names it by package plus version; without
// package file). Its manifest names it by package plus version; without
// both, the package is isolated. The ZIP file name is not a package id.
// Inventory already walked this central directory; entry extraction below
// reuses the same open and must not be charged as a second package.
@@ -702,6 +781,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new(".")),
&package.display().to_string(),
&mut state.cache.archive,
)?)
};
direct.retain(|(_, name)| match pass {
@@ -733,21 +813,27 @@ fn restore_package_pass<R: context::LibraryRestorer>(
base: PathBuf,
app: context::AppKey,
/// Manifest inventory, including the reopen key. Extraction opens
/// `path` again; `inventory.archive` is the pass a later budget must
/// not charge a second time.
/// `path` again; `inventory.archive` is not charged a second time.
inventory: context::PackageInventory,
entries: Vec<(usize, PathBuf)>,
}
let mut nested_packages = Vec::with_capacity(nested.len());
for (index, name) in nested {
state.cache.limits.check_depth(1)?;
let nested_label = rel.join(&name);
let nested_path = extract_entry(&mut archive, index, &temporary, &nested_label)
let nested_path = extract_entry(
&mut archive,
index,
&temporary,
&nested_label,
&mut state.cache.archive,
)
.with_context(|| format!("extract `{}`", nested_label.display()))?;
let mut nested_archive = open_package(&nested_path)?;
let mut nested_archive = state.cache.archive.open_archive(&nested_path)?;
// Manifest first, before any library or metadata entry is chosen. Splits
// that name the same package and version, inside this bundle only, share
// one context. A split with no plain package name or no version is
// one context. A split with no recognized package name or version is
// isolated and cannot borrow a sibling's module. `nested_path` is the
// reopen key: the central directory listed here is not charged again
// when entries are extracted below.
@@ -756,6 +842,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
&nested_path,
package,
&nested_path.display().to_string(),
&mut state.cache.archive,
)?;
let mut entries = Vec::new();
for nested_index in 0..nested_archive.len() {
@@ -805,7 +892,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
nested_package.inventory.archive.reopen_key,
nested_package.path
);
let mut nested_archive = open_package(&nested_package.path)?;
let mut nested_archive = state.cache.archive.open_archive(&nested_package.path)?;
for (nested_index, entry_name) in nested_package
.entries
.iter()
@@ -853,14 +940,24 @@ fn restore_package_entry<R: Read + Seek, T: context::LibraryRestorer>(
app: &context::AppKey,
state: &mut PackageRestoreState<'_, T>,
) -> Result<Vec<EntryOutcome>> {
let entry_path = extract_entry(archive, index, temporary, Path::new(label))?;
let entry_path = extract_entry(
archive,
index,
temporary,
Path::new(label),
&mut state.cache.archive,
)?;
let entry_file =
File::open(&entry_path).with_context(|| format!("open extracted `{label}`"))?;
let entry_data = map_read_only(&entry_file, &entry_path)?;
let protected_override = state.restorer.protected_override(&entry_path);
let is_so = protected_override.is_some()
|| (is_elf64_aarch64(&entry_data) && is_protected_libil2cpp(&entry_data));
|| (is_elf64_aarch64(&entry_data)
&& senbei_engine::android::probe_libil2cpp_with_limits(
&entry_data,
&state.cache.limits,
)?);
let is_meta = !is_so && senbei_metadata::is_metadata(&entry_data);
let outcome = |kind, status, provenance: Vec<String>| EntryOutcome {
label: label.to_owned(),
@@ -894,12 +991,19 @@ fn restore_package_entry<R: Read + Seek, T: context::LibraryRestorer>(
drop(entry_data);
drop(entry_file);
let prior = state.cache.is_prior_success(&identity);
let workspace = prior.then(|| tempfile::tempdir_in(temporary.path())).transpose()
let workspace = prior
.then(|| tempfile::tempdir_in(temporary.path()))
.transpose()
.context("create duplicate context workspace")?;
let scratch = workspace.as_ref().map(|directory| directory.path().join("restored.so"));
let scratch = workspace
.as_ref()
.map(|directory| directory.path().join("restored.so"));
let restore_dest = scratch.as_deref().unwrap_or(dest);
return Ok(
match state.restorer.restore(&entry_path, restore_dest, state.verbose) {
match state
.restorer
.restore(&entry_path, restore_dest, state.verbose)
{
Ok(restored) => {
// A prior content-only success is restored only in the
// private workspace to recover its module context.
@@ -1104,11 +1208,6 @@ fn write_metadata_blob(dest: &Path, data: &[u8]) -> Result<()> {
.context("write metadata output")
}
fn open_package(path: &Path) -> Result<ZipArchive<std::fs::File>> {
let file = std::fs::File::open(path).with_context(|| format!("open `{}`", path.display()))?;
ZipArchive::new(file).with_context(|| format!("read package `{}`", path.display()))
}
/// Stream one package entry to a temporary, seekable file. The Android engine
/// needs random access to ELF section tables, while the ZIP reader itself is
/// consumed directly without creating an in-memory compressed or decompressed
@@ -1118,23 +1217,28 @@ fn extract_entry<R: Read + Seek>(
index: usize,
temporary: &tempfile::TempDir,
label: &Path,
budget: &mut archive_limits::ArchiveBudget,
) -> Result<PathBuf> {
let mut entry = archive.by_index(index)?;
let key = format!("{}-{index:08x}", label.display());
// `:` appears in `package::entry` labels and is invalid in Windows file
// names; sanitize every path-ish separator.
let destination = temporary.path().join(key.replace(['\\', '/', ':'], "_"));
let output_size = entry.size();
let mut output = BufWriter::new(std::fs::File::create(&destination)?);
let written = std::io::copy(&mut entry, &mut output)?;
let mut file = tempfile::Builder::new()
.prefix("entry-")
.tempfile_in(temporary.path())?;
{
let mut output = BufWriter::new(file.as_file_mut());
budget.copy_entry(
&mut entry,
&mut output,
output_size,
&label.display().to_string(),
)?;
output.flush()?;
if written != output_size {
bail!(
"entry `{key}` decompressed to 0x{:x}, expected 0x{output_size:x}",
written
);
}
Ok(destination)
// A failed copy drops the unique temporary immediately. Successful entries
// remain seekable until the enclosing package workspace is dropped.
file.into_temp_path()
.keep()
.context("retain extracted package entry")
}
fn map_read_only(file: &File, path: &Path) -> Result<Mmap> {
+131
View File
@@ -0,0 +1,131 @@
use super::*;
use std::io::Cursor;
use zip::write::SimpleFileOptions;
fn package(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in entries {
writer
.start_file(
*name,
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated),
)
.unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn compressed_entry_limit_leaves_no_restored_output() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("app.apk");
std::fs::write(&input, package(&[("lib/test.so", &vec![0; 4096])])).unwrap();
let limits = ResourceLimits {
max_entry_bytes: 1024,
..ResourceLimits::default()
};
let out = temp.path().join("out");
let error = restore_package_with_limits(
&input,
Path::new("app.apk"),
&out,
&mut HashSet::new(),
false,
&limits,
)
.unwrap_err();
assert!(format!("{error:#}").contains("extraction limit"));
assert!(!out.exists());
}
#[test]
fn archive_bytes_are_cumulative_across_packages_in_a_folder_run() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first.apk");
let second = temp.path().join("second.apk");
let bytes = package(&[("lib/test.so", &vec![0; 400])]);
std::fs::write(&first, &bytes).unwrap();
std::fs::write(&second, &bytes).unwrap();
let limits = ResourceLimits {
max_entry_bytes: 1024,
max_total_bytes: 600,
..ResourceLimits::default()
};
let mut cache = run_cache_with_limits(&limits);
let out = temp.path().join("out");
let first =
restore_package_cached(&first, Path::new("first.apk"), &out, &mut cache, false).unwrap();
assert!(matches!(first[0].status, EntryStatus::NotTarget));
let error = restore_package_cached(&second, Path::new("second.apk"), &out, &mut cache, false)
.unwrap_err();
assert!(format!("{error:#}").contains("extraction limit"));
}
#[test]
fn nested_entries_share_the_outer_archive_count_budget() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("bundle.apks");
let nested = package(&[("lib/test.so", b"not an ELF")]);
std::fs::write(&input, package(&[("base.apk", &nested)])).unwrap();
let limits = ResourceLimits {
max_entries: 1,
..ResourceLimits::default()
};
let error = restore_package_with_limits(
&input,
Path::new("bundle.apks"),
&temp.path().join("out"),
&mut HashSet::new(),
false,
&limits,
)
.unwrap_err();
assert!(format!("{error:#}").contains("entry limit"), "{error:#}");
}
#[test]
fn zero_depth_refuses_nested_apks_before_extracting_them() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("bundle.apks");
std::fs::write(&input, package(&[("base.apk", b"not even a ZIP")])).unwrap();
let limits = ResourceLimits {
max_depth: 0,
..ResourceLimits::default()
};
let error = restore_package_with_limits(
&input,
Path::new("bundle.apks"),
&temp.path().join("out"),
&mut HashSet::new(),
false,
&limits,
)
.unwrap_err();
assert!(
format!("{error:#}").contains("nesting depth 1"),
"{error:#}"
);
}
#[test]
fn failed_entry_extraction_removes_its_partial_temporary() {
let bytes = package(&[("lib/test.so", &vec![0; 4096])]);
let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap();
let workspace = tempfile::tempdir().unwrap();
let mut budget = archive_limits::ArchiveBudget::from_limits(&ResourceLimits {
max_entry_bytes: 1024,
..ResourceLimits::default()
});
assert!(
extract_entry(
&mut archive,
0,
&workspace,
Path::new("app.apk::lib/test.so"),
&mut budget
)
.is_err()
);
assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 0);
}
+54 -7
View File
@@ -95,6 +95,27 @@ pub fn run_folder_opts(
verbose: bool,
no_log: bool,
scan_all: bool,
) -> anyhow::Result<Summary> {
run_folder_with_limits(
root,
out_dir,
quiet,
verbose,
no_log,
scan_all,
&senbei_engine::android::ResourceLimits::default(),
)
}
/// Folder mode with explicit Android archive and decoder ceilings.
pub fn run_folder_with_limits(
root: &Path,
out_dir: Option<&Path>,
quiet: u8,
verbose: bool,
no_log: bool,
scan_all: bool,
limits: &senbei_engine::android::ResourceLimits,
) -> anyhow::Result<Summary> {
let t0 = std::time::Instant::now();
let out_root = out_dir
@@ -119,7 +140,7 @@ pub fn run_folder_opts(
// [`crate::scan::find_targets_opts`]). Files the free directory metadata
// already rules out are never opened — on asset-heavy trees the per-file
// open+read latency, not the traversal, is the whole cost.
let scan = crate::scan::find_targets_opts(root, scan_all);
let scan = crate::scan::find_targets_with_limits(root, scan_all, limits);
let candidates = scan.crackproof.as_slice();
let metas = scan.metadata.as_slice();
let scan_stats = &scan.stats;
@@ -218,7 +239,7 @@ pub fn run_folder_opts(
// Module 0x0C stays inside the application that produced it: an extracted
// `lib/<abi>/` tree shares with that tree's `assets/` metadata only, and a
// package shares only with splits of the same package name and version.
let mut android_cache = crate::android::run_cache();
let mut android_cache = crate::android::run_cache_with_limits(limits);
let loose = crate::android::loose_scopes(&scan.android_so, metas);
for scoped in loose.iter().filter(|scoped| {
scan.android_so
@@ -311,7 +332,8 @@ pub fn run_folder_opts(
// set. Their libraries run before their metadata because each package
// restores its own libraries first and the shared cache collects modules
// across the group. Bundles stay one package each.
let package_groups = crate::android::loose_apk_groups(&scan.android_packages);
let package_groups =
crate::android::loose_apk_groups_cached(&scan.android_packages, &mut android_cache);
for group in &package_groups {
if group.len() > 1 {
s.packages += group.len();
@@ -514,6 +536,25 @@ pub fn run_file_v(
quiet: u8,
verbose: bool,
no_log: bool,
) -> anyhow::Result<Summary> {
run_file_with_limits(
input,
out_dir,
quiet,
verbose,
no_log,
&senbei_engine::android::ResourceLimits::default(),
)
}
/// Single-file mode with explicit Android archive and decoder ceilings.
pub fn run_file_with_limits(
input: &Path,
out_dir: Option<&Path>,
quiet: u8,
verbose: bool,
no_log: bool,
limits: &senbei_engine::android::ResourceLimits,
) -> anyhow::Result<Summary> {
let t0 = std::time::Instant::now();
let out_root = out_dir
@@ -556,8 +597,8 @@ pub fn run_file_v(
// library probe needs the whole file (its payload section is found through
// the section-header table at the end), while a package is a container
// handled entry-by-entry. Anything else falls through to the PE pipeline.
let is_android_so =
crate::android::is_elf64_aarch64(&prefix) && crate::android::is_protected_so_file(input);
let is_android_so = crate::android::is_elf64_aarch64(&prefix)
&& crate::android::probe_so_file_with_limits(input, limits).unwrap_or(true);
let is_android_package = !is_android_so && crate::android::is_app_package(input, &prefix);
if is_meta {
@@ -603,7 +644,12 @@ pub fn run_file_v(
}
}
} else if is_android_so {
match crate::android::restore_so_file(input, &dest, verbose && quiet == 0) {
match crate::android::restore_so_file_with_limits(
input,
&dest,
verbose && quiet == 0,
limits,
) {
Ok(embedded) => {
s.unpacked = 1;
if let Some(log) = &log {
@@ -652,12 +698,13 @@ pub fn run_file_v(
s.packages = 1;
let rel = PathBuf::from(input.file_name().unwrap_or_default());
let mut seen = std::collections::HashSet::new();
match crate::android::restore_package(
match crate::android::restore_package_with_limits(
input,
&rel,
&out_root,
&mut seen,
verbose && quiet == 0,
limits,
) {
Ok(outcomes) => {
let bar = crate::ui::progress(0, true);
+19 -4
View File
@@ -111,6 +111,21 @@ pub struct ScanStats {
/// [`find_targets`], but with the pre-filter explicitly controlled. When
/// `scan_all` is true selected target names below [`MIN_SIZE`] are also probed.
pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
find_targets_with_limits(
root,
scan_all,
&senbei_engine::android::ResourceLimits::default(),
)
}
/// Like [`find_targets_opts`], with Android probe allocations bounded by the
/// same limits as restoration. An over-limit probe remains a restore candidate
/// so the job reports the limit error rather than silently skipping the file.
pub fn find_targets_with_limits(
root: &Path,
scan_all: bool,
limits: &senbei_engine::android::ResourceLimits,
) -> ScanResult {
// Phase 1: serial traversal collecting regular-file paths only. No file is
// opened here; `readdir` is fast relative to the content probe that follows,
// and `entry.metadata()` is served from the directory entry on Windows, so
@@ -177,7 +192,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
let workers = senbei_engine::thread_cap().clamp(1, n.max(1));
if workers <= 1 {
for (p, c) in paths.iter().zip(class.iter_mut()) {
*c = classify(p);
*c = classify(p, limits);
}
} else {
let chunk = n.div_ceil(workers);
@@ -185,7 +200,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
for (pc, cc) in paths.chunks(chunk).zip(class.chunks_mut(chunk)) {
scope.spawn(move || {
for (p, c) in pc.iter().zip(cc.iter_mut()) {
*c = classify(p);
*c = classify(p, limits);
}
});
}
@@ -239,7 +254,7 @@ pub fn scan_all_env() -> bool {
/// payload lives in a section found via the section-header table at the *end*
/// of the file, so an ELF64/AArch64 prefix triggers a full-file read. Only
/// selected `.so` images pay for it.
fn classify(path: &Path) -> Option<Class> {
fn classify(path: &Path, limits: &senbei_engine::android::ResourceLimits) -> Option<Class> {
let head = read_prefix(path, DETECT_PREFIX)?;
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if crate::android::is_package_name(path) && crate::android::is_app_package(path, &head) {
@@ -253,7 +268,7 @@ fn classify(path: &Path) -> Option<Class> {
}
if crate::android::is_so_name(path)
&& crate::android::is_elf64_aarch64(&head)
&& crate::android::is_protected_so_file(path)
&& crate::android::probe_so_file_with_limits(path, limits).unwrap_or(true)
{
return Class::AndroidSo;
}