feat: add folder-based Android unpack workflow

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