Files
Senbei/senbei-engine/src/windows/layout/discovery.rs
T

508 lines
18 KiB
Rust

//! 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"
);
}
}