diff --git a/Cargo.lock b/Cargo.lock index 9df140a..ec734a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aes" version = "0.8.4" @@ -34,6 +40,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cfg-if" version = "1.0.4" @@ -59,6 +71,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -95,6 +122,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -175,6 +212,16 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -251,7 +298,6 @@ dependencies = [ "anyhow", "senbei-android-io", "senbei-android-metadata", - "senbei-android-stage2", "serde_json", ] @@ -277,29 +323,7 @@ dependencies = [ ] [[package]] -name = "senbei-android-io" -version = "0.1.0" -dependencies = [ - "anyhow", - "senbei-android-elf", - "senbei-android-metadata", - "senbei-android-stage2", - "serde", - "serde_json", - "sha2", - "tempfile", -] - -[[package]] -name = "senbei-android-metadata" -version = "0.1.0" -dependencies = [ - "serde", - "thiserror", -] - -[[package]] -name = "senbei-android-stage2" +name = "senbei-android-engine" version = "0.1.0" dependencies = [ "goblin", @@ -312,6 +336,30 @@ dependencies = [ "thiserror", ] +[[package]] +name = "senbei-android-io" +version = "0.1.0" +dependencies = [ + "anyhow", + "flate2", + "senbei-android-elf", + "senbei-android-engine", + "senbei-android-metadata", + "serde", + "serde_json", + "sha2", + "tempfile", + "zip", +] + +[[package]] +name = "senbei-android-metadata" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror", +] + [[package]] name = "serde" version = "1.0.229" @@ -366,6 +414,12 @@ dependencies = [ "digest", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "syn" version = "2.0.119" @@ -454,6 +508,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index caf0c2f..388d306 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [ "senbei-android-elf", "senbei-android-io", "senbei-android-metadata", - "senbei-android-stage2", + "senbei-android-engine", ] default-members = ["senbei-android-cli"] resolver = "2" @@ -19,6 +19,7 @@ license = "AGPL-3.0-only" [workspace.dependencies] aes = "0.8" anyhow = "1" +flate2 = "1" goblin = "0.10" memmap2 = "0.9" serde = { version = "1", features = ["derive"] } @@ -26,12 +27,13 @@ serde_json = "1" sha2 = "0.10" tempfile = "3" thiserror = "2" +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } 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" } +senbei-android-engine = { path = "senbei-android-engine" } [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/README.md b/README.md index 3e3e5f8..771e5af 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,9 @@ # Senbei Android -用于静态还原 Android 版受保护的 `libil2cpp.so` 和 IL2CPP -`global-metadata.dat`。生产路径已经完全 Rust 化,不执行保护器代码,也不依赖 -Unicorn、IDA 或 Python。 - ## 兼容性 -| 游戏 | 平台 | 版本 | 架构 | libil2cpp.so | global-metadata.dat | -|------|------|------|------|--------------|---------------------| -| リバースブルー×リバースエンド | Android | 1.28.2 | AArch64 | 支持 | v31 MethodDef token | +### リバースブルー×リバースエンド -当前 SO 实现针对该版本的 Stage 2 模块格式,运行时会从模块产物中发现 -`0x9B` 的种子、AES-256 key schedule 和相关配置,不硬编码样本 offset。 -metadata 默认使用模块 `0x0C` 中确认的 seed `0xA6FAE968`。 - -## 构建 - -```powershell -cargo build --release -``` - -生成的程序为: - -```text -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 -``` - -示例: - -```powershell -senbei-android restore-so ` - Native\libil2cpp.so ` - Native\libil2cpp_restored.so ` - --index Native\libil2cpp_stage2_modules\index.json ` - --report Native\libil2cpp_restore_report.json -``` - -省略 `--index` 时,默认读取输入文件同目录下的: - -```text -libil2cpp_stage2_modules\index.json -``` - -可选参数: - -- `--dump-aux FILE`:保存解码后的辅助 ELF 数据。 -- `--outer-only`:只还原主容器,不物化辅助动态链接表。 -- `--preserve-entrypoint`:保留保护器入口点;正常干净输出不应使用此项。 - -完整还原会静态处理 `0x9B/0x9D/0x9E` 数据,恢复 ELF load image、隐藏动态 -符号、字符串、SysV/GNU hash、version、`.rela.dyn` 和 `.rela.plt`,移除 -`SHT_LOUSER` 私有区并将入口点归零。 - -## 还原 metadata - -```powershell -senbei-android restore-metadata INPUT OUTPUT --report REPORT_JSON -``` - -示例: - -```powershell -senbei-android restore-metadata ` - Package\base\assets\bin\Data\Managed\Metadata\global-metadata.dat ` - Package\base\assets\bin\Data\Managed\Metadata\global-metadata_restored.dat ` - --report metadata_restore_report.json -``` - -可用 `--seed 0xA6FAE968` 显式指定十六进制 seed,也支持十进制。还原操作是 -幂等的,并严格先检测状态、再决定是否解密: - -- 先验证 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 - -| Crate | 职责 | -|-------|------| -| `senbei-android-cli` | 命令行参数解析与结果输出 | -| `senbei-android-io` | 路径推导、原地覆盖保护、原子写入与 JSON 报告 | -| `senbei-android-elf` | AArch64 ELF 还原与结构验证 | -| `senbei-android-crypto` | `0x9B/0x9D` 容器、AES、Huffman/LZ 和字变换 | -| `senbei-android-metadata` | v31 MethodDef token 静态逆变换与覆盖验证 | +| 平台 | 版本 | 架构 | libil2cpp.so | global-metadata.dat | +|------|------|------|--------------|---------------------| +| Android | 1.28.2 | AArch64 | ✅ | v31 | diff --git a/senbei-android-cli/Cargo.toml b/senbei-android-cli/Cargo.toml index b64f9a2..9d274b9 100644 --- a/senbei-android-cli/Cargo.toml +++ b/senbei-android-cli/Cargo.toml @@ -15,7 +15,6 @@ 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 93a4248..3da1b59 100644 --- a/senbei-android-cli/src/main.rs +++ b/senbei-android-cli/src/main.rs @@ -1,14 +1,10 @@ -use std::ffi::OsString; use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use senbei_android_io::{ - ExtractStage2Job, RestoreMetadataJob, RestoreSoJob, run_extract_stage2, run_restore_metadata, - run_restore_so, -}; +use senbei_android_io::run_folder; fn main() -> std::process::ExitCode { - match run(std::env::args_os().skip(1)) { + match run(std::env::args_os().skip(1).collect()) { Ok(()) => std::process::ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error:#}"); @@ -17,264 +13,42 @@ fn main() -> std::process::ExitCode { } } -fn run(args: impl Iterator) -> Result<()> { - let mut args = args.peekable(); - let Some(command) = args.next() else { - print_help(); - bail!("missing command"); - }; - let command = command.to_string_lossy(); - 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(()) - } - "-V" | "--version" => { - println!("senbei-android {}", env!("CARGO_PKG_VERSION")); - Ok(()) - } - _ => bail!("unknown command `{command}`; use --help for usage"), - } -} - -fn discover_metadata(args: Vec) -> Result<()> { - let mut positional = Vec::new(); +fn run(args: Vec) -> Result<()> { + let mut input = None; for value in args { if value == "-h" || value == "--help" { - println!("senbei-android discover-metadata INPUT"); + print_help(); + return Ok(()); + } + if value == "-V" || value == "--version" { + println!("senbei-android {}", env!("CARGO_PKG_VERSION")); return Ok(()); } if value.to_string_lossy().starts_with('-') { - bail!( - "unknown discover-metadata option `{}`", - value.to_string_lossy() - ); + bail!("unknown option `{}`; use --help", 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])), + if input.is_some() { + bail!("only one input folder is accepted"); } - cursor += 1; + input = Some(PathBuf::from(value)); } - 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(); + let input = input.context("missing input folder; use --help for usage")?; + if !input.is_dir() { + bail!("input must be a folder: `{}`", input.display()); + } + let summary = run_folder(&input)?; println!( - "Extracted {} streams, {} modules and {} compact artifacts", - result.streams.len(), - module_images, - result.artifacts.len() + "restored {} SO(s), skipped {} SO(s), restored {} metadata file(s), skipped {} metadata file(s), {} archive(s)", + summary.so_restored, + summary.so_skipped, + summary.metadata_restored, + summary.metadata_skipped, + summary.archives ); - println!("Index {}", output_dir.join("index.json").display()); + println!("output {}", input.join("unpack").display()); Ok(()) } -fn restore_so(args: Vec) -> Result<()> { - let mut positional = Vec::new(); - let mut index = None; - let mut report = None; - let mut dump_auxiliary = None; - let mut outer_only = false; - let mut preserve_entrypoint = false; - let mut cursor = 0; - while cursor < args.len() { - match args[cursor].to_string_lossy().as_ref() { - "--index" => index = Some(option_path(&args, &mut cursor, "--index")?), - "--report" => report = Some(option_path(&args, &mut cursor, "--report")?), - "--dump-aux" => { - dump_auxiliary = Some(option_path(&args, &mut cursor, "--dump-aux")?); - } - "--outer-only" => outer_only = true, - "--preserve-entrypoint" => preserve_entrypoint = true, - "-h" | "--help" => { - print_so_help(); - return Ok(()); - } - option if option.starts_with('-') => bail!("unknown restore-so option `{option}`"), - _ => positional.push(PathBuf::from(&args[cursor])), - } - cursor += 1; - } - let [input, output] = positional.as_slice() else { - bail!("restore-so requires INPUT and OUTPUT; use --help for usage"); - }; - let result = run_restore_so(&RestoreSoJob { - input: input.clone(), - output: output.clone(), - index, - report, - dump_auxiliary, - outer_only, - preserve_entrypoint, - })?; - println!("Restored {} bytes to {}", result.output_size, result.output); - println!("SHA-256 {}", result.output_sha256); - Ok(()) -} - -fn restore_metadata(args: Vec) -> Result<()> { - let mut positional = Vec::new(); - let mut report = None; - let mut seed = senbei_android_metadata::DEFAULT_METHOD_TOKEN_SEED; - let mut cursor = 0; - while cursor < args.len() { - match args[cursor].to_string_lossy().as_ref() { - "--seed" => { - let value = option_string(&args, &mut cursor, "--seed")?; - seed = parse_u32(&value).with_context(|| format!("invalid --seed `{value}`"))?; - } - "--report" => report = Some(option_path(&args, &mut cursor, "--report")?), - "-h" | "--help" => { - print_metadata_help(); - return Ok(()); - } - option if option.starts_with('-') => { - bail!("unknown restore-metadata option `{option}`"); - } - _ => positional.push(PathBuf::from(&args[cursor])), - } - cursor += 1; - } - let [input, output] = positional.as_slice() else { - bail!("restore-metadata requires INPUT and OUTPUT; use --help for usage"); - }; - let result = run_restore_metadata(&RestoreMetadataJob { - input: input.clone(), - output: output.clone(), - seed, - report, - })?; - println!( - "Metadata status={} restored {}/{} MethodDef tokens ({} already canonical)", - result.encryption_status, - result.changed_tokens, - result.methods, - result.already_correct_before - ); - Ok(()) -} - -fn option_path(args: &[OsString], cursor: &mut usize, name: &str) -> Result { - *cursor += 1; - args.get(*cursor) - .map(PathBuf::from) - .with_context(|| format!("{name} requires a path")) -} - -fn option_string(args: &[OsString], cursor: &mut usize, name: &str) -> Result { - *cursor += 1; - args.get(*cursor) - .map(|value| value.to_string_lossy().into_owned()) - .with_context(|| format!("{name} requires a value")) -} - -fn parse_u32(value: &str) -> 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(u64::from_str_radix(hex, 16)?) - } else { - Ok(value.parse()?) - } -} - fn print_help() { - println!("senbei-android {}", env!("CARGO_PKG_VERSION")); - 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"); - println!(" --report FILE Write a JSON restoration report"); - println!(" --dump-aux FILE Dump decoded auxiliary ELF data"); - println!(" --outer-only Skip auxiliary ELF table materialization"); - println!(" --preserve-entrypoint Keep the protector entrypoint"); -} - -fn print_metadata_help() { - println!("senbei-android restore-metadata INPUT OUTPUT [OPTIONS]"); - println!(" --seed VALUE Module 0x0C seed (decimal or 0x-prefixed hex)"); - println!(" --report FILE Write a JSON restoration report"); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_decimal_and_hex_seeds() { - assert_eq!(parse_u32("42").unwrap(), 42); - assert_eq!(parse_u32("0xA6FAE968").unwrap(), 0xa6fa_e968); - } + println!("senbei-android INPUT_FOLDER"); } diff --git a/senbei-android-stage2/Cargo.toml b/senbei-android-engine/Cargo.toml similarity index 93% rename from senbei-android-stage2/Cargo.toml rename to senbei-android-engine/Cargo.toml index 489b620..c164822 100644 --- a/senbei-android-stage2/Cargo.toml +++ b/senbei-android-engine/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "senbei-android-stage2" +name = "senbei-android-engine" version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/senbei-android-stage2/src/error.rs b/senbei-android-engine/src/error.rs similarity index 100% rename from senbei-android-stage2/src/error.rs rename to senbei-android-engine/src/error.rs diff --git a/senbei-android-stage2/src/extract.rs b/senbei-android-engine/src/extract.rs similarity index 100% rename from senbei-android-stage2/src/extract.rs rename to senbei-android-engine/src/extract.rs diff --git a/senbei-android-engine/src/lib.rs b/senbei-android-engine/src/lib.rs new file mode 100644 index 0000000..780215d --- /dev/null +++ b/senbei-android-engine/src/lib.rs @@ -0,0 +1,32 @@ +//! 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}; + +/// Return whether `data` has the protected AArch64 Stage 1 section layout. +/// +/// This is a cheap, read-only probe used by folder mode to distinguish the +/// protected target from ordinary Unity libraries before invoking extraction. +#[must_use] +pub fn is_protected_libil2cpp(data: &[u8]) -> bool { + if !stage1::looks_protected(data) { + return false; + } + let Ok(stage1) = stage1::inspect( + data, + std::path::Path::new(""), + DEFAULT_OUTER_SIZE, + DEFAULT_CIPHER_CONSTANT, + ) else { + return false; + }; + senbei_android_crypto::Module9bConfig::parse_embedded(&stage1.plaintext).is_ok() +} diff --git a/senbei-android-stage2/src/report.rs b/senbei-android-engine/src/report.rs similarity index 100% rename from senbei-android-stage2/src/report.rs rename to senbei-android-engine/src/report.rs diff --git a/senbei-android-stage2/src/stage1.rs b/senbei-android-engine/src/stage1.rs similarity index 92% rename from senbei-android-stage2/src/stage1.rs rename to senbei-android-engine/src/stage1.rs index fe629e5..8b52c68 100644 --- a/senbei-android-stage2/src/stage1.rs +++ b/senbei-android-engine/src/stage1.rs @@ -8,6 +8,37 @@ pub(crate) const SHT_LOUSER: u32 = 0x8000_0000; pub const DEFAULT_CIPHER_CONSTANT: u32 = 0xbf20_165d; pub const DEFAULT_OUTER_SIZE: usize = 0x23c; +pub(crate) fn looks_protected(data: &[u8]) -> bool { + let Ok(elf) = Elf::parse(data) else { + return false; + }; + if elf.header.e_machine != EM_AARCH64 + || elf + .section_headers + .iter() + .filter(|section| section.sh_type == SHT_LOUSER) + .count() + != 1 + { + return false; + } + [ + ".dynsym", + ".dynstr", + ".gnu.hash", + ".gnu.version", + ".gnu.version_r", + ] + .into_iter() + .all(|wanted| { + elf.section_headers.iter().any(|section| { + elf.shdr_strtab + .get_at(section.sh_name) + .is_some_and(|name| name == wanted) + }) + }) +} + #[derive(Debug, Clone, Copy)] pub(crate) struct Stage1Header { pub key: u32, diff --git a/senbei-android-stage2/src/stream.rs b/senbei-android-engine/src/stream.rs similarity index 100% rename from senbei-android-stage2/src/stream.rs rename to senbei-android-engine/src/stream.rs diff --git a/senbei-android-io/Cargo.toml b/senbei-android-io/Cargo.toml index a744e46..500883e 100644 --- a/senbei-android-io/Cargo.toml +++ b/senbei-android-io/Cargo.toml @@ -8,13 +8,15 @@ description = "Filesystem and CLI orchestration for Senbei Android" [dependencies] anyhow.workspace = true +flate2.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true tempfile.workspace = true +zip.workspace = true senbei-android-elf.workspace = true senbei-android-metadata.workspace = true -senbei-android-stage2.workspace = true +senbei-android-engine.workspace = true [lints] workspace = true diff --git a/senbei-android-io/src/folder.rs b/senbei-android-io/src/folder.rs new file mode 100644 index 0000000..f8bd0ea --- /dev/null +++ b/senbei-android-io/src/folder.rs @@ -0,0 +1,505 @@ +use std::collections::BTreeSet; +use std::fs::{File, create_dir_all, read_dir}; +use std::io::{Read, Seek, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use flate2::read::DeflateDecoder; +use senbei_android_engine::{ExtractOptions, extract_stage2, is_protected_libil2cpp}; +use sha2::{Digest, Sha256}; +use tempfile::{TempDir, tempdir}; +use zip::ZipArchive; + +use crate::{RestoreMetadataJob, RestoreSoJob, run_restore_metadata, run_restore_so}; + +const METADATA_NAME: &str = "global-metadata.dat"; +const METADATA_SUFFIX: [&str; 6] = [ + "assets", + "bin", + "Data", + "Managed", + "Metadata", + METADATA_NAME, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum TargetKind { + So, + Metadata, +} + +#[derive(Debug, Clone)] +struct Target { + kind: TargetKind, + source: PathBuf, + destination: PathBuf, + label: String, + identity: String, + source_priority: u8, +} + +/// Summary of one folder-mode restoration run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FolderSummary { + pub so_restored: usize, + pub so_skipped: usize, + pub metadata_restored: usize, + pub metadata_skipped: usize, + pub archives: usize, +} + +/// Find protected Android targets below `root`, restore them, and write only +/// the clean files below `/unpack`. +pub fn run_folder(root: &Path) -> Result { + if !root.is_dir() { + bail!("input must be a folder: `{}`", root.display()); + } + let root = std::fs::canonicalize(root) + .with_context(|| format!("canonicalize input folder `{}`", root.display()))?; + let output_root = root.join("unpack"); + create_dir_all(&output_root) + .with_context(|| format!("create output folder `{}`", output_root.display()))?; + + let temporary = tempdir().context("create temporary archive workspace")?; + let mut targets = Vec::new(); + let mut archives = BTreeSet::new(); + collect_targets( + &root, + &root, + &output_root, + &temporary, + &mut targets, + &mut archives, + )?; + targets = deduplicate_targets(targets); + targets.sort_by(|left, right| left.destination.cmp(&right.destination)); + + let mut summary = FolderSummary { + so_restored: 0, + so_skipped: 0, + metadata_restored: 0, + metadata_skipped: 0, + archives: archives.len(), + }; + let mut seen_destinations = BTreeSet::new(); + + for target in targets { + if !seen_destinations.insert(target.destination.clone()) { + bail!( + "duplicate target destination `{}`", + target.destination.display() + ); + } + match target.kind { + TargetKind::So => { + if restore_so_target(&target, &temporary)? { + summary.so_restored += 1; + } else { + summary.so_skipped += 1; + } + } + TargetKind::Metadata => { + if restore_metadata_target(&target)? { + summary.metadata_restored += 1; + } else { + summary.metadata_skipped += 1; + } + } + } + } + Ok(summary) +} + +fn restore_so_target(target: &Target, temporary: &TempDir) -> Result { + let data = std::fs::read(&target.source) + .with_context(|| format!("read protected `{}`", target.label))?; + if !is_protected_libil2cpp(&data) { + bail!( + "target is not a protected AArch64 libil2cpp ELF: `{}`", + target.label + ); + } + let key = target.label.bytes().fold(0_u64, |hash, byte| { + hash.wrapping_mul(131).wrapping_add(u64::from(byte)) + }); + let stage2_dir = temporary.path().join(format!("stage2-{key:016x}")); + let index = stage2_dir.join("index.json"); + if let Err(error) = extract_stage2(&ExtractOptions::with_defaults( + target.source.clone(), + stage2_dir, + )) + .with_context(|| format!("extract Stage 1/Stage 2 for `{}`", target.label)) + { + eprintln!("skip SO `{}`: {error:#}", target.label); + return Ok(false); + } + if let Err(error) = run_restore_so(&RestoreSoJob { + input: target.source.clone(), + output: target.destination.clone(), + index: Some(index), + report: None, + dump_auxiliary: None, + outer_only: false, + preserve_entrypoint: false, + }) + .with_context(|| format!("restore `{}`", target.label)) + { + eprintln!("skip SO `{}`: {error:#}", target.label); + return Ok(false); + } + Ok(true) +} + +fn restore_metadata_target(target: &Target) -> Result { + let input = std::fs::read(&target.source) + .with_context(|| format!("read metadata `{}`", target.label))?; + let discovery = senbei_android_metadata::discover_method_token_seeds(&input) + .with_context(|| format!("inspect metadata `{}`", target.label))?; + if discovery.version != 31 { + return Ok(false); + } + if discovery.images.iter().all(|image| image.clean) { + return Ok(false); + } + let seed = match discovery.seed_candidates.as_slice() { + [] => senbei_android_metadata::DEFAULT_METHOD_TOKEN_SEED, + [seed] => *seed, + candidates => bail!( + "metadata `{}` has ambiguous MethodDef seeds: {} candidates", + target.label, + candidates.len() + ), + }; + run_restore_metadata(&RestoreMetadataJob { + input: target.source.clone(), + output: target.destination.clone(), + seed, + report: None, + })?; + Ok(true) +} + +fn collect_targets( + root: &Path, + current: &Path, + output_root: &Path, + temporary: &TempDir, + targets: &mut Vec, + archives: &mut BTreeSet, +) -> Result<()> { + let mut entries = read_dir(current) + .with_context(|| format!("scan folder `{}`", current.display()))? + .collect::, _>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + if entry.file_name().eq_ignore_ascii_case("unpack") { + continue; + } + collect_targets(root, &path, output_root, temporary, targets, archives)?; + continue; + } + if !file_type.is_file() { + continue; + } + let relative = path + .strip_prefix(root) + .map_err(|_| anyhow::anyhow!("input path escaped root: `{}`", path.display()))? + .to_path_buf(); + if is_so_path(&relative) { + let bytes = + std::fs::read(&path).with_context(|| format!("probe `{}`", path.display()))?; + if is_protected_libil2cpp(&bytes) { + targets.push(Target { + kind: TargetKind::So, + source: path, + destination: output_root.join(&relative), + label: relative.display().to_string(), + identity: content_identity(&bytes), + source_priority: 0, + }); + } + } else if is_metadata_path(&relative) { + let bytes = std::fs::read(&path) + .with_context(|| format!("read metadata `{}`", path.display()))?; + targets.push(Target { + kind: TargetKind::Metadata, + source: path, + destination: output_root.join(&relative), + label: relative.display().to_string(), + identity: content_identity(&bytes), + source_priority: 0, + }); + } else if is_archive(&path) { + extract_archive_targets(&path, &relative, output_root, temporary, targets, archives)?; + } + } + Ok(()) +} + +fn is_archive(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("apk") || value.eq_ignore_ascii_case("apks") + }) +} + +fn extract_archive_targets( + archive_path: &Path, + archive_relative: &Path, + output_root: &Path, + temporary: &TempDir, + targets: &mut Vec, + archives: &mut BTreeSet, +) -> Result<()> { + archives.insert(archive_relative.to_path_buf()); + let is_apks = archive_path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("apks")); + let mut archive = ZipArchive::new( + File::open(archive_path) + .with_context(|| format!("open archive `{}`", archive_path.display()))?, + ) + .with_context(|| format!("read archive `{}`", archive_path.display()))?; + let mut nested = Vec::new(); + let mut direct = Vec::new(); + for index in 0..archive.len() { + let (path, is_directory) = { + let entry = archive.by_index(index)?; + (entry.enclosed_name().map(PathBuf::from), entry.is_dir()) + }; + if is_directory { + continue; + } + let Some(path) = path else { + bail!( + "archive entry has unsafe path in `{}`", + archive_path.display() + ); + }; + if is_apks + && path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("apk")) + { + nested.push((index, path)); + } else if !is_apks && (is_so_path(&path) || is_metadata_path(&path)) { + direct.push((index, path)); + } + } + drop(archive); + + // Keep the archive suffix in the output directory name. An input tree may + // already contain an extracted `base/` beside `base.apk`; dropping the + // suffix would make those two independent targets collide. + let archive_base = archive_relative.to_path_buf(); + for (index, path) in direct { + let source = + extract_entry_from_archive_path(archive_path, index, temporary, archive_relative)?; + push_archive_target( + output_root, + targets, + archive_base.clone(), + path, + source, + archive_relative, + if is_apks { 2 } else { 1 }, + )?; + } + for (index, nested_path) in nested { + let nested_source = + extract_entry_from_archive_path(archive_path, index, temporary, archive_relative)?; + let nested_base = archive_base.join(nested_path.with_extension("")); + extract_nested_apk( + &nested_source, + nested_base, + archive_relative.join(&nested_path), + output_root, + temporary, + targets, + )?; + } + Ok(()) +} + +fn extract_nested_apk( + apk_path: &Path, + output_base: PathBuf, + nested_label: PathBuf, + output_root: &Path, + temporary: &TempDir, + targets: &mut Vec, +) -> Result<()> { + let mut archive = ZipArchive::new( + File::open(apk_path) + .with_context(|| format!("open nested APK `{}`", apk_path.display()))?, + ) + .with_context(|| format!("read nested APK `{}`", apk_path.display()))?; + let mut entries = Vec::new(); + for index in 0..archive.len() { + let (path, is_directory) = { + let entry = archive.by_index(index)?; + (entry.enclosed_name().map(PathBuf::from), entry.is_dir()) + }; + if is_directory { + continue; + } + let Some(path) = path else { + bail!( + "nested APK entry has unsafe path in `{}`", + apk_path.display() + ); + }; + if is_so_path(&path) || is_metadata_path(&path) { + entries.push((index, path)); + } + } + drop(archive); + for (index, path) in entries { + let source = extract_entry_from_archive_path(apk_path, index, temporary, &nested_label)?; + push_archive_target( + output_root, + targets, + output_base.clone(), + path, + source, + &nested_label, + 2, + )?; + } + Ok(()) +} + +fn push_archive_target( + output_root: &Path, + targets: &mut Vec, + base: PathBuf, + entry_path: PathBuf, + source: PathBuf, + archive_label: &Path, + source_priority: u8, +) -> Result<()> { + let kind = if is_so_path(&entry_path) { + TargetKind::So + } else { + TargetKind::Metadata + }; + let bytes = std::fs::read(&source) + .with_context(|| format!("probe archive target `{}`", archive_label.display()))?; + if kind == TargetKind::So && !is_protected_libil2cpp(&bytes) { + return Ok(()); + } + targets.push(Target { + kind, + source, + destination: output_root.join(&base).join(&entry_path), + label: format!("{}::{}", archive_label.display(), entry_path.display()), + identity: content_identity(&bytes), + source_priority, + }); + Ok(()) +} + +fn is_so_path(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("so")) +} + +fn is_metadata_path(path: &Path) -> bool { + let components = path + .components() + .filter_map(|component| component.as_os_str().to_str()) + .collect::>(); + components.len() >= METADATA_SUFFIX.len() + && components[components.len() - METADATA_SUFFIX.len()..] + .iter() + .zip(METADATA_SUFFIX) + .all(|(actual, expected)| actual.eq_ignore_ascii_case(expected)) +} + +fn content_identity(data: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(data); + format!("{:x}", digest.finalize()) +} + +fn deduplicate_targets(mut targets: Vec) -> Vec { + targets.sort_by(|left, right| { + (left.kind, &left.identity) + .cmp(&(right.kind, &right.identity)) + .then_with(|| right.source_priority.cmp(&left.source_priority)) + .then_with(|| left.label.cmp(&right.label)) + }); + let mut seen = BTreeSet::new(); + targets + .into_iter() + .filter(|target| seen.insert((target.kind, target.identity.clone()))) + .collect() +} + +fn extract_entry_from_archive_path( + archive_path: &Path, + index: usize, + temporary: &TempDir, + archive_label: &Path, +) -> Result { + let mut archive = ZipArchive::new(File::open(archive_path)?)?; + extract_entry(&mut archive, index, temporary, archive_label) +} + +fn extract_entry( + archive: &mut ZipArchive, + index: usize, + temporary: &TempDir, + archive_label: &Path, +) -> Result { + let mut entry = archive.by_index_raw(index)?; + let key = format!("{}-{index:08x}", archive_label.display()); + let destination = temporary.path().join(key.replace(['\\', '/'], "_")); + let compressed_size = usize::try_from(entry.compressed_size()) + .map_err(|_| anyhow::anyhow!("archive entry compressed size exceeds usize"))?; + let output_size = usize::try_from(entry.size()) + .map_err(|_| anyhow::anyhow!("archive entry size exceeds usize"))?; + let mut compressed = vec![0_u8; compressed_size]; + entry.read_exact(&mut compressed).with_context(|| { + format!( + "read raw archive `{}` entry index {index}", + archive_label.display() + ) + })?; + let mut output_data = Vec::with_capacity(output_size); + match entry.compression() { + zip::CompressionMethod::Stored => output_data.extend_from_slice(&compressed), + zip::CompressionMethod::Deflated => { + DeflateDecoder::new(compressed.as_slice()) + .read_to_end(&mut output_data) + .with_context(|| { + format!( + "deflate archive `{}` entry index {index}", + archive_label.display() + ) + })?; + } + method => bail!( + "unsupported compression method {method:?} in archive `{}` entry index {index}", + archive_label.display() + ), + } + if output_data.len() != output_size { + bail!( + "archive `{}` entry index {index} decompressed to 0x{:x}, expected 0x{:x}", + archive_label.display(), + output_data.len(), + output_size + ); + } + let mut output = File::create(&destination)?; + output.write_all(&output_data)?; + Ok(destination) +} diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs index f0997fb..34168fe 100644 --- a/senbei-android-io/src/lib.rs +++ b/senbei-android-io/src/lib.rs @@ -1,14 +1,18 @@ //! Filesystem orchestration for the Android restoration commands. +mod folder; + +pub use folder::{FolderSummary, run_folder}; + use std::io::Write; 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::{ +use senbei_android_engine::{ DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, }; +use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; use serde::Serialize; use tempfile::NamedTempFile; diff --git a/senbei-android-stage2/src/lib.rs b/senbei-android-stage2/src/lib.rs deleted file mode 100644 index b8736cc..0000000 --- a/senbei-android-stage2/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! 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};