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.
This commit is contained in:
2026-08-16 05:47:16 +08:00
parent fe4f904409
commit dc6e72a8bb
5 changed files with 34 additions and 31 deletions
+2 -1
View File
@@ -41,7 +41,8 @@ lives in [`web/`](web/).
| 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. |
| `ManagedDll` | Protected .NET assembly (has a CLR data directory). |
| `._` companion | Stub + external encrypted payload layout, spliced automatically. |
+9 -8
View File
@@ -48,17 +48,18 @@ src/
Detection is content-based (`unpacker::detect`), never extension-based: the
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
managed DLL.
magic, then the PE characteristics classify the input as EXE or DLL and the
CLR data directory splits each into native vs managed (`NativeExe` /
`ManagedExe` / `NativeDll` / `ManagedDll`).
`unpack_auto` then dispatches:
- `Exe` → 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.
- `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
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
+12 -16
View File
@@ -55,7 +55,8 @@ pub(crate) fn is_supported_magic(magic: u32) -> bool {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Exe,
NativeExe,
ManagedExe,
NativeDll,
ManagedDll,
}
@@ -109,7 +110,7 @@ fn key_table(input: &[u8]) -> Option<[u32; 8]> {
///
/// 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.
/// the CLR data-directory RVA distinguishes managed from native for both.
pub fn detect(input: &[u8]) -> Option<Detected> {
let keys = key_table(input)?;
let magic = keys[1];
@@ -132,21 +133,15 @@ pub fn detect(input: &[u8]) -> Option<Detected> {
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.
// Managed vs native via the 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.
// can mis-flag a native image 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,
// 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
// the whole run.
let opt_magic_off = pe_off.wrapping_add(24) as usize;
@@ -165,10 +160,11 @@ pub fn detect(input: &[u8]) -> Option<Detected> {
return None;
}
let clr_rva = primitives::get_u32(input, clr_rva_offset);
let kind = if clr_rva != 0 {
Kind::ManagedDll
} else {
Kind::NativeDll
let kind = match (is_dll, clr_rva != 0) {
(false, false) => Kind::NativeExe,
(false, true) => Kind::ManagedExe,
(true, false) => Kind::NativeDll,
(true, true) => Kind::ManagedDll,
};
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> {
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
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 => {
// Two Crackproof DLL layouts exist. The older one (the byte-identical
// DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect
+4 -2
View File
@@ -45,7 +45,8 @@ const files = new Map();
const rowEls = new Map();
const KIND_LABEL = {
exe: 'protected EXE',
'native-exe': 'protected native EXE',
'managed-exe': 'protected managed EXE',
'native-dll': 'protected native DLL',
'managed-dll': 'protected managed DLL',
metadata: 'il2cpp metadata',
@@ -323,7 +324,8 @@ async function unpackModule(name, entry) {
let comp = compEntry ? await read(compEntry.file) : undefined;
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
// 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.
+7 -4
View File
@@ -24,7 +24,8 @@ pub struct UnpackResult {
#[wasm_bindgen]
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)]
pub fn kind(&self) -> String {
self.kind.clone()
@@ -102,7 +103,8 @@ impl MetadataResult {
fn kind_str(kind: senbei::unpacker::Kind) -> &'static str {
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::ManagedDll => "managed-dll",
}
@@ -110,8 +112,9 @@ fn kind_str(kind: senbei::unpacker::Kind) -> &'static str {
/// Classify a file's bytes without unpacking.
///
/// Returns `"exe"`, `"native-dll"`, `"managed-dll"`, `"metadata"` (an il2cpp
/// `global-metadata.dat`), or `undefined` for anything unrecognized.
/// Returns `"native-exe"`, `"managed-exe"`, `"native-dll"`, `"managed-dll"`,
/// `"metadata"` (an il2cpp `global-metadata.dat`), or `undefined` for
/// anything unrecognized.
#[wasm_bindgen]
pub fn detect(input: &[u8]) -> Option<String> {
if senbei::metadata::is_metadata(input) {