mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
First public commit
This commit is contained in:
+1051
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user