4 Commits
Author SHA1 Message Date
Momoko-Ayase ef13419b93 Bump version to 1.0.1 2026-08-16 05:51:39 +08:00
Momoko-Ayase dc6e72a8bb Split the Exe kind into NativeExe / ManagedExe
The detector already used the CLR data-directory RVA to split DLLs into
NativeDll / ManagedDll; EXEs were a single undifferentiated Exe kind.
Apply the same CLR check to EXEs so callers can tell a protected .NET
executable from a native one without unpacking. Routing is unchanged:
both EXE kinds go to the EXE pipeline.

- CLI per-file lines and the run log now print NativeExe / ManagedExe
  (the kind comes from the same Debug formatting as the DLL variants).
- The web API's detect()/unpack_file() kind strings become
  'native-exe' / 'managed-exe'; the web UI gains matching labels, and
  the trap-retry guard (DLL-probe recovery) keys off both EXE kinds.

Golden corpus unchanged (35/35 byte-identical); kind is classification
only and never affects output bytes.
2026-08-16 05:47:16 +08:00
Momoko-Ayase fe4f904409 Strengthen the dd8 shift detector with a RET+int3 padding fingerprint
The selector replayed each candidate shift over three sample pages and
counted 0xCC bytes at dd8-mutated positions. That signal is biased
upward for wrong candidates (255 pseudo-random XORs manufacture ~1
spurious 0xCC per page for free) and cannot express 'this candidate
destroys real padding', so the decision leaned on a 2x-margin-plus-floor
rule tuned around the noise.

Score candidates instead by a structural fingerprint of real x64 code:
the MSVC function-end padding pattern (a 0xC3 RET followed by a run of
>= 4 0xCC bytes), summed over up to four sample pages taken with a
head/tail margin. Because dd8 touches only 255 of 4096 bytes per page,
an encrypted page keeps most runs under 'no dd8' and restores them only
under the correct shift, while an already-plaintext page loses runs
under any shift — wrong candidates score *below* the baseline, which the
old count could never say. The gate becomes a positive delta over the
baseline (floor 8) instead of the 2x margin.

When every candidate's fingerprint is silent (sampled pages with no
padded epilogues), fall back to the previous mutated-position count with
its 2x-margin-plus-floor rule, so pages without padding still resolve.

Across the 35-input golden corpus every decision now comes from the
fingerprint with wide, sign-correct margins; all outputs are unchanged
(byte-identical goldens).
2026-08-16 05:33:44 +08:00
Momoko-Ayase 7f143b3b27 Support managed (CLR) EXEs in the old-layout EXE pipeline
Managed EXE builds differ from their native counterparts in the old
layout: the encrypted import-name table pointer is null (their imports
are just the CLR bootstrap stub), the config block's entry-point field
is 0, and the COR20 header / BSJB metadata stream / CLR resources are
stored verbatim in the protected file rather than arriving through the
section-block pass.

- Skip the import-string walk when the table pointer is null instead of
  chasing header garbage as a pointer chain (previously a caught
  out-of-bounds panic reported as corrupt input).
- Keep the protected header's entry point when the config block stores
  0, instead of overwriting it with 0.
- Restore the COR20 header, BSJB metadata stream, and CLR resources
  verbatim from the protected file after the .text dd8 pass, and
  suppress the native COR20-directory clearing when the restore ran.

Validated by decompiling the unpacked managed EXEs with ilspycmd: full
assemblies (types, methods, IL bodies) decompile cleanly. Golden corpus
unchanged (35/35 byte-identical).
2026-08-16 05:33:44 +08:00
10 changed files with 276 additions and 73 deletions
Generated
+1 -1
View File
@@ -209,7 +209,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei" name = "senbei"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indicatif", "indicatif",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "senbei" name = "senbei"
version = "1.0.0" version = "1.0.1"
edition = "2024" edition = "2024"
description = "Static unpacker for Crackproof-protected PE files" description = "Static unpacker for Crackproof-protected PE files"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+2 -1
View File
@@ -41,7 +41,8 @@ lives in [`web/`](web/).
| Kind | Description | | Kind | Description |
| --- | --- | | --- | --- |
| `Exe` | Crackproof-protected executable (PE32+ and PE32). | | `NativeExe` | Crackproof-protected native executable (PE32+ and PE32). |
| `ManagedExe` | Protected .NET executable (has a CLR data directory). |
| `NativeDll` | Protected native (unmanaged) DLL. | | `NativeDll` | Protected native (unmanaged) DLL. |
| `ManagedDll` | Protected .NET assembly (has a CLR data directory). | | `ManagedDll` | Protected .NET assembly (has a CLR data directory). |
| `._` companion | Stub + external encrypted payload layout, spliced automatically. | | `._` companion | Stub + external encrypted payload layout, spliced automatically. |
+9 -3
View File
@@ -48,12 +48,18 @@ src/
Detection is content-based (`unpacker::detect`), never extension-based: the Detection is content-based (`unpacker::detect`), never extension-based: the
key table is derived from the file header and checked against the format key table is derived from the file header and checked against the format
magic, then the PE characteristics classify the input as EXE, native DLL, or magic, then the PE characteristics classify the input as EXE or DLL and the
managed DLL. CLR data directory splits each into native vs managed (`NativeExe` /
`ManagedExe` / `NativeDll` / `ManagedDll`).
`unpack_auto` then dispatches: `unpack_auto` then dispatches:
- `Exe` → the EXE pipeline (handles both PE32+ and PE32). - `NativeExe` / `ManagedExe` → the EXE pipeline (handles both PE32+ and
PE32). Managed EXEs take the same path: their import-string table is null
(imports are the CLR bootstrap stub), the entry point comes from the
protected header (the config block stores 0 for managed images), and the
COR20 header, BSJB metadata stream, and CLR resources are restored verbatim
from the protected file, mirroring the managed-DLL restore.
- `NativeDll` / `ManagedDll` → the DLL pipeline first; on failure, the EXE - `NativeDll` / `ManagedDll` → the DLL pipeline first; on failure, the EXE
pipeline as a fallback. Two DLL layouts exist in the wild: an older layout pipeline as a fallback. Two DLL layouts exist in the wild: an older layout
the DLL pipeline parses, and a newer one that protects DLLs with the the DLL pipeline parses, and a newer one that protects DLLs with the
+67 -1
View File
@@ -1040,8 +1040,13 @@ impl<'a> Unpacker<'a> {
// layout has no such table — imports are rebuilt from the PE Import // layout has no such table — imports are rebuilt from the PE Import
// Directory after the header is reconstructed (see `process_imports_idt` // Directory after the header is reconstructed (see `process_imports_idt`
// below). Skip the walk5 pass entirely for the new layout. // below). Skip the walk5 pass entirely for the new layout.
//
// Managed assemblies leave the walk5 slot null as well: their imports
// are just the CLR bootstrap stub, so there is no encrypted name table
// to walk. Reading the table at address 0 would chase header garbage as
// a pointer chain, so a null slot means "nothing to decrypt".
let mut walk5 = get_u32(&u.decompressed, at7); let mut walk5 = get_u32(&u.decompressed, at7);
if !new_layout { if !new_layout && walk5 != 0 {
loop { loop {
let outer = get_u32(&u.decompressed, walk5.wrapping_add(12)); let outer = get_u32(&u.decompressed, walk5.wrapping_add(12));
if outer == 0 { if outer == 0 {
@@ -1290,7 +1295,13 @@ impl<'a> Unpacker<'a> {
let backup: Vec<u8> = u.decompressed[meta_start..meta_start + 144].to_vec(); let backup: Vec<u8> = u.decompressed[meta_start..meta_start + 144].to_vec();
u.decrypt_data5(u.info[3].wrapping_add(ep_off), 144); u.decrypt_data5(u.info[3].wrapping_add(ep_off), 144);
let ep = get_u32(&u.decompressed, u.info[3].wrapping_add(ep_off)); let ep = get_u32(&u.decompressed, u.info[3].wrapping_add(ep_off));
// Managed assemblies store 0 as the entry point here (their EP is a
// property of the CLR header, not the PE). Keep the protected
// header's EP in that case — overwriting with 0 would produce an
// image whose entry point is the DOS header.
if ep != 0 {
write_u32(&mut u.decompressed, pe_off2.wrapping_add(40), ep); write_u32(&mut u.decompressed, pe_off2.wrapping_add(40), ep);
}
for n in 0..128 { for n in 0..128 {
u.decompressed[(pe_off2 + 136 + n) as usize] = u.decompressed[(pe_off2 + 136 + n) as usize] =
u.decompressed[(u.info[3] + dd_off + n) as usize]; u.decompressed[(u.info[3] + dd_off + n) as usize];
@@ -1388,6 +1399,61 @@ impl<'a> Unpacker<'a> {
} }
} }
// Old-layout managed (CLR) restore: same verbatim regions as the
// new-layout restore above (COR20 header + BSJB MetaData stream) plus
// the COR20 resources blob. Crackproof preserves only these regions
// verbatim in the protected file — the IL method bodies between the
// COR20 header and the resources ARE packer-encrypted and arrive via
// the section-block pass, so copying the whole section's raw data (as
// the older-DLL pipeline does for its layout) would clobber them with
// the placeholder zeros the protected file carries there. Runs after
// the old-layout dd8 pass, so the restored bytes are final.
if !new_layout {
let clr_rva = get_u32(&u.decompressed, pe_off2.wrapping_add(0xF8));
let clr_size = get_u32(&u.decompressed, pe_off2.wrapping_add(0xFC));
if clr_rva != 0
&& clr_size != 0
&& (clr_rva as u64 + clr_size as u64) <= u.decompressed.len() as u64
&& let Some(cor_off) = prot_rva_to_off(u.file_data, pe_off, clr_rva)
&& (cor_off as u64 + 0x48) <= u.file_data.len() as u64
&& get_u32(u.file_data, cor_off) == 0x48
{
let s = cor_off as usize;
let d = clr_rva as usize;
u.decompressed[d..d + 0x48].copy_from_slice(&u.file_data[s..s + 0x48]);
restored_clr = true;
// MetaData RVA/size from the just-restored COR20 header.
let md_rva = get_u32(&u.decompressed, clr_rva + 0x08);
let md_size = get_u32(&u.decompressed, clr_rva + 0x0C);
if md_rva != 0
&& md_size != 0
&& (md_rva as u64 + md_size as u64) <= u.decompressed.len() as u64
&& let Some(md_off) = prot_rva_to_off(u.file_data, pe_off, md_rva)
&& (md_off as u64 + md_size as u64) <= u.file_data.len() as u64
&& &u.file_data[md_off as usize..md_off as usize + 4] == b"BSJB"
{
let s = md_off as usize;
let d = md_rva as usize;
let n = md_size as usize;
u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]);
}
// COR20 resources (managed .resources blob), verbatim too.
let res_rva = get_u32(&u.decompressed, clr_rva + 0x18);
let res_size = get_u32(&u.decompressed, clr_rva + 0x1C);
if res_rva != 0
&& res_size != 0
&& (res_rva as u64 + res_size as u64) <= u.decompressed.len() as u64
&& let Some(res_off) = prot_rva_to_off(u.file_data, pe_off, res_rva)
&& (res_off as u64 + res_size as u64) <= u.file_data.len() as u64
{
let s = res_off as usize;
let d = res_rva as usize;
let n = res_size as usize;
u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]);
}
}
}
// The payload's TLS directory (DD[9]) arrives blanked: Crackproof strips // The payload's TLS directory (DD[9]) arrives blanked: Crackproof strips
// the struct and re-installs TLS itself when it maps the module. Prefer // the struct and re-installs TLS itself when it maps the module. Prefer
// recovering the real one from the stub's plaintext `.rdata` — dropping // recovering the real one from the stub's plaintext `.rdata` — dropping
+12 -16
View File
@@ -55,7 +55,8 @@ pub(crate) fn is_supported_magic(magic: u32) -> bool {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind { pub enum Kind {
Exe, NativeExe,
ManagedExe,
NativeDll, NativeDll,
ManagedDll, ManagedDll,
} }
@@ -109,7 +110,7 @@ fn key_table(input: &[u8]) -> Option<[u32; 8]> {
/// ///
/// Routing: `keys[1]` must be the Crackproof magic (`KONN`). /// Routing: `keys[1]` must be the Crackproof magic (`KONN`).
/// The PE IMAGE_FILE_DLL characteristic distinguishes EXE vs DLL; /// The PE IMAGE_FILE_DLL characteristic distinguishes EXE vs DLL;
/// the CLR data-directory RVA further distinguishes ManagedDll from NativeDll. /// the CLR data-directory RVA distinguishes managed from native for both.
pub fn detect(input: &[u8]) -> Option<Detected> { pub fn detect(input: &[u8]) -> Option<Detected> {
let keys = key_table(input)?; let keys = key_table(input)?;
let magic = keys[1]; let magic = keys[1];
@@ -132,21 +133,15 @@ pub fn detect(input: &[u8]) -> Option<Detected> {
let chars = let chars =
(input[chars_offset as usize] as u16) | ((input[chars_offset as usize + 1] as u16) << 8); (input[chars_offset as usize] as u16) | ((input[chars_offset as usize + 1] as u16) << 8);
let is_dll = (chars & 0x2000) != 0; let is_dll = (chars & 0x2000) != 0;
if !is_dll { // Managed vs native via the CLR data-directory RVA.
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 // peOff + 24 = start of optional header. The data directories start at a
// magic-dependent offset within it: PE32 (0x10B) at +96, PE32+ (0x20B) at // 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 // +112. Using the PE32+ offset on a PE32 image reads the wrong dword and
// can mis-flag a native DLL as managed. // can mis-flag a native image as managed.
// //
// `get_u16`/`get_u32` index unchecked, so every read past the already- // `get_u16`/`get_u32` index unchecked, so every read past the already-
// checked Characteristics word must be bounds-checked first: a truncated // checked Characteristics word must be bounds-checked first: a truncated
// DLL (e.g. `e_lfanew` pointing at len-24) would otherwise panic here, // file (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 // and this detector runs on the folder scan threads where a panic aborts
// the whole run. // the whole run.
let opt_magic_off = pe_off.wrapping_add(24) as usize; let opt_magic_off = pe_off.wrapping_add(24) as usize;
@@ -165,10 +160,11 @@ pub fn detect(input: &[u8]) -> Option<Detected> {
return None; return None;
} }
let clr_rva = primitives::get_u32(input, clr_rva_offset); let clr_rva = primitives::get_u32(input, clr_rva_offset);
let kind = if clr_rva != 0 { let kind = match (is_dll, clr_rva != 0) {
Kind::ManagedDll (false, false) => Kind::NativeExe,
} else { (false, true) => Kind::ManagedExe,
Kind::NativeDll (true, false) => Kind::NativeDll,
(true, true) => Kind::ManagedDll,
}; };
Some(Detected { kind, magic }) Some(Detected { kind, magic })
} }
@@ -184,7 +180,7 @@ pub fn unpack_auto(input: &[u8]) -> Result<(Kind, Vec<u8>), UnpackError> {
pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), UnpackError> { pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), UnpackError> {
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?; let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
let out = match detected.kind { let out = match detected.kind {
Kind::Exe => unpack_exe_v(input, verbose)?, Kind::NativeExe | Kind::ManagedExe => unpack_exe_v(input, verbose)?,
Kind::NativeDll | Kind::ManagedDll => { Kind::NativeDll | Kind::ManagedDll => {
// Two Crackproof DLL layouts exist. The older one (the byte-identical // Two Crackproof DLL layouts exist. The older one (the byte-identical
// DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect // DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect
+163 -34
View File
@@ -1874,47 +1874,147 @@ pub(crate) fn decrypt_and_decompress_data(
// (0x40327253) yet require different shifts, so the only reliable discriminator // (0x40327253) yet require different shifts, so the only reliable discriminator
// is the .text content itself. // is the .text content itself.
// //
// Detection scoring formula: for each candidate shift, replay decrypt_data8 // Detection replays the three candidate states — no dd8 (already plaintext),
// across a few sample pages (25/50/75% of .text) and count how many of the 255 // shift 0, shift 15 — over a few sample pages (head/tail margin skipped:
// mutated positions become 0xCC — the MSVC int3 padding byte. The correct shift // entry/exit regions have atypical padding density) and picks the state whose
// hits int3 pads disproportionately often (~3-10x the baseline), so the // decoded pages look most like real x64 code. The primary signal is a
// highest-scoring shift wins. If neither shift clears 2x the baseline, .text // *structural* fingerprint: the MSVC function-end padding pattern, a 0xC3 RET
// is already plaintext → skip (return 99). // opcode followed by a run of >= 4 0xCC int3 bytes. dd8 XORs one pseudo-random
// byte per 16-byte block, so an already-plaintext page keeps its padding runs
// only under "no dd8", while a packer-encrypted page restores them only under
// the correct shift — a wrong candidate destroys every run it touches and
// essentially never manufactures a RET followed by a long int3 run by chance.
// This separates the states far more cleanly than a bare 0xCC count, which a
// wrong candidate inflates for free (~255 coincidences per page at p=1/256).
//
// When no candidate produces any RET-anchored padding (sampled pages with
// dense code and no padded epilogues), the fingerprint is silent, so the
// decision falls back to the older mutated-position 0xCC count. Both signals
// use the same decision rule: a candidate must beat the no-dd8 baseline by a
// clear 2x margin AND an absolute floor, otherwise dd8 is skipped — a wrongly
// applied dd8 scrambles ~1 byte per 16 with no error surfaced downstream.
// //
// This replaces an earlier entry-stub oracle that matched the 14 fixed CRT-stub // This replaces an earlier entry-stub oracle that matched the 14 fixed CRT-stub
// bytes at the AEP. That oracle false-positived on a newer EXE-64 build: dd8 // bytes at the AEP. That oracle false-positived on a newer EXE-64 build: dd8
// corrupted only the call rel32 (bytes 5-8, the wildcard region), so the stub // corrupted only the call rel32 (bytes 5-8, the wildcard region), so the stub
// matched under BOTH shifts and the selector defaulted to 0 when the truth was // matched under BOTH shifts and the selector defaulted to 0 when the truth was
// 15. The 0xCC statistic samples hundreds of positions per page and is not // 15. A whole-page padding statistic samples hundreds of positions per page
// fooled by a stub whose fixed bytes happen to survive. // and is not fooled by a stub whose fixed bytes happen to survive.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Minimum 0xCC run length after a RET for the run to count as MSVC
/// function-end padding.
const MIN_CC_RUN: u32 = 4;
/// Total length of MSVC function-end padding runs in a page: each 0xC3 byte
/// followed by >= [`MIN_CC_RUN`] 0xCC bytes contributes the run length.
fn ret_int3_score(page: &[u8]) -> u32 {
let mut total = 0u32;
let mut i = 0;
while i < page.len() {
if page[i] == 0xC3 {
let mut j = i + 1;
while j < page.len() && page[j] == 0xCC {
j += 1;
}
let run = (j - i - 1) as u32;
if run >= MIN_CC_RUN {
total += run;
}
i = j;
} else {
i += 1;
}
}
total
}
/// Replay the dd8 page-XOR in place on one sample page.
fn dd8_apply(buf: &mut [u8; 0x1000], abs_page: u32, shift: u32) {
let mut key = abs_page << shift;
for bi in 0..256u32 {
let mixed = key.rotate_right(15).wrapping_add(bi);
key = mixed.wrapping_add(bi);
// The packer's dd8 loop does not XOR block i=0 (see decrypt_data8).
if bi == 0 {
continue;
}
let tidx = (bi.wrapping_mul(16).wrapping_add(mixed & 0xF)) as usize;
buf[tidx] ^= key as u8;
}
}
/// Sum the RET+int3 fingerprint over the sample pages for one candidate
/// (`None` = the no-dd8 baseline, page as-is).
fn fingerprint_score(
data: &[u8],
text_off: usize,
abs_base: u32,
sample_pages: &[u32],
shift: Option<u32>,
) -> u32 {
let mut total = 0u32;
for &sp in sample_pages {
let pg_off = text_off + (sp as usize) * 0x1000;
if pg_off + 0x1000 > data.len() {
continue;
}
let mut page = [0u8; 0x1000];
page.copy_from_slice(&data[pg_off..pg_off + 0x1000]);
if let Some(sh) = shift {
dd8_apply(&mut page, abs_base.wrapping_add(sp), sh);
}
total += ret_int3_score(&page);
}
total
}
pub(crate) fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32) -> u32 { pub(crate) fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32) -> u32 {
if text_size < 0x1000 { let num_pages_total = text_size >> 12;
// Fewer than two pages: nothing meaningful to sample; preserve the
// historical behavior (shift 0 — the dd8 loop is empty or single-page).
if num_pages_total < 2 {
return 0; return 0;
} }
let text_off = text_va as usize; let text_off = text_va as usize;
let num_pages_total = text_size >> 12;
// Sample pages at 25/50/75% of .text, falling back to the midpoint for tiny // Sample up to 4 pages, skipping a head/tail margin. Small .text: sample
// sections. // every page.
let mut sample_pages: Vec<u32> = Vec::new(); let mut sample_pages: Vec<u32> = Vec::new();
for frac in [0.25f64, 0.5, 0.75] { if num_pages_total <= 4 {
let pg = (num_pages_total as f64 * frac) as u32; sample_pages.extend(0..num_pages_total);
if pg > 0 && pg < num_pages_total { } else {
sample_pages.push(pg); let margin = (num_pages_total / 8).max(1);
let lo = margin;
let hi = num_pages_total - margin;
if hi <= lo {
sample_pages.extend(0..num_pages_total);
} else {
let step = ((hi - lo) / 4).max(1);
let mut i = 0;
while i < 4 {
let p = lo + i * step;
if p < num_pages_total {
sample_pages.push(p);
}
i += 1;
} }
} }
if sample_pages.is_empty() && num_pages_total > 1 {
sample_pages.push(num_pages_total / 2);
} }
if sample_pages.is_empty() { if sample_pages.is_empty() {
return 0; return 0;
} }
let none_hits = score_dd8_baseline(data, text_off, &sample_pages); let abs_base = text_va >> 12;
let s0 = score_dd8_shift(data, text_off, text_va, &sample_pages, 0); // Require a clear 2x margin over the already-plaintext baseline AND an
let s15 = score_dd8_shift(data, text_off, text_va, &sample_pages, 15); // absolute floor. The 2x test alone trips on noise when the counts are
let mut best_score = none_hits; // tiny: an external-companion DLL whose .text is already plaintext scores
// s15=4 vs none=1 — a spurious 4x — and gets dd8 wrongly applied,
// corrupting ~1 byte per 16. The floor rejects that noise while sitting
// far below every genuinely-encrypted build's score.
const MIN_DD8_HITS: u32 = 8;
let margin_pick = |none: u32, s0: u32, s15: u32| -> u32 {
let mut best_score = none;
let mut best_shift = 99u32; // 99 == skip dd8 let mut best_shift = 99u32; // 99 == skip dd8
for (shift, hits) in [(0u32, s0), (15u32, s15)] { for (shift, hits) in [(0u32, s0), (15u32, s15)] {
if hits > best_score { if hits > best_score {
@@ -1922,22 +2022,51 @@ pub(crate) fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3
best_shift = shift; best_shift = shift;
} }
} }
// Require a clear 2x margin over the already-plaintext baseline AND an if best_shift != 99 && (best_score < none * 2 || best_score < MIN_DD8_HITS) {
// absolute floor. The 2x test alone
// trips on noise when the counts are tiny: an external-companion DLL whose
// .text is already plaintext scores s15=4 vs none=1 — a spurious 4x — and
// gets dd8 wrongly applied, corrupting ~1 byte per 16. Across the whole
// golden corpus every build that genuinely needs dd8 scores >= 10 (lowest
// observed scores at 10-12; up to 107), so a floor of 8 rejects the noise
// while keeping every golden's shift selection unchanged.
const MIN_DD8_HITS: u32 = 8;
if best_shift != 99 && (best_score < none_hits * 2 || best_score < MIN_DD8_HITS) {
best_shift = 99; best_shift = 99;
} }
best_shift
};
// Primary: RET+int3 padding fingerprint. The fingerprint is diluted across
// the whole page (dd8 touches only 255 of 4096 bytes, so even an encrypted
// page keeps most of its padding runs), so instead of the fallback's 2x
// margin the gate is a *positive delta* over the no-dd8 baseline: on an
// already-plaintext .text each wrong shift destroys runs (scores below the
// baseline), while the correct shift on an encrypted page restores them
// (scores above it). The floor on the delta rejects noise-level gains.
let r_none = fingerprint_score(data, text_off, abs_base, &sample_pages, None);
let r0 = fingerprint_score(data, text_off, abs_base, &sample_pages, Some(0));
let r15 = fingerprint_score(data, text_off, abs_base, &sample_pages, Some(15));
// Fallback: mutated-position 0xCC count, for pages whose code has no
// RET-anchored padding at all (the fingerprint is silent there).
let (none_hits, s0, s15);
let best_shift = if r_none != 0 || r0 != 0 || r15 != 0 {
none_hits = 0;
s0 = 0;
s15 = 0;
let mut best_score = r_none;
let mut shift = 99u32;
for (s, score) in [(0u32, r0), (15u32, r15)] {
if score > best_score {
best_score = score;
shift = s;
}
}
if shift != 99 && best_score.saturating_sub(r_none) < MIN_DD8_HITS {
shift = 99;
}
shift
} else {
none_hits = score_dd8_baseline(data, text_off, &sample_pages);
s0 = score_dd8_shift(data, text_off, text_va, &sample_pages, 0);
s15 = score_dd8_shift(data, text_off, text_va, &sample_pages, 15);
margin_pick(none_hits, s0, s15)
};
if std::env::var("SEL_DIAG").is_ok() { if std::env::var("SEL_DIAG").is_ok() {
eprintln!( eprintln!(
"SEL dd8 best_shift={} s0={} s15={} none_hits={} samples={:?}", "SEL dd8 best_shift={} fp=({},{},{}) cc=({},{},{}) samples={:?}",
best_shift, s0, s15, none_hits, sample_pages best_shift, r_none, r0, r15, none_hits, s0, s15, sample_pages
); );
} }
best_shift best_shift
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "senbei-web" name = "senbei-web"
version = "1.0.0" version = "1.0.1"
edition = "2024" edition = "2024"
description = "WebAssembly browser frontend for senbei" description = "WebAssembly browser frontend for senbei"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+4 -2
View File
@@ -45,7 +45,8 @@ const files = new Map();
const rowEls = new Map(); const rowEls = new Map();
const KIND_LABEL = { const KIND_LABEL = {
exe: 'protected EXE', 'native-exe': 'protected native EXE',
'managed-exe': 'protected managed EXE',
'native-dll': 'protected native DLL', 'native-dll': 'protected native DLL',
'managed-dll': 'protected managed DLL', 'managed-dll': 'protected managed DLL',
metadata: 'il2cpp metadata', metadata: 'il2cpp metadata',
@@ -323,7 +324,8 @@ async function unpackModule(name, entry) {
let comp = compEntry ? await read(compEntry.file) : undefined; let comp = compEntry ? await read(compEntry.file) : undefined;
let r = await runUnpack(input, comp, false); let r = await runUnpack(input, comp, false);
if (!r.ok && r.trap && entry.kind !== 'exe') { const isExe = entry.kind === 'native-exe' || entry.kind === 'managed-exe';
if (!r.ok && r.trap && !isExe) {
// The DLL-routing probe trapped (panics can't be caught in wasm). Retry // The DLL-routing probe trapped (panics can't be caught in wasm). Retry
// once with the forced-EXE pipeline in a fresh worker — this mirrors the // once with the forced-EXE pipeline in a fresh worker — this mirrors the
// CLI's dll-first/exe-fallback outcome for EXE-shell-layout DLLs. // CLI's dll-first/exe-fallback outcome for EXE-shell-layout DLLs.
+7 -4
View File
@@ -24,7 +24,8 @@ pub struct UnpackResult {
#[wasm_bindgen] #[wasm_bindgen]
impl UnpackResult { impl UnpackResult {
/// Detected module kind: `"exe"`, `"native-dll"`, or `"managed-dll"`. /// Detected module kind: `"native-exe"`, `"managed-exe"`, `"native-dll"`,
/// or `"managed-dll"`.
#[wasm_bindgen(getter)] #[wasm_bindgen(getter)]
pub fn kind(&self) -> String { pub fn kind(&self) -> String {
self.kind.clone() self.kind.clone()
@@ -102,7 +103,8 @@ impl MetadataResult {
fn kind_str(kind: senbei::unpacker::Kind) -> &'static str { fn kind_str(kind: senbei::unpacker::Kind) -> &'static str {
match kind { match kind {
senbei::unpacker::Kind::Exe => "exe", senbei::unpacker::Kind::NativeExe => "native-exe",
senbei::unpacker::Kind::ManagedExe => "managed-exe",
senbei::unpacker::Kind::NativeDll => "native-dll", senbei::unpacker::Kind::NativeDll => "native-dll",
senbei::unpacker::Kind::ManagedDll => "managed-dll", senbei::unpacker::Kind::ManagedDll => "managed-dll",
} }
@@ -110,8 +112,9 @@ fn kind_str(kind: senbei::unpacker::Kind) -> &'static str {
/// Classify a file's bytes without unpacking. /// Classify a file's bytes without unpacking.
/// ///
/// Returns `"exe"`, `"native-dll"`, `"managed-dll"`, `"metadata"` (an il2cpp /// Returns `"native-exe"`, `"managed-exe"`, `"native-dll"`, `"managed-dll"`,
/// `global-metadata.dat`), or `undefined` for anything unrecognized. /// `"metadata"` (an il2cpp `global-metadata.dat`), or `undefined` for
/// anything unrecognized.
#[wasm_bindgen] #[wasm_bindgen]
pub fn detect(input: &[u8]) -> Option<String> { pub fn detect(input: &[u8]) -> Option<String> {
if senbei::metadata::is_metadata(input) { if senbei::metadata::is_metadata(input) {