From 131ced6db5917d08b06a65bf52c3929d133b16c9 Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 01:47:25 +0800 Subject: [PATCH] feat: add static stage extraction and metadata detection --- Cargo.lock | 75 +++- Cargo.toml | 3 + README.md | 27 +- senbei-android-cli/Cargo.toml | 2 + senbei-android-cli/src/main.rs | 110 +++++- senbei-android-crypto/src/lib.rs | 135 ++++++- senbei-android-elf/src/restore.rs | 122 ++++-- senbei-android-io/Cargo.toml | 1 + senbei-android-io/src/lib.rs | 38 ++ senbei-android-metadata/src/lib.rs | 238 ++++++++++++ senbei-android-stage2/Cargo.toml | 20 + senbei-android-stage2/src/error.rs | 63 ++++ senbei-android-stage2/src/extract.rs | 529 +++++++++++++++++++++++++++ senbei-android-stage2/src/lib.rs | 12 + senbei-android-stage2/src/report.rs | 115 ++++++ senbei-android-stage2/src/stage1.rs | 231 ++++++++++++ senbei-android-stage2/src/stream.rs | 168 +++++++++ 17 files changed, 1840 insertions(+), 49 deletions(-) create mode 100644 senbei-android-stage2/Cargo.toml create mode 100644 senbei-android-stage2/src/error.rs create mode 100644 senbei-android-stage2/src/extract.rs create mode 100644 senbei-android-stage2/src/lib.rs create mode 100644 senbei-android-stage2/src/report.rs create mode 100644 senbei-android-stage2/src/stage1.rs create mode 100644 senbei-android-stage2/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 5da0292..9df140a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,17 @@ dependencies = [ "r-efi", ] +[[package]] +name = "goblin" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17582616a7718cca54cec18e534a76c7c4aec11a8b9a85695712f262fd15a4c8" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "inout" version = "0.1.4" @@ -143,6 +154,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -164,6 +181,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -201,6 +224,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "senbei-android-cli" version = "0.1.0" @@ -208,6 +251,8 @@ dependencies = [ "anyhow", "senbei-android-io", "senbei-android-metadata", + "senbei-android-stage2", + "serde_json", ] [[package]] @@ -238,6 +283,7 @@ dependencies = [ "anyhow", "senbei-android-elf", "senbei-android-metadata", + "senbei-android-stage2", "serde", "serde_json", "sha2", @@ -252,6 +298,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "senbei-android-stage2" +version = "0.1.0" +dependencies = [ + "goblin", + "memmap2", + "senbei-android-crypto", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + [[package]] name = "serde" version = "1.0.229" @@ -279,7 +339,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -306,6 +366,17 @@ dependencies = [ "digest", ] +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -347,7 +418,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e544dd7..caf0c2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "senbei-android-elf", "senbei-android-io", "senbei-android-metadata", + "senbei-android-stage2", ] default-members = ["senbei-android-cli"] resolver = "2" @@ -18,6 +19,7 @@ license = "AGPL-3.0-only" [workspace.dependencies] aes = "0.8" anyhow = "1" +goblin = "0.10" memmap2 = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -29,6 +31,7 @@ senbei-android-crypto = { path = "senbei-android-crypto" } senbei-android-elf = { path = "senbei-android-elf" } senbei-android-io = { path = "senbei-android-io" } senbei-android-metadata = { path = "senbei-android-metadata" } +senbei-android-stage2 = { path = "senbei-android-stage2" } [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/README.md b/README.md index 87ed4f8..3e3e5f8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ target\release\senbei-android.exe ## 还原 libil2cpp.so +先直接从受保护 SO 静态提取 Stage 1/Stage 2 和模块索引: + +```powershell +senbei-android extract-stage2 INPUT OUTPUT_DIR +``` + +默认在 `OUTPUT_DIR` 写入紧凑的 `index.json` 及后续还原实际需要的模块产物; +需要保留完整 Stage 2 镜像用于分析时,额外传入 `--stage2-out FILE`。 + ```powershell senbei-android restore-so INPUT OUTPUT --index INDEX_JSON --report REPORT_JSON ``` @@ -74,7 +83,23 @@ senbei-android restore-metadata ` ``` 可用 `--seed 0xA6FAE968` 显式指定十六进制 seed,也支持十进制。还原操作是 -幂等的:已规范化的 image 会保持不变。 +幂等的,并严格先检测状态、再决定是否解密: + +- 先验证 metadata magic、版本、表边界、MethodDef token 类型与完整归属关系。 +- 所有 image 的 RID 已规范时报告 `encryption_status: "clean"`,不执行逆置换, + 输出与输入逐字节一致。 +- 存在非规范 RID 时,必须先确认它们构成合法置换,并让指定 seed 对所有 image + 完整通过五轮逆置换校验;只有此时才报告 `encryption_status: "encrypted"` 并写出结果。 +- seed 错误、算法变化或数据损坏会直接报错,不生成输出文件和报告。 + +不确定样本 seed 时可先执行只读诊断: + +```powershell +senbei-android discover-metadata INPUT +``` + +该命令不会修改文件,会列出每个 image 的状态、seed residue 以及满足当前 v31 +算法的 32 位 seed 候选。 ## Workspace diff --git a/senbei-android-cli/Cargo.toml b/senbei-android-cli/Cargo.toml index 3d6c2a2..b64f9a2 100644 --- a/senbei-android-cli/Cargo.toml +++ b/senbei-android-cli/Cargo.toml @@ -12,8 +12,10 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +serde_json.workspace = true senbei-android-io.workspace = true senbei-android-metadata.workspace = true +senbei-android-stage2.workspace = true [lints] workspace = true diff --git a/senbei-android-cli/src/main.rs b/senbei-android-cli/src/main.rs index c4203fc..93a4248 100644 --- a/senbei-android-cli/src/main.rs +++ b/senbei-android-cli/src/main.rs @@ -2,7 +2,10 @@ use std::ffi::OsString; use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use senbei_android_io::{RestoreMetadataJob, RestoreSoJob, run_restore_metadata, run_restore_so}; +use senbei_android_io::{ + ExtractStage2Job, RestoreMetadataJob, RestoreSoJob, run_extract_stage2, run_restore_metadata, + run_restore_so, +}; fn main() -> std::process::ExitCode { match run(std::env::args_os().skip(1)) { @@ -24,6 +27,8 @@ fn run(args: impl Iterator) -> Result<()> { match command.as_ref() { "restore-so" => restore_so(args.collect()), "restore-metadata" => restore_metadata(args.collect()), + "discover-metadata" => discover_metadata(args.collect()), + "extract-stage2" => extract_stage2(args.collect()), "-h" | "--help" => { print_help(); Ok(()) @@ -36,6 +41,87 @@ fn run(args: impl Iterator) -> Result<()> { } } +fn discover_metadata(args: Vec) -> Result<()> { + let mut positional = Vec::new(); + for value in args { + if value == "-h" || value == "--help" { + println!("senbei-android discover-metadata INPUT"); + return Ok(()); + } + if value.to_string_lossy().starts_with('-') { + bail!( + "unknown discover-metadata option `{}`", + value.to_string_lossy() + ); + } + positional.push(PathBuf::from(value)); + } + let [input] = positional.as_slice() else { + bail!("discover-metadata requires INPUT; use --help for usage"); + }; + let data = + std::fs::read(input).with_context(|| format!("read metadata `{}`", input.display()))?; + let report = senbei_android_metadata::discover_method_token_seeds(&data) + .with_context(|| format!("discover metadata seed `{}`", input.display()))?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn extract_stage2(args: Vec) -> Result<()> { + let mut positional = Vec::new(); + let mut stage2_output = None; + let mut outer_size = senbei_android_stage2::DEFAULT_OUTER_SIZE; + let mut cipher_constant = senbei_android_stage2::DEFAULT_CIPHER_CONSTANT; + let mut cursor = 0; + while cursor < args.len() { + match args[cursor].to_string_lossy().as_ref() { + "--stage2-out" => { + stage2_output = Some(option_path(&args, &mut cursor, "--stage2-out")?); + } + "--outer-size" => { + let value = option_string(&args, &mut cursor, "--outer-size")?; + outer_size = usize::try_from(parse_u64(&value)?) + .with_context(|| format!("invalid --outer-size `{value}`"))?; + } + "--cipher-constant" => { + let value = option_string(&args, &mut cursor, "--cipher-constant")?; + cipher_constant = parse_u32(&value) + .with_context(|| format!("invalid --cipher-constant `{value}`"))?; + } + "-h" | "--help" => { + print_extract_help(); + return Ok(()); + } + option if option.starts_with('-') => { + bail!("unknown extract-stage2 option `{option}"); + } + _ => positional.push(PathBuf::from(&args[cursor])), + } + cursor += 1; + } + let [input, output_dir] = positional.as_slice() else { + bail!("extract-stage2 requires INPUT and OUTPUT_DIR; use --help for usage"); + }; + let mut job = ExtractStage2Job::new(input.clone(), output_dir.clone()); + job.stage2_output = stage2_output; + job.outer_size = outer_size; + job.cipher_constant = cipher_constant; + let result = run_extract_stage2(&job)?; + let module_images = result + .module_registry + .iter() + .filter(|module| module.classification == "module_image") + .count(); + println!( + "Extracted {} streams, {} modules and {} compact artifacts", + result.streams.len(), + module_images, + result.artifacts.len() + ); + println!("Index {}", output_dir.join("index.json").display()); + Ok(()) +} + fn restore_so(args: Vec) -> Result<()> { let mut positional = Vec::new(); let mut index = None; @@ -112,8 +198,11 @@ fn restore_metadata(args: Vec) -> Result<()> { report, })?; println!( - "Restored {}/{} MethodDef tokens ({} already canonical)", - result.changed_tokens, result.methods, result.already_correct_before + "Metadata status={} restored {}/{} MethodDef tokens ({} already canonical)", + result.encryption_status, + result.changed_tokens, + result.methods, + result.already_correct_before ); Ok(()) } @@ -133,11 +222,15 @@ fn option_string(args: &[OsString], cursor: &mut usize, name: &str) -> Result Result { + Ok(u32::try_from(parse_u64(value)?)?) +} + +fn parse_u64(value: &str) -> Result { if let Some(hex) = value .strip_prefix("0x") .or_else(|| value.strip_prefix("0X")) { - Ok(u32::from_str_radix(hex, 16)?) + Ok(u64::from_str_radix(hex, 16)?) } else { Ok(value.parse()?) } @@ -148,9 +241,18 @@ fn print_help() { println!("Usage:"); println!(" senbei-android restore-so INPUT OUTPUT [OPTIONS]"); println!(" senbei-android restore-metadata INPUT OUTPUT [OPTIONS]"); + println!(" senbei-android discover-metadata INPUT"); + println!(" senbei-android extract-stage2 INPUT OUTPUT_DIR [OPTIONS]"); println!(" senbei-android --version"); } +fn print_extract_help() { + println!("senbei-android extract-stage2 INPUT OUTPUT_DIR [OPTIONS]"); + println!(" --stage2-out FILE Write the raw decrypted Stage 2 image"); + println!(" --outer-size VALUE Stage 1 outer wrapper size (default 0x23C)"); + println!(" --cipher-constant VALUE Stage 1 cipher constant (default 0xBF20165D)"); +} + fn print_so_help() { println!("senbei-android restore-so INPUT OUTPUT [OPTIONS]"); println!(" --index FILE Stage 2 module index.json"); diff --git a/senbei-android-crypto/src/lib.rs b/senbei-android-crypto/src/lib.rs index e752863..49fe6c9 100644 --- a/senbei-android-crypto/src/lib.rs +++ b/senbei-android-crypto/src/lib.rs @@ -103,6 +103,18 @@ pub struct Module9bConfig { impl Module9bConfig { /// Parse the unique AES-256 decryption schedule and adjacent configuration. pub fn parse(image: &[u8]) -> Result { + Self::parse_inner(image, true) + } + + /// Parse the decoder configuration embedded in the raw Stage 2 image. + /// + /// The embedded decoder ends before the interpreter-only `skip_aes` + /// field, so that flag is definitionally false for this layout. + pub fn parse_embedded(image: &[u8]) -> Result { + Self::parse_inner(image, false) + } + + fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result { const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; let mut matches = image .windows(MARKER.len()) @@ -117,7 +129,7 @@ impl Module9bConfig { let header_seed = read_u32(image, schedule_offset - 8)?; let schedule_size = read_u32(image, schedule_offset - 4)?; - if schedule_size != 0xf4 { + if !matches!(schedule_size, 0 | 0xf4) { return invalid(format!( "unexpected 0x9B AES schedule size 0x{schedule_size:x}" )); @@ -148,16 +160,24 @@ impl Module9bConfig { let container_seed_offset = schedule_offset .checked_add(0x100) .ok_or_else(|| Error::Invalid("container seed offset overflow".to_owned()))?; - let skip_aes_offset = schedule_offset - .checked_add(0x240) - .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; - let skip_aes = *image.get(skip_aes_offset).ok_or_else(|| { - Error::Invalid("0x9B static configuration exceeds its image".to_owned()) - })? != 0; + let skip_aes = if has_skip_aes { + let skip_aes_offset = schedule_offset + .checked_add(0x240) + .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; + *image.get(skip_aes_offset).ok_or_else(|| { + Error::Invalid("module static configuration exceeds its image".to_owned()) + })? != 0 + } else { + false + }; Ok(Self { header_seed, - container_seed: read_u32(image, container_seed_offset)?, + container_seed: if has_skip_aes { + read_u32(image, container_seed_offset)? + } else { + header_seed + }, aes_key, skip_aes, schedule_offset, @@ -572,6 +592,105 @@ pub fn transform_segment( Ok(transformed) } +/// 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. +pub fn decode_container( + data: &[u8], + config: &Module9bConfig, + expected_size: usize, +) -> Result> { + let header = ContainerHeader::parse(data, 0, config.container_seed)?; + let header_size = usize::try_from(header.output_size) + .map_err(|_| Error::Invalid("container output size exceeds usize".to_owned()))?; + if header_size != expected_size { + return invalid(format!( + "container output size 0x{header_size:x} != expected 0x{expected_size:x}" + )); + } + let decoder = HuffmanLzDecoder::new(&header.tree)?; + let decrypt_aes = !(config.skip_aes || header.skip_aes); + let mut output = vec![0_u8; expected_size]; + + for (segment_index, encoded) in header.segments.iter().enumerate() { + let start = header + .start + .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)?; + let transformed = transform_segment( + encoded_data, + config.container_seed, + &config.aes_key, + decrypt_aes, + )?; + if transformed.len() < 16 { + return invalid(format!( + "decoded segment {segment_index} is shorter than its header" + )); + } + let base_offset = read_u32(&transformed, 0)? as usize; + let writer_count = read_u32(&transformed, 4)? as usize; + let table_offset = read_u32(&transformed, 8)? as usize; + let data_offset = read_u32(&transformed, 12)? as usize; + let table_size = writer_count + .checked_mul(16) + .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?; + let table_end = table_offset + .checked_add(table_size) + .ok_or_else(|| Error::Invalid("writer table end overflow".to_owned()))?; + if table_end > transformed.len() || data_offset > transformed.len() { + return invalid(format!( + "decoded segment {segment_index} has invalid writer offsets" + )); + } + + let mut data_cursor = data_offset; + for writer_index in 0..writer_count { + let record = + table_offset + .checked_add(writer_index.checked_mul(16).ok_or_else(|| { + Error::Invalid("writer record offset overflow".to_owned()) + })?) + .ok_or_else(|| Error::Invalid("writer record offset overflow".to_owned()))?; + let output_offset = read_u32(&transformed, record)? as usize; + let output_size = read_u32(&transformed, record + 4)? as usize; + let encoded_size = read_u32(&transformed, record + 8)? as usize; + let reserved = read_u32(&transformed, record + 12)?; + let encoded_end = data_cursor + .checked_add(encoded_size) + .ok_or_else(|| Error::Invalid("writer data end overflow".to_owned()))?; + if reserved != 0 || encoded_end > transformed.len() { + return invalid(format!( + "segment {segment_index} writer {writer_index} has invalid bounds" + )); + } + 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); + 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()) + .ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?; + 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" + )) + })?; + destination.copy_from_slice(decoded); + data_cursor = encoded_end; + } + } + Ok(output) +} + #[cfg(test)] mod tests { use super::*; diff --git a/senbei-android-elf/src/restore.rs b/senbei-android-elf/src/restore.rs index 8aae28c..c88dbe9 100644 --- a/senbei-android-elf/src/restore.rs +++ b/senbei-android-elf/src/restore.rs @@ -437,12 +437,33 @@ impl AuxiliaryElfImage { let dynsym_end = u64::from(result.dynsym_offset) + u64::from(result.dynsym_count) * ELF64_SYMBOL_SIZE as u64; let dynstr_end = u64::from(result.dynstr_offset) + u64::from(result.dynstr_size); - if relocation1_end != u64::from(result.relocation2_offset) - || relocation2_end != u64::from(result.dynsym_offset) + let expected_relocation2 = align_up(relocation1_end, 0x10)?; + let expected_dynsym = align_up(relocation2_end, 0x10)?; + if expected_relocation2 != u64::from(result.relocation2_offset) + || expected_dynsym != u64::from(result.dynsym_offset) || dynsym_end != u64::from(result.dynstr_offset) || dynstr_end != data.len() as u64 { - return invalid("auxiliary ELF tables are not contiguous"); + return invalid(format!( + "auxiliary ELF layout mismatch: rela1_end=0x{relocation1_end:x}/rela2=0x{:x}, rela2_end=0x{relocation2_end:x}/dynsym=0x{:x}, dynsym_end=0x{dynsym_end:x}/dynstr=0x{:x}, dynstr_end=0x{dynstr_end:x}/size=0x{:x}", + result.relocation2_offset, + result.dynsym_offset, + result.dynstr_offset, + data.len() + )); + } + for (start, end) in [ + (relocation1_end, expected_relocation2), + (relocation2_end, expected_dynsym), + ] { + if slice_u64(data, start, end - start)? + .iter() + .any(|&byte| byte != 0) + { + return invalid(format!( + "auxiliary ELF alignment padding 0x{start:x}..0x{end:x} is nonzero" + )); + } } if result.dynsym_count < 2 { return invalid("auxiliary dynamic symbol table is empty"); @@ -537,9 +558,12 @@ fn restore_hidden_symbols( let target_offset = target_index as usize * ELF64_SYMBOL_SIZE; symbols[target_offset..target_offset + ELF64_SYMBOL_SIZE].copy_from_slice(source_symbol); } - if cursor != table_end { + let string_padding = patch_data.get(cursor..table_end).ok_or_else(|| { + Error::Invalid("0x9E symbol strings exceed the primary patch blob".to_owned()) + })?; + if string_padding.len() > 3 || string_padding.iter().any(|&byte| byte != 0) { return invalid(format!( - "0x9E symbol strings end at 0x{cursor:x}, expected 0x{table_end:x}" + "0x9E symbol strings have invalid padding at 0x{cursor:x}..0x{table_end:x}" )); } let first_target_index = patched_indices @@ -715,12 +739,11 @@ fn patch_dynamic_tags( } fn required_section_indices(names: &[String]) -> Result> { - const REQUIRED: [&str; 10] = [ + const REQUIRED: [&str; 9] = [ ".dynsym", ".gnu.version", ".gnu.version_r", ".gnu.hash", - ".hash", ".dynstr", ".rela.dyn", ".rela.plt", @@ -742,6 +765,18 @@ fn required_section_indices(names: &[String]) -> Result return invalid(format!("ELF contains duplicate section {required}")), } } + let sysv_hash = names + .iter() + .enumerate() + .filter_map(|(index, name)| (name == ".hash").then_some(index)) + .collect::>(); + match sysv_hash.as_slice() { + [index] => { + result.insert(".hash", *index); + } + [] => {} + _ => return invalid("ELF contains duplicate section .hash"), + } Ok(result) } @@ -811,9 +846,13 @@ fn materialize_static_elf_tables( merged_versions.extend_from_slice(&VER_NDX_GLOBAL.to_le_bytes()); } let merged_names = dynamic_symbol_names(&merged_symbols, &merged_strings)?; - let sysv_hash = build_sysv_hash(&merged_names)?; + let sysv_hash = indices + .contains_key(".hash") + .then(|| build_sysv_hash(&merged_names)) + .transpose()?; let gnu_hash_table = build_gnu_hash(&merged_names)?; let new_symbol_count = merged_names.len(); + let new_dynstr_size = merged_strings.len(); if rela_dyn.entry_size != ELF64_RELA_SIZE as u64 || rela_plt.entry_size != ELF64_RELA_SIZE as u64 @@ -887,7 +926,7 @@ fn materialize_static_elf_tables( alignment: u64, data: Vec, } - let tables = vec![ + let mut tables = vec![ TablePayload { name: ".dynsym", alignment: 8, @@ -908,11 +947,15 @@ fn materialize_static_elf_tables( alignment: 8, data: gnu_hash_table, }, - TablePayload { + ]; + if let Some(data) = sysv_hash { + tables.push(TablePayload { name: ".hash", alignment: 4, - data: sysv_hash, - }, + data, + }); + } + tables.extend([ TablePayload { name: ".dynstr", alignment: 1, @@ -928,7 +971,7 @@ fn materialize_static_elf_tables( alignment: 8, data: merged_rela_plt, }, - ]; + ]); let metadata_start = dynsym.offset; let mut cursor = metadata_start; let mut placements = BTreeMap::new(); @@ -981,24 +1024,23 @@ fn materialize_static_elf_tables( } let section_address = |name: &'static str| -> u64 { updated_sections[indices[name]].address }; - patch_dynamic_tags( - output, - dynamic, - &BTreeMap::from([ - (DT_PLTRELSZ, (rela_plt_count * ELF64_RELA_SIZE) as u64), - (DT_HASH, section_address(".hash")), - (DT_STRTAB, section_address(".dynstr")), - (DT_SYMTAB, section_address(".dynsym")), - (DT_RELA, section_address(".rela.dyn")), - (DT_RELASZ, (rela_dyn_count * ELF64_RELA_SIZE) as u64), - (DT_STRSZ, tables[5].data.len() as u64), - (DT_JMPREL, section_address(".rela.plt")), - (DT_GNU_HASH, section_address(".gnu.hash")), - (DT_VERSYM, section_address(".gnu.version")), - (DT_RELACOUNT, relative_count as u64), - (DT_VERNEED, section_address(".gnu.version_r")), - ]), - )?; + let mut dynamic_values = BTreeMap::from([ + (DT_PLTRELSZ, (rela_plt_count * ELF64_RELA_SIZE) as u64), + (DT_STRTAB, section_address(".dynstr")), + (DT_SYMTAB, section_address(".dynsym")), + (DT_RELA, section_address(".rela.dyn")), + (DT_RELASZ, (rela_dyn_count * ELF64_RELA_SIZE) as u64), + (DT_STRSZ, new_dynstr_size as u64), + (DT_JMPREL, section_address(".rela.plt")), + (DT_GNU_HASH, section_address(".gnu.hash")), + (DT_VERSYM, section_address(".gnu.version")), + (DT_RELACOUNT, relative_count as u64), + (DT_VERNEED, section_address(".gnu.version_r")), + ]); + if indices.contains_key(".hash") { + dynamic_values.insert(DT_HASH, section_address(".hash")); + } + patch_dynamic_tags(output, dynamic, &dynamic_values)?; let mut restored_layout = layout.clone(); restored_layout.section_headers = updated_sections; @@ -1012,7 +1054,7 @@ fn materialize_static_elf_tables( new_symbol_count, old_dynstr_size: old_strings.len(), auxiliary_dynstr_size: auxiliary.dynstr_size, - new_dynstr_size: tables[5].data.len(), + new_dynstr_size, rela_dyn_count, rela_plt_count, relative_prefix_count: relative_count, @@ -1247,8 +1289,20 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result { let payload = map_read_only(&payload_file, payload_path)?; let layout = ElfLayout::parse(&source, true)?; let private = layout.private_section()?; - if private.offset != layout.file_load_end()? { - return invalid("SHT_LOUSER does not begin at the file-backed PT_LOAD end"); + let file_load_end = layout.file_load_end()?; + let aligned_load_end = align_up(file_load_end, 0x10)?; + if private.offset != aligned_load_end { + return invalid(format!( + "SHT_LOUSER offset 0x{:x} != aligned file-backed PT_LOAD end 0x{aligned_load_end:x} (raw 0x{file_load_end:x})", + private.offset + )); + } + let load_padding = slice_u64(&source, file_load_end, private.offset - file_load_end)?; + if load_padding.iter().any(|&byte| byte != 0) { + return invalid(format!( + "nonzero padding between PT_LOAD end 0x{file_load_end:x} and SHT_LOUSER 0x{:x}", + private.offset + )); } let descriptor = ProtectedDescriptor::decrypt(&payload, config.header_seed)?; let load_end = layout.load_end()?; diff --git a/senbei-android-io/Cargo.toml b/senbei-android-io/Cargo.toml index 35aad71..a744e46 100644 --- a/senbei-android-io/Cargo.toml +++ b/senbei-android-io/Cargo.toml @@ -14,6 +14,7 @@ sha2.workspace = true tempfile.workspace = true senbei-android-elf.workspace = true senbei-android-metadata.workspace = true +senbei-android-stage2.workspace = true [lints] workspace = true diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs index 48fab34..f0997fb 100644 --- a/senbei-android-io/src/lib.rs +++ b/senbei-android-io/src/lib.rs @@ -6,6 +6,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; +use senbei_android_stage2::{ + DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, +}; use serde::Serialize; use tempfile::NamedTempFile; @@ -30,6 +33,29 @@ pub struct RestoreMetadataJob { pub report: Option, } +/// Filesystem arguments for pure-static Stage 1 and Stage 2 extraction. +#[derive(Debug, Clone)] +pub struct ExtractStage2Job { + pub input: PathBuf, + pub output_dir: PathBuf, + pub stage2_output: Option, + pub outer_size: usize, + pub cipher_constant: u32, +} + +impl ExtractStage2Job { + #[must_use] + pub fn new(input: PathBuf, output_dir: PathBuf) -> Self { + Self { + input, + output_dir, + stage2_output: None, + outer_size: DEFAULT_OUTER_SIZE, + cipher_constant: DEFAULT_CIPHER_CONSTANT, + } + } +} + impl RestoreMetadataJob { #[must_use] pub fn new(input: PathBuf, output: PathBuf) -> Self { @@ -87,6 +113,18 @@ pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result Ok(result) } +/// Extract Stage 2 modules directly from one protected ELF. +pub fn run_extract_stage2(job: &ExtractStage2Job) -> Result { + extract_stage2(&ExtractOptions { + input: job.input.clone(), + output_dir: job.output_dir.clone(), + stage2_output: job.stage2_output.clone(), + outer_size: job.outer_size, + cipher_constant: job.cipher_constant, + }) + .context("extract protected Stage 1/Stage 2 payload") +} + fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { let input_absolute = absolute(input)?; let output_absolute = absolute(output)?; diff --git a/senbei-android-metadata/src/lib.rs b/senbei-android-metadata/src/lib.rs index 3abe1ff..1a13817 100644 --- a/senbei-android-metadata/src/lib.rs +++ b/senbei-android-metadata/src/lib.rs @@ -25,6 +25,7 @@ const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; pub struct Report { pub version: u32, pub seed: String, + pub encryption_status: String, pub images: usize, pub images_with_methods: usize, pub types: usize, @@ -36,6 +37,26 @@ pub struct Report { pub transformed_images: usize, } +/// Per-image constraints recovered from the encrypted MethodDef RID +/// permutation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImageKeyDiscovery { + pub image: usize, + pub method_count: u32, + pub modulus: u32, + pub clean: bool, + pub seed_residues: Vec, +} + +/// Result of statically testing the known five-round permutation against a +/// metadata file without assuming a seed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SeedDiscoveryReport { + pub version: u32, + pub images: Vec, + pub seed_candidates: Vec, +} + /// Metadata parsing or validation failure. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum Error { @@ -318,6 +339,11 @@ pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report) Report { version, seed: format!("0x{seed:08X}"), + encryption_status: if changed_tokens == 0 { + "clean".to_owned() + } else { + "encrypted".to_owned() + }, images: image_count, images_with_methods, types: type_count, @@ -331,6 +357,199 @@ pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report) )) } +/// Discover seeds compatible with the known v31 five-round RID permutation. +/// +/// This is diagnostic and does not modify metadata. It enumerates the only +/// possible per-image key residues and intersects them over the 32-bit seed +/// domain. An empty candidate list means that the sample changed the +/// permutation itself rather than merely embedding a different seed. +pub fn discover_method_token_seeds(data: &[u8]) -> Result { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Ok(SeedDiscoveryReport { + version, + images: Vec::new(), + seed_candidates: Vec::new(), + }); + } + let (method_offset, method_size) = table(data, HDR_METHODS)?; + let (type_offset, type_size) = table(data, HDR_TYPES)?; + let (image_offset, image_size) = table(data, HDR_IMAGES)?; + if method_size % METHOD_STRIDE != 0 + || type_size % TYPE_STRIDE != 0 + || image_size % IMAGE_STRIDE != 0 + { + return malformed("v31 table size is not divisible by its entry stride"); + } + let method_count = method_size / METHOD_STRIDE; + let type_count = type_size / TYPE_STRIDE; + let image_count = image_size / IMAGE_STRIDE; + let mut reports = Vec::with_capacity(image_count); + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = usize::try_from(read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?) + .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; + let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; + let type_end = type_start + .checked_add(type_entries) + .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; + if type_end > type_count { + return malformed(format!("image {image_index} type range exceeds the table")); + } + let mut methods = Vec::new(); + for type_index in type_start..type_end { + let type_base = type_offset + type_index * TYPE_STRIDE; + let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; + if method_entries == 0 { + continue; + } + let method_start = + usize::try_from(read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?).map_err( + |_| Error::Malformed(format!("type {type_index} has negative methodStart")), + )?; + let method_end = method_start + .checked_add(method_entries) + .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; + if method_end > method_count { + return malformed(format!("type {type_index} method range exceeds the table")); + } + methods.extend(method_start..method_end); + } + if methods.is_empty() { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: 0, + modulus: 0, + clean: true, + seed_residues: Vec::new(), + }); + continue; + } + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("image method minimum is missing".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("image method maximum is missing".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return validation(format!( + "image {image_index} method block is not contiguous" + )); + } + let mut values = Vec::with_capacity(methods.len()); + let mut clean = true; + for method_index in methods { + let token = read_u32( + data, + method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + )?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return validation(format!( + "method {method_index} has non-MethodDef token 0x{token:08x}" + )); + } + let expected = u32::try_from(method_index - method_base + 1) + .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; + let rid = token & 0x00ff_ffff; + clean &= rid == expected; + values.push((rid, expected)); + } + let count = u32::try_from(values.len()) + .map_err(|_| Error::Validation("image method count exceeds u32".to_owned()))?; + if clean { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: count / 2, + clean, + seed_residues: Vec::new(), + }); + continue; + } + let low = values + .iter() + .map(|(rid, _)| *rid) + .min() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + let high = values + .iter() + .map(|(rid, _)| *rid) + .max() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + if high - low + 1 != count || count < 2 { + return validation(format!( + "image {image_index} RID interval is not a permutation" + )); + } + let half = count / 2; + let quarter = count / 4; + let mut residues = Vec::new(); + for key_delta in 0..half { + let key = quarter + key_delta; + let valid = values + .iter() + .all(|(rid, expected)| decrypt_rid_with_key(*rid, low, high, key) == *expected); + if valid { + residues.push(key_delta); + } + } + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: half, + clean, + seed_residues: residues, + }); + } + + let constraints = reports + .iter() + .filter(|report| !report.clean) + .collect::>(); + let mut seeds = Vec::new(); + if let Some(anchor) = constraints.iter().max_by_key(|report| report.modulus) { + for &residue in &anchor.seed_residues { + let mut candidate = u64::from(residue); + let modulus = u64::from(anchor.modulus); + while candidate <= u64::from(u32::MAX) { + let valid = constraints.iter().all(|report| { + report.modulus != 0 + && !report.seed_residues.is_empty() + && report + .seed_residues + .iter() + .any(|&value| candidate % u64::from(report.modulus) == u64::from(value)) + }); + if valid { + seeds.push(candidate as u32); + } + candidate = candidate.saturating_add(modulus); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + Ok(SeedDiscoveryReport { + version, + images: reports, + seed_candidates: seeds, + }) +} + +fn decrypt_rid_with_key(rid: u32, low: u32, high: u32, key: u32) -> u32 { + let count = high - low + 1; + let mut value = rid - low; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + low +} + #[cfg(test)] mod tests { use super::*; @@ -394,6 +613,8 @@ mod tests { let (data, methods) = build(&tokens); let (restored, report) = restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "encrypted"); + assert!(report.changed_tokens > 0); assert_eq!(report.correct_after, 7); for index in 0..7 { assert_eq!( @@ -415,7 +636,24 @@ mod tests { let (data, _) = build(&tokens); let (restored, report) = restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "clean"); assert_eq!(report.changed_tokens, 0); assert_eq!(restored, data); } + + #[test] + fn encrypted_metadata_rejects_the_wrong_seed() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, _) = build(&tokens); + let wrong_seed = DEFAULT_METHOD_TOKEN_SEED.wrapping_add(1); + + assert!(matches!( + restore_method_tokens(&data, wrong_seed), + Err(Error::Validation(_)) + )); + } } diff --git a/senbei-android-stage2/Cargo.toml b/senbei-android-stage2/Cargo.toml new file mode 100644 index 0000000..489b620 --- /dev/null +++ b/senbei-android-stage2/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "senbei-android-stage2" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Static Stage 1 and Stage 2 extraction for Senbei Android" + +[dependencies] +goblin.workspace = true +memmap2.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true +senbei-android-crypto.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-stage2/src/error.rs b/senbei-android-stage2/src/error.rs new file mode 100644 index 0000000..2711d68 --- /dev/null +++ b/senbei-android-stage2/src/error.rs @@ -0,0 +1,63 @@ +use std::path::{Path, PathBuf}; + +/// Stage 1 or Stage 2 extraction failure. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{action} `{path}`: {source}")] + Io { + action: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("parse ELF `{path}`: {source}")] + Elf { + path: PathBuf, + #[source] + source: goblin::error::Error, + }, + #[error("serialize extraction index: {0}")] + Json(#[from] serde_json::Error), + #[error("embedded Stage 2 decoder configuration: {0}")] + EmbeddedConfig(#[source] senbei_android_crypto::Error), + #[error( + "depth {depth} stream 0x{stream_id:02X} interpreter 0x{interpreter_id:02X} configuration: {source}" + )] + InterpreterConfig { + depth: usize, + stream_id: u32, + interpreter_id: u32, + #[source] + source: senbei_android_crypto::Error, + }, + #[error( + "depth {depth} stream 0x{stream_id:02X} record {record_index} command 0x{command_id:02X} {part}: {source}" + )] + RecordDecode { + depth: usize, + stream_id: u32, + record_index: usize, + command_id: u32, + part: &'static str, + #[source] + source: senbei_android_crypto::Error, + }, + #[error("{0}")] + Invalid(String), +} + +impl Error { + pub(crate) fn io(action: &'static str, path: &Path, source: std::io::Error) -> Self { + Self::Io { + action, + path: path.to_path_buf(), + source, + } + } +} + +pub(crate) type Result = std::result::Result; + +pub(crate) fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} diff --git a/senbei-android-stage2/src/extract.rs b/senbei-android-stage2/src/extract.rs new file mode 100644 index 0000000..c79c598 --- /dev/null +++ b/senbei-android-stage2/src/extract.rs @@ -0,0 +1,529 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fs::{File, create_dir_all}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use memmap2::MmapOptions; +use senbei_android_crypto::{Module9bConfig, decode_container}; +use serde_json::to_vec_pretty; +use sha2::{Digest, Sha256}; +use tempfile::NamedTempFile; + +use crate::error::{Error, Result, invalid}; +use crate::report::{ + ArtifactReport, DecoderReport, ExtractionReport, ModuleRegistryEntry, RecordReport, + Stage1Report, StreamParent, StreamReport, +}; +use crate::stage1::{ + DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, SHT_LOUSER, Stage1Result, inspect, +}; +use crate::stream::{DIRECT_FLAG, Record, parse_record_stream}; + +/// Inputs and output locations for one complete static Stage 2 extraction. +#[derive(Debug, Clone)] +pub struct ExtractOptions { + pub input: PathBuf, + pub output_dir: PathBuf, + pub stage2_output: Option, + pub outer_size: usize, + pub cipher_constant: u32, +} + +impl ExtractOptions { + #[must_use] + pub fn with_defaults(input: PathBuf, output_dir: PathBuf) -> Self { + Self { + input, + output_dir, + stage2_output: None, + outer_size: DEFAULT_OUTER_SIZE, + cipher_constant: DEFAULT_CIPHER_CONSTANT, + } + } +} + +#[derive(Debug)] +struct LoadedModule { + image: Vec, + metadata: Option>, + image_path: String, + metadata_path: Option, + sha256: String, + depth: usize, + record_index: usize, + command_id: u32, + init_offset: u32, + entry_offset: u32, +} + +#[derive(Debug, Clone, Copy)] +struct ArtifactSpec<'a> { + suffix: &'a str, + kind: &'a str, + classification: &'a str, +} + +struct Extractor { + output_dir: PathBuf, + streams: Vec, + artifacts: Vec, + registry: BTreeMap, + seen_streams: HashSet<(u32, String)>, +} + +pub fn extract_stage2(options: &ExtractOptions) -> Result { + let input_path = absolute(&options.input)?; + let output_dir = absolute(&options.output_dir)?; + if !input_path.is_file() { + return invalid(format!( + "protected ELF does not exist: {}", + input_path.display() + )); + } + if let Some(stage2_output) = &options.stage2_output { + let stage2_output = absolute(stage2_output)?; + if stage2_output == input_path { + return invalid("refusing to overwrite the protected ELF with Stage 2 output"); + } + } + create_dir_all(&output_dir) + .map_err(|source| Error::io("create Stage 2 output directory", &output_dir, source))?; + + let file = File::open(&input_path) + .map_err(|source| Error::io("open protected ELF", &input_path, source))?; + // SAFETY: the mapping is read-only, the file remains open for the mapping + // lifetime, and extraction never mutates or truncates the source. + let source = unsafe { MmapOptions::new().map(&file) } + .map_err(|source| Error::io("map protected ELF", &input_path, source))?; + let stage1 = inspect( + &source, + &input_path, + options.outer_size, + options.cipher_constant, + )?; + if let Some(stage2_output) = &options.stage2_output { + write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?; + } + + let core_config = + Module9bConfig::parse_embedded(&stage1.plaintext).map_err(Error::EmbeddedConfig)?; + let bootstrap_end = stage1 + .remaining_file_offset + .checked_add(stage1.remaining_size) + .ok_or_else(|| Error::Invalid("Stage 2 bootstrap range overflow".to_owned()))?; + let bootstrap = source + .get(stage1.remaining_file_offset..bootstrap_end) + .ok_or_else(|| Error::Invalid("Stage 2 bootstrap range is outside the ELF".to_owned()))?; + let mut extractor = Extractor { + output_dir: output_dir.clone(), + streams: Vec::new(), + artifacts: Vec::new(), + registry: BTreeMap::new(), + seen_streams: HashSet::new(), + }; + extractor.extract_stream( + bootstrap, + 0xe2, + 0, + None, + Some(stage1.remaining_file_offset), + core_config, + )?; + + let module_registry = extractor + .registry + .values() + .map(|module| ModuleRegistryEntry { + command_id: module.command_id, + size: module.image.len(), + sha256: module.sha256.clone(), + depth: module.depth, + record_index: module.record_index, + image_path: module.image_path.clone(), + metadata_path: module.metadata_path.clone(), + init_offset: module.init_offset, + entry_offset: module.entry_offset, + classification: if module.metadata.is_some() { + "module_image".to_owned() + } else { + "decoded_data".to_owned() + }, + }) + .collect::>(); + let report = ExtractionReport { + format_version: 4, + protected_elf: input_path.display().to_string(), + output_dir: output_dir.display().to_string(), + stage1: stage1_report(&stage1, options.outer_size), + streams: extractor.streams, + artifacts: extractor.artifacts, + errors: Vec::new(), + module_registry, + }; + write_json_atomic(&output_dir.join("index.json"), &report)?; + Ok(report) +} + +impl Extractor { + fn extract_stream( + &mut self, + stream: &[u8], + stream_id: u32, + depth: usize, + parent: Option, + source_file_offset: Option, + config: Module9bConfig, + ) -> Result<()> { + let digest = sha256(stream); + if !self.seen_streams.insert((stream_id, digest.clone())) { + return Ok(()); + } + let (header, records, table_size) = + parse_record_stream(stream, stream_id).map_err(|source| { + Error::Invalid(format!( + "depth {depth} stream 0x{stream_id:02X} record table: {source}" + )) + })?; + let mut stream_report = StreamReport { + depth, + stream_id, + parent, + source_file_offset, + available_size: stream.len(), + descriptor_table_size: table_size, + encrypted_header_words: header.encrypted_words, + decrypted_header_words: header.decrypted_words, + record_state: header.record_state, + sha256: digest, + decoder: decoder_report( + if depth == 0 { + "embedded_stage2" + } else { + "decoded_interpreter" + }, + (depth != 0).then_some(stream_id), + &config, + ), + records: Vec::with_capacity(records.len()), + }; + let mut direct_records = Vec::new(); + let mut modules_at_level = BTreeSet::new(); + + for record in records { + let mut result = record_report(record); + let mut image_data = None; + let mut metadata_data = None; + + if !record.direct() && record.image_size != 0 { + 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 classification = if record.metadata_size != 0 { + "module_image" + } else { + "decoded_data" + }; + let artifact = self.write_artifact( + &record, + depth, + stream_id, + ArtifactSpec { + suffix: "module.bin", + kind: "decoded_container", + classification, + }, + &image, + )?; + result.image = Some(artifact.clone()); + image_data = Some((image, artifact)); + } + if record.metadata_size != 0 { + 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 artifact = self.write_artifact( + &record, + depth, + stream_id, + ArtifactSpec { + suffix: "metadata.bin", + kind: "decoded_metadata", + classification: "decoded_metadata", + }, + &metadata, + )?; + result.metadata = Some(artifact.clone()); + metadata_data = Some((metadata, artifact)); + } + if let Some((image, image_artifact)) = image_data { + let (metadata, metadata_path) = if let Some((data, artifact)) = metadata_data { + (Some(data), Some(artifact.path)) + } else { + (None, None) + }; + self.register_module(LoadedModule { + sha256: image_artifact.sha256.clone(), + image_path: image_artifact.path.clone(), + metadata_path, + image, + metadata, + depth, + record_index: record.index, + command_id: record.command_id, + init_offset: record.init_offset, + entry_offset: record.entry_offset, + })?; + modules_at_level.insert(record.command_id); + } + if record.direct() && record.image_size != 0 { + direct_records.push((record, stream_report.records.len())); + } + stream_report.records.push(result); + } + + let mut children = Vec::new(); + for (record, report_index) in direct_records { + let next_stream_id = record.command_id.wrapping_sub(0x10); + if modules_at_level.contains(&next_stream_id) { + stream_report.records[report_index].nested_stream_id = Some(next_stream_id); + children.push((record, next_stream_id)); + continue; + } + let direct_data = record_slice(stream, record.image_offset, record.image_size)?; + let artifact = self.write_artifact( + &record, + depth, + stream_id, + ArtifactSpec { + suffix: "direct.bin", + kind: "direct", + classification: "direct_data", + }, + direct_data, + )?; + stream_report.records[report_index].image = Some(artifact); + } + + self.streams.push(stream_report); + for (record, next_stream_id) in children { + let child_data = record_slice(stream, record.image_offset, record.image_size)?; + let parent = StreamParent { + stream_id, + record_index: record.index, + command_id: record.command_id, + }; + let interpreter = self.registry.get(&next_stream_id).ok_or_else(|| { + Error::Invalid(format!( + "depth {depth} stream 0x{stream_id:02X} child 0x{next_stream_id:02X} has no interpreter module" + )) + })?; + let interpreter_config = + Module9bConfig::parse(&interpreter.image).map_err(|source| { + Error::InterpreterConfig { + depth: depth + 1, + stream_id: next_stream_id, + interpreter_id: next_stream_id, + source, + } + })?; + self.extract_stream( + child_data, + next_stream_id, + depth + 1, + Some(parent), + None, + interpreter_config, + )?; + } + Ok(()) + } + + fn register_module(&mut self, module: LoadedModule) -> Result<()> { + if let Some(previous) = self.registry.get(&module.command_id) { + if previous.sha256 != module.sha256 { + return invalid(format!( + "module 0x{:02X} produced conflicting images: {} and {}", + module.command_id, previous.sha256, module.sha256 + )); + } + return Ok(()); + } + self.registry.insert(module.command_id, module); + Ok(()) + } + + fn write_artifact( + &mut self, + record: &Record, + depth: usize, + stream_id: u32, + spec: ArtifactSpec<'_>, + data: &[u8], + ) -> Result { + let digest = sha256(data); + let filename = format!( + "d{depth:02}_s{stream_id:02X}_r{:03}_id{:08X}_{}.{}", + record.index, + record.command_id, + &digest[..12], + spec.suffix + ); + let path = self.output_dir.join(filename); + write_atomic(&path, data)?; + let artifact = ArtifactReport { + kind: spec.kind.to_owned(), + path: path + .file_name() + .ok_or_else(|| Error::Invalid("artifact path has no file name".to_owned()))? + .to_string_lossy() + .into_owned(), + size: data.len(), + sha256: digest, + depth, + stream_id, + record_index: Some(record.index), + command_id: Some(record.command_id), + classification: spec.classification.to_owned(), + }; + self.artifacts.push(artifact.clone()); + Ok(artifact) + } +} + +fn record_report(record: Record) -> RecordReport { + RecordReport { + index: record.index, + command_id: record.command_id, + flags: record.flags, + image_offset: record.image_offset, + image_size: record.image_size, + metadata_offset: record.metadata_offset, + metadata_size: record.metadata_size, + id_copy: record.id_copy, + entry_offset: record.entry_offset, + init_offset: record.init_offset, + direct: record.flags & DIRECT_FLAG != 0, + extraction_status: "complete".to_owned(), + image: None, + metadata: None, + nested_stream_id: None, + } +} + +fn decoder_report( + kind: &str, + interpreter_id: Option, + config: &Module9bConfig, +) -> DecoderReport { + DecoderReport { + kind: kind.to_owned(), + interpreter_id, + header_seed: config.header_seed, + container_seed: config.container_seed, + schedule_offset: config.schedule_offset, + aes_key_sha256: sha256(&config.aes_key), + skip_aes: config.skip_aes, + } +} + +fn record_slice(stream: &[u8], offset: u32, size: u32) -> Result<&[u8]> { + let offset = usize::try_from(offset) + .map_err(|_| Error::Invalid("record payload offset exceeds usize".to_owned()))?; + let size = usize::try_from(size) + .map_err(|_| Error::Invalid("record payload size exceeds usize".to_owned()))?; + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("record payload range overflows usize".to_owned()))?; + stream.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "record payload range 0x{offset:x}..0x{end:x} exceeds stream 0x{:x}", + stream.len() + )) + }) +} + +fn record_tail(stream: &[u8], offset: u32) -> Result<&[u8]> { + let offset = usize::try_from(offset) + .map_err(|_| Error::Invalid("record container offset exceeds usize".to_owned()))?; + stream.get(offset..).ok_or_else(|| { + Error::Invalid(format!( + "record container offset 0x{offset:x} exceeds stream 0x{:x}", + stream.len() + )) + }) +} + +fn stage1_report(stage1: &Stage1Result, outer_size: usize) -> Stage1Report { + Stage1Report { + section_index: stage1.section_index, + section_type: SHT_LOUSER, + section_offset: stage1.section_offset, + section_size: stage1.section_size, + outer_size, + header_offset: stage1.header_offset, + header_key: stage1.header.key, + payload_offset: stage1.header.payload_offset, + payload_size: stage1.header.payload_size, + payload_key: stage1.header.payload_key, + entry_offset: stage1.header.entry_offset, + protect_size: stage1.header.protect_size, + stage2_file_offset: stage1.payload_file_offset, + stage2_size: stage1.plaintext.len(), + stage2_sha256: sha256(&stage1.plaintext), + remaining_file_offset: stage1.remaining_file_offset, + remaining_size: stage1.remaining_size, + } +} + +fn write_json_atomic(path: &Path, value: &impl serde::Serialize) -> Result<()> { + let mut bytes = to_vec_pretty(value)?; + bytes.push(b'\n'); + write_atomic(path, &bytes) +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + create_dir_all(parent) + .map_err(|source| Error::io("create output directory", parent, source))?; + let mut temporary = NamedTempFile::new_in(parent) + .map_err(|source| Error::io("create temporary output", parent, source))?; + temporary + .write_all(data) + .and_then(|()| temporary.as_file().sync_all()) + .map_err(|source| Error::io("write temporary output", temporary.path(), source))?; + temporary + .persist(path) + .map_err(|error| Error::io("replace output", path, error.error))?; + Ok(()) +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir() + .map(|current| current.join(path)) + .map_err(|source| Error::io("query current directory", path, source)) + } +} + +fn sha256(data: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(data); + format!("{:x}", digest.finalize()) +} diff --git a/senbei-android-stage2/src/lib.rs b/senbei-android-stage2/src/lib.rs new file mode 100644 index 0000000..b8736cc --- /dev/null +++ b/senbei-android-stage2/src/lib.rs @@ -0,0 +1,12 @@ +//! Pure-static Stage 1 decryption and recursive Stage 2 module extraction. + +mod error; +mod extract; +mod report; +mod stage1; +mod stream; + +pub use error::Error; +pub use extract::{ExtractOptions, extract_stage2}; +pub use report::ExtractionReport; +pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; diff --git a/senbei-android-stage2/src/report.rs b/senbei-android-stage2/src/report.rs new file mode 100644 index 0000000..9435067 --- /dev/null +++ b/senbei-android-stage2/src/report.rs @@ -0,0 +1,115 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Stage1Report { + pub section_index: usize, + pub section_type: u32, + pub section_offset: usize, + pub section_size: usize, + pub outer_size: usize, + pub header_offset: usize, + pub header_key: u32, + pub payload_offset: u32, + pub payload_size: u32, + pub payload_key: u32, + pub entry_offset: u32, + pub protect_size: u32, + pub stage2_file_offset: usize, + pub stage2_size: usize, + pub stage2_sha256: String, + pub remaining_file_offset: usize, + pub remaining_size: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DecoderReport { + pub kind: String, + pub interpreter_id: Option, + pub header_seed: u32, + pub container_seed: u32, + pub schedule_offset: usize, + pub aes_key_sha256: String, + pub skip_aes: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ArtifactReport { + pub kind: String, + pub path: String, + pub size: usize, + pub sha256: String, + pub depth: usize, + pub stream_id: u32, + pub record_index: Option, + pub command_id: Option, + pub classification: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordReport { + pub index: usize, + pub command_id: u32, + pub flags: u32, + pub image_offset: u32, + pub image_size: u32, + pub metadata_offset: u32, + pub metadata_size: u32, + pub id_copy: u32, + pub entry_offset: u32, + pub init_offset: u32, + pub direct: bool, + pub extraction_status: String, + pub image: Option, + pub metadata: Option, + pub nested_stream_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StreamParent { + pub stream_id: u32, + pub record_index: usize, + pub command_id: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StreamReport { + pub depth: usize, + pub stream_id: u32, + pub parent: Option, + pub source_file_offset: Option, + pub available_size: usize, + pub descriptor_table_size: usize, + pub encrypted_header_words: [u32; 2], + pub decrypted_header_words: [u32; 2], + pub record_state: u32, + pub sha256: String, + pub decoder: DecoderReport, + pub records: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ModuleRegistryEntry { + pub command_id: u32, + pub size: usize, + pub sha256: String, + pub depth: usize, + pub record_index: usize, + pub image_path: String, + pub metadata_path: Option, + pub init_offset: u32, + pub entry_offset: u32, + pub classification: String, +} + +/// Machine-readable output of one complete static Stage 2 extraction. +#[derive(Debug, Clone, Serialize)] +pub struct ExtractionReport { + pub format_version: u32, + pub protected_elf: String, + pub output_dir: String, + pub stage1: Stage1Report, + pub streams: Vec, + pub artifacts: Vec, + pub errors: Vec, + pub module_registry: Vec, +} diff --git a/senbei-android-stage2/src/stage1.rs b/senbei-android-stage2/src/stage1.rs new file mode 100644 index 0000000..fe629e5 --- /dev/null +++ b/senbei-android-stage2/src/stage1.rs @@ -0,0 +1,231 @@ +use std::path::Path; + +use goblin::elf::{Elf, header::EM_AARCH64}; + +use crate::error::{Error, Result, invalid}; + +pub(crate) const SHT_LOUSER: u32 = 0x8000_0000; +pub const DEFAULT_CIPHER_CONSTANT: u32 = 0xbf20_165d; +pub const DEFAULT_OUTER_SIZE: usize = 0x23c; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Stage1Header { + pub key: u32, + pub reserved: u32, + pub payload_offset: u32, + pub payload_size: u32, + pub payload_key: u32, + pub entry_offset: u32, + pub protect_size: u32, + pub size_copy: u32, +} + +#[derive(Debug)] +pub(crate) struct Stage1Result { + pub section_index: usize, + pub section_offset: usize, + pub section_size: usize, + pub header_offset: usize, + pub payload_file_offset: usize, + pub remaining_file_offset: usize, + pub remaining_size: usize, + pub header: Stage1Header, + pub plaintext: Vec, +} + +pub(crate) fn inspect( + data: &[u8], + path: &Path, + outer_size: usize, + cipher_constant: u32, +) -> Result { + let elf = Elf::parse(data).map_err(|source| Error::Elf { + path: path.to_path_buf(), + source, + })?; + if elf.header.e_machine != EM_AARCH64 { + return invalid(format!( + "expected AArch64 ELF (machine 0x{EM_AARCH64:X}), got 0x{:X}", + elf.header.e_machine + )); + } + let matches = elf + .section_headers + .iter() + .enumerate() + .filter(|(_, section)| section.sh_type == SHT_LOUSER) + .collect::>(); + if matches.len() != 1 { + return invalid(format!( + "expected exactly one SHT_LOUSER section, found {}", + matches.len() + )); + } + let (section_index, section) = matches[0]; + let section_offset = usize::try_from(section.sh_offset) + .map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?; + let section_size = usize::try_from(section.sh_size) + .map_err(|_| Error::Invalid("SHT_LOUSER size exceeds usize".to_owned()))?; + let section_end = section_offset + .checked_add(section_size) + .ok_or_else(|| Error::Invalid("SHT_LOUSER range overflows usize".to_owned()))?; + if section_end > data.len() { + return invalid("SHT_LOUSER range extends beyond the input file"); + } + let header_relative = outer_size; + if outer_size + .checked_add(0x1000) + .is_none_or(|end| end > section_size) + { + return invalid("Stage 1 outer header leaves no complete parameter area"); + } + let header_offset = section_offset + .checked_add(header_relative) + .ok_or_else(|| Error::Invalid("Stage 1 header offset overflow".to_owned()))?; + let header_raw = bytes(data, header_offset, 0x1000)?; + let header = decrypt_header(header_raw, cipher_constant)?; + if header.reserved != 0 { + return invalid(format!( + "Stage 1 header reserved word is nonzero: 0x{:x}", + header.reserved + )); + } + if header.size_copy != header.payload_size { + return invalid(format!( + "Stage 1 payload size copy 0x{:x} != size 0x{:x}", + header.size_copy, header.payload_size + )); + } + let private_size = section_size - outer_size; + let payload_offset = usize::try_from(header.payload_offset) + .map_err(|_| Error::Invalid("Stage 1 payload offset exceeds usize".to_owned()))?; + let payload_size = usize::try_from(header.payload_size) + .map_err(|_| Error::Invalid("Stage 1 payload size exceeds usize".to_owned()))?; + let payload_end = payload_offset + .checked_add(payload_size) + .ok_or_else(|| Error::Invalid("Stage 1 payload range overflow".to_owned()))?; + if payload_offset < 0x20 || payload_end > private_size { + return invalid(format!( + "Stage 1 payload range 0x{payload_offset:x}..0x{payload_end:x} exceeds private size 0x{private_size:x}" + )); + } + if payload_size == 0 || payload_size % 4 != 0 { + return invalid(format!( + "Stage 1 payload size must be nonzero and word aligned: 0x{payload_size:x}" + )); + } + 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 { + return invalid("Stage 1 entry offset is outside the payload"); + } + let protect_size = usize::try_from(header.protect_size) + .map_err(|_| Error::Invalid("Stage 1 protect size exceeds usize".to_owned()))?; + if protect_size > payload_size { + return invalid("Stage 1 mprotect length exceeds the payload"); + } + let payload_file_offset = header_offset + .checked_add(payload_offset) + .ok_or_else(|| Error::Invalid("Stage 1 payload file offset overflow".to_owned()))?; + let encrypted = bytes(data, payload_file_offset, payload_size)?; + let plaintext = decrypt_words(encrypted, header.payload_key, cipher_constant)?; + let aligned_payload_end = (payload_end + 3) & !3; + let remaining_relative = aligned_payload_end; + if remaining_relative > private_size { + return invalid("aligned Stage 2 cursor exceeds SHT_LOUSER"); + } + let remaining_file_offset = section_offset + .checked_add(outer_size) + .and_then(|value| value.checked_add(remaining_relative)) + .ok_or_else(|| Error::Invalid("Stage 2 stream offset overflow".to_owned()))?; + Ok(Stage1Result { + section_index, + section_offset, + section_size, + header_offset, + payload_file_offset, + remaining_file_offset, + remaining_size: private_size - remaining_relative, + header, + plaintext, + }) +} + +fn decrypt_header(raw: &[u8], constant: u32) -> Result { + let key = read_u32(raw, 0)?; + let mut decoded = decrypt_words(&raw[..0x20], key, constant)?; + decoded[..4].copy_from_slice(&key.to_le_bytes()); + Ok(Stage1Header { + key, + reserved: read_u32(&decoded, 4)?, + payload_offset: read_u32(&decoded, 8)?, + payload_size: read_u32(&decoded, 12)?, + payload_key: read_u32(&decoded, 16)?, + entry_offset: read_u32(&decoded, 20)?, + protect_size: read_u32(&decoded, 24)?, + size_copy: read_u32(&decoded, 28)?, + }) +} + +fn decrypt_words(ciphertext: &[u8], key: u32, constant: u32) -> Result> { + if ciphertext.len() % 4 != 0 { + return invalid("Stage 1 word cipher input is not 4-byte aligned"); + } + let mut plaintext = ciphertext.to_vec(); + for (index, chunk) in plaintext.chunks_exact_mut(4).enumerate() { + let index = u32::try_from(index) + .map_err(|_| Error::Invalid("Stage 1 word index exceeds u32".to_owned()))?; + let mut word = u32::from_le_bytes( + chunk + .try_into() + .map_err(|_| Error::Invalid("Stage 1 word has an invalid size".to_owned()))?, + ); + word = word.wrapping_add(index.wrapping_add(3).wrapping_mul(key)); + word ^= constant.wrapping_mul(index.wrapping_add(1)); + chunk.copy_from_slice(&word.to_le_bytes()); + } + Ok(plaintext) +} + +fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "byte range 0x{offset:x}..0x{end:x} is outside the input" + )) + }) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes = bytes(data, offset, 4)?; + Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| { + Error::Invalid("invalid u32 byte range".to_owned()) + })?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage1_word_transform_round_trips() { + let key = 0x1234_5678; + let constant = DEFAULT_CIPHER_CONSTANT; + let plain = [0x1122_3344_u32, 0xaabb_ccdd, 0x0102_0304]; + let mut cipher = Vec::new(); + for (index, value) in plain.into_iter().enumerate() { + let index = index as u32; + let word = (value ^ constant.wrapping_mul(index + 1)) + .wrapping_sub((index + 3).wrapping_mul(key)); + cipher.extend_from_slice(&word.to_le_bytes()); + } + let decoded = decrypt_words(&cipher, key, constant).unwrap(); + let expected = plain + .into_iter() + .flat_map(u32::to_le_bytes) + .collect::>(); + assert_eq!(decoded, expected); + } +} diff --git a/senbei-android-stage2/src/stream.rs b/senbei-android-stage2/src/stream.rs new file mode 100644 index 0000000..b1d4e19 --- /dev/null +++ b/senbei-android-stage2/src/stream.rs @@ -0,0 +1,168 @@ +use senbei_android_crypto::gf32_mul_fixed; + +use crate::error::{Error, Result, invalid}; + +pub(crate) const RECORD_SIZE: usize = 0x5c; +pub(crate) const DIRECT_FLAG: u32 = 2; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Record { + pub index: usize, + pub command_id: u32, + pub flags: u32, + pub image_offset: u32, + pub image_size: u32, + pub metadata_offset: u32, + pub metadata_size: u32, + pub id_copy: u32, + pub entry_offset: u32, + pub init_offset: u32, +} + +impl Record { + pub(crate) fn direct(self) -> bool { + self.flags & DIRECT_FLAG != 0 + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct StreamHeader { + pub encrypted_words: [u32; 2], + pub decrypted_words: [u32; 2], + pub record_state: u32, +} + +pub(crate) fn parse_record_stream( + stream: &[u8], + stream_id: u32, +) -> Result<(StreamHeader, Vec, usize)> { + if stream.len() < 8 { + return invalid(format!( + "stream 0x{stream_id:02X} is shorter than its 8-byte header" + )); + } + let cipher0 = read_u32(stream, 0)?; + let cipher1 = read_u32(stream, 4)?; + 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); + let plain0 = + gf32_mul_fixed(cipher0.wrapping_add(0xcbf0_c1d8)) ^ 0xeb_e81dba_u32.wrapping_add(base); + let plain1 = gf32_mul_fixed(cipher1.wrapping_add(cipher0)) + ^ 0xeb_e81dba_u32.wrapping_mul(5).wrapping_add(base); + let header = StreamHeader { + encrypted_words: [cipher0, cipher1], + decrypted_words: [plain0, plain1], + record_state: plain1.wrapping_add(base), + }; + + let mut records = Vec::new(); + let mut first_payload = stream.len(); + for index in 0..256_usize { + let start = + 8_usize + .checked_add(index.checked_mul(RECORD_SIZE).ok_or_else(|| { + Error::Invalid("record descriptor offset overflow".to_owned()) + })?) + .ok_or_else(|| Error::Invalid("record descriptor offset overflow".to_owned()))?; + let end = start + .checked_add(RECORD_SIZE) + .ok_or_else(|| Error::Invalid("record descriptor end overflow".to_owned()))?; + if end > stream.len() { + return invalid(format!( + "stream 0x{stream_id:02X} descriptor table is truncated at record {index}" + )); + } + let record = decrypt_record(&stream[start..end], index, header.record_state)?; + if record.id_copy != 0 && record.command_id != record.id_copy { + return invalid(format!( + "stream 0x{stream_id:02X} record {index} command/id mismatch: 0x{:X} != 0x{:X}", + record.command_id, record.id_copy + )); + } + for (offset, size) in [ + (record.image_offset, record.image_size), + (record.metadata_offset, record.metadata_size), + ] { + if offset != 0 && size != 0 { + let offset = usize::try_from(offset).map_err(|_| { + Error::Invalid(format!( + "stream 0x{stream_id:02X} record {index} payload offset exceeds usize" + )) + })?; + if offset >= stream.len() { + return invalid(format!( + "stream 0x{stream_id:02X} record {index} payload offset 0x{offset:x} exceeds stream 0x{:x}", + stream.len() + )); + } + first_payload = first_payload.min(offset); + } + } + records.push(record); + if end == first_payload { + return Ok((header, records, first_payload)); + } + if end > first_payload { + return invalid(format!( + "stream 0x{stream_id:02X} descriptor table crosses first payload at 0x{first_payload:x}" + )); + } + } + invalid(format!( + "stream 0x{stream_id:02X} has no descriptor boundary in 256 records" + )) +} + +fn decrypt_record(raw: &[u8], index: usize, state: u32) -> Result { + if raw.len() != RECORD_SIZE { + return invalid(format!( + "record {index} has size 0x{:x}, expected 0x{RECORD_SIZE:x}", + raw.len() + )); + } + let product = state.wrapping_add(0x96f6_0b71).wrapping_mul(state); + let index_mask = product.wrapping_shl(((index + 1) & 3) as u32); + let mix = state.wrapping_mul(0x06a5_5bcc).wrapping_add(product); + let mut accumulator = 0x7993_4cf6_u32; + let mut feedback = 0xf02f_7685_u32; + let mut words = [0_u32; RECORD_SIZE / 4]; + for (word_index, chunk) in raw.chunks_exact(4).enumerate() { + feedback = feedback.wrapping_mul(feedback); + let cipher = u32::from_le_bytes( + chunk + .try_into() + .map_err(|_| Error::Invalid("record word has an invalid size".to_owned()))?, + ); + let mut value = gf32_mul_fixed(cipher ^ (feedback >> 3)) ^ index_mask; + value = value.wrapping_add(accumulator).wrapping_add(state); + value = value.wrapping_sub(mix >> ((word_index * 4 + 3) & 5)); + words[word_index] = value; + accumulator = accumulator.wrapping_add(0xe64d_33d8); + feedback = cipher; + } + Ok(Record { + index, + command_id: words[0], + flags: words[1], + image_offset: words[2], + image_size: words[3], + metadata_offset: words[4], + metadata_size: words[5], + id_copy: words[6], + entry_offset: words[7], + init_offset: words[8], + }) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes = data.get(offset..offset + 4).ok_or_else(|| { + Error::Invalid(format!("record header range 0x{offset:x} is out of bounds")) + })?; + Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| { + Error::Invalid("invalid record u32 range".to_owned()) + })?)) +}