From a380031567e08e1909daa0f8db3a91b999424b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B6=BE=E7=80=AC=E6=A1=83=E6=A1=83?= Date: Wed, 4 Nov 2026 13:33:38 +0800 Subject: [PATCH] fix(android): bound archive extraction and decoder resources --- senbei-cli/src/main.rs | 117 +++- senbei-cli/tests/resource_limits.rs | 107 ++++ senbei-crypto/src/android/mod.rs | 5 +- senbei-crypto/src/android/protector.rs | 369 ++++++++++- senbei-engine/src/android/extract/error.rs | 2 + senbei-engine/src/android/extract/mod.rs | 4 +- senbei-engine/src/android/extract/pipeline.rs | 302 ++++++++- senbei-engine/src/android/extract/probe.rs | 122 +++- senbei-engine/src/android/extract/stage1.rs | 16 +- senbei-engine/src/android/limits.rs | 273 +++++++++ senbei-engine/src/android/mod.rs | 13 +- senbei-engine/src/android/restore/error.rs | 2 + senbei-engine/src/android/restore/mod.rs | 4 +- senbei-engine/src/android/restore/pipeline.rs | 478 ++++++++++++++- senbei-engine/src/windows/mod.rs | 14 +- senbei-io/src/android/archive_limits.rs | 575 ++++++++++++++++++ senbei-io/src/android/context.rs | 45 +- senbei-io/src/android/mod.rs | 232 +++++-- senbei-io/src/android/resource_tests.rs | 131 ++++ senbei-io/src/job.rs | 61 +- senbei-io/src/scan.rs | 23 +- 21 files changed, 2714 insertions(+), 181 deletions(-) create mode 100644 senbei-cli/tests/resource_limits.rs create mode 100644 senbei-engine/src/android/limits.rs create mode 100644 senbei-io/src/android/archive_limits.rs create mode 100644 senbei-io/src/android/resource_tests.rs diff --git a/senbei-cli/src/main.rs b/senbei-cli/src/main.rs index cc8c7e4..201fd5e 100644 --- a/senbei-cli/src/main.rs +++ b/senbei-cli/src/main.rs @@ -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 [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--strict-integrity] [--no-log] [--no-pause] [-V|--version] [-h|--help]" + "senbei [--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()); + } +} diff --git a/senbei-cli/tests/resource_limits.rs b/senbei-cli/tests/resource_limits.rs new file mode 100644 index 0000000..4df357d --- /dev/null +++ b/senbei-cli/tests/resource_limits.rs @@ -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:?}" + ); +} diff --git a/senbei-crypto/src/android/mod.rs b/senbei-crypto/src/android/mod.rs index a8eef22..ae6ff4e 100644 --- a/senbei-crypto/src/android/mod.rs +++ b/senbei-crypto/src/android/mod.rs @@ -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, }; diff --git a/senbei-crypto/src/android/protector.rs b/senbei-crypto/src/android/protector.rs index 8e5fb0a..38541b8 100644 --- a/senbei-crypto/src/android/protector.rs +++ b/senbei-crypto/src/android/protector.rs @@ -14,6 +14,30 @@ pub enum Error { type Result = std::result::Result; +/// 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(message: impl Into) -> Result { 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> { - 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, + ) -> Result> { + 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> { - 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> { + 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> { + decode_container_inner(data, config, expected_size, Some(limit)) +} + +fn decode_container_inner( + data: &[u8], + config: &Module9bConfig, + expected_size: usize, + limit: Option, ) -> Result> { 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 { + 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 { + 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 { + 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; } diff --git a/senbei-engine/src/android/extract/error.rs b/senbei-engine/src/android/extract/error.rs index 990dc40..3617f17 100644 --- a/senbei-engine/src/android/extract/error.rs +++ b/senbei-engine/src/android/extract/error.rs @@ -44,6 +44,8 @@ pub enum Error { }, #[error("{0}")] Invalid(String), + #[error(transparent)] + Limit(#[from] crate::android::LimitExceeded), } impl Error { diff --git a/senbei-engine/src/android/extract/mod.rs b/senbei-engine/src/android/extract/mod.rs index 3fee210..b9489bf 100644 --- a/senbei-engine/src/android/extract/mod.rs +++ b/senbei-engine/src/android/extract/mod.rs @@ -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}; diff --git a/senbei-engine/src/android/extract/pipeline.rs b/senbei-engine/src/android/extract/pipeline.rs index 88ab493..4964720 100644 --- a/senbei-engine/src/android/extract/pipeline.rs +++ b/senbei-engine/src/android/extract/pipeline.rs @@ -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, @@ -70,6 +79,20 @@ struct Extractor { } pub fn extract_stage2(options: &ExtractOptions) -> Result { + 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 { 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 { &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 { 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, source_file_offset: Option, - 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,16 +255,24 @@ 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) - .map_err(|source| Error::RecordDecode { - depth, - stream_id, - record_index: record.index, - command_id: record.command_id, - part: "image decode", - source, - })?; + 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, + record_index: record.index, + command_id: record.command_id, + part: "image decode", + source, + })?; let classification = if record.metadata_size != 0 { "module_image" } else { @@ -243,17 +293,22 @@ 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) - .map_err(|source| Error::RecordDecode { - depth, - stream_id, - record_index: record.index, - command_id: record.command_id, - part: "metadata decode", - source, - })?; + 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, + record_index: record.index, + command_id: record.command_id, + part: "metadata decode", + source, + })?; let artifact = self.write_artifact( &record, depth, @@ -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 { 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 { + 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 { + // 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()); + } +} diff --git a/senbei-engine/src/android/extract/probe.rs b/senbei-engine/src/android/extract/probe.rs index b956978..55c581b 100644 --- a/senbei-engine/src/android/extract/probe.rs +++ b/senbei-engine/src/android/extract/probe.rs @@ -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 { + let stage1 = match stage1::inspect( data, Path::new(""), 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 { + 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(&[])); + } } diff --git a/senbei-engine/src/android/extract/stage1.rs b/senbei-engine/src/android/extract/stage1.rs index da53087..64d26cb 100644 --- a/senbei-engine/src/android/extract/stage1.rs +++ b/senbei-engine/src/android/extract/stage1.rs @@ -38,6 +38,7 @@ pub(crate) fn inspect( path: &Path, outer_size: usize, cipher_constant: u32, + max_payload_bytes: Option, ) -> Result { 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> 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()))?; diff --git a/senbei-engine/src/android/limits.rs b/senbei-engine/src/android/limits.rs new file mode 100644 index 0000000..0f00321 --- /dev/null +++ b/senbei-engine/src/android/limits.rs @@ -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 + }) + )); + } +} diff --git a/senbei-engine/src/android/mod.rs b/senbei-engine/src/android/mod.rs index c8aa08b..c3d893a 100644 --- a/senbei-engine/src/android/mod.rs +++ b/senbei-engine/src/android/mod.rs @@ -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}; diff --git a/senbei-engine/src/android/restore/error.rs b/senbei-engine/src/android/restore/error.rs index 2f193bc..68e9a1b 100644 --- a/senbei-engine/src/android/restore/error.rs +++ b/senbei-engine/src/android/restore/error.rs @@ -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 { diff --git a/senbei-engine/src/android/restore/mod.rs b/senbei-engine/src/android/restore/mod.rs index 1bc0bf4..0f27bd2 100644 --- a/senbei-engine/src/android/restore/mod.rs +++ b/senbei-engine/src/android/restore/mod.rs @@ -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, +}; diff --git a/senbei-engine/src/android/restore/pipeline.rs b/senbei-engine/src/android/restore/pipeline.rs index 2a1097a..bc5ad3c 100644 --- a/senbei-engine/src/android/restore/pipeline.rs +++ b/senbei-engine/src/android/restore/pipeline.rs @@ -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 { .map_err(|error| Error::io("map temporary output", path, error)) } -fn read_file(path: &Path) -> Result> { - 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> { + 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> { + let data = read_file_limited(path, limits, bytes)?; + Ok(data) +} + +fn read_file_limited( + path: &Path, + limits: &ResourceLimits, + bytes: &mut ByteBudget, +) -> Result> { + 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 { - 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 { + 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( +struct DecodeContext<'a, V> { + limits: &'a ResourceLimits, + bytes: &'a mut ByteBudget, + records: &'a mut crate::android::EntryBudget, + validate: V, +} + +fn decode_container( payload: &[u8], header: &ContainerHeader, config: &Module9bConfig, verbose: bool, + context: &mut DecodeContext<'_, V>, mut writer: F, ) -> Result where F: FnMut(u64, &[u8]) -> Result, + 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 { table_placements(tables, start).map(|(_, end)| end) } +struct MaterializeInput<'a> { + auxiliary_symbol_seed: Option, + 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, - 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 { + 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 { 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 { 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 { "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 { 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 { .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 { 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 { 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 { 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 { &layout, &symbol_patch_data, &decoded, - auxiliary_symbol_seed, - constructor_module - .as_ref() - .map(|(image, entry)| (image.as_slice(), *entry)), + MaterializeInput { + auxiliary_symbol_seed, + 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 { 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 { + 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 { let mut data = vec![0_u8; 0x279]; let words = [ diff --git a/senbei-engine/src/windows/mod.rs b/senbei-engine/src/windows/mod.rs index e8f4c6f..fb20c42 100644 --- a/senbei-engine/src/windows/mod.rs +++ b/senbei-engine/src/windows/mod.rs @@ -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, UnpackError>) -> Result, 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(), diff --git a/senbei-io/src/android/archive_limits.rs b/senbei-io/src/android/archive_limits.rs new file mode 100644 index 0000000..910e0a2 --- /dev/null +++ b/senbei-io/src/android/archive_limits.rs @@ -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, +} + +/// 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, + 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 { + 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 { + 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, +} + +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> { + 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> { + 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 { + 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 { + Ok(inspect_archive(file, max_entries, max_directory_bytes)?.entries) +} + +fn inspect_archive( + file: &mut File, + max_entries: usize, + max_directory_bytes: u64, +) -> Result { + 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 { + 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()); + } + } +} diff --git a/senbei-io/src/android/context.rs b/senbei-io/src/android/context.rs index 81d76bc..f416699 100644 --- a/senbei-io/src/android/context.rs +++ b/senbei-io/src/android/context.rs @@ -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, /// Successfully restored protected libraries, keyed by encrypted content. libraries: HashMap, @@ -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( reopen_key: &Path, package_set: &Path, isolate_as: &str, + budget: &mut super::archive_limits::ArchiveBudget, ) -> Result { let entry_count = archive.len(); let mut manifest_index = None; @@ -379,20 +390,13 @@ pub(crate) fn inventory_manifest_package( }; let mut entry = archive.by_index(index)?; // The package and version attributes are in the root ` 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 { - crate::android::restore_so_with_engine(input, dest, verbose) + crate::android::restore_so_with_engine_limits(input, dest, verbose, &self.0) } } diff --git a/senbei-io/src/android/mod.rs b/senbei-io/src/android/mod.rs index a101c04..00bac4a 100644 --- a/senbei-io/src/android/mod.rs +++ b/senbei-io/src/android/mod.rs @@ -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 { + 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 { 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 { /// 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 { 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,15 +168,18 @@ pub(crate) fn restore_so_with_engine( .pop() .map(|(image, bytes)| (Some(image), Some(bytes))) .unwrap_or((None, None)); - restore_libil2cpp(&RestoreOptions { - input: input.to_path_buf(), - output: dest.to_path_buf(), - index: stage2_dir.join("index.json"), - dump_auxiliary: None, - outer_only: false, - preserve_entrypoint: false, - verbose, - }) + restore_libil2cpp_with_limits( + &RestoreOptions { + input: input.to_path_buf(), + output: dest.to_path_buf(), + index: stage2_dir.join("index.json"), + dump_auxiliary: None, + 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>> { - 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>> { + 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 { + 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( // 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( 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> { + loose_apk_groups_cached(packages, &mut context::AndroidRunCache::default()) +} + +pub(crate) fn loose_apk_groups_cached( + packages: &[PathBuf], + cache: &mut context::AndroidRunCache, +) -> Vec> { let mut groups: Vec<(context::AppKey, Vec)> = Vec::new(); let mut alone = Vec::new(); for package in packages { @@ -346,7 +400,7 @@ pub(crate) fn loose_apk_groups(packages: &[PathBuf]) -> Vec> { 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> { grouped } -fn apk_manifest_key(package: &Path) -> Option { - 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 { + 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 { 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> { + 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> { + 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, verbose: bool, ) -> Result> { + 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, + verbose: bool, + limits: &ResourceLimits, +) -> Result> { + 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( .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( } } // 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( .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( 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) - .with_context(|| format!("extract `{}`", nested_label.display()))?; - let mut nested_archive = open_package(&nested_path)?; + 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 = 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( &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( 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( app: &context::AppKey, state: &mut PackageRestoreState<'_, T>, ) -> Result> { - 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| EntryOutcome { label: label.to_owned(), @@ -894,12 +991,19 @@ fn restore_package_entry( 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> { - 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( index: usize, temporary: &tempfile::TempDir, label: &Path, + budget: &mut archive_limits::ArchiveBudget, ) -> Result { 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)?; - output.flush()?; - if written != output_size { - bail!( - "entry `{key}` decompressed to 0x{:x}, expected 0x{output_size:x}", - written - ); + 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()?; } - 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 { diff --git a/senbei-io/src/android/resource_tests.rs b/senbei-io/src/android/resource_tests.rs new file mode 100644 index 0000000..8e9bdae --- /dev/null +++ b/senbei-io/src/android/resource_tests.rs @@ -0,0 +1,131 @@ +use super::*; +use std::io::Cursor; +use zip::write::SimpleFileOptions; + +fn package(entries: &[(&str, &[u8])]) -> Vec { + 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); +} diff --git a/senbei-io/src/job.rs b/senbei-io/src/job.rs index c9ea25c..5641bec 100644 --- a/senbei-io/src/job.rs +++ b/senbei-io/src/job.rs @@ -95,6 +95,27 @@ pub fn run_folder_opts( verbose: bool, no_log: bool, scan_all: bool, +) -> anyhow::Result { + 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 { 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//` 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 { + 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 { 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); diff --git a/senbei-io/src/scan.rs b/senbei-io/src/scan.rs index 7fbfaff..7a9993c 100644 --- a/senbei-io/src/scan.rs +++ b/senbei-io/src/scan.rs @@ -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 { +fn classify(path: &Path, limits: &senbei_engine::android::ResourceLimits) -> Option { 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 { } 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; }