mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
refactor: consolidate platform engines into senbei-engine
This commit is contained in:
@@ -3,8 +3,7 @@ name = "senbei-pe"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "PE detection, unpacking, and validation for Senbei"
|
||||
description = "PE format parsing and address mapping for Senbei"
|
||||
|
||||
[dependencies]
|
||||
senbei-crypto.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
mod pipeline;
|
||||
|
||||
pub use pipeline::*;
|
||||
@@ -1,789 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
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
@@ -1,448 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//! 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,
|
||||
};
|
||||
@@ -1,609 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
//! 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(§ion_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
//! 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-pe/src/engine/mod.rs")
|
||||
|| file.ends_with("senbei-pe\\src\\engine\\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-pe/src/engine/mod.rs")
|
||||
|| file.ends_with("senbei-pe\\src\\engine\\mod.rs")
|
||||
);
|
||||
assert!(line > 0);
|
||||
assert!(column > 0);
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
+137
-3
@@ -1,5 +1,139 @@
|
||||
//! PE detection, unpacking, and structural validation.
|
||||
//! Basic PE format parsing and address mapping.
|
||||
|
||||
mod engine;
|
||||
use thiserror::Error;
|
||||
|
||||
pub use engine::*;
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("input is not a PE image")]
|
||||
Invalid,
|
||||
#[error("PE range is outside the input")]
|
||||
OutOfBounds,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Section {
|
||||
pub virtual_address: u32,
|
||||
pub virtual_size: u32,
|
||||
pub raw_offset: u32,
|
||||
pub raw_size: u32,
|
||||
pub characteristics: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Headers {
|
||||
pub pe_offset: usize,
|
||||
pub is_pe32_plus: bool,
|
||||
pub image_base: u64,
|
||||
pub size_of_image: u32,
|
||||
pub entry_rva: u32,
|
||||
pub sections_offset: usize,
|
||||
pub sections: u16,
|
||||
}
|
||||
|
||||
pub fn parse(data: &[u8]) -> Result<Headers> {
|
||||
if data.get(0..2) != Some(b"MZ") {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let pe_offset = read_u32(data, 0x3c)? as usize;
|
||||
if data.get(pe_offset..pe_offset + 4) != Some(b"PE\0\0") {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let sections = read_u16(data, pe_offset + 6)?;
|
||||
let optional_size = read_u16(data, pe_offset + 20)? as usize;
|
||||
let optional = pe_offset.checked_add(24).ok_or(Error::OutOfBounds)?;
|
||||
let magic = read_u16(data, optional)?;
|
||||
let is_pe32_plus = magic == 0x20b;
|
||||
if !is_pe32_plus && magic != 0x10b {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let entry_rva = read_u32(data, optional + 16)?;
|
||||
let image_base = if is_pe32_plus {
|
||||
read_u64(data, optional + 24)?
|
||||
} else {
|
||||
read_u32(data, optional + 28)? as u64
|
||||
};
|
||||
let size_of_image = read_u32(data, optional + 56)?;
|
||||
let sections_offset = optional
|
||||
.checked_add(optional_size)
|
||||
.ok_or(Error::OutOfBounds)?;
|
||||
let table_size = usize::from(sections)
|
||||
.checked_mul(40)
|
||||
.ok_or(Error::OutOfBounds)?;
|
||||
data.get(sections_offset..sections_offset + table_size)
|
||||
.ok_or(Error::OutOfBounds)?;
|
||||
Ok(Headers {
|
||||
pe_offset,
|
||||
is_pe32_plus,
|
||||
image_base,
|
||||
size_of_image,
|
||||
entry_rva,
|
||||
sections_offset,
|
||||
sections,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sections(data: &[u8], headers: Headers) -> Result<Vec<Section>> {
|
||||
(0..headers.sections)
|
||||
.map(|index| {
|
||||
let offset = headers
|
||||
.sections_offset
|
||||
.checked_add(usize::from(index) * 40)
|
||||
.ok_or(Error::OutOfBounds)?;
|
||||
Ok(Section {
|
||||
virtual_size: read_u32(data, offset + 8)?,
|
||||
virtual_address: read_u32(data, offset + 12)?,
|
||||
raw_size: read_u32(data, offset + 16)?,
|
||||
raw_offset: read_u32(data, offset + 20)?,
|
||||
characteristics: read_u32(data, offset + 36)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn rva_to_offset(data: &[u8], headers: Headers, rva: u32) -> Result<usize> {
|
||||
if rva < headers.sections_offset as u32 {
|
||||
return Ok(rva as usize);
|
||||
}
|
||||
for section in sections(data, headers)? {
|
||||
let span = section.virtual_size.max(section.raw_size);
|
||||
if rva >= section.virtual_address && rva < section.virtual_address.saturating_add(span) {
|
||||
let offset = section
|
||||
.raw_offset
|
||||
.checked_add(rva - section.virtual_address)
|
||||
.ok_or(Error::OutOfBounds)? as usize;
|
||||
if offset < data.len() {
|
||||
return Ok(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::OutOfBounds)
|
||||
}
|
||||
|
||||
fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
|
||||
let bytes: [u8; 2] = data
|
||||
.get(offset..offset + 2)
|
||||
.ok_or(Error::OutOfBounds)?
|
||||
.try_into()
|
||||
.map_err(|_| Error::OutOfBounds)?;
|
||||
Ok(u16::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||
let bytes: [u8; 4] = data
|
||||
.get(offset..offset + 4)
|
||||
.ok_or(Error::OutOfBounds)?
|
||||
.try_into()
|
||||
.map_err(|_| Error::OutOfBounds)?;
|
||||
Ok(u32::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
fn read_u64(data: &[u8], offset: usize) -> Result<u64> {
|
||||
let bytes: [u8; 8] = data
|
||||
.get(offset..offset + 8)
|
||||
.ok_or(Error::OutOfBounds)?
|
||||
.try_into()
|
||||
.map_err(|_| Error::OutOfBounds)?;
|
||||
Ok(u64::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user