refactor: consolidate platform engines into senbei-engine

This commit is contained in:
bfloat16
2026-09-06 19:31:19 +08:00
parent cbfacbc31f
commit d436a200ba
66 changed files with 1148 additions and 1012 deletions
+3
View File
@@ -0,0 +1,3 @@
mod pipeline;
pub use pipeline::*;
+789
View File
@@ -0,0 +1,789 @@
//! 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::super::{
BufferOperation, BytecodeStage, DecompressionStage, DescriptorTable, SectionPipeline,
UnpackError,
};
use senbei_crypto::bytecode::{Op, OpsLut, generate};
use senbei_crypto::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]>,
stage: DecompressionStage,
) -> 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
&& let Err(reason) = primitives::decompress_detailed(
d,
addr as u32,
compressed_addr as u32,
decomp_params[1] as u32,
size as u32,
decompressed_size as u32,
)
{
return Err(UnpackError::StageDecompressionFailed { stage, reason });
}
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::DescriptorOutOfBounds {
table: DescriptorTable::DllSectionBlocks,
offset: off,
image_len: d.len(),
});
}
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::InvalidAesKeySchedule { offset: ko2 as u32 })?;
let tab_snap = primitives::huffman_table_snapshot(d, ko0 as u32)
.ok_or(UnpackError::InvalidHuffmanTable { offset: ko0 as u32 })?;
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::SectionDecompressionFailed {
pipeline: SectionPipeline::Dll,
block: i,
});
}
}
Ok(())
};
super::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::DescriptorOutOfBounds {
table: DescriptorTable::DllZeroFill,
offset: off,
image_len: d.len(),
});
}
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::BufferRangeOutOfBounds {
operation: BufferOperation::ZeroFill,
offset: idx,
size: 1,
buffer_len: d.len(),
});
}
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::super::catch_unpack(move || unpack_dll_inner(input, verbose))
}
fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError> {
const HEADER_LEN: usize = 4128;
if input.len() < HEADER_LEN {
return Err(UnpackError::InputTooShort {
actual: input.len(),
required: HEADER_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::super::is_supported_magic(keys[1] as u32) {
return Err(UnpackError::HeaderMagicMismatch {
found: keys[1] as u32,
});
}
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::InvalidPeOffset {
offset: i64::from(pe_offset),
input_len: file_data.len(),
});
}
// 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.
let optional_magic = get_u16(file_data, (pe_offset + 24) as u32);
if optional_magic != 0x20B {
return Err(UnpackError::UnsupportedDllPeMagic {
found: optional_magic,
});
}
let size_of_image = get_i32(file_data, pe_offset + 80);
if size_of_image <= 0 || size_of_image as u64 > super::super::MAX_IMAGE_SIZE {
return Err(UnpackError::InvalidImageSize {
size: i64::from(size_of_image),
max: super::super::MAX_IMAGE_SIZE,
});
}
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 primary_end = decrypted_addr1.checked_add(3856);
if decrypted_addr1 < keys[3]
|| primary_end.is_none_or(|end| end < 0 || end as usize > out.len())
{
return Err(UnpackError::InvalidDllPrimaryDescriptor {
address: decrypted_addr1 as u32,
minimum: keys[3] as u32,
image_len: out.len(),
});
}
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,
DecompressionStage::DllCodeBlock1,
)?;
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;
senbei_crypto::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,
DecompressionStage::DllCodeBlock2,
)?;
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,
DecompressionStage::DllCodeBlock3,
)?;
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(UnpackError::BytecodeGenerationFailed(
BytecodeStage::DllPrimaryDecryptor,
))?;
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),
DecompressionStage::DllCodeBlock4,
)?;
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(
UnpackError::BytecodeGenerationFailed(BytecodeStage::DllSectionDecryptor),
)?;
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)
}
+253
View File
@@ -0,0 +1,253 @@
pub use senbei_crypto::{BufferOperation, DecompressionFailure};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecompressionStage {
ExeStage3,
ExeStage3Secondary,
ExeStage4,
ExeStage5,
Pe32FourthStage,
Pe32FifthStage,
Pe32SeventhStage,
DllCodeBlock1,
DllCodeBlock2,
DllCodeBlock3,
DllCodeBlock4,
}
impl std::fmt::Display for DecompressionStage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ExeStage3 => "EXE stage3",
Self::ExeStage3Secondary => "EXE secondary stage3",
Self::ExeStage4 => "EXE stage4",
Self::ExeStage5 => "EXE stage5",
Self::Pe32FourthStage => "PE32 fourth stage",
Self::Pe32FifthStage => "PE32 fifth stage",
Self::Pe32SeventhStage => "PE32 seventh stage",
Self::DllCodeBlock1 => "DLL code block 1",
Self::DllCodeBlock2 => "DLL code block 2",
Self::DllCodeBlock3 => "DLL code block 3",
Self::DllCodeBlock4 => "DLL code block 4",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BytecodeStage {
ExeStage4,
ExeStage5,
Pe32CustomDecryptor,
Pe32FileDecryptor,
DllPrimaryDecryptor,
DllSectionDecryptor,
}
impl std::fmt::Display for BytecodeStage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ExeStage4 => "EXE stage4",
Self::ExeStage5 => "EXE stage5",
Self::Pe32CustomDecryptor => "PE32 custom decryptor",
Self::Pe32FileDecryptor => "PE32 file decryptor",
Self::DllPrimaryDecryptor => "DLL primary decryptor",
Self::DllSectionDecryptor => "DLL section decryptor",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SectionPipeline {
ExePe32Plus,
ExePe32,
Dll,
}
impl std::fmt::Display for SectionPipeline {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ExePe32Plus => "PE32+ EXE",
Self::ExePe32 => "PE32 EXE",
Self::Dll => "DLL",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DescriptorTable {
DllSectionBlocks,
DllZeroFill,
}
impl std::fmt::Display for DescriptorTable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::DllSectionBlocks => "DLL section-block",
Self::DllZeroFill => "DLL zero-fill",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum UnpackError {
#[error("input too short (need at least {required} bytes, got {actual})")]
InputTooShort { actual: usize, required: usize },
#[error("decrypted header magic mismatch (got 0x{found:08X})")]
HeaderMagicMismatch { found: u32 },
#[error("anchor field not found — corrupt data or wrong offset")]
AnchorNotFound,
#[error("stage1 descriptor not found near anchor 0x{anchor:08X}")]
Stage1DescriptorNotFound { anchor: u32 },
#[error("stage2 field not found — corrupt data or wrong offset")]
Stage2NotFound,
#[error("chk_src_start not found — corrupt data or wrong offset")]
ChkSrcStartNotFound,
#[error("table_start not found — corrupt data or wrong offset")]
TableStartNotFound,
#[error("{0} bytecode generation failed — corrupt data or wrong offset")]
BytecodeGenerationFailed(BytecodeStage),
#[error("stage5 marker not found — this build's layout is not supported by this unpacker")]
Stage5MarkerNotFound,
#[error("not a Crackproof-protected file")]
NotCrackproof,
#[error("invalid PE header offset {offset} for {input_len}-byte input")]
InvalidPeOffset { offset: i64, input_len: usize },
#[error("DLL pipeline requires PE32+ optional-header magic, got 0x{found:04X}")]
UnsupportedDllPeMagic { found: u16 },
#[error(
"DLL primary descriptor address 0x{address:08X} is below layout base 0x{minimum:08X} or outside {image_len}-byte image"
)]
InvalidDllPrimaryDescriptor {
address: u32,
minimum: u32,
image_len: usize,
},
#[error("invalid SizeOfImage {size}; expected 1..={max}")]
InvalidImageSize { size: i64, max: u64 },
#[error(
"{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})"
)]
BufferRangeOutOfBounds {
operation: BufferOperation,
offset: usize,
size: usize,
buffer_len: usize,
},
#[error(
"EXE checksum descriptor at 0x{descriptor:08X} points outside input (offset {offset}, size {size}, input length {image_len})"
)]
ExeChecksumRangeOutOfBounds {
descriptor: u32,
offset: usize,
size: usize,
image_len: usize,
},
#[error(
"{table} descriptor out of bounds (offset {offset}, size 16, image length {image_len})"
)]
DescriptorOutOfBounds {
table: DescriptorTable,
offset: usize,
image_len: usize,
},
#[error("PE32 tbl not found — corrupt data or wrong offset")]
Pe32TblNotFound,
#[error("PE32 thirdStage decrypt failed — corrupt data or wrong offset")]
Pe32ThirdStageFailed,
#[error("PE32 customDecryptor not found in sevenStage")]
Pe32CustomDecryptorNotFound,
#[error("PE32 eighthStageKey not found")]
Pe32EighthKeyNotFound,
#[error("PE32 file LFSR not found in eighthStage")]
Pe32FileLfsrNotFound,
#[error("{stage} decompression failed: {reason}")]
StageDecompressionFailed {
stage: DecompressionStage,
reason: DecompressionFailure,
},
#[error("{pipeline} section block {block} decompression failed")]
SectionDecompressionFailed {
pipeline: SectionPipeline,
block: usize,
},
#[error("AES key schedule is outside the image at offset {offset}")]
InvalidAesKeySchedule { offset: u32 },
#[error("Huffman table is outside the image at offset {offset}")]
InvalidHuffmanTable { offset: u32 },
#[error("DLL pipeline failed: {dll}; EXE fallback failed: {exe}")]
PipelineFallbackFailed {
dll: Box<UnpackError>,
exe: Box<UnpackError>,
},
#[error(
"PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})"
)]
Pe32SecondStageRangeInvalid {
offset: u32,
size: u32,
image_len: usize,
},
#[error("PE32 relocation-data descriptor not found")]
Pe32RelocationDataNotFound,
#[error("file decryptor candidate failed structural validation")]
FileDecryptorValidationFailed,
#[error("PE32 memory image could not be rebuilt as a file-layout PE")]
Pe32OutputLayoutInvalid,
#[error("internal panic at {file}:{line}:{column}: {message}")]
InternalPanic {
message: String,
file: String,
line: u32,
column: u32,
},
}
impl From<senbei_crypto::Error> for UnpackError {
fn from(error: senbei_crypto::Error) -> Self {
match error {
senbei_crypto::Error::BufferRangeOutOfBounds {
operation,
offset,
size,
buffer_len,
} => Self::BufferRangeOutOfBounds {
operation,
offset,
size,
buffer_len,
},
}
}
}
+3
View File
@@ -0,0 +1,3 @@
mod pipeline;
pub use pipeline::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
//! 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
}
fn is_executable_rva(secs: &[Section], rva: u32) -> bool {
secs.iter().any(|section| {
let span = section.vsize.max(section.raw_size);
rva >= section.va
&& rva < section.va.wrapping_add(span)
&& (section.chars & 0x2000_0000) != 0
})
}
fn check_common_entry_branches(
stub: &[u8],
ep: u32,
secs: &[Section],
report: &mut IntegrityReport,
) {
if stub.len() < 18
|| stub[0..3] != [0x48, 0x83, 0xEC]
|| stub[4] != 0xE8
|| stub[9..12] != [0x48, 0x83, 0xC4]
|| stub[12] != stub[3]
|| stub[13] != 0xE9
{
return;
}
for (name, rel_off, instruction_len) in [("call", 5usize, 9i64), ("jump", 14usize, 18i64)] {
let rel = i32::from_le_bytes([
stub[rel_off],
stub[rel_off + 1],
stub[rel_off + 2],
stub[rel_off + 3],
]) as i64;
let target = i64::from(ep) + instruction_len + rel;
let valid = u32::try_from(target)
.ok()
.is_some_and(|rva| is_executable_rva(secs, rva));
if !valid {
report.issues.push(format!(
"entry point {name} target 0x{target:X} is outside executable sections (DD8 selection is likely wrong)"
));
}
}
}
/// 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"
));
}
if let Some(entry_stub) = out.get(off as usize..off as usize + 18) {
check_common_entry_branches(entry_stub, ep, &secs, &mut r);
}
}
}
}
// --- 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))
}
#[cfg(test)]
mod tests {
use super::*;
fn executable_text() -> Vec<Section> {
vec![Section {
va: 0x1000,
vsize: 0x4000,
raw_ptr: 0x1000,
raw_size: 0x4000,
chars: 0x6000_0020,
}]
}
#[test]
fn common_entry_stub_rejects_out_of_image_branches() {
let stub = [
0x48, 0x83, 0xEC, 0x28, 0xE8, 0x5B, 0x02, 0x41, 0x00, 0x48, 0x83, 0xC4, 0x28, 0xE9,
0x7A, 0xFE, 0x54, 0xFF,
];
let mut report = IntegrityReport::default();
check_common_entry_branches(&stub, 0x1264, &executable_text(), &mut report);
assert_eq!(report.issues.len(), 2);
}
#[test]
fn common_entry_stub_accepts_executable_branches() {
let stub = [
0x48, 0x83, 0xEC, 0x28, 0xE8, 0x5B, 0x02, 0x00, 0x00, 0x48, 0x83, 0xC4, 0x28, 0xE9,
0x7A, 0xFE, 0xFF, 0xFF,
];
let mut report = IntegrityReport::default();
check_common_entry_branches(&stub, 0x1264, &executable_text(), &mut report);
assert!(report.ok());
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Internal PE layout discovery and image reconstruction.
mod dd8;
mod discovery;
mod image;
pub(super) use dd8::{select_dd8_formula_pe32, select_dd8_shift};
pub(super) use discovery::{
discover_eighth_slots, find_bytecode_offset, find_lfsr_block, find_str_pos, find_tbl_pe32,
find_v_after_pad, find_v4_offset, get_string_to_null, section_name, trial_decrypt5_u32,
};
pub(super) use image::{
compact_memory_image_to_pe, move_pe32_imports_to_kmiat, pe32_imports_already_match_idata_layout,
};
+609
View File
@@ -0,0 +1,609 @@
//! Validation-driven selection for per-page text transforms.
use super::discovery::trial_decrypt5_u32;
/// PE32 `.text` dd8 key-formula selection with a skip decision. The packer keys
/// the per-page XOR either with `page+1` or `0x8000*(page+1)`; the formula is
/// not recorded. Replays the dd8 page pass on a scratch copy of sample pages
/// (25/50/75% of `.text`) under each formula and counts how many positions
/// decode to `0xCC` (int3 padding).
///
/// Returns `Some(true)` for the `0x8000*(page+1)` formula, `Some(false)` for
/// `page+1`, or `None` when `.text` must NOT be dd8-decrypted at all. The packer
/// dd8-encrypts `.text` on EXEs (so unpacking must replay it) but leaves a native
/// DLL's `.text` plaintext; replaying dd8 there scrambles ~1 byte per 16-byte
/// block. The decision: dd8 only *restores* int3 padding when `.text` was
/// genuinely encrypted, so apply it only when the chosen formula's whole-page
/// 0xCC count rises *clearly* above the no-dd8 baseline; otherwise skip.
///
/// "Clearly" matters: dd8 XORs 255 positions per page with pseudo-random bytes,
/// so on an already-plaintext `.text` it manufactures ~1 spurious `0xCC` per
/// sampled page for free (255/256 expected). A bare `best > baseline` test is
/// therefore biased towards *applying* dd8 on exactly the inputs that must skip
/// it — and a wrongly-applied dd8 is silent: it scrambles ~1 byte per 16 with no
/// error and nothing downstream (not even `integrity::check`, which only reads
/// 16 bytes at the entry point) notices. The [`MIN_DD8_NET_GAIN`] floor below is
/// the PE32 counterpart of the margin+floor `select_dd8_shift` already applies
/// on PE32+ for the same failure mode.
pub fn select_dd8_formula_pe32(data: &[u8], text_off: u32, text_size: u32) -> Option<bool> {
let num_pages_total = text_size / 0x1000;
let mut sample_pages: Vec<u32> = Vec::new();
for frac in [0.25f64, 0.5, 0.75] {
let pg = (num_pages_total as f64 * frac) as u32;
if pg > 0 && pg < num_pages_total {
sample_pages.push(pg);
}
}
if sample_pages.is_empty() && num_pages_total > 1 {
sample_pages.push(num_pages_total / 2);
}
let score = |big: bool| -> i64 {
let mut total = 0i64;
for &sp in &sample_pages {
let pg_off = (text_off + sp * 0x1000) as usize;
if pg_off + 0x1000 > data.len() {
continue;
}
let mut buf = [0u8; 0x1000];
buf.copy_from_slice(&data[pg_off..pg_off + 0x1000]);
let pk = if big {
0x8000u32.wrapping_mul(sp.wrapping_add(1))
} else {
sp.wrapping_add(1)
};
let mut k = pk;
let rk = k.rotate_right(15);
k = rk;
for bi in 1..256u32 {
let rk = k.rotate_right(15);
let ri = rk.wrapping_add(bi);
k = ri.wrapping_add(bi);
let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize;
if tidx < buf.len() {
buf[tidx] ^= k as u8;
}
}
total += buf.iter().filter(|&&b| b == 0xCC).count() as i64;
}
total
};
let s_small = score(false);
let s_big = score(true);
// Baseline: whole-page 0xCC over the same sample pages with NO dd8. dd8 only
// rewrites 255 bytes per page, so comparing the chosen formula's whole-page
// 0xCC against this baseline reveals whether dd8 *restores* int3 padding
// (count rises -> .text was packer-encrypted, apply) or merely scrambles
// already-plaintext code (count falls -> native-DLL .text left intact, skip).
let mut baseline: i64 = 0;
for &sp in &sample_pages {
let pg_off = (text_off + sp * 0x1000) as usize;
if pg_off + 0x1000 > data.len() {
continue;
}
baseline += data[pg_off..pg_off + 0x1000]
.iter()
.filter(|&&b| b == 0xCC)
.count() as i64;
}
let big = s_big > s_small;
let best = s_small.max(s_big);
// Minimum net 0xCC gain over the baseline before dd8 is applied. Noise on an
// already-plaintext `.text` is ~1 manufactured 0xCC per sampled page (3 pages
// -> ~3); every corpus build that genuinely needs dd8 gains +154 or more
// (observed +154 and +312), and the one native DLL that must skip scores -18.
// A floor of 32 sits ~10x above the noise and ~5x below the smallest true
// positive, so it changes no existing decision.
const MIN_DD8_NET_GAIN: i64 = 32;
let apply = best.saturating_sub(baseline) >= MIN_DD8_NET_GAIN;
if std::env::var("SEL_DIAG").is_ok() {
eprintln!(
"SEL pe32 dd8 s_small={} s_big={} baseline={} gain={} big={} apply={}",
s_small,
s_big,
baseline,
best - baseline,
big,
apply
);
}
// When no interior pages could be sampled (tiny .text) we cannot measure the
// effect; preserve the historical behavior of applying dd8.
if sample_pages.is_empty() || apply {
Some(big)
} else {
None
}
}
// ---------------------------------------------------------------------------
// dd8 page-XOR shift selection.
//
// The packer scrambles ~1 byte per 16-byte block of .text via decrypt_data8,
// keyed by `page_idx << shift` (absolute page index = text_va >> 12). Observed
// shifts are 0 and 15. The shift is NOT stored in any header/config field, so
// the decision must be validated against the resulting .text content.
//
// A recognised CRT entry stub is the strongest oracle: decode skip/0/15 and
// require both of its direct rel32 branches to land in executable .text. This
// includes the call/jump displacement bytes themselves; an older entry oracle
// wildcarded those bytes and could accept a stub whose opcodes looked right but
// whose branch targets were outside the image.
//
// Other entry shapes fall back to padding statistics over a few sample pages
// (head/tail margin skipped: entry/exit regions have atypical padding density).
// The primary signal is a *structural* fingerprint: the MSVC function-end
// padding pattern, a 0xC3 RET 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 margin AND an absolute floor, otherwise dd8 is skipped — a wrongly
// applied dd8 scrambles ~1 byte per 16 with no error surfaced downstream.
// ---------------------------------------------------------------------------
pub fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, info3: u32) -> u32 {
if let Some((shift, scores)) = select_dd8_by_entry_stub(data, text_va, text_size, info3) {
if std::env::var("SEL_DIAG").is_ok() {
eprintln!(
"SEL dd8 entry best_shift={} none={} s0={} s15={}",
shift, scores[0], scores[1], scores[2]
);
}
return shift;
}
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;
}
let text_off = text_va as usize;
// Sample up to 4 pages, skipping a head/tail margin (entry/exit regions
// have atypical padding density). Small .text: sample every page.
let mut sample_pages: Vec<u32> = Vec::new();
if num_pages_total <= 4 {
sample_pages.extend(0..num_pages_total);
} else {
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() {
return 0;
}
let abs_base = text_va >> 12;
// Require a clear 2x margin over the already-plaintext baseline AND an
// 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. 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
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() {
eprintln!(
"SEL dd8 best_shift={} fp=({},{},{}) cc=({},{},{}) samples={:?}",
best_shift, r_none, r0, r15, none_hits, s0, s15, sample_pages
);
}
best_shift
}
/// 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
}
/// Select DD8 from the common CRT entry stub when its direct call and jump
/// provide a stronger oracle than sparse padding statistics. The candidate is
/// accepted only when it is the sole one whose two branch targets stay inside
/// `.text`; unrecognised entry code falls through to the padding selector.
fn select_dd8_by_entry_stub(
data: &[u8],
text_va: u32,
text_size: u32,
info3: u32,
) -> Option<(u32, [u8; 3])> {
for entry in entry_candidates(data, text_va, text_size, info3) {
let [Some(none), Some(s0), Some(s15)] = [None, Some(0), Some(15)]
.map(|shift| entry_stub_branch_score(data, text_va, text_size, entry, shift))
else {
continue;
};
let scores = [none, s0, s15];
let best = scores.iter().copied().max()?;
if best == 2 && scores.iter().filter(|&&score| score == best).count() == 1 {
let index = scores.iter().position(|&score| score == best)?;
return Some(([99, 0, 15][index], scores));
}
}
None
}
fn entry_candidates(data: &[u8], text_va: u32, text_size: u32, info3: u32) -> Vec<u32> {
let text_end = text_va.saturating_add(text_size);
let mut entries = Vec::with_capacity(3);
if let Some(pe) = read_u32(data, 0x3C)
&& let Some(entry) = pe.checked_add(40).and_then(|offset| read_u32(data, offset))
&& (text_va..text_end).contains(&entry)
{
entries.push(entry);
}
for metadata_off in [32u32, 64] {
let Some(end) = info3
.checked_add(metadata_off)
.and_then(|offset| offset.checked_add(8))
else {
continue;
};
if end as usize > data.len() {
continue;
}
let entry = trial_decrypt5_u32(data, info3 + metadata_off);
let image_base = trial_decrypt5_u32(data, info3 + metadata_off + 4);
if image_base == info3 && (text_va..text_end).contains(&entry) && !entries.contains(&entry)
{
entries.push(entry);
}
}
entries
}
fn entry_stub_branch_score(
data: &[u8],
text_va: u32,
text_size: u32,
entry: u32,
shift: Option<u32>,
) -> Option<u8> {
let text_end = text_va.checked_add(text_size)?;
if entry < text_va || entry.checked_add(18)? > text_end {
return None;
}
let mut stub = [0u8; 18];
for (offset, byte) in stub.iter_mut().enumerate() {
*byte = dd8_candidate_byte(data, entry + offset as u32, shift)?;
}
if stub[0..3] != [0x48, 0x83, 0xEC]
|| stub[4] != 0xE8
|| stub[9..12] != [0x48, 0x83, 0xC4]
|| stub[12] != stub[3]
|| stub[13] != 0xE9
{
return None;
}
let call_rel = i32::from_le_bytes(stub[5..9].try_into().ok()?) as i64;
let jump_rel = i32::from_le_bytes(stub[14..18].try_into().ok()?) as i64;
let call_target = i64::from(entry) + 9 + call_rel;
let jump_target = i64::from(entry) + 18 + jump_rel;
let in_text = |target: i64| target >= i64::from(text_va) && target < i64::from(text_end);
Some(u8::from(in_text(call_target)) + u8::from(in_text(jump_target)))
}
fn dd8_candidate_byte(data: &[u8], rva: u32, shift: Option<u32>) -> Option<u8> {
let mut byte = *data.get(rva as usize)?;
let Some(shift) = shift else {
return Some(byte);
};
let page = rva >> 12;
let block = (rva & 0xFFF) >> 4;
let mut key = page << shift;
for index in 0..=block {
let mixed = key.rotate_right(15).wrapping_add(index);
key = mixed.wrapping_add(index);
if index != 0 {
let target = (page << 12)
.wrapping_add(index << 4)
.wrapping_add(mixed & 0xF);
if target == rva {
byte ^= key as u8;
}
}
}
Some(byte)
}
fn read_u32(data: &[u8], offset: u32) -> Option<u32> {
let start = offset as usize;
let bytes = data.get(start..start.checked_add(4)?)?;
Some(u32::from_le_bytes(bytes.try_into().ok()?))
}
// Baseline: count int3 pads already present at the first byte of each 16-byte
// block, i.e. the positions dd8 would target if its in-block offset were 0.
fn score_dd8_baseline(data: &[u8], text_off: usize, sample_pages: &[u32]) -> u32 {
let mut hits = 0u32;
for &sp in sample_pages {
let pg_off = text_off + (sp as usize) * 0x1000;
if pg_off + 0x1000 > data.len() {
continue;
}
for bi in 1..256usize {
if data[pg_off + bi * 16] == 0xCC {
hits += 1;
}
}
}
hits
}
// Replay decrypt_data8 on each sample page under `shift` and count how many of
// the 255 mutated positions decode to 0xCC.
fn score_dd8_shift(
data: &[u8],
text_off: usize,
text_va: u32,
sample_pages: &[u32],
shift: u32,
) -> u32 {
let abs_base = text_va >> 12;
let mut hits = 0u32;
for &sp in sample_pages {
let pg_off = text_off + (sp as usize) * 0x1000;
if pg_off + 0x1000 > data.len() {
continue;
}
let abs_page = abs_base.wrapping_add(sp);
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);
if bi == 0 {
continue;
}
let tidx = (bi.wrapping_mul(16).wrapping_add(mixed & 0xF)) as usize;
if tidx < 0x1000 {
let mutated = data[pg_off + tidx] ^ (key as u8);
if mutated == 0xCC {
hits += 1;
}
}
}
}
hits
}
#[cfg(test)]
mod tests {
use super::*;
fn entry_stub_fixture() -> Vec<u8> {
let mut data = vec![0u8; 0x5000];
data[0x3C..0x40].copy_from_slice(&0x100u32.to_le_bytes());
data[0x128..0x12C].copy_from_slice(&0x1264u32.to_le_bytes());
data[0x1264..0x1276].copy_from_slice(&[
0x48, 0x83, 0xEC, 0x28, 0xE8, 0x5B, 0x02, 0x00, 0x00, 0x48, 0x83, 0xC4, 0x28, 0xE9,
0x7A, 0xFE, 0xFF, 0xFF,
]);
data
}
fn apply_dd8_page(data: &mut [u8], page_rva: u32, shift: u32) {
let mut key = (page_rva >> 12) << shift;
for index in 0..256u32 {
let mixed = key.rotate_right(15).wrapping_add(index);
key = mixed.wrapping_add(index);
if index == 0 {
continue;
}
let target = page_rva.wrapping_add(index << 4).wrapping_add(mixed & 0xF) as usize;
data[target] ^= key as u8;
}
}
#[test]
fn entry_stub_selects_plaintext_and_both_dd8_shifts() {
let plain = entry_stub_fixture();
assert_eq!(select_dd8_shift(&plain, 0x1000, 0x4000, 0), 99);
for expected in [0u32, 15] {
let mut encrypted = plain.clone();
apply_dd8_page(&mut encrypted, 0x1000, expected);
assert_eq!(select_dd8_shift(&encrypted, 0x1000, 0x4000, 0), expected);
}
}
/// Seed the first `count` dd8-targeted positions of each sampled page with
/// the byte that decodes to `0xCC` under the `page+1` formula — i.e. an
/// encrypted `.text` whose plaintext is int3 padding. Positions whose key
/// byte would make the *ciphertext* itself `0xCC` are skipped so the
/// fixture contains no `0xCC` at all and every post-dd8 `0xCC` is a genuine
/// gain over a zero baseline.
fn seed_dd8_int3(data: &mut [u8], text_off: u32, pages: &[u32], count: u32) {
for &sp in pages {
let pg_off = (text_off + sp * 0x1000) as usize;
let mut k = sp.wrapping_add(1);
k = k.rotate_right(15);
let mut planted = 0u32;
for bi in 1..256u32 {
let ri = k.rotate_right(15).wrapping_add(bi);
k = ri.wrapping_add(bi);
if planted >= count {
continue;
}
let ct = 0xCCu8 ^ (k as u8);
if ct == 0xCC {
continue;
}
let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize;
data[pg_off + tidx] = ct;
planted += 1;
}
}
}
/// Review regression: a near-plaintext `.text` must NOT be dd8-decrypted.
/// dd8 XORs 255 positions per page with pseudo-random bytes, so it
/// manufactures a few `0xCC` for free — under the old bare
/// `best > baseline` test any positive gain was enough to "apply" dd8 and
/// scramble ~1 byte per 16 of a native DLL's already-plaintext code,
/// silently (nothing downstream, including the integrity check, notices).
/// Here the gain is real but small; the floor must still reject it.
#[test]
fn pe32_dd8_skips_text_whose_gain_is_only_noise_sized() {
let text_off: u32 = 0x1000;
let text_size: u32 = 8 * 0x1000;
let mut data = vec![0u8; (text_off + text_size) as usize];
seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 5);
assert!(
!data.contains(&0xCC),
"fixture must have a zero 0xCC baseline"
);
assert_eq!(
select_dd8_formula_pe32(&data, text_off, text_size),
None,
"a gain this small is indistinguishable from dd8's own noise"
);
}
/// Control for the above: a `.text` whose dd8 pass restores a large amount
/// of int3 padding clears the floor and is decrypted. Same fixture shape,
/// only the amount of restored padding differs.
#[test]
fn pe32_dd8_applies_when_padding_is_restored() {
let text_off: u32 = 0x1000;
let text_size: u32 = 8 * 0x1000;
let mut data = vec![0u8; (text_off + text_size) as usize];
seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 255);
assert_eq!(
select_dd8_formula_pe32(&data, text_off, text_size),
Some(false),
"encrypted .text must be decrypted with the page+1 formula"
);
}
}
@@ -0,0 +1,507 @@
//! Structural locators for protected PE stages.
use senbei_crypto::primitives::{get_u32, lfsr_keystream};
/// Find the 4-byte v_val that follows the LAST occurrence of `48 EB 01 B9`
/// (REX.W jmp+1; mov ecx,imm32) plus any 0xCC padding. Used to locate
/// stage4's accum2 seed. Works across builds even when API-name anchors are
/// absent.
pub fn find_v_after_pad(data: &[u8], base: u32, len: u32) -> Option<u32> {
let start = base as usize;
let end = (base.saturating_add(len)) as usize;
if end > data.len() {
return None;
}
let sig = [0x48u8, 0xEB, 0x01, 0xB9];
let slice = &data[start..end];
// last occurrence
let mut last = None;
let mut i = 0usize;
while i + sig.len() <= slice.len() {
if slice[i..i + sig.len()] == sig {
last = Some(i);
}
i += 1;
}
let pos = last?;
// skip CCs after the `48 EB 01 B9`
let mut after = pos + sig.len();
while after < slice.len() && slice[after] == 0xCC {
after += 1;
}
if after + 4 > slice.len() {
return None;
}
Some((start + after) as u32)
}
/// Predict the 4 bytes that DecryptData5(va, size) would produce at va+0..va+4
/// without mutating the buffer. The cipher's per-byte transform depends only
/// on the byte itself and the low 8 bits of (va+i), with no cross-byte state,
/// so each byte can be decrypted in isolation. Used to detect the EP/DD layout
/// offset before committing to the actual call.
pub fn trial_decrypt5_u32(data: &[u8], va: u32) -> u32 {
let mut out = [0u8; 4];
for i in 0..4u32 {
let b3 = data[(va + i) as usize];
let b = (va + i) as u8;
let b2 = b.wrapping_add(1);
let b4 = b3.rotate_left(2) ^ b2;
let b5 = b4.rotate_left(2) ^ b;
out[i as usize] = b5.rotate_left(2);
}
u32::from_le_bytes(out)
}
/// Scan stage4/stage5 for the encrypted custom-decryptor bytecode block. The
/// raw byte at p+95 is used by decrypt_data6 as the iteration count. We trial-
/// decrypt that many bytes with the LFSR keystream and accept the first
/// position where the byte stream parses as a valid opcode sequence ending in
/// 195 (ret).
pub fn find_bytecode_offset(data: &[u8], base: u32, len: u32) -> Option<u32> {
let start = base as usize;
let end = (base.saturating_add(len)) as usize;
if end > data.len() {
return None;
}
let mut ks = [0u8; 256];
lfsr_keystream(&mut ks);
// Scan forward from `start+16` on 16-byte boundaries relative to `start`.
// The bytecode block is positioned a fixed offset into stage4/stage5; the
// lowest parseable candidate is the real one (later ones are coincidental
// parses of trailing filler bytes that happen to map to valid opcodes).
// The enclosing buffer isn't necessarily 16-aligned to its absolute
// address in newer builds, so we anchor the stride to `start`.
let mut p = start + 16;
while p + 96 <= end {
let count = data[p + 95] as usize;
if count >= 8 && p + count <= end {
let mut buf = [0u8; 256];
let take = count.min(256);
for i in 0..take {
buf[i] = data[p + i] ^ ks[i];
}
if let Some(nops) = parse_bytecode_check(&buf[..take])
&& nops >= 4
{
return Some(p as u32);
}
}
p += 16;
}
None
}
/// Validate bytecode structure without allocating a `Vec` of ops. Returns
/// `Some(non_nop_op_count)` if the byte stream parses successfully as a valid
/// opcode sequence ending in 195 (ret), `None` otherwise. Allows non-trivial
/// bytecode filtering by op count.
pub fn parse_bytecode_check(buf: &[u8]) -> Option<usize> {
let mut i = 0usize;
let mut nops: usize = 0;
while i < buf.len() {
let b = buf[i];
i += 1;
match b {
4 | 44 | 52 => {
if i >= buf.len() {
return None;
}
i += 1;
nops += 1;
}
144 => {}
192 | 254 => {
if i >= buf.len() {
return None;
}
let mb = buf[i];
i += 1;
let rm = mb & 7;
let mod_ = (mb >> 6) & 3;
let reg = (mb >> 3) & 7;
if mod_ != 3 || rm != 0 {
return None;
}
if reg > 1 {
return None;
}
if b == 192 {
if i >= buf.len() {
return None;
}
i += 1;
}
nops += 1;
}
195 => return Some(nops),
_ => return None,
}
}
None
}
/// Locate stage3's v4_val: the last non-zero dword in the buffer, anchored
/// by the `C3 CC CC CC` (ret + 3 int3) immediately before it.
pub fn find_v4_offset(data: &[u8], base: u32, len: u32) -> Option<u32> {
let start = base as usize;
let end = (base.saturating_add(len)) as usize;
if end > data.len() || end < start + 4 {
return None;
}
// walk backwards looking for the first non-zero byte
let mut i = end;
while i > start && data[i - 1] == 0 {
i -= 1;
}
if i < start + 4 {
return None;
}
// v_val occupies the 4 bytes ending at i (rounded up to dword boundary)
let v_end = i;
let v_start = ((v_end + 3) & !3).saturating_sub(4);
// require that the 4 bytes preceding v_val match `C3 CC CC CC`
if v_start < start + 4 || data[v_start - 4..v_start] != [0xC3, 0xCC, 0xCC, 0xCC] {
return None;
}
Some(v_start as u32)
}
/// Scan a sub-buffer for an ASCII needle; return its absolute position.
pub fn find_str_pos(data: &[u8], base: u32, len: u32, needle: &[u8]) -> Option<u32> {
let start = base as usize;
let end = (base.saturating_add(len)) as usize;
if end > data.len() || needle.is_empty() {
return None;
}
data[start..end]
.windows(needle.len())
.position(|w| w == needle)
.map(|rel| (start + rel) as u32)
}
pub fn get_string_to_null(data: &[u8], offset: u32) -> String {
let start = offset as usize;
if start >= data.len() {
return String::new();
}
// Bounded: an unterminated run must never walk off the end of the buffer
// (panic) or scan unboundedly into unrelated data.
let limit = start.saturating_add(4096).min(data.len());
let mut i = start;
while i < limit && data[i] != 0 {
i += 1;
}
String::from_utf8_lossy(&data[start..i]).into_owned()
}
/// Read a PE section-name field: exactly 8 bytes, NOT necessarily
/// NUL-terminated (a full-width name like `.textbss` has no NUL at all).
/// Returns the name with trailing NULs stripped. Using `get_string_to_null`
/// here would run past the field into the VirtualSize/VirtualAddress dwords.
pub fn section_name(data: &[u8], offset: u32) -> String {
let start = offset as usize;
let Some(field) = data.get(start..start + 8) else {
return String::new();
};
let end = field.iter().position(|&b| b == 0).unwrap_or(8);
String::from_utf8_lossy(&field[..end]).into_owned()
}
// ---------------------------------------------------------------------------
// PE32 (32-bit) helpers
// ---------------------------------------------------------------------------
/// PE32 shell-table locator. Walks the shell region (`info[6]`) for a dword
/// equal to `info[6]` followed by a plausible shell size, returning the table
/// base (`candidate = off - 0x88`) when `candidate+0x58` holds a valid pointer.
pub fn find_tbl_pe32(data: &[u8], info: &[u32; 8]) -> Option<u32> {
let shell = info[6];
if (data.len() as u64) < 0x100 {
return None;
}
let hi = (shell as u64)
.saturating_add(0x3000)
.min(data.len() as u64 - 0x100) as u32;
let mut off = shell;
while off < hi {
if off as usize + 8 <= data.len() {
let candidate = off.wrapping_sub(0x88);
if candidate >= shell && get_u32(data, off) == info[6] {
let shell_size_val = get_u32(data, off.wrapping_add(4));
if shell_size_val > 0x1000 && shell_size_val < 0x100000 {
let v58_off = candidate.wrapping_add(0x58);
if (v58_off as usize + 4) <= data.len() {
let v58 = get_u32(data, v58_off);
if v58 > 0 && (v58 as usize) < data.len() {
return Some(candidate);
}
}
}
}
}
off = off.wrapping_add(4);
}
None
}
/// Locate an LFSR-encrypted bytecode block (decrypt_data6 form) in a region.
/// `start_off` is the byte offset to begin scanning at, `scan_backward`
/// controls direction. Returns the relative offset of the block. Includes full
/// opcode-walk validation of candidate blocks.
pub fn find_lfsr_block(
data: &[u8],
base: u32,
size: u32,
start_off: u32,
scan_backward: bool,
) -> Option<u32> {
if size < 96 {
return None;
}
let mut ks = [0u8; 128];
lfsr_keystream(&mut ks);
let check = |scan_off: u32| -> bool {
let abs_off = base.wrapping_add(scan_off) as usize;
if abs_off + 96 > data.len() {
return false;
}
let sz = data[abs_off + 95] as usize;
if !(10..=95).contains(&sz) {
return false;
}
let mut decoded = [0u8; 95];
for bi in 0..sz {
decoded[bi] = data[abs_off + bi] ^ ks[bi];
}
// Full bytecode validation (shared with the stage4/5 locator): every
// opcode must decode with a valid ModR/M and the stream must REACH a
// RET (0xC3) as an opcode. The previous check only required a 0xC3
// byte *anywhere* in the window and accepted a walk that ran off the
// end without hitting RET — a `0x04 0xC3` (ADD 0xC3) tail passed, so
// coincidental LFSR-shaped garbage was accepted as a decryptor block.
parse_bytecode_check(&decoded[..sz]).is_some()
};
if scan_backward {
let hi = size - 96;
if hi >= start_off {
let mut scan_off = hi;
loop {
if check(scan_off) {
return Some(scan_off);
}
if scan_off == start_off {
break;
}
scan_off -= 1;
}
}
} else {
let hi = size - 95;
let mut scan_off = start_off;
while scan_off < hi {
if check(scan_off) {
return Some(scan_off);
}
scan_off += 1;
}
}
None
}
/// Slots discovered in the eighthStage for the marker-less layout.
pub struct EighthSlots {
/// Absolute address of the file-data decryptor LFSR bytecode block. The
/// fileCS chain pointer is derived downstream as `file_lfsr - 0x58`.
pub file_lfsr: u32,
/// Absolute address of the compressedInfo (ptr,size) table pointer slot.
pub compressed_info_ptr: u32,
}
/// Marker-independent eighthStage slot discovery (PE32+ branch).
///
/// Newer Crackproof builds (e.g. some native/managed DLLs) omit the
/// `pm\0\0cm\0\0` and `00 00 00 40 01 00 00 00` markers that the older layout's
/// walk3/walk4/walk5 slot derivation relies on. Instead this discovers the
/// slots structurally:
/// * Scan the eighthStage for every LFSR (decrypt_data6) bytecode block.
/// * The file decryptor is the LFSR block whose `fileCS = lfsr - 0x58` holds
/// a pointer sitting just past `info[3]` (smallest positive distance).
/// * `compressedInfo` is the pointer slot whose 16-byte target, after a
/// trial `decrypt_data5`, parses as a plausible (src,sSize,dst,dSize)
/// descriptor.
///
/// Returns `None` if no plausible file LFSR is found. `eighth_start`/`eighth_dsz`
/// bound the search region; `info3` is `info[3]`; `compress_data_offset` is
/// `(!u32(file_data,0x1080)) + 0x1000`; `file_data_len` is the protected file
/// length.
#[allow(clippy::too_many_arguments)]
pub fn discover_eighth_slots(
data: &[u8],
eighth_start: u32,
eighth_dsz: u32,
info3: u32,
compress_data_offset: u32,
file_data_len: u32,
) -> Option<EighthSlots> {
// Collect all LFSR candidates (forward scan).
//
// Advance by 1 after each hit, NOT by 96. A false-positive LFSR match can sit
// just before the real file-decryptor block (observed on an il2cpp game
// assembly build, 2026-07-13: junk at rel=0x31C1, real block at 0x3210).
// Stepping by the LFSR body size then skips the real block and discovery
// fails. Byte-stepping is cheap: eighthStage is only a few KB.
let mut all_lfsrs: Vec<u32> = Vec::new();
let mut scan_off: u32 = 0;
while scan_off + 95 < eighth_dsz {
match find_lfsr_block(data, eighth_start, eighth_dsz, scan_off, false) {
Some(found) => {
all_lfsrs.push(found);
scan_off = found + 1;
}
None => break,
}
}
// Pick the file LFSR: prefer the candidate whose fileCS pointer sits the
// smallest positive distance past info[3].
let mut off_file_lfsr: Option<u32> = None;
let mut best_dist: Option<u32> = None;
for &lfsr_off in &all_lfsrs {
if lfsr_off < 0x58 {
continue;
}
let cs_off = lfsr_off - 0x58;
let cs_val = get_u32(data, eighth_start.wrapping_add(cs_off));
if !(0x1000 < cs_val && (cs_val as usize) < data.len()) {
continue;
}
if cs_val < info3 {
continue;
}
let dist = cs_val - info3;
if best_dist.is_none_or(|b| dist < b) {
best_dist = Some(dist);
off_file_lfsr = Some(lfsr_off);
}
}
// Fallback: last LFSR with any in-image fileCS pointer.
if off_file_lfsr.is_none() {
for &lfsr_off in all_lfsrs.iter().rev() {
if lfsr_off < 0x58 {
continue;
}
let cs_val = get_u32(data, eighth_start.wrapping_add(lfsr_off - 0x58));
if 0x1000 < cs_val && (cs_val as usize) < data.len() {
off_file_lfsr = Some(lfsr_off);
break;
}
}
}
let off_file_lfsr = off_file_lfsr?;
let off_file_cs = off_file_lfsr - 0x58;
// Trial-decrypt to find compressedInfo: the pointer slot in the data area
// (between fileCS region start and the LFSR) whose target parses as a valid
// (src,sSize,dst,dSize) descriptor after a transient decrypt_data5.
let scan_from = off_file_lfsr.saturating_sub(0x400);
let mut off_compressed_info: Option<u32> = None;
let mut doff = scan_from;
while doff < off_file_lfsr {
if doff == off_file_cs {
doff += 4;
continue;
}
let ptr_val = get_u32(data, eighth_start.wrapping_add(doff));
if !(0x1000 < ptr_val && (ptr_val as usize) < data.len().saturating_sub(16)) {
doff += 4;
continue;
}
// Predict decrypt_data5(ptr_val, 16) without mutating: each dword is
// position-keyed and independent, so trial_decrypt5_u32 per dword.
let src2 = trial_decrypt5_u32(data, ptr_val);
let s_sz2 = trial_decrypt5_u32(data, ptr_val + 4);
let dst2 = trial_decrypt5_u32(data, ptr_val + 8);
let d_sz2 = trial_decrypt5_u32(data, ptr_val + 12);
let src_file_off = src2.wrapping_add(compress_data_offset);
let valid = s_sz2 > 0
&& s_sz2 < 0x200000
&& (src_file_off as u64 + s_sz2 as u64) <= file_data_len as u64
&& dst2 >= 0x1000
&& (dst2 as u64 + d_sz2 as u64) <= data.len() as u64
&& d_sz2 >= s_sz2
&& d_sz2 < 0x200000;
if valid {
off_compressed_info = Some(doff);
break;
}
doff += 4;
}
let off_compressed_info = off_compressed_info?;
Some(EighthSlots {
file_lfsr: eighth_start.wrapping_add(off_file_lfsr),
compressed_info_ptr: eighth_start.wrapping_add(off_compressed_info),
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Task 4.1 regression: build a synthetic buffer whose valid bytecode block
/// sits PAST `len` but within `len*2`. Assert that the smaller window misses
/// it and the doubled window finds it.
#[test]
fn bytecode_locate_double_window_retry() {
// We place the block at offset (base + len + 16) which is inside
// the len*2 window but outside the len window.
let base: u32 = 0;
let len: u32 = 256;
// Block sits at base + len + 16 = 272, aligned to 16.
let block_pos: usize = (base + len + 16) as usize; // 272
// The buffer must be large enough for the block (block_pos + 96 bytes).
let buf_len = block_pos + 256;
let mut buf = vec![0u8; buf_len];
// Build a valid plaintext op stream:
// [4, 0, 4, 0, 4, 0, 4, 0, 195] (4 ADD-AL ops then RET)
// Padded to 10 bytes total; count >= 8.
let count: usize = 10;
let mut plain = [0u8; 256];
plain[0] = 4;
plain[1] = 0;
plain[2] = 4;
plain[3] = 0;
plain[4] = 4;
plain[5] = 0;
plain[6] = 4;
plain[7] = 0;
plain[8] = 195; // ret
// Compute the LFSR keystream and XOR the first `count` bytes to get the
// encrypted representation that the scanner would decrypt back.
let mut ks = [0u8; 256];
lfsr_keystream(&mut ks);
for i in 0..count {
buf[block_pos + i] = plain[i] ^ ks[i];
}
// Raw count byte at block_pos+95 (outside the XOR range since count=10 < 95).
buf[block_pos + 95] = count as u8;
// Verify our construction: find_bytecode_offset with len should NOT find it.
assert_eq!(
find_bytecode_offset(&buf, base, len),
None,
"smaller window should not find the block"
);
// The doubled window should find it at block_pos.
assert_eq!(
find_bytecode_offset(&buf, base, len.saturating_mul(2)),
Some(block_pos as u32),
"doubled window should locate the block"
);
}
}
+481
View File
@@ -0,0 +1,481 @@
//! PE import reconstruction and memory-image compaction.
use senbei_crypto::primitives::{get_u16, get_u32, write_u16, write_u32};
use super::super::MAX_IMAGE_SIZE;
/// Read a NUL-terminated byte string starting at `off`, bounded to 512 bytes.
/// Returns the raw bytes up to the terminator (excluding it).
fn read_cstr_bounded(data: &[u8], off: u32) -> Vec<u8> {
let start = off as usize;
if start >= data.len() {
return Vec::new();
}
let limit = (start + 512).min(data.len());
let mut end = start;
while end < limit && data[end] != 0 {
end += 1;
}
data[start..end].to_vec()
}
fn align_up_u32(value: u32, alignment: u32) -> u32 {
((value.wrapping_add(alignment - 1)) / alignment).wrapping_mul(alignment)
}
fn align_up_u64(value: u64, alignment: u64) -> u64 {
value.div_ceil(alignment) * alignment
}
#[derive(Clone)]
enum ImportFunc {
Ordinal(u32),
Name(u16, Vec<u8>),
}
struct ImportDesc {
time_date: u32,
fwd_chain: u32,
dll_name: Vec<u8>,
iat_rva: u32,
functions: Vec<ImportFunc>,
}
/// Return true when PE32 imports already sit in the original `.idata` layout
/// (so no relocation to `.kmiat` is needed). May write the IAT data directory
/// (pe+0xD8).
pub fn pe32_imports_already_match_idata_layout(data: &mut [u8], pe_header: u32) -> bool {
let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32;
let sec_table = pe_header.wrapping_add(24).wrapping_add(opt_hdr_size);
let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32;
let import_rva = get_u32(data, pe_header.wrapping_add(0x80));
let import_size = get_u32(data, pe_header.wrapping_add(0x84));
let len = data.len() as u32;
if !(import_rva > 0 && import_size > 0) {
return false;
}
for idx in 0..num_sections {
let sec_off = sec_table.wrapping_add(idx * 40);
if (sec_off as usize + 40) > data.len() {
return false;
}
if &data[sec_off as usize..sec_off as usize + 6] != b".idata" {
continue;
}
let sec_va = get_u32(data, sec_off.wrapping_add(12));
let sec_size =
get_u32(data, sec_off.wrapping_add(8)).max(get_u32(data, sec_off.wrapping_add(16)));
let sec_end = sec_va.wrapping_add(sec_size);
if !(sec_va <= import_rva
&& import_rva < sec_end
&& import_rva.wrapping_add(import_size) <= sec_end)
{
continue;
}
let first_oft = get_u32(data, import_rva);
let first_name = get_u32(data, import_rva.wrapping_add(12));
let first_iat = get_u32(data, import_rva.wrapping_add(16));
if !(sec_va <= first_oft
&& first_oft < sec_end
&& sec_va <= first_iat
&& first_iat < sec_end)
{
return false;
}
if !(0x1000 < first_name && first_name < len) {
return false;
}
let dll_name = read_cstr_bounded(data, first_name);
let lower: Vec<u8> = dll_name.iter().map(|b| b.to_ascii_lowercase()).collect();
if !lower.ends_with(b".dll") {
return false;
}
let mut iat_min = first_iat;
let mut iat_max = first_iat;
let mut idt_pos = import_rva;
while idt_pos.wrapping_add(20) <= len {
let oft_rva = get_u32(data, idt_pos);
let name_rva = get_u32(data, idt_pos.wrapping_add(12));
let iat_rva = get_u32(data, idt_pos.wrapping_add(16));
if oft_rva == 0 && name_rva == 0 && iat_rva == 0 {
break;
}
if !(sec_va <= oft_rva && oft_rva < sec_end && sec_va <= iat_rva && iat_rva < sec_end) {
return false;
}
let mut thunk = iat_rva;
while thunk.wrapping_add(4) <= sec_end {
let tv = get_u32(data, thunk);
thunk = thunk.wrapping_add(4);
if tv == 0 {
break;
}
}
iat_min = iat_min.min(iat_rva);
iat_max = iat_max.max(thunk);
idt_pos = idt_pos.wrapping_add(20);
}
if iat_max > iat_min {
write_u32(data, pe_header.wrapping_add(0xD8), iat_min);
write_u32(data, pe_header.wrapping_add(0xDC), iat_max - iat_min);
}
return true;
}
false
}
/// Rebuild PE32 import metadata (descriptors, lookup tables, names) into the
/// last section as `.kmiat`, leaving the loader-written IAT in place. Mutates
/// `data` (may grow it).
pub fn move_pe32_imports_to_kmiat(data: &mut Vec<u8>, pe_header: u32) {
const SECTION_SIZE: u32 = 0x7000;
let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32;
let opt_hdr = pe_header.wrapping_add(24);
let sec_table = opt_hdr.wrapping_add(opt_hdr_size);
let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32;
if num_sections == 0 {
return;
}
let import_rva = get_u32(data, pe_header.wrapping_add(0x80));
let import_size = get_u32(data, pe_header.wrapping_add(0x84));
let len = data.len() as u32;
if !(0x1000 < import_rva && import_rva < len && import_size > 0 && import_size < SECTION_SIZE) {
return;
}
let mut descriptors: Vec<ImportDesc> = Vec::new();
let mut idt_pos = import_rva;
while idt_pos.wrapping_add(20) <= len {
let oft_rva = get_u32(data, idt_pos);
let time_date = get_u32(data, idt_pos.wrapping_add(4));
let fwd_chain = get_u32(data, idt_pos.wrapping_add(8));
let name_rva = get_u32(data, idt_pos.wrapping_add(12));
let iat_rva = get_u32(data, idt_pos.wrapping_add(16));
if oft_rva == 0 && name_rva == 0 && iat_rva == 0 {
break;
}
if !(0x1000 < name_rva && name_rva < len) {
break;
}
let dll_name = read_cstr_bounded(data, name_rva);
let thunk_rva = if 0x1000 < oft_rva && oft_rva < len {
oft_rva
} else {
iat_rva
};
let mut functions: Vec<ImportFunc> = Vec::new();
let mut thunk_pos = thunk_rva;
while 0x1000 < thunk_pos.wrapping_add(4) && thunk_pos.wrapping_add(4) <= len {
let thunk_val = get_u32(data, thunk_pos);
if thunk_val == 0 {
break;
}
if thunk_val & 0x8000_0000 != 0 {
functions.push(ImportFunc::Ordinal(thunk_val & 0xFFFF));
} else {
let hint = if thunk_val.wrapping_add(2) <= len {
get_u16(data, thunk_val)
} else {
0
};
let func_name = if thunk_val.wrapping_add(2) < len {
read_cstr_bounded(data, thunk_val.wrapping_add(2))
} else {
Vec::new()
};
functions.push(ImportFunc::Name(hint, func_name));
}
thunk_pos = thunk_pos.wrapping_add(4);
}
descriptors.push(ImportDesc {
time_date,
fwd_chain,
dll_name,
iat_rva,
functions,
});
idt_pos = idt_pos.wrapping_add(20);
}
if descriptors.is_empty() {
return;
}
for desc in &mut descriptors {
let lower: Vec<u8> = desc
.dll_name
.iter()
.map(|b| b.to_ascii_lowercase())
.collect();
if lower.starts_with(b"api-ms-win-crt-") {
desc.dll_name = b"ucrtbase.dll".to_vec();
} else {
desc.dll_name = lower;
}
}
descriptors.sort_by_key(|d| d.iat_rva);
let last_sec = sec_table.wrapping_add((num_sections - 1) * 40);
let kmiat_rva = get_u32(data, last_sec.wrapping_add(12));
// A zero last-section VA means a corrupt section table: building .kmiat at
// RVA 0 would zero the DOS/PE headers and emit a structurally broken image
// with no error. Bail and keep the original import table.
if kmiat_rva == 0 {
return;
}
// Grow the image when .kmiat overruns it, but cap the growth: a corrupt VA
// could otherwise request a multi-gigabyte allocation, which aborts the
// process (uncatchable). Use u64 math so a near-u32::MAX VA cannot wrap the
// end calculation the way the previous wrapping/plain-add mix could.
let kmiat_end = kmiat_rva as u64 + SECTION_SIZE as u64;
if kmiat_end > MAX_IMAGE_SIZE {
return;
}
if kmiat_end > data.len() as u64 {
data.resize(kmiat_end as usize, 0);
}
// Zero the .kmiat region.
for b in &mut data[kmiat_rva as usize..kmiat_end as usize] {
*b = 0;
}
let idt_size = (descriptors.len() as u32 + 1) * 20;
let oft_start = kmiat_rva;
let mut idt_rva = oft_start;
for desc in &descriptors {
idt_rva = idt_rva.wrapping_add((desc.functions.len() as u32 + 1) * 4);
}
idt_rva = align_up_u32(idt_rva.wrapping_add(0x2C), 4);
// Size check: compute the final name_pos and bail if it overruns .kmiat.
let mut name_pos_check = idt_rva.wrapping_add(idt_size);
for desc in &descriptors {
name_pos_check = name_pos_check.wrapping_add(desc.dll_name.len() as u32 + 1);
for func in &desc.functions {
if let ImportFunc::Name(_, fname) = func {
name_pos_check = name_pos_check.wrapping_add(2 + fname.len() as u32 + 1);
}
}
}
if name_pos_check > kmiat_rva.wrapping_add(SECTION_SIZE) {
// Section too small; keep existing import table untouched.
return;
}
let mut oft_pos = oft_start;
let mut name_pos = idt_rva.wrapping_add(idt_size);
for (idx, desc) in descriptors.iter().enumerate() {
let idt_entry = idt_rva.wrapping_add(idx as u32 * 20);
let current_oft = oft_pos;
write_u32(data, idt_entry, current_oft);
write_u32(data, idt_entry.wrapping_add(4), desc.time_date);
write_u32(data, idt_entry.wrapping_add(8), desc.fwd_chain);
let dll_name_pos = name_pos;
write_u32(data, idt_entry.wrapping_add(12), dll_name_pos);
write_u32(data, idt_entry.wrapping_add(16), desc.iat_rva);
let dnp = dll_name_pos as usize;
data[dnp..dnp + desc.dll_name.len()].copy_from_slice(&desc.dll_name);
data[dnp + desc.dll_name.len()] = 0;
name_pos = name_pos.wrapping_add(desc.dll_name.len() as u32 + 1);
for func in &desc.functions {
match func {
ImportFunc::Ordinal(ord) => {
write_u32(data, oft_pos, 0x8000_0000 | ord);
}
ImportFunc::Name(hint, fname) => {
let hint_name_rva = name_pos;
write_u32(data, oft_pos, hint_name_rva);
write_u16(data, hint_name_rva, *hint as u32);
let fp = (hint_name_rva + 2) as usize;
data[fp..fp + fname.len()].copy_from_slice(fname);
data[fp + fname.len()] = 0;
name_pos = name_pos.wrapping_add(2 + fname.len() as u32 + 1);
}
}
oft_pos = oft_pos.wrapping_add(4);
}
write_u32(data, oft_pos, 0);
oft_pos = oft_pos.wrapping_add(4);
}
// Null-terminator IDT entry (20 zero bytes) after the last descriptor.
let term = idt_rva.wrapping_add(descriptors.len() as u32 * 20) as usize;
for b in &mut data[term..term + 20] {
*b = 0;
}
let ls = last_sec as usize;
data[ls..ls + 8].copy_from_slice(b".kmiat\x00\x00");
write_u32(data, last_sec.wrapping_add(8), SECTION_SIZE);
write_u32(data, last_sec.wrapping_add(16), SECTION_SIZE);
write_u32(data, last_sec.wrapping_add(36), 0xE000_0060);
write_u32(data, pe_header.wrapping_add(0x80), idt_rva);
write_u32(data, pe_header.wrapping_add(0x84), idt_size);
write_u32(
data,
pe_header.wrapping_add(80),
kmiat_rva.wrapping_add(SECTION_SIZE),
);
}
/// Convert the unpacked RVA-addressed image back to a compact PE file layout
/// (headers at 0x400, sections packed consecutively, FileAlignment 0x200).
/// Returns `None` if the accumulated output size wraps or exceeds
/// [`MAX_IMAGE_SIZE`]: the final allocation is sized from header-derived
/// section data, and an uncapped `vec![0; n]` from a corrupt header would abort
/// the process (which `catch_unpack` cannot trap).
pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>> {
const FILE_ALIGNMENT: u32 = 0x200;
const HEADER_SIZE: u32 = 0x400;
let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32;
let opt_hdr = pe_header.wrapping_add(24);
let sec_table = opt_hdr.wrapping_add(opt_hdr_size);
let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32;
struct SecLayout {
sec_off: u32,
va: u32,
vsize: u32,
raw_ptr: u32,
raw_size: u32,
}
let mut raw_cursor: u64 = HEADER_SIZE as u64;
let mut raw_layout: Vec<SecLayout> = Vec::new();
for idx in 0..num_sections {
let sec_off = sec_table.wrapping_add(idx * 40);
let vsize = get_u32(data, sec_off.wrapping_add(8));
let va = get_u32(data, sec_off.wrapping_add(12));
let sd_start = va as usize;
let sd_end = if (va.wrapping_add(vsize) as usize) <= data.len() {
va.wrapping_add(vsize) as usize
} else {
data.len()
};
let section_data: &[u8] = if sd_start <= sd_end && sd_start <= data.len() {
&data[sd_start..sd_end]
} else {
&[]
};
let mut last_nonzero: i64 = -1;
for pos in (0..section_data.len()).rev() {
if section_data[pos] != 0 {
last_nonzero = pos as i64;
break;
}
}
let meaningful = if last_nonzero >= 0 {
(last_nonzero + 1) as u32
} else {
0
};
let mut raw_size = if meaningful != 0 {
align_up_u32(meaningful, FILE_ALIGNMENT)
} else {
0
};
if vsize != 0 && raw_size == 0 {
raw_size = FILE_ALIGNMENT;
}
raw_size = raw_size.min(align_up_u32(section_data.len() as u32, FILE_ALIGNMENT));
let raw_ptr = if raw_size != 0 { raw_cursor as u32 } else { 0 };
raw_layout.push(SecLayout {
sec_off,
va,
vsize,
raw_ptr,
raw_size,
});
if raw_size != 0 {
// Accumulate in u64 and cap: section sizes are header-derived, and
// a corrupt table could otherwise wrap raw_cursor (small alloc,
// huge recorded raw_ptrs → OOB panic) or request an abort-sized
// allocation.
raw_cursor = align_up_u64(raw_cursor + raw_size as u64, FILE_ALIGNMENT as u64);
if raw_cursor > MAX_IMAGE_SIZE {
return None;
}
}
}
let mut compact = vec![0u8; raw_cursor as usize];
let hdr_copy = (HEADER_SIZE as usize).min(data.len());
compact[..hdr_copy].copy_from_slice(&data[..hdr_copy]);
write_u32(&mut compact, opt_hdr.wrapping_add(36), FILE_ALIGNMENT);
write_u32(&mut compact, opt_hdr.wrapping_add(60), HEADER_SIZE);
for sl in &raw_layout {
write_u32(&mut compact, sl.sec_off.wrapping_add(16), sl.raw_size);
write_u32(&mut compact, sl.sec_off.wrapping_add(20), sl.raw_ptr);
if sl.raw_size != 0 {
let sd_start = sl.va as usize;
let sd_end = if (sl.va.wrapping_add(sl.vsize) as usize) <= data.len() {
sl.va.wrapping_add(sl.vsize) as usize
} else {
data.len()
};
let section_data: &[u8] = if sd_start <= sd_end {
&data[sd_start..sd_end]
} else {
&[]
};
let copy_size = (sl.raw_size as usize).min(section_data.len());
let rp = sl.raw_ptr as usize;
compact[rp..rp + copy_size].copy_from_slice(&section_data[..copy_size]);
}
}
Some(compact)
}
#[cfg(test)]
mod tests {
use super::*;
/// Review regression: a zero last-section VA (corrupt section table) must
/// bail instead of building .kmiat at RVA 0 — the old code zeroed
/// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image
/// as a success. A near-2 GiB VA must likewise refuse to grow the image
/// past [`MAX_IMAGE_SIZE`].
#[test]
fn kmiat_bogus_section_va_bails_without_wiping_headers() {
for last_sec_va in [0u32, 0x5000_0000] {
let pe: u32 = 0x80;
let mut data = vec![0xAAu8; 0x8000];
// COFF header: 1 section, optional header size 0xE0 (PE32).
write_u16(&mut data, pe + 6, 1);
write_u16(&mut data, pe + 20, 0xE0);
// Import directory at pe+0x80: one descriptor + null terminator.
write_u32(&mut data, pe + 0x80, 0x1100);
write_u32(&mut data, pe + 0x84, 0x28);
write_u32(&mut data, 0x1100, 0x1200); // OFT rva
write_u32(&mut data, 0x1100 + 12, 0x1300); // name rva
write_u32(&mut data, 0x1100 + 16, 0x1400); // IAT rva
for b in &mut data[0x1100 + 20..0x1100 + 40] {
*b = 0; // null terminator descriptor
}
data[0x1300..0x1300 + 13].copy_from_slice(b"KERNEL32.dll\0");
write_u32(&mut data, 0x1200, 0x1500); // thunk -> hint/name
write_u32(&mut data, 0x1204, 0); // thunk terminator
data[0x1500..0x1502].copy_from_slice(&0u16.to_le_bytes());
data[0x1502..0x1502 + 12].copy_from_slice(b"ExitProcess\0");
// Section table at pe+24+0xE0 = 0x178; VA field at +12.
write_u32(&mut data, 0x178 + 12, last_sec_va);
let head_before: Vec<u8> = data[..0x400].to_vec();
let len_before = data.len();
move_pe32_imports_to_kmiat(&mut data, pe);
assert_eq!(
data.len(),
len_before,
"VA 0x{last_sec_va:08X}: image must not grow"
);
assert_eq!(
&data[..0x400],
&head_before[..],
"VA 0x{last_sec_va:08X}: headers must be untouched"
);
}
}
}
+391
View File
@@ -0,0 +1,391 @@
//! PE detection, unpacking, and structural validation.
pub mod dll;
mod error;
pub mod exe;
pub mod integrity;
mod layout;
pub(crate) mod parallel;
use senbei_crypto::primitives;
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
pub use dll::{unpack_dll, unpack_dll_v};
pub use error::*;
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
pub use integrity::{IntegrityReport, check as check_integrity};
pub use parallel::thread_cap;
/// 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 = senbei_crypto::MAX_IMAGE_SIZE;
#[derive(Clone)]
pub(crate) struct PanicCapture(Arc<Mutex<Option<PanicDetails>>>);
#[derive(Clone)]
struct PanicDetails {
message: String,
file: String,
line: u32,
column: u32,
}
thread_local! {
static ACTIVE_PANIC_CAPTURE: RefCell<Option<PanicCapture>> = const { RefCell::new(None) };
}
struct PanicCaptureGuard(Option<PanicCapture>);
impl Drop for PanicCaptureGuard {
fn drop(&mut self) {
ACTIVE_PANIC_CAPTURE.with(|slot| {
slot.replace(self.0.take());
});
}
}
impl PanicCapture {
fn new() -> Self {
Self(Arc::new(Mutex::new(None)))
}
fn record(&self, info: &std::panic::PanicHookInfo<'_>) {
let location = info.location();
let details = PanicDetails {
message: panic_message(info.payload()),
file: location
.map(|value| value.file().to_owned())
.unwrap_or_else(|| "<unknown>".to_owned()),
line: location.map_or(0, std::panic::Location::line),
column: location.map_or(0, std::panic::Location::column),
};
let mut captured = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if captured.is_none() {
*captured = Some(details);
}
}
fn into_error(self, payload: &(dyn std::any::Any + Send)) -> UnpackError {
let details = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.unwrap_or_else(|| PanicDetails {
message: panic_message(payload),
file: "<unknown>".to_owned(),
line: 0,
column: 0,
});
UnpackError::InternalPanic {
message: details.message,
file: details.file,
line: details.line,
column: details.column,
}
}
fn merge_from(&self, other: &Self) {
let details = other
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let Some(details) = details else { return };
let mut captured = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if captured.is_none() {
*captured = Some(details);
}
}
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_owned()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"non-string panic payload".to_owned()
}
}
fn install_panic_capture_hook() {
static INSTALL: std::sync::Once = std::sync::Once::new();
INSTALL.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let capture = ACTIVE_PANIC_CAPTURE
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten();
if let Some(capture) = capture {
capture.record(info);
} else {
previous(info);
}
}));
});
}
pub(crate) fn current_panic_capture() -> Option<PanicCapture> {
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
}
pub(crate) fn with_panic_capture<R>(capture: Option<PanicCapture>, f: impl FnOnce() -> R) -> R {
let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture));
let _guard = PanicCaptureGuard(previous);
f()
}
/// Run an unpack pipeline, converting any internal panic into a clean
/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input
/// (truncated/garbled files chase offsets out of bounds). The panic location and
/// payload are captured for diagnostics without printing 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>,
{
install_panic_capture_hook();
let capture = PanicCapture::new();
let r = with_panic_capture(Some(capture.clone()), || {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
});
match r {
Ok(result) => result,
Err(payload) => Err(capture.into_error(payload.as_ref())),
}
}
/// 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 {
NativeExe,
ManagedExe,
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 the PE signature with checked arithmetic so a crafted offset
// cannot wrap the bounds check on a narrower target.
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 distinguishes managed from native for both.
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;
// 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 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
// 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;
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 = 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 })
}
/// 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::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
// 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,
Err(exe_err) => {
return Err(UnpackError::PipelineFallbackFailed {
dll: Box::new(dll_err),
exe: Box::new(exe_err),
});
}
},
}
}
};
Ok((detected.kind, out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn caught_panic_reports_location_and_message() {
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
panic!("test panic");
})
.expect_err("panic must become an error");
let UnpackError::InternalPanic {
message,
file,
line,
column,
} = error
else {
panic!("unexpected error: {error}");
};
assert_eq!(message, "test panic");
assert!(
file.ends_with("senbei-engine/src/windows/mod.rs")
|| file.ends_with("senbei-engine\\src\\windows\\mod.rs")
);
assert!(line > 0);
assert!(column > 0);
}
#[test]
fn worker_panic_keeps_the_worker_source_location() {
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
let capture = current_panic_capture();
let result = std::thread::spawn(move || {
with_panic_capture(capture, || panic!("worker panic"));
})
.join();
if let Err(payload) = result {
std::panic::resume_unwind(payload);
}
Ok(Vec::new())
})
.expect_err("worker panic must become an error");
let UnpackError::InternalPanic {
message,
file,
line,
column,
} = error
else {
panic!("unexpected error: {error}");
};
assert_eq!(message, "worker panic");
assert!(
file.ends_with("senbei-engine/src/windows/mod.rs")
|| file.ends_with("senbei-engine\\src\\windows\\mod.rs")
);
assert!(line > 0);
assert!(column > 0);
}
}
+240
View File
@@ -0,0 +1,240 @@
//! 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 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::InternalPanic`).
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);
let panic_capture = super::current_panic_capture();
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;
let panic_capture = panic_capture.clone();
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();
// Keep details local until this panic wins `first_panic`;
// otherwise simultaneous workers could pair one worker's
// location with another worker's propagated payload.
let block_capture = panic_capture.as_ref().map(|_| super::PanicCapture::new());
let r = super::with_panic_capture(block_capture.clone(), || {
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() {
if let (Some(parent), Some(block)) =
(&panic_capture, &block_capture)
{
parent.merge_from(block);
}
*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));
}
}