mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
Merge bfloat16-senbei workspace restructure, bump to 1.1.0
Adopts the fork's workspace split (senbei-cli / senbei-crypto / senbei-io / senbei-metadata / senbei-pe), its structured error taxonomy, entry-transform and layout validation, PE32 dd8 key-formula selection with a skip floor, the CRT entry-stub dd8 oracle, and the extensionless-file scan skip. Kept from senbei on top of the restructure: - ManagedExe detection/routing and the CLR (COR20 + BSJB) metadata restore in the EXE pipeline. - The RET+int3 padding fingerprint as the primary dd8 padding signal, ahead of the mutated-position 0xCC fallback. - docs/, .github/, samples/, tests/ (moved to senbei-cli/tests), and the web/ wasm frontend (rewired to the split crates), all of which the fork had dropped. - The fork's README compatibility matrix is not taken: it names real games, which the public-repo hygiene rules forbid. - The wasm32 localtime fallback in logfile and unpack_bytes_force_exe (the web app's trap-recovery entry point), both lost in the restructure. Golden corpus: 35/35 byte-identical. clippy -D warnings clean; wasm32 check clean for the full workspace.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "senbei-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Command-line entry point for Senbei"
|
||||
license.workspace = true
|
||||
keywords = ["unpacker", "reverse-engineering", "pe", "security-research"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[[bin]]
|
||||
name = "senbei"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
senbei-io.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
senbei-io.workspace = true
|
||||
senbei-metadata.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,112 @@
|
||||
use senbei_io::{job, pause, scan};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let mut path: Option<String> = None;
|
||||
let mut out: Option<String> = None;
|
||||
let mut quiet: u8 = 0;
|
||||
let mut no_pause = false;
|
||||
let mut no_log = false;
|
||||
let mut verbose = false;
|
||||
let mut scan_all = false;
|
||||
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
"-V" | "--version" => {
|
||||
println!("Senbei {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
"-q" | "--quiet" => quiet = quiet.saturating_add(1),
|
||||
"-v" | "--verbose" => verbose = true,
|
||||
"--no-pause" => no_pause = true,
|
||||
"--no-log" => no_log = true,
|
||||
"--scan-all" => scan_all = true,
|
||||
"--out" => match args.next() {
|
||||
Some(v) if !v.starts_with('-') => out = Some(v),
|
||||
_ => {
|
||||
eprintln!("error: --out requires a directory argument");
|
||||
return std::process::ExitCode::from(2);
|
||||
}
|
||||
},
|
||||
other if other.starts_with('-') => {
|
||||
eprintln!("error: unknown option '{other}'");
|
||||
print_help();
|
||||
return std::process::ExitCode::from(2);
|
||||
}
|
||||
other => {
|
||||
if let Some(prev) = &path {
|
||||
eprintln!("error: multiple input paths given ('{prev}' and '{other}')");
|
||||
return std::process::ExitCode::from(2);
|
||||
}
|
||||
path = Some(other.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let code = match path {
|
||||
None => {
|
||||
print_help();
|
||||
2
|
||||
}
|
||||
Some(p) => {
|
||||
if quiet < 2 {
|
||||
println!("Senbei {}", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
let p = Path::new(&p);
|
||||
let out_path = out.as_deref().map(Path::new);
|
||||
let result = if p.is_dir() {
|
||||
job::run_folder_opts(
|
||||
p,
|
||||
out_path,
|
||||
quiet,
|
||||
verbose,
|
||||
no_log,
|
||||
scan_all || scan::scan_all_env(),
|
||||
)
|
||||
} else {
|
||||
job::run_file_v(p, out_path, quiet, verbose, no_log)
|
||||
};
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
if quiet < 2 {
|
||||
println!(
|
||||
"{} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
|
||||
summary.unpacked,
|
||||
summary.skipped,
|
||||
summary.errors,
|
||||
summary.suspect,
|
||||
summary.metadata
|
||||
);
|
||||
println!("done in {} ms", summary.duration_ms);
|
||||
}
|
||||
if summary.errors > 0 { 1 } else { 0 }
|
||||
}
|
||||
Err(error) => {
|
||||
if quiet < 2 {
|
||||
eprintln!("error: {error:#}");
|
||||
}
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pause::maybe_pause(no_pause);
|
||||
std::process::ExitCode::from(code as u8)
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
|
||||
);
|
||||
println!(
|
||||
" --scan-all probe every file in a folder, including ones the scan\n\
|
||||
\x20 pre-filter skips (under 4128 bytes, extensionless,\n\
|
||||
\x20 or a bulk-asset extension). Much slower on large trees."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Shared test fixtures.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Path to the workspace-root `samples/` — the user-managed corpus dropped in
|
||||
/// by hand. Git-ignored except its README; tests here run against whatever is
|
||||
/// present. `CARGO_MANIFEST_DIR` is `senbei-cli/`, so go one level up.
|
||||
pub fn samples_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../samples")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use senbei_io::job::{default_out_root_for_file, out_name};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn out_name_inserts_unpack_before_last_dot() {
|
||||
assert_eq!(out_name(Path::new("foo.exe")), Path::new("foo.unpack.exe"));
|
||||
assert_eq!(
|
||||
out_name(Path::new("a/b/bar.dll")),
|
||||
Path::new("a/b/bar.unpack.dll")
|
||||
);
|
||||
assert_eq!(out_name(Path::new("x.y.dll")), Path::new("x.y.unpack.dll"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_name_no_dot_appends_unpack() {
|
||||
assert_eq!(out_name(Path::new("nodot")), Path::new("nodot.unpack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_out_root_for_file_is_parent_unpack() {
|
||||
assert_eq!(
|
||||
default_out_root_for_file(Path::new("a/b/foo.exe")),
|
||||
Path::new("a/b/unpack")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_out_root_for_file_cwd_when_no_parent() {
|
||||
let p = default_out_root_for_file(Path::new("foo.exe"));
|
||||
assert_eq!(p, Path::new(".").join("unpack"));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use senbei_io::logfile::{Log, local_stamp_compact, local_stamp_display};
|
||||
|
||||
#[test]
|
||||
fn local_stamp_compact_matches_shape() {
|
||||
let s = local_stamp_compact();
|
||||
// YYYYMMDD-HHMMSS → 15 chars, digit groups around dash
|
||||
assert_eq!(s.len(), 15, "got {s}");
|
||||
assert_eq!(&s[8..9], "-");
|
||||
assert!(s.as_bytes().iter().enumerate().all(|(i, b)| {
|
||||
if i == 8 {
|
||||
*b == b'-'
|
||||
} else {
|
||||
b.is_ascii_digit()
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_stamp_display_matches_shape() {
|
||||
let s = local_stamp_display();
|
||||
// YYYY-MM-DD HH:MM:SS → 19 chars
|
||||
assert_eq!(s.len(), 19, "got {s}");
|
||||
assert_eq!(&s[4..5], "-");
|
||||
assert_eq!(&s[7..8], "-");
|
||||
assert_eq!(&s[10..11], " ");
|
||||
assert_eq!(&s[13..14], ":");
|
||||
assert_eq!(&s[16..17], ":");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_writes_timestamped_file_in_target_dir() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let log = Log::create(td.path()).unwrap();
|
||||
log.step("hello");
|
||||
let path = log.path().to_path_buf();
|
||||
drop(log);
|
||||
assert!(path.starts_with(td.path()));
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
assert!(
|
||||
name.starts_with("senbei-") && name.ends_with(".log"),
|
||||
"unexpected log name: {name}"
|
||||
);
|
||||
// senbei-YYYYMMDD-HHMMSS.log
|
||||
let core = name.trim_start_matches("senbei-").trim_end_matches(".log");
|
||||
assert_eq!(core.len(), 15, "stamp in name: {name}");
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("hello"));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use senbei_io::job;
|
||||
use std::path::Path;
|
||||
|
||||
fn list_logs(dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with("senbei-") && n.ends_with(".log"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_file_no_log_creates_no_logfile() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let input = td.path().join("not_crackproof.bin");
|
||||
std::fs::write(&input, b"not a pe").unwrap();
|
||||
let out = td.path().join("out");
|
||||
let s = job::run_file_v(&input, Some(&out), 2, false, true).unwrap();
|
||||
assert_eq!(s.errors, 1);
|
||||
// With no_log, no senbei-*.log under out (even if the dir was created).
|
||||
assert!(list_logs(&out).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_file_writes_log_under_out_with_header_footer() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let input = td.path().join("not_crackproof.bin");
|
||||
std::fs::write(&input, b"not a pe").unwrap();
|
||||
let out = td.path().join("out");
|
||||
let s = job::run_file_v(&input, Some(&out), 2, false, false).unwrap();
|
||||
assert_eq!(s.errors, 1);
|
||||
let logs = list_logs(&out);
|
||||
assert_eq!(logs.len(), 1, "expected one log under out, got {logs:?}");
|
||||
let text = std::fs::read_to_string(&logs[0]).unwrap();
|
||||
assert!(text.contains("Senbei "), "header version: {text}");
|
||||
assert!(text.contains("started "), "{text}");
|
||||
assert!(text.contains("input "), "{text}");
|
||||
assert!(text.contains("out "), "{text}");
|
||||
assert!(text.contains("ERR "), "{text}");
|
||||
assert!(text.contains("done in "), "{text}");
|
||||
assert!(text.contains("summary:"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_file_default_out_root_is_parent_unpack() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let input = td.path().join("not_crackproof.bin");
|
||||
std::fs::write(&input, b"not a pe").unwrap();
|
||||
let _ = job::run_file_v(&input, None, 2, false, false).unwrap();
|
||||
let unpack = td.path().join("unpack");
|
||||
assert!(unpack.is_dir());
|
||||
assert_eq!(list_logs(&unpack).len(), 1);
|
||||
// log must NOT be next to input's parent root without unpack
|
||||
assert!(list_logs(td.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_folder_log_lives_under_out_not_root() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
// empty tree: 0 candidates still creates log under unpack
|
||||
let s = job::run_folder_v(td.path(), None, 2, false, false).unwrap();
|
||||
assert_eq!(s.unpacked, 0);
|
||||
let unpack = td.path().join("unpack");
|
||||
assert!(unpack.is_dir());
|
||||
assert_eq!(list_logs(&unpack).len(), 1);
|
||||
assert!(
|
||||
list_logs(td.path()).is_empty(),
|
||||
"log must not sit on input root"
|
||||
);
|
||||
let text = std::fs::read_to_string(&list_logs(&unpack)[0]).unwrap();
|
||||
assert!(text.contains("done in "));
|
||||
assert!(text.contains("summary:"));
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Corpus test over the user-managed `senbei/samples` folder.
|
||||
//!
|
||||
//! Drop real Crackproof `*.exe` / `*.dll` inputs in there (and/or il2cpp
|
||||
//! `*.dat` metadata blobs), optionally alongside a byte-exact golden named
|
||||
//! `<base>.golden.<ext>`. Each input is processed and classified:
|
||||
//!
|
||||
//! - golden present, bytes identical -> pass (silent)
|
||||
//! - golden present, bytes differ -> FAIL (the test fails)
|
||||
//! - no golden -> WARNING (printed; needs a manual check)
|
||||
//!
|
||||
//! Inputs go through [`senbei_io::job::unpack_bytes`], the same routing the CLI
|
||||
//! uses, **not** `unpack_auto` directly. That matters: `unpack_auto` alone
|
||||
//! cannot reach the external-companion layout, whose stub is meaningless
|
||||
//! without its `<name>._` payload — a corpus wired to `unpack_auto` silently
|
||||
//! covers none of the splice / export-overlay / TLS-restore code, nor the
|
||||
//! marker-less "new layout" those builds use. A `<input>._` sibling in the
|
||||
//! samples folder is picked up automatically, exactly as it is on disk.
|
||||
//!
|
||||
//! An input whose bytes carry the il2cpp metadata magic is routed through
|
||||
//! [`senbei_metadata::deobfuscate`] instead, giving the method-token remap
|
||||
//! real-world coverage (its unit tests only build synthetic layouts).
|
||||
//!
|
||||
//! The folder is git-ignored (see `senbei/samples/README.md`), so the set of
|
||||
//! samples is whatever happens to be on the machine. An empty/absent folder is
|
||||
//! a no-op pass.
|
||||
|
||||
mod common;
|
||||
use common::samples_dir;
|
||||
use std::path::Path;
|
||||
|
||||
/// An input is a `.exe`/`.dll`/`.dat` whose name doesn't carry the `.golden.`
|
||||
/// marker — those are goldens, not inputs. External companions (`<name>._`)
|
||||
/// have extension `_` and are therefore never inputs in their own right; they
|
||||
/// are consumed by their base module.
|
||||
fn is_input(path: &Path) -> bool {
|
||||
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
if ext != "exe" && ext != "dll" && ext != "dat" {
|
||||
return false;
|
||||
}
|
||||
// Reject goldens like `foo.golden.exe`.
|
||||
!path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_ascii_lowercase().contains(".golden."))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Golden path for an input: `<base>.golden.<ext>` next to it.
|
||||
fn golden_for(input: &Path) -> std::path::PathBuf {
|
||||
let ext = input.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
let stem = input.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
input.with_file_name(format!("{stem}.golden.{ext}"))
|
||||
}
|
||||
|
||||
/// External-companion path for an input: `<full file name>._` next to it,
|
||||
/// matching what the CLI looks for on disk.
|
||||
fn companion_for(input: &Path) -> Option<std::path::PathBuf> {
|
||||
let name = input.file_name()?;
|
||||
let mut n = name.to_os_string();
|
||||
n.push("._");
|
||||
let p = input.with_file_name(n);
|
||||
p.is_file().then_some(p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_unpack_against_goldens() {
|
||||
let dir = samples_dir();
|
||||
// An absent/empty corpus fails only when explicitly required — a green
|
||||
// run that unpacked nothing hides every unpack regression, but on public
|
||||
// CI there is no corpus at all (binaries are never committed), so the
|
||||
// gate is opt-in via SENBEI_REQUIRE_SAMPLES rather than implied by CI.
|
||||
// Locally the corpus is the user-managed samples/ folder (see
|
||||
// samples/README.md).
|
||||
let require = std::env::var_os("SENBEI_REQUIRE_SAMPLES").is_some();
|
||||
if !dir.is_dir() {
|
||||
assert!(
|
||||
!require,
|
||||
"samples: {} does not exist — corpus required (CI)",
|
||||
dir.display()
|
||||
);
|
||||
eprintln!("samples: {} does not exist, nothing to test", dir.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut inputs: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.is_file() && is_input(p))
|
||||
.collect();
|
||||
inputs.sort();
|
||||
|
||||
if inputs.is_empty() {
|
||||
assert!(
|
||||
!require,
|
||||
"samples: no .exe/.dll inputs in {} — corpus required (CI)",
|
||||
dir.display()
|
||||
);
|
||||
eprintln!("samples: no .exe/.dll inputs in {}", dir.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut passed = 0usize;
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
for input in &inputs {
|
||||
let name = input.file_name().unwrap().to_string_lossy().to_string();
|
||||
let bytes = match std::fs::read(input) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: read error: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let got = if senbei_metadata::is_metadata(&bytes) {
|
||||
// il2cpp metadata: method-token de-obfuscation, no PE pipeline and
|
||||
// no integrity check (the output is not a PE image).
|
||||
match senbei_metadata::deobfuscate(&bytes) {
|
||||
Ok((out, _report)) => out,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: de-obfuscation failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Splice in the external companion when one sits next to the input,
|
||||
// then run the CLI's routing (which also overlays the stub's export
|
||||
// table and TLS directory for spliced inputs).
|
||||
let companion = match companion_for(input) {
|
||||
Some(p) => match std::fs::read(&p) {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: companion read error: {e}"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let image = match senbei_io::job::unpack_bytes(&bytes, companion.as_deref()) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: unpack failed: {e:?}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The static integrity check is a second, golden-independent gate:
|
||||
// it catches an output that is structurally plausible but would
|
||||
// crash at runtime (0xC0000005) even when a stale golden still
|
||||
// byte-matches. (Goldens are byte comparisons only — "matches
|
||||
// golden" ≠ runs.)
|
||||
if !image.integrity.ok() {
|
||||
failures.push(format!(
|
||||
"{name}: integrity check failed: {}",
|
||||
image.integrity.issues.join("; ")
|
||||
));
|
||||
continue;
|
||||
}
|
||||
image.bytes
|
||||
};
|
||||
|
||||
let golden = golden_for(input);
|
||||
if !golden.exists() {
|
||||
warnings.push(format!(
|
||||
"{name}: unpacked OK ({} bytes) but no golden ({}) — MANUAL CHECK",
|
||||
got.len(),
|
||||
golden.file_name().unwrap().to_string_lossy()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let want = match std::fs::read(&golden) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: golden read error: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if got.len() != want.len() {
|
||||
failures.push(format!(
|
||||
"{name}: length differs: got {} want {}",
|
||||
got.len(),
|
||||
want.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Some((i, (a, b))) = got.iter().zip(&want).enumerate().find(|(_, (a, b))| a != b) {
|
||||
failures.push(format!(
|
||||
"{name}: first diff at 0x{i:X}: got {a:02X} want {b:02X}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
passed += 1;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"samples: {} input(s) — {} pass, {} warning(s), {} failure(s)",
|
||||
inputs.len(),
|
||||
passed,
|
||||
warnings.len(),
|
||||
failures.len()
|
||||
);
|
||||
for w in &warnings {
|
||||
eprintln!(" WARN {w}");
|
||||
}
|
||||
for f in &failures {
|
||||
eprintln!(" FAIL {f}");
|
||||
}
|
||||
|
||||
assert!(failures.is_empty(), "{} sample(s) failed", failures.len());
|
||||
}
|
||||
Reference in New Issue
Block a user