mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef13419b93 | ||
|
|
dc6e72a8bb | ||
|
|
fe4f904409 | ||
|
|
7f143b3b27 |
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+68
-2
@@ -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));
|
||||||
write_u32(&mut u.decompressed, pe_off2.wrapping_add(40), ep);
|
// 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);
|
||||||
|
}
|
||||||
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
@@ -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
|
||||||
|
|||||||
+171
-42
@@ -1874,70 +1874,199 @@ 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);
|
|
||||||
let s15 = score_dd8_shift(data, text_off, text_va, &sample_pages, 15);
|
|
||||||
let mut best_score = none_hits;
|
|
||||||
let mut best_shift = 99u32; // 99 == skip dd8
|
|
||||||
for (shift, hits) in [(0u32, s0), (15u32, s15)] {
|
|
||||||
if hits > best_score {
|
|
||||||
best_score = hits;
|
|
||||||
best_shift = shift;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Require a clear 2x margin over the already-plaintext baseline AND an
|
// Require a clear 2x margin over the already-plaintext baseline AND an
|
||||||
// absolute floor. The 2x test alone
|
// absolute floor. The 2x test alone trips on noise when the counts are
|
||||||
// trips on noise when the counts are tiny: an external-companion DLL whose
|
// tiny: an external-companion DLL whose .text is already plaintext scores
|
||||||
// .text is already plaintext scores s15=4 vs none=1 — a spurious 4x — and
|
// s15=4 vs none=1 — a spurious 4x — and gets dd8 wrongly applied,
|
||||||
// gets dd8 wrongly applied, corrupting ~1 byte per 16. Across the whole
|
// corrupting ~1 byte per 16. The floor rejects that noise while sitting
|
||||||
// golden corpus every build that genuinely needs dd8 scores >= 10 (lowest
|
// far below every genuinely-encrypted build's score.
|
||||||
// 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;
|
const MIN_DD8_HITS: u32 = 8;
|
||||||
if best_shift != 99 && (best_score < none_hits * 2 || best_score < MIN_DD8_HITS) {
|
let margin_pick = |none: u32, s0: u32, s15: u32| -> u32 {
|
||||||
best_shift = 99;
|
let mut best_score = none;
|
||||||
}
|
let mut best_shift = 99u32; // 99 == skip dd8
|
||||||
|
for (shift, hits) in [(0u32, s0), (15u32, s15)] {
|
||||||
|
if hits > best_score {
|
||||||
|
best_score = hits;
|
||||||
|
best_shift = shift;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best_shift != 99 && (best_score < none * 2 || best_score < MIN_DD8_HITS) {
|
||||||
|
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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user