First public commit

This commit is contained in:
2026-08-09 00:08:31 +08:00
commit 21cd151e15
49 changed files with 14041 additions and 0 deletions
+1051
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
pub mod job;
pub mod logfile;
pub mod metadata;
pub mod pause;
pub mod scan;
pub mod ui;
pub mod unpacker;
+148
View File
@@ -0,0 +1,148 @@
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
pub struct Log {
path: PathBuf,
file: Mutex<File>,
}
impl Log {
pub fn create(dir: &Path) -> std::io::Result<Self> {
let ts = local_stamp_compact();
// The stamp has one-second granularity and `File::create` truncates, so
// two runs into the same out dir within a second would clobber each
// other's log. Probe for a free name with create_new instead.
let mut path = dir.join(format!("senbei-{ts}.log"));
let mut file = File::create_new(&path);
for n in 2..100 {
if !matches!(&file, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists) {
break;
}
path = dir.join(format!("senbei-{ts}-{n}.log"));
file = File::create_new(&path);
}
let file = Mutex::new(file?);
Ok(Self { path, file })
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn step(&self, msg: &str) {
if let Ok(mut f) = self.file.lock() {
let _ = writeln!(f, "{msg}");
}
}
}
/// Local wall-clock `YYYYMMDD-HHMMSS` for log filenames.
pub fn local_stamp_compact() -> String {
let t = local_parts();
format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
t.year, t.month, t.day, t.hour, t.minute, t.second
)
}
/// Local wall-clock `YYYY-MM-DD HH:MM:SS` for log header.
pub fn local_stamp_display() -> String {
let t = local_parts();
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
t.year, t.month, t.day, t.hour, t.minute, t.second
)
}
struct LocalParts {
year: u32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
}
fn local_parts() -> LocalParts {
#[cfg(windows)]
{
use windows::Win32::System::SystemInformation::GetLocalTime;
let st = unsafe { GetLocalTime() };
LocalParts {
year: st.wYear as u32,
month: st.wMonth as u32,
day: st.wDay as u32,
hour: st.wHour as u32,
minute: st.wMinute as u32,
second: st.wSecond as u32,
}
}
#[cfg(all(not(windows), not(target_arch = "wasm32")))]
{
// Local wall clock via POSIX localtime_r — same semantics as Windows GetLocalTime.
use std::time::{SystemTime, UNIX_EPOCH};
let secs_u = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let t: libc::time_t = secs_u as libc::time_t;
let mut tm = unsafe { std::mem::zeroed::<libc::tm>() };
let ok = unsafe { libc::localtime_r(&t, &mut tm) };
if ok.is_null() {
return utc_parts(secs_u); // emergency only if localtime_r fails
}
LocalParts {
year: (tm.tm_year + 1900) as u32,
month: (tm.tm_mon + 1) as u32,
day: tm.tm_mday as u32,
hour: tm.tm_hour as u32,
minute: tm.tm_min as u32,
second: tm.tm_sec as u32,
}
}
#[cfg(all(not(windows), target_arch = "wasm32"))]
{
// wasm has no local timezone database and SystemTime::now() panics
// without a JS time source. The run log is a CLI concern — the wasm
// build never writes one — so a fixed epoch stamp suffices.
utc_parts(0)
}
}
/// Convert Unix UTC seconds to civil Y-M-D h:m:s (Howard Hinnant).
/// Used as non-Windows fallback; keep pub(crate) if unit-tested.
fn utc_parts(secs: u64) -> LocalParts {
let s = secs as i64;
let time_of_day = s.rem_euclid(86400) as u32;
let days = s.div_euclid(86400);
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
LocalParts {
year: y as u32,
month: m,
day: d,
hour: time_of_day / 3600,
minute: (time_of_day % 3600) / 60,
second: time_of_day % 60,
}
}
/// UTC civil stamp helper (used only as emergency fallback path via `utc_parts`).
#[allow(dead_code)] // retained for unit-style reuse / non-Windows emergency path symmetry
pub(crate) fn fmt_stamp(secs: u64) -> String {
let t = utc_parts(secs);
format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
t.year, t.month, t.day, t.hour, t.minute, t.second
)
}
+113
View File
@@ -0,0 +1,113 @@
use senbei::{job, pause};
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() {
// Reject a missing value (and a following flag swallowed as the
// value): previously `--out` at end of argv silently fell back
// to the default output directory.
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 => {
// Previously the last positional silently won.
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 r = if p.is_dir() {
job::run_folder_opts(
p,
out_path,
quiet,
verbose,
no_log,
scan_all || senbei::scan::scan_all_env(),
)
} else {
job::run_file_v(p, out_path, quiet, verbose, no_log)
};
match r {
Ok(s) => {
if quiet < 2 {
println!(
"{} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
s.unpacked, s.skipped, s.errors, s.suspect, s.metadata
);
println!("done in {} ms", s.duration_ms);
}
if s.errors > 0 { 1 } else { 0 }
}
Err(e) => {
// Fatal: out-dir/log create, etc.
if quiet < 2 {
eprintln!("error: {e:#}");
}
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, or a bulk-asset\n\
\x20 extension like .ab/.xml/.acb). Much slower on game trees."
);
}
+348
View File
@@ -0,0 +1,348 @@
//! il2cpp `global-metadata.dat` de-obfuscation.
//!
//! Crackproof's `-GMD` option obfuscates the **method-token** field of every
//! `Il2CppMethodDefinition` in `global-metadata.dat`. il2cpp resolves a method's
//! compiled function/invoker by indexing the per-module
//! `Il2CppCodeGenModule.methodPointers` / `invokerIndices` tables — which are
//! sized to the module's *compiled* method count — by `(token_row - 1)`. That
//! only works when each module's method tokens are the **contiguous** range
//! `1..=methodPointerCount`. `-GMD` replaces them with sparse, original-metadata-style
//! tokens (e.g. mscorlib rows reach ~55k for only ~14k compiled methods) and the
//! running game's Crackproof loader remaps them back at load time.
//!
//! A statically-unpacked il2cpp game assembly run without Crackproof reads the
//! tokens raw, so `(token_row - 1)` runs off the end of those tables — an
//! out-of-bounds read that crashes deep in il2cpp init (first hit:
//! `System.Array`'s interface method setup). See the project notes for the full
//! trace.
//!
//! This module reverses the obfuscation purely from the metadata's own
//! structure. Methods are laid out grouped by type, and types grouped by image
//! (module), so a method's correct token row is simply its position within its
//! module's method range. We re-derive that range from the images/types tables
//! and rewrite each method token to `0x06000000 | (local_index + 1)`.
//!
//! Only method tokens are touched: field tokens are already contiguous and type
//! tokens resolve correctly. The transform is a no-op on an unobfuscated
//! metadata (its tokens already equal `local_index + 1`), so it is safe to run on
//! any il2cpp game — `remapped == 0` then reports that nothing changed.
/// il2cpp `global-metadata.dat` sanity magic (`Il2CppGlobalMetadataHeader.sanity`).
const MAGIC: u32 = 0xFAB1_1BAF;
/// Metadata format version this de-obfuscator understands. The struct strides
/// and header field offsets below are specific to it; other versions are left
/// untouched rather than risk corrupting a layout we have not verified.
/// (Observed on real games shipping version 31 / Unity 2022.3.)
const SUPPORTED_VERSION: u32 = 31;
// --- Il2CppGlobalMetadataHeader field byte-offsets (each is an i32 offset/size
// pair). Shared layout across recent versions. ---
const HDR_METHODS: usize = 0x30; // methodsOffset / methodsSize
const HDR_TYPES: usize = 0xA0; // typeDefinitionsOffset / size
const HDR_IMAGES: usize = 0xA8; // imagesOffset / size
// --- version-31 struct strides and field offsets ---
const METHOD_STRIDE: usize = 0x24; // sizeof(Il2CppMethodDefinition)
const METHOD_TOKEN_OFF: usize = 0x18; // .token (u32)
const TYPE_STRIDE: usize = 0x58; // sizeof(Il2CppTypeDefinition)
const TYPE_METHOD_START_OFF: usize = 0x24; // .methodStart (i32)
const TYPE_METHOD_COUNT_OFF: usize = 0x40; // .method_count (u16)
const IMAGE_STRIDE: usize = 0x28; // sizeof(Il2CppImageDefinition)
const IMAGE_TYPE_START_OFF: usize = 0x08; // .typeStart (i32)
const IMAGE_TYPE_COUNT_OFF: usize = 0x0C; // .typeCount (u32)
/// `IMAGE_CODE_GEN_MODULE` method-definition token table id (`0x06 << 24`).
const METHOD_TOKEN_TABLE: u32 = 0x0600_0000;
/// `Il2Cpp*Index` "no value" sentinel (`kTypeIndexInvalid` etc.).
const NO_METHODS: u32 = 0xFFFF_FFFF;
/// Outcome of a successful [`deobfuscate`] pass.
#[derive(Debug, Clone, Copy)]
pub struct Report {
pub version: u32,
/// Total `Il2CppMethodDefinition` entries.
pub methods: usize,
/// Number of method tokens actually rewritten (0 ⇒ the input was already
/// de-obfuscated, i.e. not `-GMD`-protected).
pub remapped: usize,
/// Number of modules (images) that own at least one method.
pub modules: usize,
}
/// Why [`deobfuscate`] declined to process the input. None of these mutate the
/// input; the caller leaves the file untouched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// Missing the il2cpp metadata sanity magic — not a `global-metadata.dat`.
NotMetadata,
/// Recognised metadata, but an unhandled format version.
UnsupportedVersion(u32),
/// Magic/version matched but the table layout is inconsistent with the
/// supported version (truncated, mis-sized, or overlapping ranges).
Malformed,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NotMetadata => write!(f, "not an il2cpp global-metadata.dat"),
Error::UnsupportedVersion(v) => write!(f, "unsupported metadata version {v}"),
Error::Malformed => write!(f, "malformed metadata for version {SUPPORTED_VERSION}"),
}
}
}
impl std::error::Error for Error {}
/// Cheap check for the il2cpp metadata sanity magic, for scanning prefixes.
pub fn is_metadata(data: &[u8]) -> bool {
rd_u32(data, 0) == Some(MAGIC)
}
/// De-obfuscate the method tokens in an il2cpp `global-metadata.dat`.
///
/// On success returns the (possibly-rewritten) file bytes and a [`Report`]. The
/// transform is idempotent: a metadata that is already de-obfuscated comes back
/// byte-identical with `report.remapped == 0`.
pub fn deobfuscate(data: &[u8]) -> Result<(Vec<u8>, Report), Error> {
if rd_u32(data, 0) != Some(MAGIC) {
return Err(Error::NotMetadata);
}
let version = rd_u32(data, 4).ok_or(Error::Malformed)?;
if version != SUPPORTED_VERSION {
return Err(Error::UnsupportedVersion(version));
}
let (m_off, m_size) = table(data, HDR_METHODS)?;
let (t_off, t_size) = table(data, HDR_TYPES)?;
let (i_off, i_size) = table(data, HDR_IMAGES)?;
// The strides must divide their tables exactly and the tables must lie
// within the file: a mismatch means our version-31 layout is wrong for this
// file, so bail without touching it rather than scribble at bad offsets.
if m_size % METHOD_STRIDE != 0 || t_size % TYPE_STRIDE != 0 || i_size % IMAGE_STRIDE != 0 {
return Err(Error::Malformed);
}
let method_count = m_size / METHOD_STRIDE;
let type_count = t_size / TYPE_STRIDE;
let image_count = i_size / IMAGE_STRIDE;
if !fits(data, m_off, m_size) || !fits(data, t_off, t_size) || !fits(data, i_off, i_size) {
return Err(Error::Malformed);
}
// Map every method to its owning module and record each module's first
// (lowest) method index. A method's correct token row is its 1-based offset
// from that first index (methods are contiguous & grouped per module).
let mut module_of = vec![u32::MAX; method_count];
let mut module_first = vec![u32::MAX; image_count];
for (img, first_slot) in module_first.iter_mut().enumerate() {
let ib = i_off + img * IMAGE_STRIDE;
let type_start = rd_u32(data, ib + IMAGE_TYPE_START_OFF).ok_or(Error::Malformed)?;
let type_cnt = rd_u32(data, ib + IMAGE_TYPE_COUNT_OFF).ok_or(Error::Malformed)?;
let mut first = u32::MAX;
for t in type_start..type_start.saturating_add(type_cnt) {
if t as usize >= type_count {
return Err(Error::Malformed);
}
let tb = t_off + (t as usize) * TYPE_STRIDE;
let ms = rd_u32(data, tb + TYPE_METHOD_START_OFF).ok_or(Error::Malformed)?;
let mc = rd_u16(data, tb + TYPE_METHOD_COUNT_OFF).ok_or(Error::Malformed)? as u32;
if ms == NO_METHODS || mc == 0 {
continue;
}
first = first.min(ms);
for m in ms..ms.saturating_add(mc) {
let mi = m as usize;
if mi >= method_count {
return Err(Error::Malformed);
}
if module_of[mi] != u32::MAX {
return Err(Error::Malformed); // a method in two modules — layout is wrong
}
module_of[mi] = img as u32;
}
}
*first_slot = first;
}
// Rewrite each owned method's token to `0x06000000 | (local_index + 1)`.
// Any method NOT owned by an image would keep its original (obfuscated)
// token — a silent partial remap that still crashes il2cpp at runtime, so
// treat it as a malformed layout instead of shipping it.
let mut out = data.to_vec();
let mut remapped = 0usize;
for (mi, &img) in module_of.iter().enumerate() {
if img == u32::MAX {
return Err(Error::Malformed); // method outside every image's range
}
let first = module_first[img as usize];
let local = (mi as u32) - first; // mi >= first by construction
let new_tok = METHOD_TOKEN_TABLE | ((local + 1) & 0x00FF_FFFF);
let off = m_off + mi * METHOD_STRIDE + METHOD_TOKEN_OFF;
// `fits` above guarantees this 4-byte write is in bounds.
if out[off..off + 4] != new_tok.to_le_bytes() {
out[off..off + 4].copy_from_slice(&new_tok.to_le_bytes());
remapped += 1;
}
}
let modules = module_first.iter().filter(|&&f| f != u32::MAX).count();
Ok((
out,
Report {
version,
methods: method_count,
remapped,
modules,
},
))
}
/// Read the (offset, size) i32 pair of a metadata table from the header.
fn table(data: &[u8], hdr_off: usize) -> Result<(usize, usize), Error> {
let off = rd_u32(data, hdr_off).ok_or(Error::Malformed)? as usize;
let size = rd_u32(data, hdr_off + 4).ok_or(Error::Malformed)? as usize;
Ok((off, size))
}
/// True if `[off, off+len)` lies within `data`.
fn fits(data: &[u8], off: usize, len: usize) -> bool {
off.checked_add(len).is_some_and(|end| end <= data.len())
}
fn rd_u32(b: &[u8], o: usize) -> Option<u32> {
let s = b.get(o..o + 4)?;
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn rd_u16(b: &[u8], o: usize) -> Option<u16> {
let s = b.get(o..o + 2)?;
Some(u16::from_le_bytes([s[0], s[1]]))
}
#[cfg(test)]
mod tests {
use super::*;
// Build a minimal but structurally valid v31 metadata with two modules:
// image 0: 1 type, 2 methods (global 0,1)
// image 1: 1 type, 3 methods (global 2,3,4)
// Method tokens are seeded with *obfuscated* (sparse) values; the correct
// de-obfuscated tokens are per-module 1-based: [1,2] and [1,2,3].
struct Built {
bytes: Vec<u8>,
m_off: usize,
}
fn build(method_tokens: &[u32]) -> Built {
// Layout: [header 0x100][images][types][methods]
let hdr = 0x100usize;
let images = hdr;
let i_count = 2;
let i_size = i_count * IMAGE_STRIDE;
let types = images + i_size;
let t_count = 2;
let t_size = t_count * TYPE_STRIDE;
let methods = types + t_size;
let m_count = method_tokens.len();
let m_size = m_count * METHOD_STRIDE;
let total = methods + m_size;
let mut b = vec![0u8; total];
let put32 = |b: &mut [u8], o: usize, v: u32| b[o..o + 4].copy_from_slice(&v.to_le_bytes());
let put16 = |b: &mut [u8], o: usize, v: u16| b[o..o + 2].copy_from_slice(&v.to_le_bytes());
put32(&mut b, 0, MAGIC);
put32(&mut b, 4, SUPPORTED_VERSION);
put32(&mut b, HDR_METHODS, methods as u32);
put32(&mut b, HDR_METHODS + 4, m_size as u32);
put32(&mut b, HDR_TYPES, types as u32);
put32(&mut b, HDR_TYPES + 4, t_size as u32);
put32(&mut b, HDR_IMAGES, images as u32);
put32(&mut b, HDR_IMAGES + 4, i_size as u32);
// image 0: typeStart=0 typeCount=1 ; image 1: typeStart=1 typeCount=1
put32(&mut b, images + IMAGE_TYPE_START_OFF, 0);
put32(&mut b, images + IMAGE_TYPE_COUNT_OFF, 1);
put32(&mut b, images + IMAGE_STRIDE + IMAGE_TYPE_START_OFF, 1);
put32(&mut b, images + IMAGE_STRIDE + IMAGE_TYPE_COUNT_OFF, 1);
// type 0: methodStart=0 count=2 ; type 1: methodStart=2 count=3
put32(&mut b, types + TYPE_METHOD_START_OFF, 0);
put16(&mut b, types + TYPE_METHOD_COUNT_OFF, 2);
put32(&mut b, types + TYPE_STRIDE + TYPE_METHOD_START_OFF, 2);
put16(&mut b, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFF, 3);
// method tokens
for (i, &tok) in method_tokens.iter().enumerate() {
put32(&mut b, methods + i * METHOD_STRIDE + METHOD_TOKEN_OFF, tok);
}
Built {
bytes: b,
m_off: methods,
}
}
fn tok(b: &[u8], m_off: usize, i: usize) -> u32 {
rd_u32(b, m_off + i * METHOD_STRIDE + METHOD_TOKEN_OFF).unwrap()
}
#[test]
fn remaps_obfuscated_method_tokens_per_module() {
// Obfuscated sparse tokens (rows way past each module's method count).
let built = build(&[
0x0600_D49F,
0x0600_FFFF,
0x0600_1234,
0x0600_ABCD,
0x0600_5555,
]);
let (out, r) = deobfuscate(&built.bytes).expect("ok");
assert_eq!(r.version, 31);
assert_eq!(r.methods, 5);
assert_eq!(r.modules, 2);
assert_eq!(r.remapped, 5);
// module 0 (methods 0,1) -> rows 1,2 ; module 1 (methods 2,3,4) -> rows 1,2,3
assert_eq!(tok(&out, built.m_off, 0), 0x0600_0001);
assert_eq!(tok(&out, built.m_off, 1), 0x0600_0002);
assert_eq!(tok(&out, built.m_off, 2), 0x0600_0001);
assert_eq!(tok(&out, built.m_off, 3), 0x0600_0002);
assert_eq!(tok(&out, built.m_off, 4), 0x0600_0003);
}
#[test]
fn idempotent_on_clean_metadata() {
// Already de-obfuscated: per-module contiguous rows.
let clean = [
0x0600_0001,
0x0600_0002,
0x0600_0001,
0x0600_0002,
0x0600_0003,
];
let built = build(&clean);
let (out, r) = deobfuscate(&built.bytes).expect("ok");
assert_eq!(r.remapped, 0, "no rewrites on already-clean metadata");
assert_eq!(out, built.bytes, "byte-identical output");
}
#[test]
fn rejects_non_metadata() {
assert_eq!(
deobfuscate(b"not metadata at all....").unwrap_err(),
Error::NotMetadata
);
}
#[test]
fn rejects_unsupported_version() {
let mut built = build(&[
0x0600_0001,
0x0600_0002,
0x0600_0001,
0x0600_0002,
0x0600_0003,
]);
built.bytes[4..8].copy_from_slice(&29u32.to_le_bytes());
assert_eq!(
deobfuscate(&built.bytes).unwrap_err(),
Error::UnsupportedVersion(29)
);
}
}
+24
View File
@@ -0,0 +1,24 @@
pub fn should_pause() -> bool {
#[cfg(windows)]
{
use windows::Win32::System::Console::GetConsoleProcessList;
let mut buf = [0u32; 4];
let n = unsafe { GetConsoleProcessList(&mut buf) };
n == 1
}
#[cfg(not(windows))]
{
false
}
}
pub fn maybe_pause(force_skip: bool) {
if force_skip || !should_pause() {
return;
}
use std::io::Write;
eprint!("\nPress Enter to exit…");
let _ = std::io::stderr().flush();
let mut buf = String::new();
let _ = std::io::stdin().read_line(&mut buf);
}
+414
View File
@@ -0,0 +1,414 @@
use crate::unpacker::detect;
use std::io::Read;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Bytes read per file for content detection. `detect` inspects the DOS/PE
/// header and the Crackproof key table at offset 4096; its deepest read is the
/// key-table dword at 4124 (so a candidate must be ≥ 4128 bytes) or the PE
/// data-directory field at `e_lfanew + 252`, which is far below 8 KiB for any
/// real PE (`e_lfanew` is a few hundred bytes). `is_metadata` needs only the
/// first 4 bytes. An 8 KiB prefix therefore yields the same verdict as the whole
/// file while avoiding pulling multi-gigabyte game assets into memory just to
/// reject them — the previous 64 KiB was 8× larger than anything detect reads.
const DETECT_PREFIX: u64 = 8 * 1024;
/// Smallest file that can possibly be a target, so anything shorter is skipped
/// without ever being opened.
///
/// A Crackproof module needs ≥ 4128 bytes for [`crate::unpacker::detect`]'s key
/// table (it reads the dword at 4124), so the bound is exact for the unpack
/// path. An il2cpp `global-metadata.dat` only needs 4 bytes to match its magic,
/// but its header alone runs to offset 0xB0 and the images/types/methods tables
/// it indexes make every real one megabytes long — a sub-4 KiB "metadata" could
/// only ever fail [`crate::metadata::deobfuscate`] with `Malformed`, so nothing
/// processable is lost.
const MIN_SIZE: u64 = 4128;
/// File extensions that are bulk data by construction and can never be a PE
/// image or an il2cpp metadata blob.
///
/// This is deliberately a **deny**-list, not an allow-list: the default is to
/// probe, so anything unrecognised is still opened. Targets are recognised by
/// content, not extension, and can carry arbitrary names — there is no closed
/// set of target extensions an allow-list of `exe`/`dll` could enumerate.
/// Only extensions that are bulk asset or text formats by construction appear
/// here.
///
/// Set `SENBEI_SCAN_ALL=1` (or pass `--scan-all`) to probe every file regardless.
const DENY_EXT: &[&str] = &[
// Unity and other engine asset containers
"ab",
"bundle",
"unity3d",
"manifest",
"resource",
"ress",
"assets",
"sharedassets",
// audio / video / image / font
"acb",
"awb",
"usm",
"wav",
"ogg",
"mp3",
"mp4",
"avi",
"png",
"jpg",
"jpeg",
"bmp",
"gif",
"tga",
"dds",
"svg",
"ttf",
"otf",
// text, markup, config, logs
"xml",
"json",
"txt",
"csv",
"md",
"toml",
"ini",
"yml",
"yaml",
"log",
"html",
"htm",
"css",
"aspx",
"browser",
"config",
"sig",
"map",
"pdb",
// rhythm-game chart/score data
"ma2",
"sr",
];
/// Whether `path`'s extension is on [`DENY_EXT`]. Extensionless files are never
/// denied (they could be anything).
fn denied_ext(path: &Path) -> bool {
let Some(ext) = path.extension() else {
return false;
};
let Some(ext) = ext.to_str() else {
return false;
};
// Extensions are ASCII in practice; compare case-insensitively without
// allocating for the overwhelmingly common non-match.
DENY_EXT
.iter()
.any(|d| d.len() == ext.len() && d.eq_ignore_ascii_case(ext))
}
/// Content classification of a single file.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Class {
/// Neither a Crackproof module nor il2cpp metadata — left untouched.
None,
/// A Crackproof-protected PE (unpack target).
Crackproof,
/// An il2cpp `global-metadata.dat` (de-obfuscation target).
Metadata,
}
/// Walk `root` recursively (skipping any directory literally named `"unpack"`)
/// and return, **in walk order**, the Crackproof candidates and the il2cpp
/// metadata blobs found — from a *single* traversal that opens each file at
/// most once.
///
/// # Why the cheap pre-filter dominates
///
/// The traversal is not the cost. Measured on a 46,446-file / 61 GB game tree,
/// `readdir` (including each entry's size, which Windows returns from the
/// directory enumeration for free) takes ~0.2 s and opening all 46,446 files
/// takes ~1 s — but *reading* from them takes 40 s. Read size is irrelevant: a
/// 4-byte read costs the same ~900 µs as an 8 KiB one, because the cost is
/// per-file I/O latency, not bandwidth (that tree lives on a user-mode virtual
/// disk that tops out near 1,300 IOPS). Thread count barely moves it either.
///
/// So the only lever is **probing fewer files**, which is what [`MIN_SIZE`] and
/// [`DENY_EXT`] do — both decided from the free directory metadata, before any
/// file is opened. On that tree they cut 46,446 probes to 1,814 and the scan
/// from ~40 s to ~2 s while still finding every target.
///
/// The surviving probes (open + short read + magic test) are fanned out across
/// worker threads. Directory traversal itself stays serial (one cheap `readdir`
/// pass, no file opens) because it feeds the parallel probe.
///
/// Thread count follows [`crate::unpacker::parallel::thread_cap`] (honoring
/// `SENBEI_THREADS`, `1` = fully sequential). Output order is independent of
/// thread count: each worker owns a disjoint contiguous slice of the path list
/// and writes the matching disjoint slice of the class list, so results are
/// deterministic.
pub fn find_targets(root: &Path) -> (Vec<PathBuf>, Vec<PathBuf>, ScanStats) {
find_targets_opts(root, scan_all_env())
}
/// Non-target tallies from a [`find_targets_opts`] walk.
#[derive(Default, Clone, Copy, Debug)]
pub struct ScanStats {
/// Files that were content-probed but matched neither detector (skipped).
pub skipped: usize,
/// Directory entries the walker could not read (permissions, transient
/// I/O errors). These files were never classified — surface this to the
/// user instead of silently reporting a clean scan.
pub walk_errors: usize,
/// Files selected for probing whose bytes could not be read (open/read
/// failure, or a detector panic). Unlike `skipped`, the scan could not
/// determine whether these are targets — a locked il2cpp game assembly
/// looks exactly like this, so the job layer counts them as errors.
pub probe_errors: usize,
}
/// [`find_targets`], but with the pre-filter explicitly controlled. When
/// `scan_all` is true every regular file is probed, restoring the exhaustive
/// (and on asset-heavy trees, far slower) behavior.
pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<PathBuf>, ScanStats) {
// Phase 1: serial traversal collecting regular-file paths only. No file is
// opened here; `readdir` is fast relative to the content probe that follows,
// and `entry.metadata()` is served from the directory entry on Windows, so
// the size test below costs nothing.
let mut paths: Vec<PathBuf> = Vec::new();
let mut stats = ScanStats::default();
for entry in WalkDir::new(root).into_iter().filter_entry(|e| {
if !e.file_type().is_dir() {
return true;
}
// The root itself is always walked, even if it is named "unpack" or is
// a junction the user pointed us at deliberately.
if e.depth() == 0 {
return true;
}
// Never descend into a previous output tree ("unpack", any case: NTFS
// is case-insensitive, so `Unpack` from an older run is still ours).
if e.file_name().eq_ignore_ascii_case("unpack") {
return false;
}
// Skip reparse-point directories (junctions, symlink-dirs): they point
// outside the scanned tree — walking one would silently unpack an
// entire foreign tree (e.g. a `samples` junction into the golden corpus).
!is_reparse_point(e)
}) {
let entry = match entry {
Ok(e) => e,
Err(_) => {
stats.walk_errors += 1;
continue;
}
};
if !entry.file_type().is_file() {
continue;
}
if !scan_all {
// Skip on directory metadata alone — never open these.
let too_small = entry
.metadata()
.map(|m| m.len() < MIN_SIZE)
.unwrap_or(false);
if too_small || denied_ext(entry.path()) {
continue;
}
}
paths.push(entry.into_path());
}
// Phase 2: parallel content probe over disjoint chunks (no synchronization).
// `classify` yields `None` for unreadable/panicking probes (see ScanStats);
// `Some(Class::None)` means "probed, matched neither detector".
let n = paths.len();
let mut class: Vec<Option<Class>> = vec![Some(Class::None); n];
let workers = crate::unpacker::parallel::thread_cap().clamp(1, n.max(1));
if workers <= 1 {
for (p, c) in paths.iter().zip(class.iter_mut()) {
*c = classify(p);
}
} else {
let chunk = n.div_ceil(workers);
std::thread::scope(|scope| {
for (pc, cc) in paths.chunks(chunk).zip(class.chunks_mut(chunk)) {
scope.spawn(move || {
for (p, c) in pc.iter().zip(cc.iter_mut()) {
*c = classify(p);
}
});
}
});
}
let mut candidates = Vec::new();
let mut metadata = Vec::new();
for (p, c) in paths.into_iter().zip(class) {
match c {
Some(Class::Crackproof) => candidates.push(p),
Some(Class::Metadata) => metadata.push(p),
Some(Class::None) => stats.skipped += 1,
// Unreadable / panicking probe: NOT skipped — the scan could not
// classify it, so it may be a target we failed to unpack.
None => stats.probe_errors += 1,
}
}
(candidates, metadata, stats)
}
/// True if a walked directory entry is a reparse point (junction or symlink).
///
/// `DirEntry::file_type` only flags true symlinks; NTFS junctions report as
/// ordinary directories, so without this check the walker descends into them.
/// Off-Windows there are no junctions — symlink dirs are already excluded
/// because `follow_links` is off (their `file_type().is_dir()` is false).
#[cfg(windows)]
fn is_reparse_point(e: &walkdir::DirEntry) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
e.metadata()
.map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
.unwrap_or(false)
}
#[cfg(not(windows))]
fn is_reparse_point(_e: &walkdir::DirEntry) -> bool {
false
}
/// Whether the scan pre-filter is disabled via `SENBEI_SCAN_ALL`. Any value
/// other than `0`/empty turns exhaustive scanning on. The `--scan-all` flag is
/// ORed with this.
pub fn scan_all_env() -> bool {
match std::env::var("SENBEI_SCAN_ALL") {
Ok(v) => !matches!(v.trim(), "" | "0"),
Err(_) => false,
}
}
/// Classify one file by content. Reads a short prefix once and tests the
/// Crackproof detector first, then the il2cpp metadata magic. Returns `None`
/// when the file could not be classified at all — an I/O error opening it
/// (locked, permissions) or a panic inside a detector — so the caller counts
/// it as a probe error rather than a clean "not a target" skip.
///
/// The detector is wrapped in `catch_unwind` because a panic in a scan worker
/// thread would otherwise abort the whole folder run (a scoped-thread panic
/// re-raises on join, before any per-file isolation exists). The default panic
/// hook still prints the message, keeping the bug diagnosable.
///
/// A Crackproof PE never matches the metadata magic (it is a PE, not a
/// metadata blob) and vice versa, so the order is immaterial.
fn classify(path: &Path) -> Option<Class> {
let head = read_prefix(path, DETECT_PREFIX)?;
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if detect(&head).is_some() {
Class::Crackproof
} else if crate::metadata::is_metadata(&head) {
Class::Metadata
} else {
Class::None
}
}));
r.ok()
}
/// Read up to `max` bytes from the start of `path`. Returns `None` on any I/O
/// error (the file is simply not treated as a candidate).
fn read_prefix(path: &Path, max: u64) -> Option<Vec<u8>> {
let file = std::fs::File::open(path).ok()?;
let mut buf = Vec::with_capacity(max as usize);
file.take(max).read_to_end(&mut buf).ok()?;
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn denies_bulk_asset_extensions_case_insensitively() {
for p in ["a.ab", "a.XML", "a.Acb", "a.ma2", "a.manifest", "a.PNG"] {
assert!(denied_ext(Path::new(p)), "{p} should be denied");
}
}
#[test]
fn never_denies_what_a_target_can_be_named() {
// Targets are recognised by content, not name — a protected module
// can carry any extension, or none — so names like these must always
// be probed. An allow-list would have skipped them.
for p in [
"app.exe.bak",
"managed.dll.bak",
"daemon.exe",
"GameLib.dll",
"global-metadata.dat",
"noextension",
"a.so",
"a.bin",
] {
assert!(!denied_ext(Path::new(p)), "{p} must still be probed");
}
}
/// A file below the Crackproof key-table bound is skipped without being
/// opened, but a large non-asset file is still probed.
#[test]
fn prefilter_skips_small_and_denied_files_only() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
std::fs::write(root.join("tiny.dll"), vec![0u8; 100]).unwrap();
std::fs::write(root.join("assets.ab"), vec![0u8; 100_000]).unwrap();
std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap();
// None of them are Crackproof, so both modes find nothing; the point is
// that the filtered walk does not panic and honors `scan_all`.
let (c, m, _) = find_targets_opts(root, false);
assert!(c.is_empty() && m.is_empty());
let (c, m, _) = find_targets_opts(root, true);
assert!(c.is_empty() && m.is_empty());
}
/// An il2cpp metadata blob is found by the filtered scan: `.dat` is not on
/// the deny-list and a real one is far above `MIN_SIZE`.
#[test]
fn finds_metadata_through_the_prefilter() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
let mut blob = vec![0u8; MIN_SIZE as usize + 1];
blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes());
std::fs::write(root.join("global-metadata.dat"), &blob).unwrap();
// Same magic but too small to be processable — skipped by the size floor.
std::fs::write(root.join("stub.dat"), &blob[..64]).unwrap();
let (_, m, _) = find_targets_opts(root, false);
assert_eq!(m.len(), 1);
assert!(m[0].ends_with("global-metadata.dat"));
}
/// Review regression: a previous output tree is pruned case-insensitively
/// (NTFS is case-insensitive, so `UNPACK` from an older run is still our
/// output), and probed non-targets are counted as skipped.
#[test]
fn prunes_unpack_dir_case_insensitively_and_counts_skipped() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
let out_dir = root.join("UNPACK");
std::fs::create_dir(&out_dir).unwrap();
// A metadata-magic file inside the old output tree: must NOT be found.
let mut blob = vec![0u8; MIN_SIZE as usize + 1];
blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes());
std::fs::write(out_dir.join("global-metadata.dat"), &blob).unwrap();
// A big non-target file at the root: probed, then skipped.
std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap();
let (c, m, stats) = find_targets_opts(root, false);
assert!(
c.is_empty() && m.is_empty(),
"old output tree must be pruned"
);
assert_eq!(stats.skipped, 1, "the probed non-target counts as skipped");
assert_eq!(stats.walk_errors, 0);
}
}
+73
View File
@@ -0,0 +1,73 @@
use crate::unpacker::{IntegrityReport, Kind};
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use std::path::Path;
/// Create a progress bar for `n` items. Hidden when `quiet` is true.
pub fn progress(n: u64, quiet: bool) -> ProgressBar {
if quiet || n == 0 {
return ProgressBar::hidden();
}
let bar = ProgressBar::new(n);
bar.set_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_bar()),
);
bar
}
/// Print a green success line, suspending the progress bar.
pub fn ok(bar: &ProgressBar, quiet: bool, rel: &Path, kind: Kind, dest: &Path) {
if quiet {
return;
}
let msg = format!(
"{} {:?} {} -> {}",
"".green(),
kind,
rel.display(),
dest.display()
);
bar.suspend(|| println!("{msg}"));
}
/// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`,
/// reporting how many method tokens were remapped.
pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) {
if quiet {
return;
}
let msg = format!(
"{} metadata {} -> {} ({} method tokens remapped)",
"".green(),
rel.display(),
dest.display(),
remapped
);
bar.suspend(|| println!("{msg}"));
}
/// Print a red error line, suspending the progress bar.
pub fn err(bar: &ProgressBar, quiet: bool, rel: &Path, e: &anyhow::Error) {
if quiet {
return;
}
let msg = format!("{} {} {e:#}", "".red(), rel.display());
bar.suspend(|| eprintln!("{msg}"));
}
/// Print a yellow warning line for a file that unpacked but failed the static
/// integrity check (likely to crash at runtime), suspending the progress bar.
pub fn suspect(bar: &ProgressBar, quiet: bool, rel: &Path, report: &IntegrityReport) {
if quiet {
return;
}
let msg = format!(
"{} {} integrity check failed: {}",
"!".yellow(),
rel.display(),
report.issues.join("; ")
);
bar.suspend(|| eprintln!("{msg}"));
}
+119
View File
@@ -0,0 +1,119 @@
// Bytecode interpreter for the custom-decryptor stages. Those stages are tiny
// instruction programs embedded in the decrypted buffer; we compile each
// program down to a Vec<Op> and interpret it.
#[derive(Clone, Copy)]
pub enum Op {
Add(u8),
Sub(u8),
Xor(u8),
Rol(u32),
Ror(u32),
Inc,
Dec,
}
pub fn apply(ops: &[Op], mut x: u8) -> u8 {
for &op in ops {
x = match op {
Op::Add(n) => x.wrapping_add(n),
Op::Sub(n) => x.wrapping_sub(n),
Op::Xor(n) => x ^ n,
Op::Rol(n) => x.rotate_left(n & 7),
Op::Ror(n) => x.rotate_right(n & 7),
Op::Inc => x.wrapping_add(1),
Op::Dec => x.wrapping_sub(1),
};
}
x
}
/// A precomputed 256-entry byte→byte translation table for a fixed op list.
///
/// `apply` is a pure function of a single byte, but the hot decrypt paths run it
/// over multi-megabyte regions. Building the full table once and translating
/// each byte with a single lookup turns an O(region × ops) walk into O(region) —
/// a large constant-factor win on those paths.
pub struct OpsLut {
t: [u8; 256],
}
impl OpsLut {
pub fn new(ops: &[Op]) -> Self {
let mut t = [0u8; 256];
let mut i = 0;
while i < 256 {
t[i] = apply(ops, i as u8);
i += 1;
}
Self { t }
}
/// Translate `d[off .. off + n]` in place through the table.
#[inline]
pub fn map_region(&self, d: &mut [u8], off: usize, n: usize) {
for b in &mut d[off..off + n] {
*b = self.t[*b as usize];
}
}
}
pub fn generate(data: &[u8], offset: u32) -> Option<Vec<Op>> {
// Bounds-checked cursor: a corrupt `data_offset` (bad decrypt_data6 / the
// alignment fallback) must yield `None`, not an out-of-bounds panic — the
// panic path would surface as a misleading `UnpackError::Corrupt` instead
// of the precise `BytecodeGenFailed`, and any future caller without a
// `catch_unwind` wrapper would abort outright.
let mut pos = offset as usize;
let mut next = move || {
let b = data.get(pos).copied()?;
pos += 1;
Some(b)
};
let mut ops = Vec::new();
loop {
match next()? {
4 => ops.push(Op::Add(next()?)),
44 => ops.push(Op::Sub(next()?)),
52 => ops.push(Op::Xor(next()?)),
144 => {} // nop
192 => {
let mb = next()?;
let rm = mb & 7;
let reg = (mb >> 3) & 7;
let mod_ = (mb >> 6) & 3;
if mod_ != 3 || rm != 0 {
return None;
}
let imm = next()? as u32;
match reg {
0 => ops.push(Op::Rol(imm)),
1 => ops.push(Op::Ror(imm)),
_ => {
return None;
}
}
}
254 => {
let mb = next()?;
let rm = mb & 7;
let reg = (mb >> 3) & 7;
let mod_ = (mb >> 6) & 3;
if mod_ != 3 || rm != 0 {
return None;
}
match reg {
0 => ops.push(Op::Inc),
1 => ops.push(Op::Dec),
_ => {
return None;
}
}
}
195 => return Some(ops),
_ => {
return None;
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
const fn build_table() -> [u32; 256] {
let mut table = [0u32; 256];
let mut i = 0;
while i < 256 {
let mut c = i as u32;
let mut k = 0;
while k < 8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
k += 1;
}
table[i] = c;
i += 1;
}
table
}
const TABLE: [u32; 256] = build_table();
pub fn append(initial: u32, data: &[u8]) -> u32 {
let mut crc = !initial;
for &b in data {
crc = TABLE[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
!crc
}
pub fn compute(data: &[u8]) -> u32 {
append(0, data)
}
+745
View File
@@ -0,0 +1,745 @@
//! Native/managed-DLL unpack pipeline for the older protected-DLL layout.
//!
//! Naming note: the stage names used by this layout do NOT line up 1:1 with the
//! shared primitives. Mapping used here:
//! DecryptData1 (XOR+ROR over dwords) -> primitives::decrypt_data3
//! DecryptData3 (shift-5 byte rotate) -> local `decrypt_data3_shift5`
//! DecryptData4/5 (AES+XORROR+huff) -> local `decrypt_data4`
//! DecryptData6 (shift-6 byte rotate) -> local `decrypt_data6_shift6`
//! DecryptData7 (nibble-swap rolling) -> primitives::decrypt_data7
//! Decompress (LFSR keystream) -> primitives::decrypt_data6
//! HuffmanDecompress -> primitives::decompress
//! AesDecrypt -> primitives::aes_decrypt
//! CalculateChecksumWithSizeXor -> primitives::calculate_checksum
//! CalculateCrc32 -> crc32::compute (via above)
use super::UnpackError;
use super::bytecode::{Op, OpsLut, generate};
use super::primitives::{self, *};
/// Read a signed 32-bit little-endian value.
fn get_i32(d: &[u8], offset: i32) -> i32 {
get_u32(d, offset as u32) as i32
}
/// Write a signed 32-bit little-endian value.
fn write_i32(d: &mut [u8], offset: i32, value: i32) {
write_u32(d, offset as u32, value as u32);
}
/// `DecryptData3` (shift-5): byte-level bit rotation over a (addr,size) pair.
fn decrypt_data3_shift5(d: &mut [u8], offset: i32) {
let addr = get_i32(d, offset);
let size = get_i32(d, offset + 4);
let mut key1: u8 = (addr as u8).wrapping_add((addr >> 8) as u8);
let mut key2: u8 = key1.wrapping_add(1);
for i in 0..size {
let idx = (addr + i) as usize;
let val = d[idx];
let step1 = key2 ^ val.rotate_left(3);
let step2 = key1 ^ step1.rotate_left(3);
d[idx] = step2.rotate_left(3);
key1 = key1.wrapping_add(1);
key2 = key2.wrapping_add(1);
}
}
/// `DecryptData6` (shift-6): byte-level bit rotation over an explicit
/// (offset, size) range, with the low byte of `offset` as the rolling key.
fn decrypt_data6_shift6(d: &mut [u8], offset: i32, size: i32) {
let mut key1: u8 = offset as u8;
let mut key2: u8 = (offset as u8).wrapping_add(1);
for i in 0..size {
let idx = (offset + i) as usize;
let val = d[idx];
let step1 = key2 ^ val.rotate_left(2);
let step2 = key1 ^ step1.rotate_left(2);
d[idx] = step2.rotate_left(2);
key1 = key1.wrapping_add(1);
key2 = key2.wrapping_add(1);
}
}
/// `DecryptData4`/`DecryptData5`: AES-CBC decrypt + XOR/ROR (DecryptData1
/// with rotate 19) + optional per-byte transform + Huffman decompress.
fn decrypt_data4(
d: &mut [u8],
offset: i32,
key: i32,
decomp_params: &[i32; 4],
transform: Option<&[Op]>,
) -> Result<(), UnpackError> {
let addr = get_i32(d, offset);
let size = get_i32(d, offset + 4);
let compressed_addr = get_i32(d, offset + 8);
let decompressed_size = get_i32(d, offset + 12);
aes_decrypt(d, addr as u32, size as u32, decomp_params[3] as u32);
// DecryptData1(offset, key, 19) == primitives::decrypt_data3 with shift 19
decrypt_data3(d, offset as u32, key as u32, 19);
if let Some(ops) = transform
&& size > 0
{
OpsLut::new(ops).map_region(d, addr as usize, size as usize);
}
if size != decompressed_size {
// decompress reports corruption (after partial writes) via its bool;
// surface it instead of shipping a garbage block.
if !decompress(
d,
addr as u32,
compressed_addr as u32,
decomp_params[1] as u32,
size as u32,
decompressed_size as u32,
) {
return Err(UnpackError::DecompressFailed);
}
}
Ok(())
}
/// `InitializeKeys`.
fn initialize_keys(file_data: &[u8]) -> [i32; 8] {
let mut keys = [0i32; 8];
keys[0] = get_i32(file_data, 4096);
let mut prev_key = keys[0];
for i in 0..7i32 {
let val = get_i32(file_data, 4 * i + 4100);
keys[(i + 1) as usize] = val ^ prev_key;
prev_key = (i * i) ^ (val.wrapping_add(prev_key).wrapping_sub(i));
}
keys
}
/// `ProcessRelocBlock`.
fn process_reloc_block(d: &mut [u8], mut pos: i32) {
loop {
decrypt_data6_shift6(d, pos, 16);
let src_addr = get_i32(d, pos);
let size = get_i32(d, pos + 4);
let dst_addr = get_i32(d, pos + 8);
let verify = get_i32(d, pos + 12);
pos += 16;
if src_addr != 0 && size != 0 && dst_addr != 0 && verify == size {
let s = src_addr as usize;
let dd = dst_addr as usize;
let n = size as usize;
d.copy_within(s..s + n, dd);
}
if size == 0 {
break;
}
}
}
/// Number of section headers to walk, and the guard the walks share.
///
/// The section table has no sentinel entry, so "iterate until VirtualSize is 0"
/// silently truncates the walk at the first section with a legitimately zero
/// VirtualSize (or a corrupt early field) — the later sections then keep the
/// packer's raw pointers and the image is broken with no error. Walk by
/// `NumberOfSections` instead, capped, with an all-zero-name break to guard the
/// other direction (a corrupt, overstated count): real sections always have a
/// name, header padding is all zero.
const MAX_SECTIONS: i32 = 96;
fn section_count(file_data: &[u8], pe_offset: i32) -> i32 {
(get_u16(file_data, (pe_offset + 6) as u32) as i32).min(MAX_SECTIONS)
}
fn section_header_blank(file_data: &[u8], off: i32) -> bool {
let s = off as usize;
match file_data.get(s..s + 8) {
Some(name) => name.iter().all(|&b| b == 0),
None => true,
}
}
/// `ProcessImportTable`.
fn process_import_table(d: &mut [u8], mut import_table_offset: i32) {
while get_i32(d, import_table_offset + 12) != 0 {
let name_offset = get_i32(d, import_table_offset + 12);
decrypt_data7(d, name_offset as u32, name_offset as u8);
let thunk_addr0 = get_i32(d, import_table_offset);
let orig_thunk_addr = get_i32(d, import_table_offset + 16);
let mut thunk_addr = if thunk_addr0 == 0 {
orig_thunk_addr
} else {
thunk_addr0
};
loop {
// PE32+ thunks are 8 bytes: an ordinal import carries bit 63 with
// the ordinal in the low word; only a by-name thunk holds a
// hint/name RVA (in the low dword). Reading just the low dword
// would mistake an ordinal for a tiny RVA and scribble over the
// image header.
let v = get_u64(d, thunk_addr as u32);
if v == 0 {
break;
}
if (v & 0x8000_0000_0000_0000) == 0 {
let entry = v as u32;
decrypt_data7(d, entry.wrapping_add(2), entry as u8);
d[entry as usize] = 0;
d[entry.wrapping_add(1) as usize] = 0;
}
thunk_addr += 8;
}
import_table_offset += 20;
}
}
/// `DecryptAndDecompressData`.
fn decrypt_and_decompress_data(
d: &mut [u8],
clean: &[u8],
section_image_base: i32,
mut section_data_offset: i32,
decrypt_func: &[Op],
decomp_params: &[i32; 4],
) -> Result<(), UnpackError> {
// Entry loop — Pass 1 (sequential): the 16-byte descriptors are decrypted
// in a position-keyed chain (decrypt_data6_shift6) terminated by a zero-size
// record, so collection cannot be parallelized.
struct Blk {
dest_offset: i32,
size: i32,
src_offset: i32,
expected_crc: i32,
}
let mut blocks: Vec<Blk> = Vec::new();
loop {
// Guard: need 16 bytes at section_data_offset in `d`
let off = section_data_offset as usize;
if off.saturating_add(16) > d.len() {
return Err(UnpackError::OutOfBounds(off));
}
decrypt_data6_shift6(d, section_data_offset, 16);
let dest_offset = get_i32(d, section_data_offset);
let size = get_i32(d, section_data_offset + 4);
let src_offset = get_i32(d, section_data_offset + 8);
let expected_crc = get_i32(d, section_data_offset + 12);
section_data_offset += 16;
if size == 0 {
break;
}
blocks.push(Blk {
dest_offset,
size,
src_offset,
expected_crc,
});
}
// Pass 2: each block writes only [src_offset, src_offset+max(size,crc)) and
// reads only immutable input + the (snapshotted) key tables, so blocks with
// disjoint write spans are independent. `parallel_for` carves the spans
// into safe disjoint &mut slices (these blocks are only ever laid out
// disjointly; overlapping spans degrade to a sequential pass).
{
let lut = OpsLut::new(decrypt_func);
let ko0 = decomp_params[0];
let ko2 = decomp_params[2];
let ks_snap =
primitives::aes_schedule_snapshot(d, ko2 as u32).ok_or(UnpackError::Corrupt)?;
let tab_snap = primitives::huffman_table_snapshot(d, ko0 as u32)
.ok_or(UnpackError::DecompressFailed)?;
let spans: Vec<(usize, usize)> = blocks
.iter()
.map(|b| {
let s = b.src_offset as usize;
(s, s + b.size.max(b.expected_crc) as usize)
})
.collect();
let do_block = |i: usize, base: usize, span: &mut [u8]| -> Result<(), UnpackError> {
let b = &blocks[i];
let src = (b.dest_offset as i64 + section_image_base as i64) as usize;
let rel = (b.src_offset as usize) - base;
let n = b.size as usize;
// Bounds-checked copy from `clean` (potentially truncated input).
primitives::try_copy_from_slice(span, rel, n, clean, src)?;
aes_decrypt_ks(&ks_snap, span, rel as u32, b.size as u32);
lut.map_region(span, rel, n);
if b.size != b.expected_crc {
// decompress reports corruption (after partial writes) via its
// bool; surface it instead of shipping a garbage block.
if !decompress_tbl(
&tab_snap,
span,
rel as u32,
rel as u32,
b.size as u32,
b.expected_crc as u32,
) {
return Err(UnpackError::DecompressFailed);
}
}
Ok(())
};
super::parallel::parallel_for(d, &spans, 1, do_block)?;
}
// Zero-fill loop.
loop {
let off = section_data_offset as usize;
// The entry block loop above correctly requires 16 bytes; this loop
// decrypts 16 too, so guard 16 (an 8-byte guard would let
// decrypt_data6_shift6 index past the end of a truncated descriptor).
if off.saturating_add(16) > d.len() {
return Err(UnpackError::OutOfBounds(off));
}
decrypt_data6_shift6(d, section_data_offset, 16);
let zero_offset = get_i32(d, section_data_offset);
let zero_size = get_i32(d, section_data_offset + 4);
section_data_offset += 16;
if zero_size == 0 {
break;
}
for i in 0..zero_size {
let idx = (zero_offset + i) as usize;
if idx >= d.len() {
return Err(UnpackError::OutOfBounds(idx));
}
d[idx] = 0;
}
}
Ok(())
}
/// Unpack a native/managed DLL in the older protected-DLL layout. Returns the
/// unpacked image bytes.
pub fn unpack_dll(input: &[u8]) -> Result<Vec<u8>, UnpackError> {
unpack_dll_v(input, false)
}
/// Like [`unpack_dll`], but prints detailed `[N/9]` step progress to stdout when
/// `verbose` is true. Output bytes are identical regardless.
pub fn unpack_dll_v(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError> {
// Trap any out-of-bounds panic from a truncated/garbled file and report it
// as a clean error so the public API stays panic-free.
super::catch_unpack(move || unpack_dll_inner(input, verbose))
}
fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError> {
if input.len() < 4096 {
return Err(UnpackError::InputTooShort(input.len()));
}
// `file_data` and `original_file_data` both borrow the same protected input.
let file_data = input;
let original_file_data = input;
let keys = initialize_keys(file_data);
if verbose {
println!("[1/9] Initializing keys...");
println!(" keys[0] key = 0x{:08X}", keys[0] as u32);
println!(" keys[1] signature = 0x{:08X}", keys[1] as u32);
println!(" keys[3] base = 0x{:08X}", keys[3] as u32);
println!(" keys[4] src_off = 0x{:08X}", keys[4] as u32);
println!(" keys[5] size = 0x{:08X}", keys[5] as u32);
println!(" keys[6] anchor = 0x{:08X}", keys[6] as u32);
}
if !super::is_supported_magic(keys[1] as u32) {
return Err(UnpackError::DllUnpack(
"Not a Crackproof protected file (KONN magic mismatch)".into(),
));
}
let pe_offset = get_i32(file_data, 60);
if pe_offset < 0 || (pe_offset as usize).saturating_add(84) > file_data.len() {
return Err(UnpackError::DllUnpack("implausible PE offset".into()));
}
// This pipeline is PE32+-only: its header fixups write the data
// directories at PE32+ offsets (pe+144..180, pe+136 for the DD blob). On a
// PE32 image those land in the wrong optional-header fields and produce a
// structurally plausible but unloadable file. Reject early with a clear
// error so `unpack_auto`'s EXE-pipeline fallback handles PE32 DLLs (that
// path is PE32-aware — see run_pe32), instead of us mangling them here.
if get_i32(file_data, pe_offset + 24) & 0xFFFF != 0x20B {
return Err(UnpackError::DllUnpack(
"not a PE32+ image (the DLL pipeline handles 64-bit only)".into(),
));
}
let size_of_image = get_i32(file_data, pe_offset + 80);
if size_of_image <= 0 || size_of_image as u64 > super::MAX_IMAGE_SIZE {
return Err(UnpackError::DllUnpack("implausible SizeOfImage".into()));
}
let mut out = vec![0u8; size_of_image as usize];
let base_offset = keys[6] - keys[3] + 0x2000;
if verbose {
println!("[2/9] Decrypting key table...");
println!(" size_of_image = 0x{:08X}", size_of_image as u32);
println!(" base_offset = 0x{:08X}", base_offset as u32);
}
// DecryptKeyTable.
{
let src_base = keys[4] + 4096;
let mut scramble = (!base_offset).wrapping_add(keys[0]);
let count = base_offset >> 2;
for i in 0..count {
let dst_offset = keys[3] + 4 * i;
let src_val = get_i32(file_data, src_base + 4 * i);
write_i32(&mut out, dst_offset, src_val ^ scramble);
scramble = (i * i) ^ (i.wrapping_add(src_val).wrapping_add(scramble));
}
}
// Array.Copy(fileData, keys[4]+baseOffset+4096, outputData, keys[3]+baseOffset, keys[5]-baseOffset)
{
let src = (keys[4] + base_offset + 4096) as usize;
let dst = (keys[3] + base_offset) as usize;
let n = (keys[5] - base_offset) as usize;
primitives::try_copy_from_slice(&mut out, dst, n, file_data, src)?;
}
write_i32(&mut out, keys[3], 4096);
out[..4096].copy_from_slice(&file_data[..4096]);
let v144 = get_i32(&out, keys[6] + 5600);
let v148 = get_i32(&out, keys[6] + 5596);
let v152 = get_i32(&out, keys[6] + 5632);
let v156 = get_i32(&out, keys[6] + 5636);
write_i32(&mut out, pe_offset + 144, v144);
write_i32(&mut out, pe_offset + 148, v148);
write_i32(&mut out, pe_offset + 152, v152);
write_i32(&mut out, pe_offset + 156, v156);
write_i32(&mut out, pe_offset + 176, 0);
write_i32(&mut out, pe_offset + 180, 0);
let mut checksum_offset1 = keys[6] + 5776;
let mut xor_accumulator: u32 = 0;
while get_i32(&out, checksum_offset1 + 4) != 0 {
xor_accumulator ^= calculate_checksum(&out, checksum_offset1 as u32);
checksum_offset1 += 8;
}
let checksum1 = calculate_checksum(&out, (keys[6] + 5648) as u32) as i32;
let enc_key = get_u32(&out, (keys[6] + 5612) as u32);
let decrypt_offset1 = keys[6] + 5712;
decrypt_data3(
&mut out,
decrypt_offset1 as u32,
xor_accumulator ^ (checksum1 as u32) ^ enc_key,
21,
);
let decrypted_addr1 = get_i32(&out, decrypt_offset1);
if verbose {
println!("[3/9] Decrypting primary descriptor...");
println!(" xor_accumulator = 0x{:08X}", xor_accumulator);
println!(" checksum1 = 0x{:08X}", checksum1 as u32);
println!(" decrypted_addr1 = 0x{:08X}", decrypted_addr1 as u32);
}
let import_offset = get_i32(&out, decrypted_addr1 + 3444);
let decrypted_addr2_size = get_i32(&out, decrypted_addr1 + 3632);
decrypt_data3(
&mut out,
(decrypted_addr1 + 3632) as u32,
import_offset as u32,
19,
);
let addr2 = decrypted_addr2_size;
let reloc_block_offset = addr2 + 9248;
let reloc_type = get_i32(&out, reloc_block_offset);
if (reloc_type & 0x0F) == 1 {
decrypt_data3_shift5(&mut out, addr2 + 9252);
} else if reloc_type == 2 {
let p = get_i32(&out, addr2 + 9252);
process_reloc_block(&mut out, p);
}
let reloc_block_offset2 = reloc_block_offset + 16;
let reloc_type2 = get_i32(&out, reloc_block_offset2);
if (reloc_type2 & 0x0F) == 1 {
decrypt_data3_shift5(&mut out, reloc_block_offset2 + 4);
} else if reloc_type2 == 2 {
let p = get_i32(&out, reloc_block_offset2 + 4);
process_reloc_block(&mut out, p);
}
let mut decomp_params = [0i32; 4];
let param_base = addr2 + 9160;
decrypt_data3_shift5(&mut out, param_base);
decomp_params[0] = get_i32(&out, param_base);
decrypt_data3_shift5(&mut out, param_base + 8);
decomp_params[1] = get_i32(&out, param_base + 8);
decrypt_data3_shift5(&mut out, param_base + 32);
decomp_params[2] = get_i32(&out, param_base + 32);
decrypt_data3_shift5(&mut out, param_base + 40);
decomp_params[3] = get_i32(&out, param_base + 40);
if verbose {
println!("[4/9] Processing relocations & decomp params...");
println!(" addr2 = 0x{:08X}", addr2 as u32);
println!(
" decomp_params = [0x{:08X}, 0x{:08X}, 0x{:08X}, 0x{:08X}]",
decomp_params[0] as u32,
decomp_params[1] as u32,
decomp_params[2] as u32,
decomp_params[3] as u32
);
}
let checksum2 = calculate_checksum(&out, (keys[6] + 5640) as u32) as i32;
let mut table_val = get_i32(&out, decrypted_addr1 + 3448);
for k in 1..=100 {
table_val = table_val.wrapping_add(k);
}
for k in 1..=200 {
table_val = table_val.wrapping_add(k);
}
for k in 1..=300 {
table_val = table_val.wrapping_add(k);
}
for k in 1..=400 {
table_val = table_val.wrapping_add(k);
}
let addr3_offset = decrypted_addr1 + 3712;
decrypt_data4(
&mut out,
addr3_offset,
table_val ^ checksum2 ^ (xor_accumulator as i32),
&decomp_params,
None,
)?;
let addr3b = get_i32(&out, decrypted_addr1 + 3728);
if verbose {
println!("[5/9] Decrypting code block 1 (addr3)...");
println!(" checksum2 = 0x{:08X}", checksum2 as u32);
println!(" addr3b = 0x{:08X}", addr3b as u32);
}
let crc_data_offset = decrypted_addr1 + 3488;
let crc_data_addr = get_i32(&out, crc_data_offset);
let crc_data_size = get_i32(&out, crc_data_offset + 4);
let crc_val = {
let a = crc_data_addr as usize;
let n = crc_data_size as usize;
super::crc32::compute(&out[a..a + n]) as i32
};
let crc_xored = crc_data_size ^ crc_val;
let trailing_val = get_i32(&out, crc_data_addr + crc_data_size - 4);
decrypt_data4(
&mut out,
decrypted_addr1 + 3728,
crc_xored ^ (xor_accumulator as i32) ^ trailing_val,
&decomp_params,
None,
)?;
let checksum3 = calculate_checksum(&out, (decrypted_addr1 + 3480) as u32) as i32;
let not_val = !get_u32(&out, (addr3b + 1968) as u32);
let addr4_offset = decrypted_addr1 + 3760;
let xor_key = (xor_accumulator as i32) ^ checksum3;
decrypt_data4(
&mut out,
addr4_offset,
(not_val ^ (xor_key as u32)) as i32,
&decomp_params,
None,
)?;
let addr4 = get_i32(&out, addr4_offset);
let lfsr = addr4 + 3200;
// Decompress == primitives::decrypt_data6 (LFSR keystream, len at +95).
decrypt_data6(&mut out, lfsr as u32);
if verbose {
println!("[6/9] Decrypting code blocks 2-3 (addr3b, addr4)...");
println!(" crc_val = 0x{:08X}", crc_val as u32);
println!(" checksum3 = 0x{:08X}", checksum3 as u32);
println!(" addr4 = 0x{:08X}", addr4 as u32);
}
let checksum4 = calculate_checksum(&out, (decrypted_addr1 + 3472) as u32) as i32;
let mut lfsr_seed_val = get_i32(&out, addr4 + 3160);
for k in 1..=100 {
lfsr_seed_val = lfsr_seed_val.wrapping_add(k);
}
for k in 1..=200 {
lfsr_seed_val = lfsr_seed_val.wrapping_add(k);
}
for k in 1..=300 {
lfsr_seed_val = lfsr_seed_val.wrapping_add(k);
}
let decrypt_func = generate(&out, lfsr as u32)
.ok_or_else(|| UnpackError::DllUnpack("Failed to build decryption expression".into()))?;
let addr5_offset = decrypted_addr1 + 3840;
let addr5 = get_i32(&out, addr5_offset);
decrypt_data4(
&mut out,
addr5_offset,
lfsr_seed_val ^ xor_key ^ checksum4,
&decomp_params,
Some(&decrypt_func),
)?;
if verbose {
println!("[7/9] Decrypting code block 4 (addr5)...");
println!(" checksum4 = 0x{:08X}", checksum4 as u32);
println!(" addr5 = 0x{:08X}", addr5 as u32);
}
let metadata_offset = addr5 + 12312;
let mut metadata_addr = get_i32(&out, metadata_offset);
while get_i32(&out, metadata_addr + 4) != 0 {
decrypt_data6_shift6(&mut out, metadata_addr, 16);
metadata_addr += 16;
}
let lfsr2 = metadata_offset + 88;
decrypt_data6(&mut out, lfsr2 as u32);
let decrypt_func2 = generate(&out, lfsr2 as u32).ok_or_else(|| {
UnpackError::DllUnpack("Failed to build second decryption expression".into())
})?;
let section_image_base = 4095 - get_i32(original_file_data, 4224);
let section_data_offset = get_i32(&out, addr5 + 11976);
if verbose {
println!("[8/9] Decrypting & decompressing sections...");
println!(
" section_image_base = 0x{:08X}",
section_image_base as u32
);
println!(
" section_data_offset = 0x{:08X}",
section_data_offset as u32
);
}
// Managed-only pre-fill of .text (Task 3.2). No-op for native (clr_rva == 0).
// Data directories start at optional-header +96 on PE32, +112 on PE32+ —
// hardcoding +112 misreads the CLR RVA on a 32-bit image.
let dd_base = if get_u16(file_data, (pe_offset + 24) as u32) == 0x20B {
112
} else {
96
};
let clr_dir_rva = get_i32(file_data, pe_offset + 24 + dd_base + 14 * 8);
if clr_dir_rva != 0 {
let sh_start = get_u16(file_data, (pe_offset + 20) as u32) as i32 + pe_offset + 24;
for i in 0..section_count(file_data, pe_offset) {
let off = sh_start + i * 40;
if section_header_blank(file_data, off) {
break;
}
let sec_va = get_i32(file_data, off + 12);
let sec_vsize = get_i32(file_data, off + 8);
let sec_raw = get_i32(file_data, off + 20);
let sec_raw_size = get_i32(file_data, off + 16);
if clr_dir_rva >= sec_va && clr_dir_rva < sec_va + sec_vsize {
let avail = sec_raw_size.min(file_data.len() as i32 - sec_raw);
let copy_len = avail.min(out.len() as i32 - sec_va);
if copy_len > 0 {
let s = sec_raw as usize;
let dd = sec_va as usize;
let n = copy_len as usize;
out[dd..dd + n].copy_from_slice(&file_data[s..s + n]);
}
break;
}
}
}
decrypt_and_decompress_data(
&mut out,
original_file_data,
section_image_base,
section_data_offset,
&decrypt_func2,
&decomp_params,
)?;
let import_table_offset = get_i32(&out, addr5 + 12016);
if import_table_offset != 0 {
process_import_table(&mut out, import_table_offset);
}
out[..4096].copy_from_slice(&file_data[..4096]);
if verbose {
println!("[9/9] Fixing up PE header & section table...");
}
let section_header_base = get_u16(file_data, (pe_offset + 20) as u32) as i32 + pe_offset;
let section_start = section_header_base + 24;
let image_data_addr = get_i32(original_file_data, section_start - 128);
let image_data_size = get_i32(original_file_data, section_start - 124);
let mut entry_point_adjustment = 0i32;
{
for i in 0..section_count(file_data, pe_offset) {
let offset = section_start + i * 40;
if section_header_blank(file_data, offset) {
break;
}
let virtual_size = get_i32(file_data, offset + 8);
let virtual_addr = get_i32(file_data, offset + 12);
let raw_data_offset = get_i32(file_data, offset + 20);
if image_data_addr >= virtual_addr
&& image_data_addr + image_data_size <= virtual_addr + virtual_size
{
entry_point_adjustment = raw_data_offset + image_data_addr - virtual_addr;
}
write_i32(&mut out, offset + 16, virtual_size);
write_i32(&mut out, offset + 20, virtual_addr);
}
}
if image_data_size != 0 {
let s = entry_point_adjustment as usize;
let dd = image_data_addr as usize;
let n = image_data_size as usize;
out[dd..dd + n].copy_from_slice(&file_data[s..s + n]);
}
// Managed-only CLR header recopy (Task 3.2). No-op for native.
let clr_rva = get_i32(file_data, pe_offset + 24 + dd_base + 14 * 8);
let clr_size = get_i32(file_data, pe_offset + 24 + dd_base + 14 * 8 + 4);
if clr_rva != 0 && clr_size != 0 {
for i in 0..section_count(file_data, pe_offset) {
let offset = section_start + i * 40;
if section_header_blank(file_data, offset) {
break;
}
let sec_va = get_i32(file_data, offset + 12);
let sec_raw = get_i32(file_data, offset + 20);
let sec_vsize = get_i32(file_data, offset + 8);
if clr_rva >= sec_va && clr_rva + clr_size <= sec_va + sec_vsize {
let clr_file_off = sec_raw + (clr_rva - sec_va);
let s = clr_file_off as usize;
let dd = clr_rva as usize;
let n = clr_size as usize;
out[dd..dd + n].copy_from_slice(&file_data[s..s + n]);
break;
}
}
}
decrypt_data6_shift6(&mut out, keys[3] + 16, 656);
let pe_offset2 = get_i32(&out, 60);
let final_size_of_image = get_i32(&out, keys[3] + 32);
write_i32(&mut out, pe_offset2 + 40, final_size_of_image);
{
let s = (keys[3] + 48) as usize;
let dd = (pe_offset2 + 136) as usize;
out.copy_within(s..s + 128, dd);
}
Ok(out)
}
+2812
View File
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
//! Static sanity check for unpacked PE images.
//!
//! The unpack pipelines can succeed structurally (no error, no panic) yet emit
//! a binary the OS loader rejects at runtime with `0xC0000005`
//! (STATUS_ACCESS_VIOLATION) — e.g. when the entry-point stub or import strings
//! were left encrypted because a layout heuristic picked the wrong offset. This
//! module inspects the *output* bytes alone (no reference, no execution) and
//! reports defects that are near-certain runtime crashes.
//!
//! It is intentionally conservative: it only flags conditions that cannot occur
//! in a correctly unpacked image, so a clean report is not a guarantee of
//! correctness, but a non-clean report is a reliable "this is broken" signal.
//!
//! All reads are bounds-checked; the check never panics on any input.
/// Result of a static integrity check over an unpacked image.
#[derive(Debug, Clone, Default)]
pub struct IntegrityReport {
/// Each entry describes one detected defect. Empty means no defect found.
pub issues: Vec<String>,
}
impl IntegrityReport {
/// True when no defects were detected.
pub fn ok(&self) -> bool {
self.issues.is_empty()
}
}
// `checked_add`, not `+`: `usize` is 32-bit on wasm32, so a header-derived
// offset near `u32::MAX` would wrap the range and panic (`start > end`) in a
// module documented never to panic on any input.
fn rd_u16(d: &[u8], off: u32) -> Option<u16> {
let i = off as usize;
d.get(i..i.checked_add(2)?)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
}
fn rd_u32(d: &[u8], off: u32) -> Option<u32> {
let i = off as usize;
d.get(i..i.checked_add(4)?)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
/// A parsed section-table entry (only the fields we translate against).
struct Section {
va: u32,
vsize: u32,
raw_ptr: u32,
raw_size: u32,
chars: u32,
}
/// Walk the output's own section table and translate an RVA to a file offset.
/// Works for both memory-image output (raw_ptr == va) and compacted disk
/// output (real raw pointers), because it consults whatever the output declares.
/// Returns the offset only if the translated range `[off, off+need)` lies inside
/// the file.
fn rva_to_off(secs: &[Section], file_len: usize, rva: u32, need: u32) -> Option<u32> {
for s in secs {
// The mapped span is the larger of virtual and raw size, so an RVA that
// falls in the virtual tail of a section still resolves.
let span = s.vsize.max(s.raw_size);
if span == 0 {
continue;
}
if rva >= s.va && rva < s.va.wrapping_add(span) {
let delta = rva - s.va;
let off = s.raw_ptr.checked_add(delta)?;
let end = off.checked_add(need)?;
if (end as usize) <= file_len {
return Some(off);
}
return None;
}
}
None
}
/// Inspect an unpacked PE image and report any defect that would make the OS
/// loader fault at runtime. `out` is the bytes the unpacker produced.
pub fn check(out: &[u8]) -> IntegrityReport {
let mut r = IntegrityReport::default();
let file_len = out.len();
// --- DOS + PE headers ---------------------------------------------------
if rd_u16(out, 0) != Some(0x5A4D) {
r.issues.push("missing 'MZ' DOS signature".into());
return r; // nothing else is meaningful
}
let pe_off = match rd_u32(out, 0x3C) {
Some(v) => v,
None => {
r.issues.push("truncated DOS header (no e_lfanew)".into());
return r;
}
};
if rd_u32(out, pe_off) != Some(0x0000_4550) {
r.issues
.push(format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}"));
return r;
}
let num_sections = match rd_u16(out, pe_off.wrapping_add(6)) {
Some(v) => v as u32,
None => {
r.issues.push("truncated COFF header".into());
return r;
}
};
let opt_hdr_size = rd_u16(out, pe_off.wrapping_add(20)).unwrap_or(0) as u32;
let opt = pe_off.wrapping_add(24);
let magic = match rd_u16(out, opt) {
Some(v) => v,
None => {
r.issues.push("truncated optional header".into());
return r;
}
};
let is64 = match magic {
0x20B => true,
0x10B => false,
other => {
r.issues
.push(format!("bad optional-header magic 0x{other:X}"));
return r;
}
};
if num_sections == 0 || num_sections > 96 {
r.issues
.push(format!("implausible section count {num_sections}"));
}
let size_of_image = rd_u32(out, pe_off.wrapping_add(80)).unwrap_or(0);
if size_of_image == 0 {
r.issues.push("SizeOfImage is zero".into());
}
// --- Section table ------------------------------------------------------
let sec_table = opt.wrapping_add(opt_hdr_size);
let mut secs: Vec<Section> = Vec::new();
for i in 0..num_sections {
let base = sec_table.wrapping_add(i * 40);
// If the table runs past EOF the image is structurally broken.
let (vsize, va, raw_size, raw_ptr, chars) = match (
rd_u32(out, base.wrapping_add(8)),
rd_u32(out, base.wrapping_add(12)),
rd_u32(out, base.wrapping_add(16)),
rd_u32(out, base.wrapping_add(20)),
rd_u32(out, base.wrapping_add(36)),
) {
(Some(a), Some(b), Some(c), Some(d), Some(e)) => (a, b, c, d, e),
_ => {
r.issues
.push("section table extends past end of file".into());
return r;
}
};
// Raw data must lie within the file for compacted (disk-layout) output.
if raw_size != 0 {
let end = raw_ptr.wrapping_add(raw_size) as usize;
if end > file_len {
r.issues.push(format!(
"section #{i} raw data [0x{raw_ptr:X}..0x{end:X}] exceeds file size 0x{file_len:X}"
));
}
}
secs.push(Section {
va,
vsize,
raw_ptr,
raw_size,
chars,
});
}
// --- Managed (CLR) detection ------------------------------------------
// The COR20 (CLR) data directory, when present and non-zero, marks a managed
// assembly. Such images are dispatched through the CLR (via the COR20 header
// + BSJB metadata), not the native loader, so the native-loader heuristics
// below (zeroed EP stub, encrypted first import name) do NOT apply: CrackProof
// legitimately leaves a managed DLL's native EP and import strings in a state
// the native loader would reject, and that state is preserved here.
// Detect it before the EP / import checks so we can scope them to native
// images only.
let clr_rva = rd_u32(
out,
opt.wrapping_add(if is64 { 112 } else { 96 })
.wrapping_add(14 * 8),
)
.unwrap_or(0);
let is_managed = clr_rva != 0;
// --- Native DLL relocatability ------------------------------------------
// A native DLL is almost always loaded at a non-preferred base, so a
// missing base-relocation directory (DD[5]) is a guaranteed crash on
// rebase — exactly the failure mode produced when an unpacker wrongly
// applies the /FIXED-EXE fixup (zero BaseReloc + DllCharacteristics) to a
// DLL. Managed assemblies are exempt: the CLR rebases nothing through the
// native table, and their golden outputs legitimately carry no DD[5].
let dd_base = opt.wrapping_add(if is64 { 112 } else { 96 });
let chars_coff = rd_u16(out, pe_off.wrapping_add(22)).unwrap_or(0);
let is_dll = (chars_coff & 0x2000) != 0;
if is_dll && !is_managed {
let reloc_rva = rd_u32(out, dd_base.wrapping_add(5 * 8)).unwrap_or(0);
if reloc_rva == 0 {
r.issues.push(
"native DLL has no base relocation table (DD[5] is zero) — will crash when loaded at a non-preferred base"
.into(),
);
}
}
// --- Entry point --------------------------------------------------------
// An entry RVA that does not resolve to a section, or whose target bytes are
// all zero, is a guaranteed access violation the instant the loader jumps to
// it. A zeroed/encrypted entry stub is the classic broken-unpack symptom.
let ep = rd_u32(out, pe_off.wrapping_add(40)).unwrap_or(0);
if ep == 0 {
// A DLL may legitimately have no entry point; an EXE never does.
if !is_dll {
r.issues.push("entry point RVA is zero".into());
}
} else if !is_managed {
match rva_to_off(&secs, file_len, ep, 16) {
None => {
r.issues.push(format!(
"entry point RVA 0x{ep:X} does not map into any section"
));
}
Some(off) => {
let stub = &out[off as usize..off as usize + 16];
if stub.iter().all(|&b| b == 0) {
r.issues.push(format!(
"entry point at RVA 0x{ep:X} is all zeros (stub not recovered)"
));
} else if stub.iter().all(|&b| b == 0xCC) {
// 16 bytes of int3 padding where the entry stub should be:
// the stub region was never recovered, the loader walks
// straight into a debug-break wall.
r.issues.push(format!(
"entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)"
));
}
// The entry must live in an executable section.
let exec = secs.iter().any(|s| {
let span = s.vsize.max(s.raw_size);
ep >= s.va && ep < s.va.wrapping_add(span) && (s.chars & 0x2000_0000) != 0
});
if !exec {
r.issues.push(format!(
"entry point RVA 0x{ep:X} is not in an executable section"
));
}
}
}
}
// --- Import table -------------------------------------------------------
// If an import directory is present, every descriptor's DLL name must be
// readable printable ASCII. Encrypted/garbage names mean import-string
// decryption failed, and the loader faults resolving them — checking only
// the first descriptor misses later ones still left as ciphertext. Skipped
// for managed assemblies (their import table is a CLR bootstrap stub the
// native loader doesn't resolve the same way). Note this no longer gates
// on NumberOfRvaAndSizes: a corrupt optional header shrinking that field
// must not silence the walk while a bogus import RVA still points at
// ciphertext.
if !is_managed {
let imp_rva = rd_u32(out, dd_base.wrapping_add(8)).unwrap_or(0);
if imp_rva != 0 {
match rva_to_off(&secs, file_len, imp_rva, 20) {
None => r.issues.push(format!(
"import directory RVA 0x{imp_rva:X} does not map into any section"
)),
Some(desc_off) => {
// 256 descriptors is far beyond any real import table; the
// cap keeps a corrupt, never-null table from walking on.
for i in 0..256u32 {
let d_off = desc_off.wrapping_add(i.wrapping_mul(20));
let name_rva = rd_u32(out, d_off.wrapping_add(12)).unwrap_or(0);
// name_rva == 0 is the terminating null descriptor (or
// a read past the table) — done.
if name_rva == 0 {
break;
}
match rva_to_off(&secs, file_len, name_rva, 1) {
None => r.issues.push(format!(
"import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section"
)),
Some(noff) => {
if !looks_like_dll_name(out, noff) {
r.issues.push(format!(
"import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)"
));
}
}
}
}
}
}
}
}
// --- Managed (CLR) header + metadata ------------------------------------
// For a managed assembly the COR20 (CLR) header and the BSJB MetaData stream
// it points at must survive unpacking intact, or the runtime rejects the
// image with BadImageFormatException ("Invalid COR20 header signature" /
// bad metadata) before any code runs. CrackProof copies both regions through
// verbatim; a unpacker that lets the .text dd8 pass scribble over them (they
// live inside .text) produces a structurally-valid-looking PE that the CLR
// still refuses to load. Validate: COR20 cb == 0x48, and the MetaData stream
// begins with the "BSJB" signature.
if is_managed {
match rva_to_off(&secs, file_len, clr_rva, 0x48) {
None => r.issues.push(format!(
"CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section"
)),
Some(coff) => {
let cb = rd_u32(out, coff).unwrap_or(0);
if cb != 0x48 {
r.issues.push(format!(
"COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt"
));
} else {
// MetaData RVA/size live at COR20 + 0x08 / + 0x0C.
let md_rva = rd_u32(out, coff.wrapping_add(8)).unwrap_or(0);
if md_rva != 0 {
match rva_to_off(&secs, file_len, md_rva, 4) {
None => r.issues.push(format!(
"CLR MetaData RVA 0x{md_rva:X} does not map into any section"
)),
Some(moff) => {
let sig = out.get(moff as usize..moff as usize + 4);
if sig != Some(b"BSJB") {
r.issues.push(format!(
"CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)"
));
}
}
}
}
}
}
}
}
r
}
/// True if the NUL-terminated string starting at `off` looks like a DLL name:
/// at least one byte, all printable ASCII up to the NUL, within a sane length.
fn looks_like_dll_name(d: &[u8], off: u32) -> bool {
let start = off as usize;
let mut end = start;
let limit = (start + 256).min(d.len());
while end < limit && d[end] != 0 {
end += 1;
}
if end == start || end >= limit {
return false; // empty, or no NUL within a sane window
}
d[start..end].iter().all(|&b| (0x20..0x7F).contains(&b))
}
+210
View File
@@ -0,0 +1,210 @@
//! Pure, panic-free Crackproof unpacker core. No file I/O lives here.
mod bytecode;
mod crc32;
pub mod dll;
pub mod exe;
pub mod integrity;
pub(crate) mod parallel;
pub(crate) mod primitives;
mod tables;
pub use dll::{unpack_dll, unpack_dll_v};
pub use exe::{UnpackError, unpack as unpack_exe, unpack_v as unpack_exe_v};
pub use integrity::{IntegrityReport, check as check_integrity};
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
/// (or, as a sign-extended negative `i32`, multi-exabyte) allocation, which
/// would abort the process — an abort that `catch_unpack` below cannot trap.
/// Real protected binaries are far below this.
pub(crate) const MAX_IMAGE_SIZE: u64 = 1 << 30; // 1 GiB
/// Run an unpack pipeline, converting any internal panic into a clean
/// [`UnpackError::Corrupt`] so the public API stays panic-free on any input
/// (truncated/garbled files chase offsets out of bounds). The default panic
/// hook is suppressed transiently so a trapped panic does not spill a
/// backtrace to stderr.
///
/// Note: allocation *failures* abort the process and are NOT caught here; size
/// requests are bounds-checked against [`MAX_IMAGE_SIZE`] before allocating.
pub(crate) fn catch_unpack<F>(f: F) -> Result<Vec<u8>, UnpackError>
where
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
{
// Hook suppression is skipped on wasm: the prebuilt std cannot unwind
// there, so a panic traps immediately — and the suppressed hook would
// hide the panic message, leaving a bare `unreachable` with no clue.
#[cfg(not(target_arch = "wasm32"))]
let prev = std::panic::take_hook();
#[cfg(not(target_arch = "wasm32"))]
std::panic::set_hook(Box::new(|_| {}));
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
#[cfg(not(target_arch = "wasm32"))]
std::panic::set_hook(prev);
r.unwrap_or(Err(UnpackError::Corrupt))
}
/// Crackproof header magic stored in `keys[1]`/`info[1]`.
pub(crate) const MAGIC_KONN: u32 = 0x4E4E4F4B; // b"KONN" little-endian (= 1313754955)
/// True if `magic` is the Crackproof magic this unpacker supports.
pub(crate) fn is_supported_magic(magic: u32) -> bool {
magic == MAGIC_KONN
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Exe,
NativeDll,
ManagedDll,
}
#[derive(Debug, Clone, Copy)]
pub struct Detected {
pub kind: Kind,
pub magic: u32,
}
// ---------------------------------------------------------------------------
// Content-based detection
// ---------------------------------------------------------------------------
/// Derive the 8-element Crackproof key table from the header at offset 4096.
/// Returns `None` if the input is too short or doesn't have a valid PE signature.
fn key_table(input: &[u8]) -> Option<[u32; 8]> {
// Need at least 4128 bytes: the key-table loop below reads dwords up to
// offset 4124 (bytes 4124..4127). Guarding only `< 4096` would let a
// 4096..4127-byte PE (e.g. a 4 KiB stub) panic in `get_u32`.
if input.len() < 4128 {
return None;
}
// Validate PE signature. `checked_add`, not `+`: `usize` is 32-bit on
// wasm32, where an `e_lfanew` of 0xFFFF_FFFC..=0xFFFF_FFFF wraps the bound
// check, and the slice below then panics with start > end. `detect` runs on
// the folder-scan threads and (in the web app) on the main thread outside
// the disposable-worker isolation, so it must not panic on any input.
let e_lfanew = primitives::get_u32(input, 0x3C);
let pe_start = e_lfanew as usize;
if pe_start.checked_add(4).is_none_or(|end| end > input.len()) {
return None;
}
if &input[pe_start..pe_start + 4] != b"PE\0\0" {
return None;
}
// Derive 8 keys per the Crackproof header-key formula.
let mut keys = [0u32; 8];
keys[0] = primitives::get_u32(input, 4096);
let mut k = keys[0];
for i in 0u32..7 {
let cell = primitives::get_u32(input, 4100u32.wrapping_add(i.wrapping_mul(4)));
keys[(i + 1) as usize] = k ^ cell;
k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_sub(i));
}
Some(keys)
}
/// Detect whether `input` is a Crackproof-protected binary and classify it.
/// Returns `None` if the magic doesn't match.
///
/// Routing: `keys[1]` must be the Crackproof magic (`KONN`).
/// The PE IMAGE_FILE_DLL characteristic distinguishes EXE vs DLL;
/// the CLR data-directory RVA further distinguishes ManagedDll from NativeDll.
pub fn detect(input: &[u8]) -> Option<Detected> {
let keys = key_table(input)?;
let magic = keys[1];
// Anything whose magic doesn't match is left untouched rather than
// detected-then-errored, honoring the "anything that doesn't match is
// left untouched" contract.
if !is_supported_magic(magic) {
return None;
}
// Use the PE DLL characteristic to distinguish EXE from DLL.
// IMAGE_FILE_HEADER.Characteristics is at peOff+4+18; bit 0x2000 = IMAGE_FILE_DLL.
let pe_off = primitives::get_u32(input, 0x3C);
let chars_offset = pe_off.wrapping_add(4).wrapping_add(18);
if (chars_offset as usize)
.checked_add(2)
.is_none_or(|end| end > input.len())
{
return None;
}
let chars =
(input[chars_offset as usize] as u16) | ((input[chars_offset as usize + 1] as u16) << 8);
let is_dll = (chars & 0x2000) != 0;
if !is_dll {
return Some(Detected {
kind: Kind::Exe,
magic,
});
}
// DLL: determine managed vs native via CLR data-directory RVA.
// peOff + 24 = start of optional header. The data directories start at a
// magic-dependent offset within it: PE32 (0x10B) at +96, PE32+ (0x20B) at
// +112. Using the PE32+ offset on a PE32 image reads the wrong dword and
// can mis-flag a native DLL as managed.
//
// `get_u16`/`get_u32` index unchecked, so every read past the already-
// checked Characteristics word must be bounds-checked first: a truncated
// DLL (e.g. `e_lfanew` pointing at len-24) would otherwise panic here,
// and this detector runs on the folder scan threads where a panic aborts
// the whole run.
let opt_magic_off = pe_off.wrapping_add(24) as usize;
let b = input.get(opt_magic_off..opt_magic_off.checked_add(2)?)?;
let opt_magic = u16::from_le_bytes([b[0], b[1]]);
let dd_off: u32 = if opt_magic == 0x20B { 112 } else { 96 };
// + 14*8 = IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
let clr_rva_offset = pe_off
.wrapping_add(24)
.wrapping_add(dd_off)
.wrapping_add(14u32.wrapping_mul(8));
if (clr_rva_offset as usize)
.checked_add(4)
.is_none_or(|end| end > input.len())
{
return None;
}
let clr_rva = primitives::get_u32(input, clr_rva_offset);
let kind = if clr_rva != 0 {
Kind::ManagedDll
} else {
Kind::NativeDll
};
Some(Detected { kind, magic })
}
/// Detect the file type and dispatch to the matching pipeline.
/// Returns the detected `Kind` together with the unpacked image bytes.
pub fn unpack_auto(input: &[u8]) -> Result<(Kind, Vec<u8>), UnpackError> {
unpack_auto_v(input, false)
}
/// Like [`unpack_auto`], but prints detailed `[N/9]` unpack-step progress to
/// stdout when `verbose` is true. Output bytes are identical regardless.
pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), UnpackError> {
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
let out = match detected.kind {
Kind::Exe => unpack_exe_v(input, verbose)?,
Kind::NativeDll | Kind::ManagedDll => {
// Two Crackproof DLL layouts exist. The older one (the byte-identical
// DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect
// DLLs with the EXE-style shell layout instead — `dll::unpack_dll`
// cannot parse them and errors. Try the DLL pipeline first; on
// failure, fall back to the EXE pipeline, which handles the new
// layout (including managed-DLL CLR metadata restore). The DLL-first
// order keeps the old-layout goldens byte-identical (the EXE
// pipeline "succeeds" on them but with different bytes).
match dll::unpack_dll_v(input, verbose) {
Ok(out) => out,
Err(dll_err) => match exe::unpack_v(input, verbose) {
Ok(out) => out,
// Surface the DLL-pipeline error, not the EXE one: for a
// genuinely corrupt DLL the DLL error is the more relevant
// diagnostic, and the EXE fallback is best-effort.
Err(_) => return Err(dll_err),
},
}
}
};
Ok((detected.kind, out))
}
+227
View File
@@ -0,0 +1,227 @@
//! Deterministic block-parallel fan-out for the section decrypt/decompress
//! loops.
//!
//! Each block writes a disjoint output span and reads only immutable input plus
//! snapshotted key tables, so distributing blocks across worker threads
//! produces byte-identical output regardless of thread count or scheduling.
//!
//! # Soundness
//!
//! This module contains **no `unsafe`**. The output buffer is carved into the
//! per-block spans with safe `split_at_mut` chains, so Rust itself guarantees
//! no two workers can hold aliasing `&mut` slices — an earlier version handed
//! every worker a whole-buffer `&mut [u8]` reconstructed from a raw pointer,
//! which is UB under Stacked/Tree Borrows even when the concrete writes never
//! overlap. The shared data the blocks read (AES key schedule, Huffman table)
//! is copied out by the caller before the fan-out and captured by the closure,
//! so no shared borrow of the output buffer is needed either.
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
/// Worker-thread cap. `SENBEI_THREADS` overrides it (`1` forces the sequential
/// path); otherwise the host's available parallelism; otherwise 1.
pub(crate) fn thread_cap() -> usize {
if let Ok(v) = std::env::var("SENBEI_THREADS")
&& let Ok(n) = v.trim().parse::<usize>()
&& n >= 1
{
return n;
}
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
/// Run `f(i, span_base, span)` for every block `i`, fanning out across worker
/// threads when the spans are disjoint and worthwhile, else sequentially.
///
/// `spans[i]` is the `[start, end)` region of `buf` block `i` writes. The
/// closure receives `span_base = spans[i].0` and the disjoint
/// `&mut buf[start..end]`; any shared data it needs must be captured by value
/// before the call. When the spans overlap (only possible on corrupt input),
/// the whole thing degrades to a sequential whole-buffer pass (`span_base = 0`,
/// `span = buf`), which preserves the deterministic last-writer-wins behavior
/// the pipeline had before parallelization.
///
/// Returns the first `Err` any block produces; re-raises the first block panic
/// on the calling thread (so the pipeline's existing `catch_unpack` still
/// converts it to `UnpackError::Corrupt`).
pub(crate) fn parallel_for<E, F>(
buf: &mut [u8],
spans: &[(usize, usize)],
min_per_thread: usize,
f: F,
) -> Result<(), E>
where
E: Send,
F: Fn(usize, usize, &mut [u8]) -> Result<(), E> + Sync,
{
let n = spans.len();
if n == 0 {
return Ok(());
}
// Verify the spans are in-bounds and mutually disjoint. Overlapping spans
// only arise from corrupt block descriptors; the sequential whole-buffer
// fallback handles them exactly as the pre-parallel pipeline did.
let mut sorted: Vec<(u64, u64)> = spans.iter().map(|&(s, e)| (s as u64, e as u64)).collect();
let in_bounds = spans.iter().all(|&(s, e)| s <= e && e <= buf.len());
let disjoint = in_bounds && spans_disjoint(&mut sorted);
if !disjoint {
for i in 0..n {
f(i, 0, &mut *buf)?;
}
return Ok(());
}
// Carve the disjoint span pieces out of `buf` with safe splits. Rust's
// borrow checker proves the pieces never alias.
//
// Sort by the whole span, not just its start: `spans_disjoint` compares
// `(start, end)` tuples, so it accepts an empty span that shares a start
// with a non-empty one (`(100,100)` and `(100,200)`). Ordering by start
// alone would then carve them in input order, and a `(100,100)` arriving
// after `(100,200)` makes `s - base` underflow — a panic instead of the
// documented degrade-to-sequential fallback.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| spans[i]);
let mut pieces: Vec<Option<&mut [u8]>> = Vec::new();
pieces.resize_with(n, || None);
{
let mut rest: &mut [u8] = buf;
let mut base = 0usize;
for &i in &order {
let (s, e) = spans[i];
let (_, tail) = rest.split_at_mut(s - base);
let (piece, tail2) = tail.split_at_mut(e - s);
pieces[i] = Some(piece);
rest = tail2;
base = e;
}
}
let cap = thread_cap();
let per = min_per_thread.max(1);
let workers = if cap > 1 && n >= per.saturating_mul(2) {
cap.min(n / per)
} else {
1
};
if workers <= 1 {
// Fully safe baseline: sequential on the current thread; panics and
// `Err`s propagate exactly as they did before parallelization.
for (i, piece) in pieces.into_iter().enumerate() {
f(i, spans[i].0, piece.unwrap())?;
}
return Ok(());
}
// Hand each span piece to exactly one worker through a shared iterator:
// the `&mut [u8]` is moved, never aliased.
let iter = Mutex::new(pieces.into_iter().enumerate());
let stop = AtomicBool::new(false);
let first_err: Mutex<Option<E>> = Mutex::new(None);
let first_panic: Mutex<Option<Box<dyn std::any::Any + Send>>> = Mutex::new(None);
std::thread::scope(|scope| {
for _ in 0..workers {
let iter = &iter;
let stop = &stop;
let first_err = &first_err;
let first_panic = &first_panic;
let f = &f;
scope.spawn(move || {
loop {
if stop.load(Ordering::Relaxed) {
break;
}
let next = iter.lock().unwrap().next();
let Some((i, piece)) = next else { break };
let span = piece.unwrap();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
f(i, spans[i].0, span)
}));
match r {
Ok(Ok(())) => {}
Ok(Err(e)) => {
let mut slot = first_err.lock().unwrap();
if slot.is_none() {
*slot = Some(e);
}
stop.store(true, Ordering::Relaxed);
break;
}
Err(panic) => {
let mut slot = first_panic.lock().unwrap();
if slot.is_none() {
*slot = Some(panic);
}
stop.store(true, Ordering::Relaxed);
break;
}
}
}
});
}
});
if let Some(panic) = first_panic.into_inner().unwrap() {
std::panic::resume_unwind(panic);
}
match first_err.into_inner().unwrap() {
Some(e) => Err(e),
None => Ok(()),
}
}
/// True if the half-open spans are mutually disjoint. Spans are
/// `[write_base, write_base + max(compressed_len, decompressed_len))` so a block
/// whose decompressed output exceeds its compressed size is fully covered. A
/// conservative (larger) span can only push a borderline case onto the safe
/// sequential path, never the reverse, so it cannot change output.
pub(crate) fn spans_disjoint(spans: &mut [(u64, u64)]) -> bool {
spans.sort_unstable();
for w in spans.windows(2) {
if w[1].0 < w[0].1 {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
/// Review regression: an empty span sharing a start with a non-empty one
/// passes `spans_disjoint` (it genuinely overlaps nothing), so the carve
/// runs. Ordering the carve by start alone put `(100,100)` after
/// `(100,200)` — `s - base` then underflowed and panicked instead of doing
/// the work. Reachable from a corrupt descriptor chain whose block size is
/// negative and whose expected length is zero.
#[test]
fn carves_empty_span_sharing_a_start() {
let mut buf = vec![0u8; 512];
// Non-empty span first in input order, empty span second: the order
// that used to underflow.
let spans = [(100usize, 200usize), (100, 100)];
let seen: Mutex<Vec<(usize, usize, usize)>> = Mutex::new(Vec::new());
let r: Result<(), ()> = parallel_for(&mut buf, &spans, 1, |i, base, span| {
seen.lock().unwrap().push((i, base, span.len()));
for b in span.iter_mut() {
*b = 0xAB;
}
Ok(())
});
assert!(r.is_ok());
let mut seen = seen.into_inner().unwrap();
seen.sort_unstable();
assert_eq!(seen, vec![(0, 100, 100), (1, 100, 0)]);
assert!(buf[100..200].iter().all(|&b| b == 0xAB));
assert!(buf[..100].iter().all(|&b| b == 0));
assert!(buf[200..].iter().all(|&b| b == 0));
}
}
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
//! AES inverse tables (inverse S-box + InvMixColumns "Td" T-tables), generated
//! at compile time from GF(2^8) arithmetic rather than embedded as a transcribed
//! blob. These are the standard AES *decryption* tables — not proprietary data —
//! so we derive them. The generated bytes are verified byte-identical to the
//! original hand-transcribed arrays (CRC32-locked in the test at the bottom).
//!
//! Each table is 1024 bytes = 256 u32 little-endian, read by
//! `primitives::aes_round` via `get_u32(&TABLE, x * 4)`. The byte layout matches
//! the original exactly, so `aes_round` is unchanged:
//! SBOX[x] = invsbox(x) broadcast to 4 bytes
//! COLUMMIX1[x] = [0b*s, 0d*s, 09*s, 0e*s], s = invsbox(x) (Td0, this byte order)
//! COLUMMIX2/3/4 = COLUMMIX1's 4-byte group rotated left by 1 / 2 / 3 bytes
//!
//! Generated the same way as the existing `const fn` CRC-table generation in
//! `crc32.rs`.
/// GF(2^8) multiply with the AES reduction polynomial (x^8 + x^4 + x^3 + x + 1).
const fn gf_mul(mut a: u8, mut b: u8) -> u8 {
let mut p: u8 = 0;
let mut i = 0;
while i < 8 {
if b & 1 != 0 {
p ^= a;
}
let hi = a & 0x80;
a <<= 1;
if hi != 0 {
a ^= 0x1B;
}
b >>= 1;
i += 1;
}
p
}
/// The AES inverse S-box, derived from the multiplicative inverse in GF(2^8)
/// followed by inverting the forward S-box's affine transform.
const fn inv_sbox() -> [u8; 256] {
// Multiplicative inverse: inv[a] = b such that a*b == 1 (inv[0] stays 0).
let mut inv = [0u8; 256];
let mut a = 1usize;
while a < 256 {
let mut b = 1usize;
while b < 256 {
if gf_mul(a as u8, b as u8) == 1 {
inv[a] = b as u8;
break;
}
b += 1;
}
a += 1;
}
// Forward S-box: affine transform over the inverse.
let mut sb = [0u8; 256];
let mut i = 0usize;
while i < 256 {
let mut x = inv[i];
let mut s = inv[i];
let mut r = 0;
while r < 4 {
s = s.rotate_left(1);
x ^= s;
r += 1;
}
sb[i] = x ^ 0x63;
i += 1;
}
// Inverse S-box is the inverse permutation of the forward S-box.
let mut isb = [0u8; 256];
let mut i = 0usize;
while i < 256 {
isb[sb[i] as usize] = i as u8;
i += 1;
}
isb
}
/// The five generated tables (each 1024 bytes = 256 u32 LE).
struct AesTables {
cm1: [u8; 1024],
cm2: [u8; 1024],
cm3: [u8; 1024],
cm4: [u8; 1024],
sbox: [u8; 1024],
}
/// Build all five tables in one compile-time pass.
const fn build_tables() -> AesTables {
let isb = inv_sbox();
let mut cm1 = [0u8; 1024];
let mut cm2 = [0u8; 1024];
let mut cm3 = [0u8; 1024];
let mut cm4 = [0u8; 1024];
let mut sbox = [0u8; 1024];
let mut x = 0usize;
while x < 256 {
let s = isb[x];
// SBOX: invsbox(x) broadcast to all four lanes.
let mut j = 0;
while j < 4 {
sbox[x * 4 + j] = s;
j += 1;
}
// COLUMMIX1 lane bytes; CM2/3/4 are byte-rotations of the same four.
let b = [
gf_mul(0x0b, s),
gf_mul(0x0d, s),
gf_mul(0x09, s),
gf_mul(0x0e, s),
];
let mut j = 0;
while j < 4 {
cm1[x * 4 + j] = b[j];
cm2[x * 4 + j] = b[(j + 1) % 4];
cm3[x * 4 + j] = b[(j + 2) % 4];
cm4[x * 4 + j] = b[(j + 3) % 4];
j += 1;
}
x += 1;
}
AesTables {
cm1,
cm2,
cm3,
cm4,
sbox,
}
}
const TABLES: AesTables = build_tables();
pub static COLUMMIX1: [u8; 1024] = TABLES.cm1;
pub static COLUMMIX2: [u8; 1024] = TABLES.cm2;
pub static COLUMMIX3: [u8; 1024] = TABLES.cm3;
pub static COLUMMIX4: [u8; 1024] = TABLES.cm4;
pub static SBOX: [u8; 1024] = TABLES.sbox;
#[cfg(test)]
mod tests {
use super::*;
/// Lock the generated tables to the original hand-transcribed bytes. The
/// CRC32 oracles were computed from the previously-committed `tables.rs`
/// arrays; any drift in the generator (or the GF math) fails here before it
/// can reach the byte-identical corpus goldens.
#[test]
fn generated_tables_match_committed_bytes() {
assert_eq!(COLUMMIX1.len(), 1024);
assert_eq!(super::super::crc32::compute(&COLUMMIX1), 0x7e8d_5d5f);
assert_eq!(super::super::crc32::compute(&COLUMMIX2), 0xfcc4_acfc);
assert_eq!(super::super::crc32::compute(&COLUMMIX3), 0x637a_f0cd);
assert_eq!(super::super::crc32::compute(&COLUMMIX4), 0x1e7b_c381);
assert_eq!(super::super::crc32::compute(&SBOX), 0x10fd_6dc1);
// Spot-check the first dword of each (matches the original first row).
assert_eq!(&COLUMMIX1[..4], &[0x50, 0xa7, 0xf4, 0x51]);
assert_eq!(&COLUMMIX2[..4], &[0xa7, 0xf4, 0x51, 0x50]);
assert_eq!(&COLUMMIX3[..4], &[0xf4, 0x51, 0x50, 0xa7]);
assert_eq!(&COLUMMIX4[..4], &[0x51, 0x50, 0xa7, 0xf4]);
assert_eq!(&SBOX[..4], &[0x52, 0x52, 0x52, 0x52]);
}
}