mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
Merge Android (AArch64) shared-library restoration, bump to 1.2.0
Adds the Android protection-scheme pipeline: hollowed ELF64/AArch64 libraries are restored statically (stage-1/stage-2 module extraction, container decode, dynamic-linker table rebuild), with app-package (.apk/.apks/.xapk) container handling, cross-source content dedup, and il2cpp metadata support for the Android variants (seeded RID permutation; embedded XOR-wrapped blob extraction). The single senbei CLI now routes single .so files, packages, and folders by content; outputs follow the existing .unpack-infix naming under <root>/unpack or --out. PE behavior is unchanged (35/35 goldens).
This commit is contained in:
@@ -17,4 +17,5 @@ senbei-io.workspace = true
|
||||
[dev-dependencies]
|
||||
senbei-io.workspace = true
|
||||
senbei-metadata.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -74,14 +74,7 @@ fn main() -> std::process::ExitCode {
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
if quiet < 2 {
|
||||
println!(
|
||||
"{} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
|
||||
summary.unpacked,
|
||||
summary.skipped,
|
||||
summary.errors,
|
||||
summary.suspect,
|
||||
summary.metadata
|
||||
);
|
||||
println!("{}", summary.line());
|
||||
println!("done in {} ms", summary.duration_ms);
|
||||
}
|
||||
if summary.errors > 0 { 1 } else { 0 }
|
||||
@@ -104,6 +97,12 @@ fn print_help() {
|
||||
println!(
|
||||
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
|
||||
);
|
||||
println!(
|
||||
" input a Crackproof PE (.exe/.dll), an il2cpp global-metadata.dat,\n\
|
||||
\x20 a protected Android AArch64 library (.so), an Android app\n\
|
||||
\x20 package (.apk/.apks/.xapk), or a folder containing any of\n\
|
||||
\x20 these"
|
||||
);
|
||||
println!(
|
||||
" --scan-all probe every file in a folder, including ones the scan\n\
|
||||
\x20 pre-filter skips (under 4128 bytes, extensionless,\n\
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Corpus test over the user-managed Android samples.
|
||||
//!
|
||||
//! Each immediate subdirectory of `samples/android/` that contains a `lib/`
|
||||
//! tree is one app-package sample (an extracted APK layout). For every
|
||||
//! protected AArch64 `.so` found by content probe, the test runs the real
|
||||
//! restore pipeline and checks the result:
|
||||
//!
|
||||
//! - `<name>.golden.so.sha256` next to the input pins the restored bytes
|
||||
//! (byte-identity through the digest; absent sidecar -> WARNING).
|
||||
//! - `<name>.restore-fails` (empty marker) documents an input whose restore
|
||||
//! is known to fail; the test then *requires* failure, so a future fix
|
||||
//! surfaces as a test failure too. Without the marker a failed restore is
|
||||
//! a test failure.
|
||||
//! - A restored library carrying an unwrappable embedded metadata blob must
|
||||
//! produce one, pinned by `<name>.golden.metadata.sha256`.
|
||||
//!
|
||||
//! The folder-mode driver is then run over each app dir to exercise the
|
||||
//! scan/restore/write path end to end; its error count must equal the number
|
||||
//! of marked known-failures.
|
||||
//!
|
||||
//! The corpus is git-ignored and absent on CI (no binaries in the repo);
|
||||
//! `SENBEI_REQUIRE_SAMPLES=1` turns an absent corpus into a failure, and
|
||||
//! `SENBEI_ANDROID_SAMPLES` overrides the corpus location.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use senbei_io::{android, job};
|
||||
|
||||
fn corpus_dir() -> PathBuf {
|
||||
if let Some(dir) = std::env::var_os("SENBEI_ANDROID_SAMPLES") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
common::samples_dir().join("android")
|
||||
}
|
||||
|
||||
/// Immediate subdirectories of `root` that hold an app tree (a `lib/`
|
||||
/// folder) — research notes, dumps, and other non-app material in the corpus
|
||||
/// never match.
|
||||
fn app_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.unwrap_or_else(|e| panic!("read {}: {e}", root.display()))
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.filter(|path| path.is_dir() && path.join("lib").is_dir())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Every regular `.so` below `dir`, skipping previous output trees.
|
||||
fn collect_so_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect();
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
if path.is_dir() {
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|name| !name.eq_ignore_ascii_case("unpack"))
|
||||
{
|
||||
collect_so_files(&path, out);
|
||||
}
|
||||
} else if path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
|
||||
{
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
let mut digest = sha2::Sha256::new();
|
||||
digest.update(data);
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
/// `<stem>.golden.so.sha256` next to `input`.
|
||||
fn golden_sidecar(input: &Path, artifact: &str) -> PathBuf {
|
||||
let file = input.file_name().unwrap().to_string_lossy();
|
||||
let stem = file.strip_suffix(".so").unwrap_or(&file);
|
||||
input.with_file_name(format!("{stem}.golden.{artifact}.sha256"))
|
||||
}
|
||||
|
||||
fn read_sidecar(path: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|text| text.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_samples_restore_against_goldens() {
|
||||
let root = corpus_dir();
|
||||
// Same opt-in gate as the PE corpus test: an absent corpus is a no-op
|
||||
// pass unless CI explicitly requires it.
|
||||
let require = std::env::var_os("SENBEI_REQUIRE_SAMPLES").is_some();
|
||||
if !root.is_dir() {
|
||||
assert!(
|
||||
!require,
|
||||
"android samples: {} does not exist — corpus required (CI)",
|
||||
root.display()
|
||||
);
|
||||
eprintln!(
|
||||
"android samples: {} does not exist, nothing to test",
|
||||
root.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let apps = app_dirs(&root);
|
||||
if apps.is_empty() {
|
||||
assert!(
|
||||
!require,
|
||||
"android samples: no app trees under {} — corpus required (CI)",
|
||||
root.display()
|
||||
);
|
||||
eprintln!("android samples: no app trees under {}", root.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut passed = 0usize;
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
for app in &apps {
|
||||
let mut so_files = Vec::new();
|
||||
collect_so_files(app, &mut so_files);
|
||||
let protected: Vec<PathBuf> = so_files
|
||||
.into_iter()
|
||||
.filter(|path| android::is_protected_so_file(path))
|
||||
.collect();
|
||||
let mut known_failures = 0usize;
|
||||
|
||||
for input in &protected {
|
||||
let name = input.file_name().unwrap().to_string_lossy().to_string();
|
||||
let known_fails = input.with_file_name(format!(
|
||||
"{}.restore-fails",
|
||||
name.strip_suffix(".so").unwrap_or(&name)
|
||||
));
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let dest = temp.path().join("restored.so");
|
||||
match android::restore_so_file(input, &dest, false) {
|
||||
Ok(embedded) => {
|
||||
if known_fails.is_file() {
|
||||
failures.push(format!(
|
||||
"{name}: restore succeeded but a restore-fails marker exists \
|
||||
(delete the marker — the gap is fixed)"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(&dest).expect("read restored output");
|
||||
match read_sidecar(&golden_sidecar(input, "so")) {
|
||||
Some(expected) if expected == sha256_hex(&bytes) => passed += 1,
|
||||
Some(expected) => failures.push(format!(
|
||||
"{name}: restored bytes differ from golden\n expected sha256 {expected}\n actual sha256 {}",
|
||||
sha256_hex(&bytes)
|
||||
)),
|
||||
None => warnings.push(format!(
|
||||
"{name}: no golden sidecar — restored sha256 {}",
|
||||
sha256_hex(&bytes)
|
||||
)),
|
||||
}
|
||||
if let Some(blob) = embedded {
|
||||
match read_sidecar(&golden_sidecar(input, "metadata")) {
|
||||
Some(expected) if expected == sha256_hex(&blob) => {}
|
||||
Some(expected) => failures.push(format!(
|
||||
"{name}: embedded metadata differs from golden\n expected sha256 {expected}\n actual sha256 {}",
|
||||
sha256_hex(&blob)
|
||||
)),
|
||||
None => warnings.push(format!(
|
||||
"{name}: no embedded-metadata sidecar — sha256 {}",
|
||||
sha256_hex(&blob)
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if known_fails.is_file() {
|
||||
known_failures += 1;
|
||||
} else {
|
||||
failures.push(format!("{name}: restore failed: {error:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folder-mode smoke run: the scan must route every protected library,
|
||||
// and only the marked known-failures may error.
|
||||
let out_temp = tempfile::tempdir().expect("tempdir");
|
||||
match job::run_folder_opts(app, Some(out_temp.path()), 2, false, true, false) {
|
||||
Ok(summary) => {
|
||||
if summary.errors != known_failures {
|
||||
failures.push(format!(
|
||||
"{}: folder run errors {} != known-failure markers {known_failures}",
|
||||
app.display(),
|
||||
summary.errors
|
||||
));
|
||||
}
|
||||
if summary.unpacked < protected.len().saturating_sub(known_failures) {
|
||||
failures.push(format!(
|
||||
"{}: folder run restored {} libraries, per-file pass found {} ({} known-failing)",
|
||||
app.display(),
|
||||
summary.unpacked,
|
||||
protected.len(),
|
||||
known_failures
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => failures.push(format!("{}: folder run failed: {error:#}", app.display())),
|
||||
}
|
||||
}
|
||||
|
||||
for warning in &warnings {
|
||||
eprintln!("WARNING: {warning}");
|
||||
}
|
||||
eprintln!(
|
||||
"android samples: {} app tree(s) — {passed} pass, {} warning(s), {} failure(s)",
|
||||
apps.len(),
|
||||
warnings.len(),
|
||||
failures.len()
|
||||
);
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
Reference in New Issue
Block a user