diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5da0292 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,390 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "senbei-android-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "senbei-android-io", + "senbei-android-metadata", +] + +[[package]] +name = "senbei-android-crypto" +version = "0.1.0" +dependencies = [ + "aes", + "thiserror", +] + +[[package]] +name = "senbei-android-elf" +version = "0.1.0" +dependencies = [ + "memmap2", + "senbei-android-crypto", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "senbei-android-io" +version = "0.1.0" +dependencies = [ + "anyhow", + "senbei-android-elf", + "senbei-android-metadata", + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "senbei-android-metadata" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e544dd7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +members = [ + "senbei-android-cli", + "senbei-android-crypto", + "senbei-android-elf", + "senbei-android-io", + "senbei-android-metadata", +] +default-members = ["senbei-android-cli"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "AGPL-3.0-only" + +[workspace.dependencies] +aes = "0.8" +anyhow = "1" +memmap2 = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3" +thiserror = "2" + +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" } + +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[workspace.lints.clippy] +correctness = { level = "deny", priority = -1 } +suspicious = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..87ed4f8 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# 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 + +```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,也支持十进制。还原操作是 +幂等的:已规范化的 image 会保持不变。 + +## 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 静态逆变换与覆盖验证 | diff --git a/senbei-android-cli/Cargo.toml b/senbei-android-cli/Cargo.toml new file mode 100644 index 0000000..3d6c2a2 --- /dev/null +++ b/senbei-android-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "senbei-android-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Command-line entry point for Senbei Android" + +[[bin]] +name = "senbei-android" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +senbei-android-io.workspace = true +senbei-android-metadata.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-cli/src/main.rs b/senbei-android-cli/src/main.rs new file mode 100644 index 0000000..c4203fc --- /dev/null +++ b/senbei-android-cli/src/main.rs @@ -0,0 +1,178 @@ +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}; + +fn main() -> std::process::ExitCode { + match run(std::env::args_os().skip(1)) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error:#}"); + std::process::ExitCode::from(1) + } + } +} + +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()), + "-h" | "--help" => { + print_help(); + Ok(()) + } + "-V" | "--version" => { + println!("senbei-android {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + _ => bail!("unknown command `{command}`; use --help for usage"), + } +} + +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!( + "Restored {}/{} MethodDef tokens ({} already canonical)", + 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 { + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + Ok(u32::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 --version"); +} + +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); + } +} diff --git a/senbei-android-crypto/Cargo.toml b/senbei-android-crypto/Cargo.toml new file mode 100644 index 0000000..269e2aa --- /dev/null +++ b/senbei-android-crypto/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "senbei-android-crypto" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Protector container primitives for Senbei Android" + +[dependencies] +aes.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-crypto/src/lib.rs b/senbei-android-crypto/src/lib.rs new file mode 100644 index 0000000..e752863 --- /dev/null +++ b/senbei-android-crypto/src/lib.rs @@ -0,0 +1,598 @@ +//! Cryptographic and compression primitives used by the Android protector. + +use aes::Aes256; +use aes::cipher::{Block, BlockDecrypt, KeyInit}; + +const RECORD_SIZE: usize = 0x5c; + +/// Errors raised while parsing or decoding protector containers. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Invalid(String), +} + +type Result = std::result::Result; + +fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} + +fn range(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 out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 2] = range(data, offset, 2)? + .try_into() + .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(bytes)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = range(data, offset, 4)? + .try_into() + .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(bytes)) +} + +fn align_up(value: usize, alignment: usize) -> Result { + let mask = alignment + .checked_sub(1) + .ok_or_else(|| Error::Invalid("zero alignment".to_owned()))?; + value + .checked_add(mask) + .map(|v| v & !mask) + .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) +} + +/// Multiply by the fixed element used by the native GF(2^32) transform. +#[must_use] +pub fn gf32_mul_fixed(mut value: u32) -> u32 { + let mut multiplier = 0x9451_1dd2_u32; + let mut result = 0_u32; + while multiplier != 0 { + if multiplier & 1 != 0 { + result ^= value; + } + let carry = value >> 31; + value = value.wrapping_shl(1); + if carry != 0 { + value ^= 0x5793_57eb; + } + multiplier >>= 1; + } + result +} + +fn mix_columns(block: [u8; 16]) -> [u8; 16] { + const fn xtime(value: u8) -> u8 { + (value << 1) ^ if value & 0x80 != 0 { 0x1b } else { 0 } + } + + let mut output = [0_u8; 16]; + for offset in (0..16).step_by(4) { + let [a, b, c, d] = block[offset..offset + 4] else { + unreachable!("fixed four-byte AES column") + }; + output[offset] = xtime(a) ^ (xtime(b) ^ b) ^ c ^ d; + output[offset + 1] = a ^ xtime(b) ^ (xtime(c) ^ c) ^ d; + output[offset + 2] = a ^ b ^ xtime(c) ^ (xtime(d) ^ d); + output[offset + 3] = (xtime(a) ^ a) ^ b ^ c ^ xtime(d); + } + output +} + +/// Static configuration recovered from module `0x9B`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Module9bConfig { + pub header_seed: u32, + pub container_seed: u32, + pub aes_key: [u8; 32], + pub skip_aes: bool, + pub schedule_offset: usize, +} + +impl Module9bConfig { + /// Parse the unique AES-256 decryption schedule and adjacent configuration. + pub fn parse(image: &[u8]) -> Result { + const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; + let mut matches = image + .windows(MARKER.len()) + .enumerate() + .filter_map(|(offset, bytes)| (bytes == MARKER).then_some(offset)); + let schedule_offset = matches + .next() + .ok_or_else(|| Error::Invalid("cannot locate the 0x9B AES-256 schedule".to_owned()))?; + if schedule_offset < 8 || matches.next().is_some() { + return invalid("cannot uniquely locate the 0x9B AES-256 schedule"); + } + + let header_seed = read_u32(image, schedule_offset - 8)?; + let schedule_size = read_u32(image, schedule_offset - 4)?; + if schedule_size != 0xf4 { + return invalid(format!( + "unexpected 0x9B AES schedule size 0x{schedule_size:x}" + )); + } + let bits = read_u16(image, schedule_offset)?; + let rounds = read_u16(image, schedule_offset + 2)?; + if (bits, rounds) != (0x100, 14) { + return invalid(format!( + "unexpected AES schedule header 0x{bits:x}/{rounds}" + )); + } + + let schedule = range(image, schedule_offset + 4, 15 * 16)?; + let mut round_keys = [[0_u8; 16]; 15]; + for (round, output) in round_keys.iter_mut().enumerate() { + let source = &schedule[round * 16..round * 16 + 16]; + for word in 0..4 { + let start = word * 4; + for byte in 0..4 { + output[start + byte] = source[start + 3 - byte]; + } + } + } + let mut aes_key = [0_u8; 32]; + aes_key[..16].copy_from_slice(&round_keys[14]); + aes_key[16..].copy_from_slice(&mix_columns(round_keys[13])); + + 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; + + Ok(Self { + header_seed, + container_seed: read_u32(image, container_seed_offset)?, + aes_key, + skip_aes, + schedule_offset, + }) + } +} + +/// Decrypted header at the start of direct-data object `0x9D`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProtectedDescriptor { + pub command_id: u32, + pub flags: u32, + pub outer_offset: u32, + pub outer_expected_size: u32, + pub auxiliary_offset: u32, + pub auxiliary_expected_size: u32, +} + +impl ProtectedDescriptor { + /// Decrypt the `0x5c`-byte descriptor with the module header seed. + pub fn decrypt(data: &[u8], seed: u32) -> Result { + if data.len() < RECORD_SIZE { + return invalid("0x9D descriptor is truncated"); + } + let base0 = seed.wrapping_add(0xd3e8_7144).wrapping_mul(seed); + let base1 = base0.wrapping_add(seed.wrapping_mul(0x0bd9_418d)); + let mut words = [0_u32; RECORD_SIZE / 4]; + for (index, word) in words.iter_mut().enumerate() { + let cipher = read_u32(data, index * 4)?; + let subtractor = base0.wrapping_shl(if index & 1 != 0 { 4 } else { 0 }); + *word = cipher.wrapping_sub(subtractor) + ^ base1.wrapping_shr((seed.wrapping_add((index as u32).wrapping_mul(4))) & 7); + } + if words[6..].iter().any(|&word| word != 0) { + return invalid("unexpected nonzero reserved words in the 0x9D descriptor"); + } + let descriptor = Self { + command_id: words[0], + flags: words[1], + outer_offset: words[2], + outer_expected_size: words[3], + auxiliary_offset: words[4], + auxiliary_expected_size: words[5], + }; + if descriptor.command_id != 0x9d || descriptor.outer_offset as usize != RECORD_SIZE { + return invalid("unexpected decrypted 0x9D descriptor"); + } + Ok(descriptor) + } +} + +/// One encrypted segment in a decoded `0x9D` container header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncodedSegment { + pub offset: u32, + pub size: u32, +} + +/// Parsed primary or auxiliary `0x9D` container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerHeader { + pub start: usize, + pub output_size: u32, + pub skip_aes: bool, + pub tree: Vec, + pub segments: Vec, +} + +impl ContainerHeader { + /// Parse and decrypt a container header, Huffman tree, and segment table. + pub fn parse(data: &[u8], start: usize, seed: u32) -> Result { + range(data, start, 12)?; + let seed_square = seed.wrapping_mul(seed); + let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11); + let raw0 = read_u32(data, start)?; + let raw1 = read_u32(data, start + 4)?; + let raw2 = read_u32(data, start + 8)?; + let output_size = 0xa21d_fb3a_u32 + .wrapping_shl(state & 7) + .wrapping_add(state.wrapping_mul(0xf87b_337c)) + .wrapping_add(gf32_mul_fixed(raw0)); + let flag_word = gf32_mul_fixed(raw1) + ^ state + .wrapping_add(0xbd19_c63c) + .wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)); + let segment_count = (flag_word & 0xff) as usize; + let skip_aes = (flag_word >> 8) & 0xff == 1; + let tree_size = 0x643a_3a3b_u32 + .wrapping_shl(state & 0x0b) + .wrapping_sub(state ^ 0x3b2b_f538) + .wrapping_add(gf32_mul_fixed(raw2)) as usize; + if segment_count == 0 || tree_size > 0x1b00 { + return invalid(format!( + "invalid container fields: segments={segment_count}, tree=0x{tree_size:x}" + )); + } + + let tree_start = start + .checked_add(12) + .ok_or_else(|| Error::Invalid("tree offset overflow".to_owned()))?; + let mut tree = range(data, tree_start, tree_size)?.to_vec(); + for offset in (0..tree_size & !3).step_by(4) { + let value = read_u32(&tree, offset)?; + tree[offset..offset + 4].copy_from_slice(&gf32_mul_fixed(value).to_le_bytes()); + } + let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state); + let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state)); + for (index, byte) in tree.iter_mut().enumerate() { + let shift = u32::try_from(index & 0x1b) + .map_err(|_| Error::Invalid("tree shift conversion failed".to_owned()))?; + let left = gf32_mul_fixed(tree_state.wrapping_shl(shift)); + let right = tree_delta.wrapping_shr((index & 0x17) as u32); + let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32); + *byte = byte.wrapping_add(adjustment as u8); + } + + let table_start = start + .checked_add(align_up(12 + tree_size, 4)?) + .ok_or_else(|| Error::Invalid("segment table offset overflow".to_owned()))?; + let table_size = segment_count + .checked_mul(8) + .ok_or_else(|| Error::Invalid("segment table size overflow".to_owned()))?; + let mut table = range(data, table_start, table_size)?.to_vec(); + let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state); + let table_xor = table_state.wrapping_shl(3); + let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state)); + for offset in (0..table_size).step_by(4) { + let value = read_u32(&table, offset)?; + let decoded = gf32_mul_fixed(value ^ table_xor) + .wrapping_add(table_add.wrapping_shr(((offset & 7) + 5) as u32)); + table[offset..offset + 4].copy_from_slice(&decoded.to_le_bytes()); + } + let mut segments = Vec::with_capacity(segment_count); + for index in 0..segment_count { + let offset = read_u32(&table, index * 8)?; + let size = read_u32(&table, index * 8 + 4)?; + let absolute = start + .checked_add(offset as usize) + .and_then(|value| value.checked_add(size as usize)); + if size == 0 || absolute.is_none_or(|end| end > data.len()) { + return invalid(format!("container segment {index} lies outside 0x9D")); + } + segments.push(EncodedSegment { offset, size }); + } + Ok(Self { + start, + output_size, + skip_aes, + tree, + segments, + }) + } + + /// End offset of the furthest encrypted segment. + pub fn encoded_end(&self) -> Result { + self.segments + .iter() + .map(|segment| { + self.start + .checked_add(segment.offset as usize) + .and_then(|value| value.checked_add(segment.size as usize)) + .ok_or_else(|| Error::Invalid("encoded segment end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("container has no encoded segments".to_owned())) + } +} + +/// Decoder for the protector's Huffman/LZ writer streams. +#[derive(Debug, Clone)] +pub struct HuffmanLzDecoder { + tree: Vec, + lookup_symbols: Vec, + lookup_bits: Vec, +} + +impl HuffmanLzDecoder { + /// Build the full 16-bit prefix lookup used by the static decoder. + pub fn new(tree: &[u8]) -> Result { + if tree.len() < 256 * 3 || tree.len() % 3 != 0 { + return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len())); + } + let mut result = Self { + tree: tree.to_vec(), + lookup_symbols: vec![0; 0x1_0000], + lookup_bits: vec![0; 0x1_0000], + }; + for word in 0..0x1_0000_u32 { + let (symbol, bits) = result.decode_symbol(word)?; + if bits <= 16 { + result.lookup_symbols[word as usize] = symbol; + result.lookup_bits[word as usize] = bits; + } + } + Ok(result) + } + + fn entry(&self, index: usize) -> Result<(u16, bool, u8)> { + let offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node offset overflow".to_owned()))?; + let bytes = range(&self.tree, offset, 3)?; + let raw = u16::from(bytes[0]) | (u16::from(bytes[1]) << 8); + Ok((raw & 0x7fff, raw & 0x8000 != 0, bytes[2])) + } + + fn decode_symbol(&self, word: u32) -> Result<(u16, u8)> { + let (mut value, leaf, extra) = self.entry((word & 0xff) as usize)?; + if leaf { + if extra == 0 { + return invalid("zero-width Huffman leaf"); + } + return Ok((value, extra)); + } + let mut bits = extra + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + let mut mask = 1_u32.wrapping_shl(u32::from(extra)); + loop { + let branch = usize::from(word & mask != 0); + let (next, is_leaf, _) = self.entry(usize::from(value) + branch)?; + value = next; + if is_leaf { + return Ok((value, bits)); + } + mask = mask.wrapping_shl(1); + bits = bits + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + if bits > 31 { + return invalid("Huffman code exceeds the native 32-bit window"); + } + } + } + + /// Decode one compressed writer payload to its exact expected size. + pub fn decode(&self, source: &[u8], output_size: usize) -> Result> { + let mut output = vec![0_u8; output_size]; + let mut source_pos = 0_usize; + let mut bit_buffer = 0_u64; + let mut available = 0_u8; + let mut consumed_bits = 0_usize; + let mut output_pos = 0_usize; + let mut prefix = 0_usize; + + while output_pos < output_size { + while available < 24 && source_pos < source.len() { + bit_buffer |= u64::from(source[source_pos]) << available; + source_pos += 1; + available += 8; + } + let key = (bit_buffer & 0xffff) as usize; + let mut bits = self.lookup_bits[key]; + let symbol = if bits != 0 { + self.lookup_symbols[key] + } else { + let mut value_offset = ((bit_buffer & 0xff) as usize) * 3; + let mut node = range(&self.tree, value_offset, 3)?; + let mut raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + bits = node[2]; + raw & 0x7fff + } else { + let extra = node[2]; + bits = extra + 1; + let mut mask = 1_u64 << extra; + loop { + let branch = usize::from(bit_buffer & mask != 0); + let index = usize::from(raw & 0x7fff) + branch; + value_offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node overflow".to_owned()))?; + node = range(&self.tree, value_offset, 3)?; + raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + break raw & 0x7fff; + } + mask <<= 1; + bits += 1; + } + } + }; + if bits == 0 || bits > available { + return invalid("compressed stream ends inside a Huffman code"); + } + bit_buffer >>= bits; + available -= bits; + consumed_bits = consumed_bits + .checked_add(usize::from(bits)) + .ok_or_else(|| Error::Invalid("consumed bit count overflow".to_owned()))?; + + let kind = symbol & 0x300; + let value = usize::from(symbol & 0xff); + match kind { + 0 => { + output[output_pos] = value as u8; + output_pos += 1; + } + 0x100 => { + if prefix > 0xff { + return invalid("compressed prefix exceeds 16 bits"); + } + prefix = if prefix == 0 { + value + } else { + value | (prefix << 8) + }; + } + 0x200 => { + if prefix == 0 { + prefix = 1; + } + let count = value + .checked_mul(prefix) + .ok_or_else(|| Error::Invalid("repeat count overflow".to_owned()))?; + if !matches!(value, 1 | 2 | 4) + || value > output_pos + || output_pos + .checked_add(count) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed repeated-pattern command"); + } + let pattern = output[output_pos - value..output_pos].to_vec(); + for chunk in output[output_pos..output_pos + count].chunks_exact_mut(value) { + chunk.copy_from_slice(&pattern); + } + output_pos += count; + prefix = 0; + } + 0x300 => { + let length = value; + let distance = prefix.checked_add(length).ok_or_else(|| { + Error::Invalid("back-reference distance overflow".to_owned()) + })?; + if distance > output_pos + || output_pos + .checked_add(length) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed back-reference"); + } + let source_start = output_pos - distance; + output.copy_within(source_start..source_start + length, output_pos); + output_pos += length; + prefix = 0; + } + _ => unreachable!("masked Huffman symbol kind"), + } + } + if consumed_bits.div_ceil(8) != source.len() { + return invalid(format!( + "compressed input consumption mismatch: used=0x{:x}, size=0x{:x}", + consumed_bits.div_ceil(8), + source.len() + )); + } + Ok(output) + } +} + +/// Apply the native word transform and optional AES-256-CBC decryption. +pub fn transform_segment( + data: &[u8], + seed: u32, + aes_key: &[u8; 32], + decrypt_aes: bool, +) -> Result> { + let mut transformed = data.to_vec(); + let mut state = seed; + let mut left = 0xe34e_ac63_u32; + let mut right = 0x07b4_8238_u32; + for (index, chunk) in transformed.chunks_exact_mut(4).enumerate() { + let index32 = u32::try_from(index) + .map_err(|_| Error::Invalid("segment word index exceeds u32".to_owned()))?; + left = state + .wrapping_add(0x72f6_fcbe) + .wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left)) + .wrapping_shr(index32.wrapping_mul(index32) & 0x0f); + right = state + .wrapping_sub(0x71b6_a98d) + .wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right)) + .wrapping_shl(index32 & 7); + state = left ^ right; + let bytes: [u8; 4] = chunk + .try_into() + .map_err(|_| Error::Invalid("invalid transformed word".to_owned()))?; + let mut value = u32::from_le_bytes(bytes); + value = value.wrapping_add(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d)); + value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3); + value = value.wrapping_sub(state) ^ state; + chunk.copy_from_slice(&value.to_le_bytes()); + } + + if decrypt_aes { + let cipher = Aes256::new_from_slice(aes_key) + .map_err(|_| Error::Invalid("invalid AES-256 key length".to_owned()))?; + let aligned_size = transformed.len() & !0x0f; + let mut previous = [0_u8; 16]; + for chunk in transformed[..aligned_size].chunks_exact_mut(16) { + let mut ciphertext = [0_u8; 16]; + ciphertext.copy_from_slice(chunk); + cipher.decrypt_block(Block::::from_mut_slice(chunk)); + for (byte, prior) in chunk.iter_mut().zip(previous) { + *byte ^= prior; + } + previous = ciphertext; + } + } + Ok(transformed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_mix_columns_matches_fips_example() { + let input = [ + 0xdb, 0x13, 0x53, 0x45, 0xf2, 0x0a, 0x22, 0x5c, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ]; + assert_eq!( + mix_columns(input), + [ + 0x8e, 0x4d, 0xa1, 0xbc, 0x9f, 0xdc, 0x58, 0x9d, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ] + ); + } + + #[test] + fn descriptor_rejects_truncated_input() { + assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err()); + } +} diff --git a/senbei-android-elf/Cargo.toml b/senbei-android-elf/Cargo.toml new file mode 100644 index 0000000..6d84996 --- /dev/null +++ b/senbei-android-elf/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "senbei-android-elf" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "AArch64 ELF restoration for Senbei Android" + +[dependencies] +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-elf/src/artifact.rs b/senbei-android-elf/src/artifact.rs new file mode 100644 index 0000000..464e51d --- /dev/null +++ b/senbei-android-elf/src/artifact.rs @@ -0,0 +1,105 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::error::{Error, Result, invalid}; + +const REQUIRED_IDS: [u32; 3] = [0x9b, 0x9d, 0x9e]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Artifact { + pub path: PathBuf, + pub size: u64, +} + +pub(crate) fn load_artifacts(index_path: &Path) -> Result> { + let text = std::fs::read_to_string(index_path) + .map_err(|error| Error::io("read module index", index_path, error))?; + let document: Value = serde_json::from_str(&text)?; + let root = index_path.parent().unwrap_or_else(|| Path::new(".")); + let mut result = BTreeMap::new(); + + if let Some(items) = document.get("module_registry").and_then(Value::as_array) { + for item in items { + let Some(command_id) = item.get("command_id").and_then(Value::as_u64) else { + continue; + }; + let command_id = u32::try_from(command_id) + .map_err(|_| Error::Invalid("module command ID exceeds u32".to_owned()))?; + if !REQUIRED_IDS.contains(&command_id) { + continue; + } + let Some(path) = item.get("image_path").and_then(Value::as_str) else { + continue; + }; + let size = item + .get("size") + .and_then(Value::as_u64) + .ok_or_else(|| Error::Invalid(format!("module 0x{command_id:02X} lacks size")))?; + result.insert( + command_id, + Artifact { + path: root.join(path), + size, + }, + ); + } + } + if let Some(streams) = document.get("streams").and_then(Value::as_array) { + for stream in streams { + let Some(records) = stream.get("records").and_then(Value::as_array) else { + continue; + }; + for record in records { + let Some(command_id) = record.get("command_id").and_then(Value::as_u64) else { + continue; + }; + let command_id = u32::try_from(command_id) + .map_err(|_| Error::Invalid("record command ID exceeds u32".to_owned()))?; + if !REQUIRED_IDS.contains(&command_id) { + continue; + } + let Some(image) = record.get("image") else { + continue; + }; + let Some(path) = image.get("path").and_then(Value::as_str) else { + continue; + }; + let size = image.get("size").and_then(Value::as_u64).ok_or_else(|| { + Error::Invalid(format!("record 0x{command_id:02X} lacks image size")) + })?; + result.insert( + command_id, + Artifact { + path: root.join(path), + size, + }, + ); + } + } + } + + let missing = REQUIRED_IDS + .iter() + .filter(|id| !result.contains_key(id)) + .map(|id| format!("0x{id:02X}")) + .collect::>(); + if !missing.is_empty() { + return invalid(format!( + "module index lacks required IDs: {}", + missing.join(", ") + )); + } + for (&command_id, artifact) in &result { + let metadata = std::fs::metadata(&artifact.path) + .map_err(|error| Error::io("inspect artifact", &artifact.path, error))?; + if !metadata.is_file() || metadata.len() != artifact.size { + return invalid(format!( + "invalid artifact for module 0x{command_id:02X}: {}", + artifact.path.display() + )); + } + } + Ok(result) +} diff --git a/senbei-android-elf/src/error.rs b/senbei-android-elf/src/error.rs new file mode 100644 index 0000000..058db5e --- /dev/null +++ b/senbei-android-elf/src/error.rs @@ -0,0 +1,35 @@ +use std::path::{Path, PathBuf}; + +/// ELF restoration failure. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{action} `{path}`: {source}")] + Io { + action: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("cannot parse module index: {0}")] + Json(#[from] serde_json::Error), + #[error(transparent)] + Crypto(#[from] 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-elf/src/hash.rs b/senbei-android-elf/src/hash.rs new file mode 100644 index 0000000..550e012 --- /dev/null +++ b/senbei-android-elf/src/hash.rs @@ -0,0 +1,107 @@ +use crate::error::{Error, Result, invalid}; + +#[must_use] +pub(crate) fn elf_hash(name: &[u8]) -> u32 { + let mut value = 0_u32; + for &byte in name { + value = value.wrapping_shl(4).wrapping_add(u32::from(byte)); + let high = value & 0xf000_0000; + if high != 0 { + value ^= high >> 24; + value &= !high; + } + } + value +} + +#[must_use] +pub(crate) fn gnu_hash(name: &[u8]) -> u32 { + name.iter().fold(5381_u32, |value, &byte| { + value.wrapping_mul(33).wrapping_add(u32::from(byte)) + }) +} + +pub(crate) fn build_sysv_hash(names: &[Vec]) -> Result> { + if names.len() < 2 { + return invalid("dynamic symbol table is unexpectedly empty"); + } + let bucket_count = names.len(); + let symbol_count = names.len(); + let mut buckets = vec![0_u32; bucket_count]; + let mut chains = vec![0_u32; symbol_count]; + for (symbol_index, name) in names.iter().enumerate().skip(1) { + let bucket_index = elf_hash(name) as usize % bucket_count; + let symbol_index32 = u32::try_from(symbol_index) + .map_err(|_| Error::Invalid("dynamic symbol index exceeds u32".to_owned()))?; + if buckets[bucket_index] == 0 { + buckets[bucket_index] = symbol_index32; + continue; + } + let mut chain_index = buckets[bucket_index] as usize; + while chains[chain_index] != 0 { + chain_index = chains[chain_index] as usize; + } + chains[chain_index] = symbol_index32; + } + let mut output = Vec::with_capacity((2 + bucket_count + symbol_count) * 4); + output.extend_from_slice( + &u32::try_from(bucket_count) + .map_err(|_| Error::Invalid("SysV bucket count exceeds u32".to_owned()))? + .to_le_bytes(), + ); + output.extend_from_slice( + &u32::try_from(symbol_count) + .map_err(|_| Error::Invalid("SysV symbol count exceeds u32".to_owned()))? + .to_le_bytes(), + ); + for value in buckets.into_iter().chain(chains) { + output.extend_from_slice(&value.to_le_bytes()); + } + Ok(output) +} + +pub(crate) fn build_gnu_hash(names: &[Vec]) -> Result> { + let hashes = names + .iter() + .skip(1) + .map(|name| gnu_hash(name)) + .collect::>(); + if hashes.is_empty() { + return invalid("GNU hash requires at least one dynamic symbol"); + } + let bloom_shift = 5_u32; + let mut bloom_word = 0_u64; + for &value in &hashes { + bloom_word |= 1_u64 << (value & 63); + bloom_word |= 1_u64 << ((value >> bloom_shift) & 63); + } + let mut chains = hashes + .into_iter() + .map(|value| value & !1) + .collect::>(); + let last = chains + .last_mut() + .ok_or_else(|| Error::Invalid("GNU hash chain is empty".to_owned()))?; + *last |= 1; + let mut output = Vec::with_capacity(28 + chains.len() * 4); + for value in [1_u32, 1, 1, bloom_shift] { + output.extend_from_slice(&value.to_le_bytes()); + } + output.extend_from_slice(&bloom_word.to_le_bytes()); + output.extend_from_slice(&1_u32.to_le_bytes()); + for value in chains { + output.extend_from_slice(&value.to_le_bytes()); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_elf_hash_is_stable() { + assert_eq!(elf_hash(b"printf"), 0x0779_05a6); + assert_eq!(gnu_hash(b"printf"), 0x156b_2bb8); + } +} diff --git a/senbei-android-elf/src/layout.rs b/senbei-android-elf/src/layout.rs new file mode 100644 index 0000000..5ffc7e9 --- /dev/null +++ b/senbei-android-elf/src/layout.rs @@ -0,0 +1,301 @@ +use crate::error::{Error, Result, invalid}; + +pub(crate) const SHT_NOBITS: u32 = 8; +pub(crate) const SHT_LOUSER: u32 = 0x8000_0000; +pub(crate) const SHF_ALLOC: u64 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LoadSegment { + pub offset: u64, + pub virtual_address: u64, + pub file_size: u64, + pub memory_size: u64, + pub flags: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SectionHeader { + pub name: u32, + pub section_type: u32, + pub flags: u64, + pub address: u64, + pub offset: u64, + pub size: u64, + pub link: u32, + pub info: u32, + pub alignment: u64, + pub entry_size: u64, +} + +impl SectionHeader { + pub const SIZE: usize = 0x40; + + fn parse(data: &[u8], offset: usize) -> Result { + Ok(Self { + name: read_u32(data, offset)?, + section_type: read_u32(data, offset + 4)?, + flags: read_u64(data, offset + 8)?, + address: read_u64(data, offset + 0x10)?, + offset: read_u64(data, offset + 0x18)?, + size: read_u64(data, offset + 0x20)?, + link: read_u32(data, offset + 0x28)?, + info: read_u32(data, offset + 0x2c)?, + alignment: read_u64(data, offset + 0x30)?, + entry_size: read_u64(data, offset + 0x38)?, + }) + } + + pub fn encode(self) -> [u8; Self::SIZE] { + let mut output = [0_u8; Self::SIZE]; + output[0..4].copy_from_slice(&self.name.to_le_bytes()); + output[4..8].copy_from_slice(&self.section_type.to_le_bytes()); + output[8..0x10].copy_from_slice(&self.flags.to_le_bytes()); + output[0x10..0x18].copy_from_slice(&self.address.to_le_bytes()); + output[0x18..0x20].copy_from_slice(&self.offset.to_le_bytes()); + output[0x20..0x28].copy_from_slice(&self.size.to_le_bytes()); + output[0x28..0x2c].copy_from_slice(&self.link.to_le_bytes()); + output[0x2c..0x30].copy_from_slice(&self.info.to_le_bytes()); + output[0x30..0x38].copy_from_slice(&self.alignment.to_le_bytes()); + output[0x38..0x40].copy_from_slice(&self.entry_size.to_le_bytes()); + output + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ElfLayout { + pub entrypoint: u64, + pub program_headers: Vec, + pub section_headers: Vec, + pub section_name_index: usize, + pub private_section_index: usize, +} + +impl ElfLayout { + pub fn parse(data: &[u8], require_private: bool) -> Result { + let ident = slice(data, 0, 6)?; + if ident[..4] != *b"\x7fELF" || ident[4] != 2 || ident[5] != 1 { + return invalid("input is not a little-endian ELF64 file"); + } + if read_u16(data, 0x12)? != 0xb7 { + return invalid("input is not an AArch64 ELF"); + } + let entrypoint = read_u64(data, 0x18)?; + let program_header_offset = usize_from_u64(read_u64(data, 0x20)?, "program header offset")?; + let section_header_offset = usize_from_u64(read_u64(data, 0x28)?, "section header offset")?; + let program_header_size = usize::from(read_u16(data, 0x36)?); + let program_header_count = usize::from(read_u16(data, 0x38)?); + let section_header_size = usize::from(read_u16(data, 0x3a)?); + let section_header_count = usize::from(read_u16(data, 0x3c)?); + let section_name_index = usize::from(read_u16(data, 0x3e)?); + if program_header_size != 0x38 || section_header_size != SectionHeader::SIZE { + return invalid("unexpected ELF program/section header size"); + } + + let mut program_headers = Vec::new(); + for index in 0..program_header_count { + let offset = checked_index(program_header_offset, index, program_header_size)?; + if read_u32(data, offset)? != 1 { + continue; + } + let segment = LoadSegment { + flags: read_u32(data, offset + 4)?, + offset: read_u64(data, offset + 8)?, + virtual_address: read_u64(data, offset + 0x10)?, + file_size: read_u64(data, offset + 0x20)?, + memory_size: read_u64(data, offset + 0x28)?, + }; + let file_end = segment + .offset + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid(format!("PT_LOAD {index} file range overflow")))?; + if file_end > data.len() as u64 { + return invalid(format!("PT_LOAD {index} exceeds input file")); + } + program_headers.push(segment); + } + if program_headers.is_empty() { + return invalid("input ELF contains no PT_LOAD segments"); + } + + let mut section_headers = Vec::with_capacity(section_header_count); + for index in 0..section_header_count { + let offset = checked_index(section_header_offset, index, section_header_size)?; + section_headers.push(SectionHeader::parse(data, offset)?); + } + if section_name_index >= section_headers.len() { + return invalid("ELF section-name index is out of range"); + } + let private = section_headers + .iter() + .enumerate() + .filter_map(|(index, section)| (section.section_type == SHT_LOUSER).then_some(index)) + .collect::>(); + let private_section_index = match private.as_slice() { + [index] => *index, + [] if !require_private => usize::MAX, + _ => { + return invalid(format!( + "expected {} SHT_LOUSER section, found {}", + if require_private { + "one" + } else { + "at most one" + }, + private.len() + )); + } + }; + Ok(Self { + entrypoint, + program_headers, + section_headers, + section_name_index, + private_section_index, + }) + } + + pub fn private_section(&self) -> Result { + self.section_headers + .get(self.private_section_index) + .copied() + .ok_or_else(|| Error::Invalid("ELF has no private section".to_owned())) + } + + pub fn load_end(&self) -> Result { + self.program_headers + .iter() + .map(|segment| { + segment + .virtual_address + .checked_add(segment.memory_size) + .ok_or_else(|| Error::Invalid("PT_LOAD memory end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("ELF has no PT_LOAD memory range".to_owned())) + } + + pub fn file_load_end(&self) -> Result { + self.program_headers + .iter() + .map(|segment| { + segment + .offset + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid("PT_LOAD file end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("ELF has no PT_LOAD file range".to_owned())) + } + + pub fn section_names(&self, data: &[u8]) -> Result> { + let table = self.section_headers[self.section_name_index]; + let strings = slice_u64(data, table.offset, table.size)?; + self.section_headers + .iter() + .map(|section| { + let offset = section.name as usize; + if offset >= strings.len() { + return Ok(String::new()); + } + let end = strings[offset..] + .iter() + .position(|&byte| byte == 0) + .map_or(strings.len(), |length| offset + length); + Ok(String::from_utf8_lossy(&strings[offset..end]).into_owned()) + }) + .collect() + } + + pub fn file_offset_to_virtual_address(&self, offset: u64, size: u64) -> Result { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("file range overflow".to_owned()))?; + for segment in &self.program_headers { + let segment_end = segment + .offset + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid("PT_LOAD file range overflow".to_owned()))?; + if segment.offset <= offset && end <= segment_end { + return segment + .virtual_address + .checked_add(offset - segment.offset) + .ok_or_else(|| Error::Invalid("virtual address overflow".to_owned())); + } + } + invalid(format!( + "file range 0x{offset:x}..0x{end:x} is not in PT_LOAD" + )) + } +} + +pub(crate) fn slice(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 out of bounds" + )) + }) +} + +pub(crate) fn slice_u64(data: &[u8], offset: u64, size: u64) -> Result<&[u8]> { + slice( + data, + usize_from_u64(offset, "file offset")?, + usize_from_u64(size, "file size")?, + ) +} + +pub(crate) fn read_u16(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 2] = slice(data, offset, 2)? + .try_into() + .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(bytes)) +} + +pub(crate) fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = slice(data, offset, 4)? + .try_into() + .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(bytes)) +} + +pub(crate) fn read_u64(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 8] = slice(data, offset, 8)? + .try_into() + .map_err(|_| Error::Invalid("invalid u64 range".to_owned()))?; + Ok(u64::from_le_bytes(bytes)) +} + +pub(crate) fn read_i64(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 8] = slice(data, offset, 8)? + .try_into() + .map_err(|_| Error::Invalid("invalid i64 range".to_owned()))?; + Ok(i64::from_le_bytes(bytes)) +} + +pub(crate) fn usize_from_u64(value: u64, field: &str) -> Result { + usize::try_from(value).map_err(|_| Error::Invalid(format!("{field} 0x{value:x} exceeds usize"))) +} + +pub(crate) fn checked_index(base: usize, index: usize, stride: usize) -> Result { + index + .checked_mul(stride) + .and_then(|value| base.checked_add(value)) + .ok_or_else(|| Error::Invalid("table index overflow".to_owned())) +} + +pub(crate) fn align_up(value: u64, alignment: u64) -> Result { + if alignment == 0 || !alignment.is_power_of_two() { + return invalid(format!("invalid alignment {alignment}")); + } + value + .checked_add(alignment - 1) + .map(|aligned| aligned & !(alignment - 1)) + .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) +} diff --git a/senbei-android-elf/src/lib.rs b/senbei-android-elf/src/lib.rs new file mode 100644 index 0000000..4c57c0a --- /dev/null +++ b/senbei-android-elf/src/lib.rs @@ -0,0 +1,10 @@ +//! Static restoration of the current protected AArch64 `libil2cpp.so`. + +mod artifact; +mod error; +mod hash; +mod layout; +mod restore; + +pub use error::Error; +pub use restore::{RestoreOptions, RestoreReport, restore_libil2cpp}; diff --git a/senbei-android-elf/src/restore.rs b/senbei-android-elf/src/restore.rs new file mode 100644 index 0000000..8aae28c --- /dev/null +++ b/senbei-android-elf/src/restore.rs @@ -0,0 +1,1407 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use memmap2::{Mmap, MmapMut, MmapOptions}; +use senbei_android_crypto::{ + ContainerHeader, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor, transform_segment, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tempfile::NamedTempFile; + +use crate::artifact::load_artifacts; +use crate::error::{Error, Result, invalid}; +use crate::hash::{build_gnu_hash, build_sysv_hash}; +use crate::layout::{ + ElfLayout, SHF_ALLOC, SHT_LOUSER, SHT_NOBITS, SectionHeader, align_up, read_i64, read_u32, + read_u64, slice, slice_u64, usize_from_u64, +}; + +const CHUNK_SIZE: usize = 16 * 1024 * 1024; +const ELF64_SYMBOL_SIZE: usize = 0x18; +const ELF64_RELA_SIZE: usize = 0x18; +const R_AARCH64_ABS64: u32 = 0x101; +const R_AARCH64_GLOB_DAT: u32 = 0x401; +const R_AARCH64_JUMP_SLOT: u32 = 0x402; +const R_AARCH64_RELATIVE: u32 = 0x403; +const VER_NDX_GLOBAL: u16 = 1; + +const DT_PLTRELSZ: u64 = 2; +const DT_HASH: u64 = 4; +const DT_STRTAB: u64 = 5; +const DT_SYMTAB: u64 = 6; +const DT_RELA: u64 = 7; +const DT_RELASZ: u64 = 8; +const DT_STRSZ: u64 = 10; +const DT_JMPREL: u64 = 23; +const DT_GNU_HASH: u64 = 0x6fff_fef5; +const DT_VERSYM: u64 = 0x6fff_fff0; +const DT_RELACOUNT: u64 = 0x6fff_fff9; +const DT_VERNEED: u64 = 0x6fff_fffe; + +/// Inputs and optional diagnostics for one `libil2cpp.so` restoration. +#[derive(Debug, Clone)] +pub struct RestoreOptions { + pub input: PathBuf, + pub output: PathBuf, + pub index: PathBuf, + pub dump_auxiliary: Option, + pub outer_only: bool, + pub preserve_entrypoint: bool, +} + +/// Container decoding counters. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct DecodeStatistics { + pub segments: usize, + pub writers: usize, + pub compressed_writers: usize, + pub encoded_bytes: u64, + pub decoded_bytes: u64, + pub file_bytes_written: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StaticConfigReport { + pub header_seed: String, + pub container_seed: String, + pub aes_key_sha256: String, + pub schedule_offset: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DescriptorReport { + pub command_id: String, + pub flags: String, + pub outer_offset: String, + pub outer_expected_size: String, + pub auxiliary_offset: String, + pub auxiliary_expected_size: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct HiddenSymbolReport { + pub patch_blob_size: u32, + pub patched_symbols: u32, + pub copied_strings: usize, + pub secondary_record_count: u32, + pub first_target_index: u32, + pub last_target_index: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PlacementReport { + pub offset: u64, + pub size: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ElfMaterializationReport { + pub hidden_symbols: HiddenSymbolReport, + pub old_symbol_count: usize, + pub auxiliary_symbol_count: u32, + pub appended_symbols: usize, + pub new_symbol_count: usize, + pub old_dynstr_size: usize, + pub auxiliary_dynstr_size: u32, + pub new_dynstr_size: usize, + pub rela_dyn_count: usize, + pub rela_plt_count: usize, + pub relative_prefix_count: usize, + pub metadata_start: u64, + pub metadata_end: u64, + pub metadata_capacity_end: u64, + pub metadata_slack: u64, + pub placements: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CleaningReport { + pub private_section_index: usize, + pub private_offset: u64, + pub private_size: u64, + pub input_entrypoint: u64, + pub output_entrypoint: u64, + pub output_section_count: usize, + pub retained_sections: Vec, + pub section_header_offset: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ValidationReport { + pub format: String, + pub machine: String, + pub sections: usize, + pub segments: usize, + pub dynamic_symbols: usize, + pub dynamic_relocations: usize, + pub pltgot_relocations: usize, + pub has_louser: bool, +} + +/// Machine-readable result of the restoration. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RestoreReport { + pub input: String, + pub input_sha256: String, + pub output: String, + pub output_sha256: String, + pub output_size: u64, + pub module_index: String, + pub static_config: StaticConfigReport, + pub descriptor: DescriptorReport, + pub primary: DecodeStatistics, + pub auxiliary: Option, + pub elf_materialization: Option, + pub cleaning: CleaningReport, + pub validation: ValidationReport, + pub elapsed_seconds: f64, +} + +fn map_read_only(file: &File, path: &Path) -> Result { + // SAFETY: the mapping is read-only and `file` remains open for the mapping's + // lifetime. The restoration never mutates or truncates the mapped source. + unsafe { MmapOptions::new().map(file) }.map_err(|error| Error::io("map", path, error)) +} + +fn map_mut(file: &File, length: usize, path: &Path) -> Result { + // SAFETY: `length` is set on the private temporary output immediately + // before this call. No other handle mutates or truncates it while mapped. + unsafe { MmapOptions::new().len(length).map_mut(file) } + .map_err(|error| Error::io("map temporary output", path, error)) +} + +fn read_file(path: &Path) -> Result> { + std::fs::read(path).map_err(|error| Error::io("read", path, error)) +} + +fn sha256_bytes(data: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(data); + format!("{:x}", digest.finalize()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(|error| Error::io("open", path, error))?; + let mut digest = Sha256::new(); + let mut buffer = vec![0_u8; CHUNK_SIZE]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| Error::io("hash", path, error))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn copy_range(source: &[u8], output: &mut File, size: usize, path: &Path) -> Result<()> { + for chunk in source[..size].chunks(CHUNK_SIZE) { + output + .write_all(chunk) + .map_err(|error| Error::io("write temporary output", path, error))?; + } + Ok(()) +} + +fn checked_add(base: usize, value: usize, field: &str) -> Result { + base.checked_add(value) + .ok_or_else(|| Error::Invalid(format!("{field} overflow"))) +} + +struct FileLayoutWriter<'a> { + output: &'a mut [u8], + layout: &'a ElfLayout, + load_end: u64, +} + +impl FileLayoutWriter<'_> { + fn write(&mut self, virtual_address: u64, data: &[u8]) -> Result { + let data_len = u64::try_from(data.len()) + .map_err(|_| Error::Invalid("decoded write length exceeds u64".to_owned()))?; + let end = virtual_address + .checked_add(data_len) + .ok_or_else(|| Error::Invalid("decoded write range overflow".to_owned()))?; + if end > self.load_end { + return invalid(format!( + "decoded write 0x{virtual_address:x}..0x{end:x} exceeds target load image" + )); + } + let mut written = 0_u64; + let mut covered_memory = 0_u64; + for segment in &self.layout.program_headers { + let memory_end = segment + .virtual_address + .checked_add(segment.memory_size) + .ok_or_else(|| Error::Invalid("PT_LOAD memory end overflow".to_owned()))?; + let overlap_start = virtual_address.max(segment.virtual_address); + let overlap_end = end.min(memory_end); + if overlap_start >= overlap_end { + continue; + } + covered_memory = covered_memory + .checked_add(overlap_end - overlap_start) + .ok_or_else(|| Error::Invalid("covered memory count overflow".to_owned()))?; + let file_end_va = segment + .virtual_address + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid("PT_LOAD file VA end overflow".to_owned()))?; + let file_overlap_end = overlap_end.min(file_end_va); + if overlap_start < file_overlap_end { + let source_offset = + usize_from_u64(overlap_start - virtual_address, "write source offset")?; + let file_offset = usize_from_u64( + segment + .offset + .checked_add(overlap_start - segment.virtual_address) + .ok_or_else(|| Error::Invalid("write file offset overflow".to_owned()))?, + "write file offset", + )?; + let count = usize_from_u64(file_overlap_end - overlap_start, "write size")?; + let destination = self + .output + .get_mut(file_offset..file_offset + count) + .ok_or_else(|| { + Error::Invalid("decoded write exceeds temporary output".to_owned()) + })?; + destination.copy_from_slice(&data[source_offset..source_offset + count]); + written += count as u64; + } + } + if covered_memory != data_len { + return invalid(format!( + "decoded write 0x{virtual_address:x}..0x{end:x} is not covered by PT_LOAD memory" + )); + } + usize_from_u64(written, "written byte count") + } +} + +fn decode_container( + payload: &[u8], + header: &ContainerHeader, + config: &Module9bConfig, + mut writer: F, +) -> Result +where + F: FnMut(u64, &[u8]) -> Result, +{ + let decoder = HuffmanLzDecoder::new(&header.tree)?; + let mut statistics = DecodeStatistics { + segments: header.segments.len(), + ..DecodeStatistics::default() + }; + let decrypt_aes = !(config.skip_aes || header.skip_aes); + for (segment_index, encoded) in header.segments.iter().enumerate() { + let start = checked_add( + header.start, + encoded.offset as usize, + "encoded segment start", + )?; + let encoded_data = slice(payload, 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 = u64::from(read_u32(&transformed, 0)?); + 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_end = table_offset + .checked_add( + writer_count + .checked_mul(16) + .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?, + ) + .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 + writer_index * 16; + let output_offset = u64::from(read_u32(&transformed, record)?); + 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 { + statistics.compressed_writers += 1; + Some(decoder.decode(source, output_size)?) + }; + let decoded_slice = decoded.as_deref().unwrap_or(source); + let target = base_offset + .checked_add(output_offset) + .ok_or_else(|| Error::Invalid("writer target address overflow".to_owned()))?; + statistics.file_bytes_written += writer(target, decoded_slice)? as u64; + statistics.writers += 1; + statistics.encoded_bytes += encoded_size as u64; + statistics.decoded_bytes += output_size as u64; + data_cursor = encoded_end; + } + eprintln!( + "[{current:02}/{total:02}] writers={writer_count} encoded=0x{size:x}", + current = segment_index + 1, + total = header.segments.len(), + size = encoded.size, + ); + } + Ok(statistics) +} + +fn read_c_string(data: &[u8], offset: usize, limit: usize) -> Result<&[u8]> { + if offset >= limit || limit > data.len() { + return invalid(format!("invalid string offset 0x{offset:x}/0x{limit:x}")); + } + let relative_end = data[offset..limit] + .iter() + .position(|&byte| byte == 0) + .ok_or_else(|| Error::Invalid(format!("unterminated string at 0x{offset:x}")))?; + Ok(&data[offset..offset + relative_end]) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AuxiliaryElfImage { + dynstr_offset: u32, + dynstr_size: u32, + dynsym_offset: u32, + dynsym_count: u32, + relocation1_offset: u32, + relocation1_count: u32, + relocation2_offset: u32, + relocation2_count: u32, +} + +impl AuxiliaryElfImage { + fn parse(data: &[u8]) -> Result { + if data.len() < 0x40 { + return invalid("decoded auxiliary ELF image is truncated"); + } + let mut words = [0_u32; 16]; + for (index, word) in words.iter_mut().enumerate() { + *word = read_u32(data, index * 4)?; + } + if [1, 3, 12, 13, 15] + .into_iter() + .any(|index| words[index] != 0) + { + return invalid("unexpected nonzero auxiliary ELF header field"); + } + if words[14] != 0xb7 { + return invalid(format!( + "unexpected auxiliary ELF machine 0x{:x}", + words[14] + )); + } + let result = Self { + dynstr_offset: words[4], + dynstr_size: words[5], + dynsym_offset: words[6], + dynsym_count: words[7], + relocation1_offset: words[8], + relocation1_count: words[9], + relocation2_offset: words[10], + relocation2_count: words[11], + }; + if result.relocation1_offset != 0x40 { + return invalid("auxiliary relocation table does not follow its header"); + } + let relocation1_end = u64::from(result.relocation1_offset) + + u64::from(result.relocation1_count) * ELF64_RELA_SIZE as u64; + let relocation2_end = u64::from(result.relocation2_offset) + + u64::from(result.relocation2_count) * ELF64_RELA_SIZE as u64; + 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) + || dynsym_end != u64::from(result.dynstr_offset) + || dynstr_end != data.len() as u64 + { + return invalid("auxiliary ELF tables are not contiguous"); + } + if result.dynsym_count < 2 { + return invalid("auxiliary dynamic symbol table is empty"); + } + if slice(data, result.dynsym_offset as usize, ELF64_SYMBOL_SIZE)? + .iter() + .any(|&byte| byte != 0) + { + return invalid("auxiliary dynamic symbol zero entry is not empty"); + } + Ok(result) + } +} + +fn restore_hidden_symbols( + output: &mut [u8], + dynsym: SectionHeader, + dynstr: SectionHeader, + patch_data: &[u8], +) -> Result<(Vec, Vec, HiddenSymbolReport)> { + if dynsym.entry_size != ELF64_SYMBOL_SIZE as u64 || dynsym.size % ELF64_SYMBOL_SIZE as u64 != 0 + { + return invalid("unexpected .dynsym entry layout"); + } + let symbol_count = usize_from_u64( + dynsym.size / ELF64_SYMBOL_SIZE as u64, + "dynamic symbol count", + )?; + let mut symbols = slice_u64(output, dynsym.offset, dynsym.size)?.to_vec(); + let mut strings = slice_u64(output, dynstr.offset, dynstr.size)?.to_vec(); + if patch_data.len() < 0x18 { + return invalid("0x9E symbol patch data is truncated"); + } + let blob_size = read_u32(patch_data, 0)?; + let secondary_record_count = read_u32(patch_data, 4)?; + let table_base = 8_usize; + let table_end = table_base + .checked_add(blob_size as usize) + .ok_or_else(|| Error::Invalid("0x9E patch blob end overflow".to_owned()))?; + if table_end > patch_data.len() { + return invalid("0x9E primary symbol patch blob exceeds its artifact"); + } + let count = read_u32(patch_data, table_base)?; + let symbol_offset = read_u32(patch_data, table_base + 4)? as usize; + let index_offset = read_u32(patch_data, table_base + 8)? as usize; + let string_offset = read_u32(patch_data, table_base + 12)? as usize; + let count_usize = count as usize; + if symbol_offset + .checked_add(count_usize * ELF64_SYMBOL_SIZE) + .is_none_or(|end| end > blob_size as usize) + || index_offset + .checked_add(count_usize * 4) + .is_none_or(|end| end > blob_size as usize) + || string_offset >= blob_size as usize + { + return invalid("0x9E symbol patch table has invalid offsets"); + } + let mut cursor = table_base + string_offset; + let mut patched_indices = HashSet::with_capacity(count_usize); + let mut copied_strings = 0_usize; + for index in 0..count_usize { + let source_offset = table_base + symbol_offset + index * ELF64_SYMBOL_SIZE; + let source_symbol = slice(patch_data, source_offset, ELF64_SYMBOL_SIZE)?; + let target_index = read_u32(patch_data, table_base + index_offset + index * 4)?; + if target_index == 0 || target_index as usize >= symbol_count { + return invalid(format!( + "0x9E target symbol index {target_index} is invalid" + )); + } + if !patched_indices.insert(target_index) { + return invalid(format!("0x9E patches symbol {target_index} more than once")); + } + let name = read_c_string(patch_data, cursor, table_end)?; + cursor += name.len() + 1; + let name_offset = read_u32(source_symbol, 0)? as usize; + if name_offset + .checked_add(name.len() + 1) + .is_none_or(|end| end > strings.len()) + { + return invalid(format!("0x9E symbol {target_index} name exceeds .dynstr")); + } + let existing = read_c_string(&strings, name_offset, strings.len())?; + if existing.is_empty() { + strings[name_offset..name_offset + name.len()].copy_from_slice(name); + strings[name_offset + name.len()] = 0; + copied_strings += 1; + } else if existing != name { + return invalid(format!( + "0x9E symbol {target_index} conflicts with existing .dynstr data" + )); + } + 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 { + return invalid(format!( + "0x9E symbol strings end at 0x{cursor:x}, expected 0x{table_end:x}" + )); + } + let first_target_index = patched_indices + .iter() + .copied() + .min() + .ok_or_else(|| Error::Invalid("0x9E patch table is empty".to_owned()))?; + let last_target_index = patched_indices + .iter() + .copied() + .max() + .ok_or_else(|| Error::Invalid("0x9E patch table is empty".to_owned()))?; + Ok(( + symbols, + strings, + HiddenSymbolReport { + patch_blob_size: blob_size, + patched_symbols: count, + copied_strings, + secondary_record_count, + first_target_index, + last_target_index, + }, + )) +} + +fn dynamic_symbol_names(symbols: &[u8], strings: &[u8]) -> Result>> { + if symbols.len() % ELF64_SYMBOL_SIZE != 0 { + return invalid("dynamic symbol table is not entry-aligned"); + } + symbols + .chunks_exact(ELF64_SYMBOL_SIZE) + .map(|symbol| { + let name_offset = read_u32(symbol, 0)? as usize; + Ok(read_c_string(strings, name_offset, strings.len())?.to_vec()) + }) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Rela { + offset: u64, + info: u64, + addend: i64, +} + +impl Rela { + fn parse(data: &[u8], offset: usize) -> Result { + Ok(Self { + offset: read_u64(data, offset)?, + info: read_u64(data, offset + 8)?, + addend: read_i64(data, offset + 0x10)?, + }) + } + + fn kind(self) -> u32 { + self.info as u32 + } + + fn symbol(self) -> u64 { + self.info >> 32 + } + + fn encode(self, output: &mut Vec) { + output.extend_from_slice(&self.offset.to_le_bytes()); + output.extend_from_slice(&self.info.to_le_bytes()); + output.extend_from_slice(&self.addend.to_le_bytes()); + } +} + +#[derive(Clone, Copy)] +struct RelocationTable<'a> { + data: &'a [u8], + offset: usize, + count: usize, + remap: Option<(usize, u32)>, +} + +impl RelocationTable<'_> { + fn relocation(self, index: usize) -> Result { + if index >= self.count { + return invalid("relocation index is out of range"); + } + let offset = self + .offset + .checked_add( + index + .checked_mul(ELF64_RELA_SIZE) + .ok_or_else(|| Error::Invalid("relocation index overflow".to_owned()))?, + ) + .ok_or_else(|| Error::Invalid("relocation offset overflow".to_owned()))?; + let mut relocation = Rela::parse(self.data, offset)?; + if let Some((old_symbol_count, auxiliary_symbol_count)) = self.remap { + let symbol = relocation.symbol(); + if symbol >= u64::from(auxiliary_symbol_count) { + return invalid("auxiliary relocation references an invalid symbol"); + } + if symbol != 0 { + let base = u64::try_from(old_symbol_count.checked_sub(1).ok_or_else(|| { + Error::Invalid("old dynamic symbol table is empty".to_owned()) + })?) + .map_err(|_| Error::Invalid("old symbol count exceeds u64".to_owned()))?; + let remapped = symbol + .checked_add(base) + .ok_or_else(|| Error::Invalid("remapped symbol index overflow".to_owned()))?; + relocation.info = (remapped << 32) | u64::from(relocation.kind()); + } + } + Ok(relocation) + } + + fn validate(self, allowed: &[u32], description: &str) -> Result<()> { + for index in 0..self.count { + let kind = self.relocation(index)?.kind(); + if !allowed.contains(&kind) { + return invalid(format!( + "{description} contains unsupported relocation type 0x{kind:x}" + )); + } + } + Ok(()) + } + + fn append_where(self, output: &mut Vec, predicate: impl Fn(u32) -> bool) -> Result { + let mut count = 0_usize; + for index in 0..self.count { + let relocation = self.relocation(index)?; + if predicate(relocation.kind()) { + relocation.encode(output); + count += 1; + } + } + Ok(count) + } +} + +fn patch_dynamic_tags( + output: &mut [u8], + dynamic: SectionHeader, + values: &BTreeMap, +) -> Result<()> { + if dynamic.size % 0x10 != 0 { + return invalid(".dynamic size is not entry-aligned"); + } + let start = usize_from_u64(dynamic.offset, ".dynamic offset")?; + let size = usize_from_u64(dynamic.size, ".dynamic size")?; + let end = start + .checked_add(size) + .ok_or_else(|| Error::Invalid(".dynamic end overflow".to_owned()))?; + slice(output, start, size)?; + let mut found = HashSet::with_capacity(values.len()); + for offset in (start..end).step_by(0x10) { + let tag = read_u64(output, offset)?; + if let Some(&value) = values.get(&tag) { + if !found.insert(tag) { + return invalid(format!("dynamic tag 0x{tag:x} occurs more than once")); + } + output[offset + 8..offset + 0x10].copy_from_slice(&value.to_le_bytes()); + } + if tag == 0 { + break; + } + } + let missing = values + .keys() + .filter(|tag| !found.contains(tag)) + .map(|tag| format!("0x{tag:x}")) + .collect::>(); + if !missing.is_empty() { + return invalid(format!("missing dynamic tags: {}", missing.join(", "))); + } + Ok(()) +} + +fn required_section_indices(names: &[String]) -> Result> { + const REQUIRED: [&str; 10] = [ + ".dynsym", + ".gnu.version", + ".gnu.version_r", + ".gnu.hash", + ".hash", + ".dynstr", + ".rela.dyn", + ".rela.plt", + ".dynamic", + ".rodata", + ]; + let mut result = HashMap::with_capacity(REQUIRED.len()); + for required in REQUIRED { + let indices = names + .iter() + .enumerate() + .filter_map(|(index, name)| (name == required).then_some(index)) + .collect::>(); + match indices.as_slice() { + [index] => { + result.insert(required, *index); + } + [] => return invalid(format!("ELF lacks required section {required}")), + _ => return invalid(format!("ELF contains duplicate section {required}")), + } + } + Ok(result) +} + +fn materialize_static_elf_tables( + output: &mut [u8], + source: &[u8], + layout: &ElfLayout, + symbol_patch_data: &[u8], + auxiliary_data: &[u8], +) -> Result<(ElfLayout, ElfMaterializationReport)> { + let names = layout.section_names(source)?; + let indices = required_section_indices(&names)?; + let section = |name: &'static str| -> SectionHeader { layout.section_headers[indices[name]] }; + let dynsym = section(".dynsym"); + let dynstr = section(".dynstr"); + let versym = section(".gnu.version"); + let verneed = section(".gnu.version_r"); + let rela_dyn = section(".rela.dyn"); + let rela_plt = section(".rela.plt"); + let dynamic = section(".dynamic"); + let rodata = section(".rodata"); + + let (old_symbols, old_strings, hidden_symbols) = + restore_hidden_symbols(output, dynsym, dynstr, symbol_patch_data)?; + let old_symbol_count = old_symbols.len() / ELF64_SYMBOL_SIZE; + if versym.size != (old_symbol_count * 2) as u64 { + return invalid(".gnu.version count does not match .dynsym"); + } + let old_versions = slice_u64(output, versym.offset, versym.size)?.to_vec(); + let version_requirements = slice_u64(output, verneed.offset, verneed.size)?.to_vec(); + + let auxiliary = AuxiliaryElfImage::parse(auxiliary_data)?; + let auxiliary_strings = slice( + auxiliary_data, + auxiliary.dynstr_offset as usize, + auxiliary.dynstr_size as usize, + )?; + let appended_count = auxiliary.dynsym_count as usize - 1; + let mut appended_symbols = Vec::with_capacity(appended_count * ELF64_SYMBOL_SIZE); + for index in 1..auxiliary.dynsym_count as usize { + let offset = auxiliary.dynsym_offset as usize + index * ELF64_SYMBOL_SIZE; + let symbol = slice(auxiliary_data, offset, ELF64_SYMBOL_SIZE)?; + let name_offset = read_u32(symbol, 0)? as usize; + read_c_string(auxiliary_strings, name_offset, auxiliary_strings.len())?; + let section_index = u16::from_le_bytes([symbol[6], symbol[7]]); + if section_index != 0 { + return invalid("auxiliary dynamic symbol is unexpectedly defined"); + } + let merged_name_offset = old_strings + .len() + .checked_add(name_offset) + .ok_or_else(|| Error::Invalid("merged dynamic string offset overflow".to_owned()))?; + let merged_name_offset = u32::try_from(merged_name_offset) + .map_err(|_| Error::Invalid("merged dynamic string offset exceeds u32".to_owned()))?; + appended_symbols.extend_from_slice(&merged_name_offset.to_le_bytes()); + appended_symbols.extend_from_slice(&symbol[4..]); + } + let mut merged_symbols = Vec::with_capacity(old_symbols.len() + appended_symbols.len()); + merged_symbols.extend_from_slice(&old_symbols); + merged_symbols.extend_from_slice(&appended_symbols); + let mut merged_strings = Vec::with_capacity(old_strings.len() + auxiliary_strings.len()); + merged_strings.extend_from_slice(&old_strings); + merged_strings.extend_from_slice(auxiliary_strings); + let mut merged_versions = Vec::with_capacity(old_versions.len() + appended_count * 2); + merged_versions.extend_from_slice(&old_versions); + for _ in 0..appended_count { + 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 gnu_hash_table = build_gnu_hash(&merged_names)?; + let new_symbol_count = merged_names.len(); + + if rela_dyn.entry_size != ELF64_RELA_SIZE as u64 + || rela_plt.entry_size != ELF64_RELA_SIZE as u64 + || rela_dyn.size % ELF64_RELA_SIZE as u64 != 0 + || rela_plt.size % ELF64_RELA_SIZE as u64 != 0 + { + return invalid("unexpected relocation entry layout"); + } + let old_dyn = RelocationTable { + data: output, + offset: usize_from_u64(rela_dyn.offset, ".rela.dyn offset")?, + count: usize_from_u64(rela_dyn.size / ELF64_RELA_SIZE as u64, ".rela.dyn count")?, + remap: None, + }; + let old_plt = RelocationTable { + data: output, + offset: usize_from_u64(rela_plt.offset, ".rela.plt offset")?, + count: usize_from_u64(rela_plt.size / ELF64_RELA_SIZE as u64, ".rela.plt count")?, + remap: None, + }; + let auxiliary1 = RelocationTable { + data: auxiliary_data, + offset: auxiliary.relocation1_offset as usize, + count: auxiliary.relocation1_count as usize, + remap: Some((old_symbol_count, auxiliary.dynsym_count)), + }; + let auxiliary2 = RelocationTable { + data: auxiliary_data, + offset: auxiliary.relocation2_offset as usize, + count: auxiliary.relocation2_count as usize, + remap: Some((old_symbol_count, auxiliary.dynsym_count)), + }; + old_dyn.validate( + &[R_AARCH64_RELATIVE, R_AARCH64_GLOB_DAT, R_AARCH64_ABS64], + "existing .rela.dyn", + )?; + old_plt.validate(&[R_AARCH64_JUMP_SLOT], "existing .rela.plt")?; + auxiliary1.validate( + &[R_AARCH64_RELATIVE, R_AARCH64_GLOB_DAT, R_AARCH64_ABS64], + "auxiliary relocation table 1", + )?; + auxiliary2.validate( + &[R_AARCH64_RELATIVE, R_AARCH64_JUMP_SLOT], + "auxiliary relocation table 2", + )?; + + let estimated_dyn = (old_dyn.count + auxiliary1.count + auxiliary2.count) + .checked_mul(ELF64_RELA_SIZE) + .ok_or_else(|| Error::Invalid("merged .rela.dyn capacity overflow".to_owned()))?; + let mut merged_rela_dyn = Vec::with_capacity(estimated_dyn); + let mut relative_count = 0_usize; + for table in [old_dyn, auxiliary1, auxiliary2] { + relative_count += + table.append_where(&mut merged_rela_dyn, |kind| kind == R_AARCH64_RELATIVE)?; + } + for table in [old_dyn, auxiliary1] { + table.append_where(&mut merged_rela_dyn, |kind| kind != R_AARCH64_RELATIVE)?; + } + let mut merged_rela_plt = Vec::with_capacity( + (old_plt.count + auxiliary2.count) + .checked_mul(ELF64_RELA_SIZE) + .ok_or_else(|| Error::Invalid("merged .rela.plt capacity overflow".to_owned()))?, + ); + old_plt.append_where(&mut merged_rela_plt, |_| true)?; + auxiliary2.append_where(&mut merged_rela_plt, |kind| kind == R_AARCH64_JUMP_SLOT)?; + let rela_dyn_count = merged_rela_dyn.len() / ELF64_RELA_SIZE; + let rela_plt_count = merged_rela_plt.len() / ELF64_RELA_SIZE; + + struct TablePayload { + name: &'static str, + alignment: u64, + data: Vec, + } + let tables = vec![ + TablePayload { + name: ".dynsym", + alignment: 8, + data: merged_symbols, + }, + TablePayload { + name: ".gnu.version", + alignment: 2, + data: merged_versions, + }, + TablePayload { + name: ".gnu.version_r", + alignment: 4, + data: version_requirements, + }, + TablePayload { + name: ".gnu.hash", + alignment: 8, + data: gnu_hash_table, + }, + TablePayload { + name: ".hash", + alignment: 4, + data: sysv_hash, + }, + TablePayload { + name: ".dynstr", + alignment: 1, + data: merged_strings, + }, + TablePayload { + name: ".rela.dyn", + alignment: 8, + data: merged_rela_dyn, + }, + TablePayload { + name: ".rela.plt", + alignment: 8, + data: merged_rela_plt, + }, + ]; + let metadata_start = dynsym.offset; + let mut cursor = metadata_start; + let mut placements = BTreeMap::new(); + for table in &tables { + cursor = align_up(cursor, table.alignment)?; + placements.insert( + table.name.to_owned(), + PlacementReport { + offset: cursor, + size: table.data.len(), + }, + ); + cursor = cursor + .checked_add(table.data.len() as u64) + .ok_or_else(|| Error::Invalid("rebuilt ELF metadata end overflow".to_owned()))?; + } + if cursor > rodata.offset { + return invalid(format!( + "rebuilt ELF tables end at 0x{cursor:x}, beyond .rodata 0x{:x}", + rodata.offset + )); + } + let zero_start = usize_from_u64(metadata_start, "metadata start")?; + let zero_end = usize_from_u64(rodata.offset, ".rodata offset")?; + output + .get_mut(zero_start..zero_end) + .ok_or_else(|| Error::Invalid("metadata capacity exceeds output mapping".to_owned()))? + .fill(0); + + let mut updated_sections = layout.section_headers.clone(); + for table in &tables { + let placement = placements + .get(table.name) + .ok_or_else(|| Error::Invalid("table placement disappeared".to_owned()))?; + let offset = usize_from_u64(placement.offset, "table placement offset")?; + let end = offset + .checked_add(table.data.len()) + .ok_or_else(|| Error::Invalid("table placement end overflow".to_owned()))?; + output + .get_mut(offset..end) + .ok_or_else(|| Error::Invalid("table placement exceeds output mapping".to_owned()))? + .copy_from_slice(&table.data); + let index = indices[table.name]; + let mut updated = updated_sections[index]; + updated.address = + layout.file_offset_to_virtual_address(placement.offset, table.data.len() as u64)?; + updated.offset = placement.offset; + updated.size = table.data.len() as u64; + updated_sections[index] = updated; + } + + 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 restored_layout = layout.clone(); + restored_layout.section_headers = updated_sections; + Ok(( + restored_layout, + ElfMaterializationReport { + hidden_symbols, + old_symbol_count, + auxiliary_symbol_count: auxiliary.dynsym_count, + appended_symbols: appended_count, + new_symbol_count, + old_dynstr_size: old_strings.len(), + auxiliary_dynstr_size: auxiliary.dynstr_size, + new_dynstr_size: tables[5].data.len(), + rela_dyn_count, + rela_plt_count, + relative_prefix_count: relative_count, + metadata_start, + metadata_end: cursor, + metadata_capacity_end: rodata.offset, + metadata_slack: rodata.offset - cursor, + placements, + }, + )) +} + +fn write_padding(file: &mut File, size: u64, path: &Path) -> Result<()> { + const ZEROES: [u8; 4096] = [0; 4096]; + let mut remaining = size; + while remaining != 0 { + let count = usize::try_from(remaining.min(ZEROES.len() as u64)) + .map_err(|_| Error::Invalid("padding size exceeds usize".to_owned()))?; + file.write_all(&ZEROES[..count]) + .map_err(|error| Error::io("write padding", path, error))?; + remaining -= count as u64; + } + Ok(()) +} + +fn finalize_clean_elf( + stream: &mut File, + temporary_path: &Path, + source: &[u8], + layout: &ElfLayout, + preserve_entrypoint: bool, +) -> Result { + let private = layout.private_section()?; + let names = layout.section_names(source)?; + if layout.private_section_index + 1 != layout.section_headers.len() { + return invalid("SHT_LOUSER section is not the final section"); + } + let retained = &layout.section_headers[..layout.private_section_index]; + let mut updated = Vec::with_capacity(retained.len()); + stream + .seek(SeekFrom::Start(private.offset)) + .map_err(|error| Error::io("seek temporary output", temporary_path, error))?; + for §ion in retained { + if section.section_type == SHT_NOBITS || section.flags & SHF_ALLOC != 0 || section.size == 0 + { + updated.push(section); + continue; + } + let section_data = slice_u64(source, section.offset, section.size)?; + let alignment = section.alignment.max(1); + let position = stream + .stream_position() + .map_err(|error| Error::io("query temporary output position", temporary_path, error))?; + let padding = (alignment - position % alignment) % alignment; + write_padding(stream, padding, temporary_path)?; + let new_offset = stream + .stream_position() + .map_err(|error| Error::io("query temporary output position", temporary_path, error))?; + stream + .write_all(section_data) + .map_err(|error| Error::io("append ELF section", temporary_path, error))?; + let mut relocated = section; + relocated.offset = new_offset; + updated.push(relocated); + } + let position = stream + .stream_position() + .map_err(|error| Error::io("query temporary output position", temporary_path, error))?; + write_padding(stream, (8 - position % 8) % 8, temporary_path)?; + let section_header_offset = stream + .stream_position() + .map_err(|error| Error::io("query section-header position", temporary_path, error))?; + for section in &updated { + stream + .write_all(§ion.encode()) + .map_err(|error| Error::io("write section header", temporary_path, error))?; + } + let mut elf_header = [0_u8; 0x40]; + stream + .seek(SeekFrom::Start(0)) + .and_then(|_| stream.read_exact(&mut elf_header)) + .map_err(|error| Error::io("read ELF header", temporary_path, error))?; + if !preserve_entrypoint { + elf_header[0x18..0x20].copy_from_slice(&0_u64.to_le_bytes()); + } + elf_header[0x28..0x30].copy_from_slice(§ion_header_offset.to_le_bytes()); + let section_count = u16::try_from(updated.len()) + .map_err(|_| Error::Invalid("output section count exceeds u16".to_owned()))?; + elf_header[0x3c..0x3e].copy_from_slice(§ion_count.to_le_bytes()); + stream + .seek(SeekFrom::Start(0)) + .and_then(|_| stream.write_all(&elf_header)) + .map_err(|error| Error::io("patch ELF header", temporary_path, error))?; + stream + .flush() + .and_then(|_| stream.sync_all()) + .map_err(|error| Error::io("flush temporary output", temporary_path, error))?; + Ok(CleaningReport { + private_section_index: layout.private_section_index, + private_offset: private.offset, + private_size: private.size, + input_entrypoint: layout.entrypoint, + output_entrypoint: if preserve_entrypoint { + layout.entrypoint + } else { + 0 + }, + output_section_count: updated.len(), + retained_sections: names[..layout.private_section_index].to_vec(), + section_header_offset, + }) +} + +fn section_by_name<'a>( + layout: &'a ElfLayout, + names: &[String], + wanted: &str, +) -> Result<&'a SectionHeader> { + let indices = names + .iter() + .enumerate() + .filter_map(|(index, name)| (name == wanted).then_some(index)) + .collect::>(); + match indices.as_slice() { + [index] => Ok(&layout.section_headers[*index]), + [] => invalid(format!("restored ELF lacks {wanted}")), + _ => invalid(format!("restored ELF contains duplicate {wanted}")), + } +} + +fn validate_restored_binary( + data: &[u8], + preserve_entrypoint: bool, + materialization: Option<&ElfMaterializationReport>, +) -> Result { + let layout = ElfLayout::parse(data, false)?; + let has_louser = layout + .section_headers + .iter() + .any(|section| section.section_type == SHT_LOUSER); + if has_louser { + return invalid("restored output still contains SHT_LOUSER"); + } + if !preserve_entrypoint && layout.entrypoint != 0 { + return invalid("restored output retains the protector entrypoint"); + } + let names = layout.section_names(data)?; + let dynsym = section_by_name(&layout, &names, ".dynsym")?; + let rela_dyn = section_by_name(&layout, &names, ".rela.dyn")?; + let rela_plt = section_by_name(&layout, &names, ".rela.plt")?; + let dynamic_symbols = usize_from_u64( + dynsym.size / ELF64_SYMBOL_SIZE as u64, + "restored dynamic symbol count", + )?; + let dynamic_relocations = usize_from_u64( + rela_dyn.size / ELF64_RELA_SIZE as u64, + "restored dynamic relocation count", + )?; + let pltgot_relocations = usize_from_u64( + rela_plt.size / ELF64_RELA_SIZE as u64, + "restored PLT relocation count", + )?; + if let Some(expected) = materialization { + if dynamic_symbols != expected.new_symbol_count + || dynamic_relocations != expected.rela_dyn_count + || pltgot_relocations != expected.rela_plt_count + { + return invalid("restored ELF table counts do not match materialization report"); + } + } + Ok(ValidationReport { + format: "ELF64".to_owned(), + machine: "AARCH64".to_owned(), + sections: layout.section_headers.len(), + segments: layout.program_headers.len(), + dynamic_symbols, + dynamic_relocations, + pltgot_relocations, + has_louser, + }) +} + +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(|error| Error::io("query current directory", path, error)) + } +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .map_err(|error| Error::io("create output directory", parent, error))?; + let mut temporary = NamedTempFile::new_in(parent) + .map_err(|error| Error::io("create temporary file", parent, error))?; + temporary + .write_all(data) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|error| Error::io("write temporary file", temporary.path(), error))?; + temporary + .persist(path) + .map_err(|error| Error::io("replace output", path, error.error))?; + Ok(()) +} + +/// Restore the current protected `libil2cpp.so` without executing protector code. +pub fn restore_libil2cpp(options: &RestoreOptions) -> Result { + let started = Instant::now(); + let input_path = absolute(&options.input)?; + let output_path = absolute(&options.output)?; + let index_path = absolute(&options.index)?; + if input_path == output_path + || (output_path.exists() + && std::fs::canonicalize(&input_path).ok() == std::fs::canonicalize(&output_path).ok()) + { + return invalid("refusing to overwrite the protected input in place"); + } + let artifacts = load_artifacts(&index_path)?; + let module = read_file(&artifacts[&0x9b].path)?; + let symbol_patch_data = read_file(&artifacts[&0x9e].path)?; + let config = Module9bConfig::parse(&module)?; + + let input_file = File::open(&input_path) + .map_err(|error| Error::io("open protected input", &input_path, error))?; + let source = map_read_only(&input_file, &input_path)?; + let payload_path = &artifacts[&0x9d].path; + let payload_file = File::open(payload_path) + .map_err(|error| Error::io("open 0x9D artifact", payload_path, error))?; + 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 descriptor = ProtectedDescriptor::decrypt(&payload, config.header_seed)?; + let load_end = layout.load_end()?; + if u64::from(descriptor.outer_expected_size) != load_end { + return invalid(format!( + "0x9D target size 0x{:x} != ELF load size 0x{load_end:x}", + descriptor.outer_expected_size + )); + } + let outer = ContainerHeader::parse( + &payload, + descriptor.outer_offset as usize, + config.container_seed, + )?; + if u64::from(outer.output_size) != load_end { + return invalid(format!( + "primary container output 0x{:x} != ELF load size 0x{load_end:x}", + outer.output_size + )); + } + let auxiliary_header = ContainerHeader::parse( + &payload, + descriptor.auxiliary_offset as usize, + config.container_seed, + )?; + if auxiliary_header.output_size != descriptor.auxiliary_expected_size { + return invalid("auxiliary container output size does not match the 0x9D descriptor"); + } + if outer.encoded_end()? != descriptor.auxiliary_offset as usize { + return invalid("primary and auxiliary 0x9D containers are not contiguous"); + } + + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .map_err(|error| Error::io("create output directory", parent, error))?; + let mut temporary = NamedTempFile::new_in(parent) + .map_err(|error| Error::io("create temporary output", parent, error))?; + let temporary_path = temporary.path().to_path_buf(); + let private_size = usize_from_u64(private.offset, "private section offset")?; + copy_range( + &source, + temporary.as_file_mut(), + private_size, + &temporary_path, + )?; + temporary + .as_file_mut() + .flush() + .map_err(|error| Error::io("flush initial output", &temporary_path, error))?; + temporary + .as_file() + .set_len(private.offset) + .map_err(|error| Error::io("size temporary output", &temporary_path, error))?; + + let mut restored_layout = layout.clone(); + let mut auxiliary_data = None; + let mut auxiliary_stats = None; + let mut materialization = None; + let primary_stats; + { + let mut output = map_mut(temporary.as_file(), private_size, &temporary_path)?; + eprintln!("Decoding primary 0x9D target-image container..."); + let mut writer = FileLayoutWriter { + output: &mut output, + layout: &layout, + load_end, + }; + primary_stats = decode_container(&payload, &outer, &config, |address, data| { + writer.write(address, data) + })?; + if !options.outer_only { + eprintln!("Decoding auxiliary 0x9D ELF materialization container..."); + let mut decoded = vec![0_u8; auxiliary_header.output_size as usize]; + let stats = decode_container(&payload, &auxiliary_header, &config, |offset, data| { + let start = usize_from_u64(offset, "auxiliary write offset")?; + let end = start + .checked_add(data.len()) + .ok_or_else(|| Error::Invalid("auxiliary decoded write overflow".to_owned()))?; + let destination = decoded.get_mut(start..end).ok_or_else(|| { + Error::Invalid("auxiliary decoded write is out of range".to_owned()) + })?; + destination.copy_from_slice(data); + Ok(data.len()) + })?; + if let Some(path) = &options.dump_auxiliary { + write_atomic(&absolute(path)?, &decoded)?; + } + eprintln!("Rebuilding static ELF dynamic-linker tables..."); + let (new_layout, report) = materialize_static_elf_tables( + &mut output, + &source, + &layout, + &symbol_patch_data, + &decoded, + )?; + restored_layout = new_layout; + materialization = Some(report); + auxiliary_stats = Some(stats); + auxiliary_data = Some(decoded); + } + output + .flush() + .map_err(|error| Error::io("flush restored image", &temporary_path, error))?; + } + drop(auxiliary_data); + + let cleaning = finalize_clean_elf( + temporary.as_file_mut(), + &temporary_path, + &source, + &restored_layout, + options.preserve_entrypoint, + )?; + let validation = { + let restored = map_read_only(temporary.as_file(), &temporary_path)?; + validate_restored_binary( + &restored, + options.preserve_entrypoint, + materialization.as_ref(), + )? + }; + temporary + .persist(&output_path) + .map_err(|error| Error::io("replace restored output", &output_path, error.error))?; + + Ok(RestoreReport { + input: input_path.display().to_string(), + input_sha256: sha256_file(&input_path)?, + output: output_path.display().to_string(), + output_sha256: sha256_file(&output_path)?, + output_size: std::fs::metadata(&output_path) + .map_err(|error| Error::io("inspect restored output", &output_path, error))? + .len(), + module_index: index_path.display().to_string(), + static_config: StaticConfigReport { + header_seed: format!("0x{:08X}", config.header_seed), + container_seed: format!("0x{:08X}", config.container_seed), + aes_key_sha256: sha256_bytes(&config.aes_key), + schedule_offset: format!("0x{:X}", config.schedule_offset), + }, + descriptor: DescriptorReport { + command_id: format!("0x{:X}", descriptor.command_id), + flags: format!("0x{:X}", descriptor.flags), + outer_offset: format!("0x{:X}", descriptor.outer_offset), + outer_expected_size: format!("0x{:X}", descriptor.outer_expected_size), + auxiliary_offset: format!("0x{:X}", descriptor.auxiliary_offset), + auxiliary_expected_size: format!("0x{:X}", descriptor.auxiliary_expected_size), + }, + primary: primary_stats, + auxiliary: auxiliary_stats, + elf_materialization: materialization, + cleaning, + validation, + elapsed_seconds: started.elapsed().as_secs_f64(), + }) +} diff --git a/senbei-android-io/Cargo.toml b/senbei-android-io/Cargo.toml new file mode 100644 index 0000000..35aad71 --- /dev/null +++ b/senbei-android-io/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "senbei-android-io" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Filesystem and CLI orchestration for Senbei Android" + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +senbei-android-elf.workspace = true +senbei-android-metadata.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs new file mode 100644 index 0000000..48fab34 --- /dev/null +++ b/senbei-android-io/src/lib.rs @@ -0,0 +1,154 @@ +//! Filesystem orchestration for the Android restoration commands. + +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 serde::Serialize; +use tempfile::NamedTempFile; + +/// Filesystem arguments for restoring one protected `libil2cpp.so`. +#[derive(Debug, Clone)] +pub struct RestoreSoJob { + pub input: PathBuf, + pub output: PathBuf, + pub index: Option, + pub report: Option, + pub dump_auxiliary: Option, + pub outer_only: bool, + pub preserve_entrypoint: bool, +} + +/// Filesystem arguments for restoring one `global-metadata.dat`. +#[derive(Debug, Clone)] +pub struct RestoreMetadataJob { + pub input: PathBuf, + pub output: PathBuf, + pub seed: u32, + pub report: Option, +} + +impl RestoreMetadataJob { + #[must_use] + pub fn new(input: PathBuf, output: PathBuf) -> Self { + Self { + input, + output, + seed: DEFAULT_METHOD_TOKEN_SEED, + report: None, + } + } +} + +/// Infer the Stage 2 module index produced for `libil2cpp.so`. +#[must_use] +pub fn default_module_index(input: &Path) -> PathBuf { + input + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("libil2cpp_stage2_modules") + .join("index.json") +} + +/// Run static SO restoration and optionally emit its JSON report. +pub fn run_restore_so(job: &RestoreSoJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let options = RestoreOptions { + input: job.input.clone(), + output: job.output.clone(), + index: job + .index + .clone() + .unwrap_or_else(|| default_module_index(&job.input)), + dump_auxiliary: job.dump_auxiliary.clone(), + outer_only: job.outer_only, + preserve_entrypoint: job.preserve_entrypoint, + }; + let result = restore_libil2cpp(&options).context("restore protected libil2cpp.so")?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +/// Restore MethodDef tokens and atomically write the cleaned metadata. +pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let input = + std::fs::read(&job.input).with_context(|| format!("read `{}`", job.input.display()))?; + let (output, result) = senbei_android_metadata::restore_method_tokens(&input, job.seed) + .with_context(|| format!("restore `{}`", job.input.display()))?; + write_atomic(&job.output, &output)?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { + let input_absolute = absolute(input)?; + let output_absolute = absolute(output)?; + let same_existing_file = + output.exists() && std::fs::canonicalize(input).ok() == std::fs::canonicalize(output).ok(); + if input_absolute == output_absolute || same_existing_file { + bail!( + "refusing to overwrite input in place: `{}`", + input.display() + ); + } + Ok(()) +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .context("query current directory")? + .join(path)) + } +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { + let mut data = serde_json::to_vec_pretty(value).context("serialize JSON report")?; + data.push(b'\n'); + write_atomic(path, &data) +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("create output directory `{}`", parent.display()))?; + let mut temporary = NamedTempFile::new_in(parent) + .with_context(|| format!("create temporary file in `{}`", parent.display()))?; + temporary + .write_all(data) + .and_then(|()| temporary.as_file().sync_all()) + .with_context(|| format!("write temporary output for `{}`", path.display()))?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("replace output `{}`", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_index_next_to_input() { + assert_eq!( + default_module_index(Path::new(r"C:\game\Native\libil2cpp.so")), + PathBuf::from(r"C:\game\Native\libil2cpp_stage2_modules\index.json") + ); + } + + #[test] + fn metadata_job_uses_current_seed() { + let job = RestoreMetadataJob::new(PathBuf::from("in"), PathBuf::from("out")); + assert_eq!(job.seed, DEFAULT_METHOD_TOKEN_SEED); + } +} diff --git a/senbei-android-metadata/Cargo.toml b/senbei-android-metadata/Cargo.toml new file mode 100644 index 0000000..2dbaeb7 --- /dev/null +++ b/senbei-android-metadata/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "senbei-android-metadata" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "IL2CPP metadata restoration for Senbei Android" + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-metadata/src/lib.rs b/senbei-android-metadata/src/lib.rs new file mode 100644 index 0000000..3abe1ff --- /dev/null +++ b/senbei-android-metadata/src/lib.rs @@ -0,0 +1,421 @@ +//! Static restoration of protected IL2CPP v31 method tokens. + +use serde::Serialize; + +/// Seed embedded in the current `libil2cpp` module `0x0C`. +pub const DEFAULT_METHOD_TOKEN_SEED: u32 = 0xa6fa_e968; + +const MAGIC: u32 = 0xfab1_1baf; +const SUPPORTED_VERSION: u32 = 31; +const HDR_METHODS: usize = 0x30; +const HDR_TYPES: usize = 0xa0; +const HDR_IMAGES: usize = 0xa8; +const METHOD_STRIDE: usize = 0x24; +const METHOD_TOKEN_OFFSET: usize = 0x18; +const TYPE_STRIDE: usize = 0x58; +const TYPE_METHOD_START_OFFSET: usize = 0x24; +const TYPE_METHOD_COUNT_OFFSET: usize = 0x40; +const IMAGE_STRIDE: usize = 0x28; +const IMAGE_TYPE_START_OFFSET: usize = 0x08; +const IMAGE_TYPE_COUNT_OFFSET: usize = 0x0c; +const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; + +/// Summary of one metadata restoration pass. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Report { + pub version: u32, + pub seed: String, + pub images: usize, + pub images_with_methods: usize, + pub types: usize, + pub methods: usize, + pub visited_methods: usize, + pub already_correct_before: usize, + pub correct_after: usize, + pub changed_tokens: usize, + pub transformed_images: usize, +} + +/// Metadata parsing or validation failure. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not an IL2CPP global-metadata.dat")] + NotMetadata, + #[error("unsupported metadata version {0}")] + UnsupportedVersion(u32), + #[error("malformed metadata: {0}")] + Malformed(String), + #[error("method-token restoration failed: {0}")] + Validation(String), +} + +type Result = std::result::Result; + +fn malformed(message: impl Into) -> Result { + Err(Error::Malformed(message.into())) +} + +fn validation(message: impl Into) -> Result { + Err(Error::Validation(message.into())) +} + +fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Malformed("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Malformed(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let value: [u8; 2] = bytes(data, offset, 2)? + .try_into() + .map_err(|_| Error::Malformed("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(value)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(value)) +} + +fn read_i32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid i32 range".to_owned()))?; + Ok(i32::from_le_bytes(value)) +} + +fn table(data: &[u8], header_offset: usize) -> Result<(usize, usize)> { + let offset = read_u32(data, header_offset)? as usize; + let size = read_u32(data, header_offset + 4)? as usize; + bytes(data, offset, size)?; + Ok((offset, size)) +} + +#[inline] +fn inverse_round(mut value: u32, count: u32, key: u32) -> u32 { + let mirror = count.wrapping_mul(2).wrapping_sub(1); + if value & 1 != 0 { + value = mirror.wrapping_sub(value); + } + value >>= 1; + if value >= count { + value = mirror.wrapping_sub(value); + } + let value = value.wrapping_sub(key); + if value > count { + value.wrapping_add(count) + } else { + value + } +} + +fn decrypt_rid(rid: u32, low: u32, high: u32, seed: u32) -> Result { + let count = high + .checked_add(1) + .and_then(|value| value.checked_sub(low)) + .ok_or_else(|| Error::Validation("invalid image RID interval".to_owned()))?; + if count < 2 { + return validation("RID inverse permutation requires at least two entries"); + } + let half = count / 2; + if half == 0 { + return validation("RID inverse permutation has a zero divisor"); + } + let key = seed % half + count / 4; + let mut value = rid + .checked_sub(low) + .ok_or_else(|| Error::Validation("encrypted RID lies below image minimum".to_owned()))?; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + .checked_add(low) + .ok_or_else(|| Error::Validation("restored RID overflow".to_owned())) +} + +/// Restore MethodDef RID values exactly as module `0x0C` does. +/// +/// The operation is idempotent for tooling purposes: an image whose tokens are +/// already canonical is detected and left untouched instead of applying the +/// native inverse permutation a second time. +pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report)> { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Err(Error::UnsupportedVersion(version)); + } + + 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 owners = vec![u32::MAX; method_count]; + let mut output = data.to_vec(); + let mut images_with_methods = 0_usize; + let mut visited_methods = 0_usize; + let mut already_correct_before = 0_usize; + let mut correct_after = 0_usize; + let mut changed_tokens = 0_usize; + let mut transformed_images = 0_usize; + + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?; + let type_start = usize::try_from(type_start) + .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 = read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?; + let method_start = usize::try_from(method_start).map_err(|_| { + Error::Malformed(format!( + "type {type_index} has methods but 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")); + } + for (method_index, owner) in owners + .iter_mut() + .enumerate() + .take(method_end) + .skip(method_start) + { + if *owner != u32::MAX { + return malformed(format!("method {method_index} belongs to multiple images")); + } + *owner = u32::try_from(image_index) + .map_err(|_| Error::Malformed("image index exceeds u32".to_owned()))?; + methods.push(method_index); + } + } + if methods.is_empty() { + continue; + } + images_with_methods += 1; + visited_methods += methods.len(); + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("nonempty image lost its method minimum".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("nonempty image lost its method maximum".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return malformed(format!( + "image {image_index} method block is not contiguous" + )); + } + + let mut tokens = Vec::with_capacity(methods.len()); + let mut image_already_clean = true; + for &method_index in &methods { + let token_offset = method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET; + let token = read_u32(data, token_offset)?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return malformed(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; + if rid == expected { + already_correct_before += 1; + } else { + image_already_clean = false; + } + tokens.push((method_index, token_offset, token, expected)); + } + + if image_already_clean { + correct_after += tokens.len(); + continue; + } + transformed_images += 1; + let low = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .min() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + let high = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .max() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + if high <= 1 { + return validation(format!( + "image {image_index} is noncanonical but native R > 1 gate would skip it" + )); + } + let interval = high - low + 1; + if interval as usize != tokens.len() { + return validation(format!( + "image {image_index} RID interval {low}..={high} is not a permutation" + )); + } + for (method_index, token_offset, token, expected) in tokens { + let restored_rid = decrypt_rid(token & 0x00ff_ffff, low, high, seed)?; + if restored_rid != expected { + return validation(format!( + "method {method_index} restored RID {restored_rid} != expected {expected}" + )); + } + let restored_token = METHOD_TOKEN_TABLE | restored_rid; + if restored_token != token { + output[token_offset..token_offset + 4] + .copy_from_slice(&restored_token.to_le_bytes()); + changed_tokens += 1; + } + correct_after += 1; + } + } + + if owners.contains(&u32::MAX) { + return malformed("one or more method definitions are not owned by an image"); + } + if visited_methods != method_count || correct_after != method_count { + return validation(format!( + "method coverage mismatch: visited={visited_methods}, correct={correct_after}, total={method_count}" + )); + } + + Ok(( + output, + Report { + version, + seed: format!("0x{seed:08X}"), + images: image_count, + images_with_methods, + types: type_count, + methods: method_count, + visited_methods, + already_correct_before, + correct_after, + changed_tokens, + transformed_images, + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn put_u16(data: &mut [u8], offset: usize, value: u16) { + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(data: &mut [u8], offset: usize, value: u32) { + data[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn encrypted_rid(expected: u32, count: u32, seed: u32) -> u32 { + (1..=count) + .find(|&candidate| decrypt_rid(candidate, 1, count, seed) == Ok(expected)) + .expect("inverse permutation must be bijective") + } + + fn build(tokens: &[u32]) -> (Vec, usize) { + let header_size = 0x100; + let images = header_size; + let types = images + IMAGE_STRIDE; + let methods = types + 2 * TYPE_STRIDE; + let mut data = vec![0_u8; methods + tokens.len() * METHOD_STRIDE]; + put_u32(&mut data, 0, MAGIC); + put_u32(&mut data, 4, SUPPORTED_VERSION); + put_u32(&mut data, HDR_METHODS, methods as u32); + put_u32( + &mut data, + HDR_METHODS + 4, + (tokens.len() * METHOD_STRIDE) as u32, + ); + put_u32(&mut data, HDR_TYPES, types as u32); + put_u32(&mut data, HDR_TYPES + 4, (2 * TYPE_STRIDE) as u32); + put_u32(&mut data, HDR_IMAGES, images as u32); + put_u32(&mut data, HDR_IMAGES + 4, IMAGE_STRIDE as u32); + put_u32(&mut data, images + IMAGE_TYPE_START_OFFSET, 0); + put_u32(&mut data, images + IMAGE_TYPE_COUNT_OFFSET, 2); + // Deliberately traverse the high method indices first. + put_u32(&mut data, types + TYPE_METHOD_START_OFFSET, 4); + put_u16(&mut data, types + TYPE_METHOD_COUNT_OFFSET, 3); + put_u32(&mut data, types + TYPE_STRIDE + TYPE_METHOD_START_OFFSET, 0); + put_u16(&mut data, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFFSET, 4); + for (index, &token) in tokens.iter().enumerate() { + put_u32( + &mut data, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + token, + ); + } + (data, methods) + } + + #[test] + fn restores_five_round_permutation_by_physical_method_index() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, methods) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.correct_after, 7); + for index in 0..7 { + assert_eq!( + read_u32( + &restored, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET + ) + .expect("token"), + METHOD_TOKEN_TABLE | (index as u32 + 1) + ); + } + } + + #[test] + fn clean_metadata_is_idempotent() { + let tokens = (1..=7) + .map(|rid| METHOD_TOKEN_TABLE | rid) + .collect::>(); + let (data, _) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.changed_tokens, 0); + assert_eq!(restored, data); + } +}