From caadbd5325da4661c8d53a594b681a63a5c3fe1e Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Tue, 11 Aug 2026 19:24:45 +0800 Subject: [PATCH] fix(unpacker): validate executable entry transforms --- senbei-pe/src/engine/exe/pipeline.rs | 58 ++++++-- senbei-pe/src/engine/integrity.rs | 83 ++++++++++++ senbei-pe/src/engine/layout/dd8.rs | 192 ++++++++++++++++++++++++--- 3 files changed, 308 insertions(+), 25 deletions(-) diff --git a/senbei-pe/src/engine/exe/pipeline.rs b/senbei-pe/src/engine/exe/pipeline.rs index cac25da..ffcb61f 100644 --- a/senbei-pe/src/engine/exe/pipeline.rs +++ b/senbei-pe/src/engine/exe/pipeline.rs @@ -831,14 +831,17 @@ impl<'a> Unpacker<'a> { // (i.e., the 4 bytes immediately after the last instance of that pattern). let v6 = find_v_after_pad(&u.decompressed, stage4_field, stage4_dlen) .unwrap_or_else(|| idb_pos.wrapping_sub(24)); - let mut accum2 = get_u32(&u.decompressed, v6); - for m in 0..3u32 { + let accum2_seed = get_u32(&u.decompressed, v6); + let mut accum2 = accum2_seed; + let mut accum2_candidates = vec![(0u32, accum2_seed)]; + for m in 0..8u32 { let bound = (m + 1).wrapping_mul(25) << 2; let mut i: u32 = 1; while i <= bound { accum2 = accum2.wrapping_add(i); i = i.wrapping_add(1); } + accum2_candidates.push((m + 1, accum2)); } let ops1 = match generate(&u.decompressed, data_offset) { @@ -852,19 +855,58 @@ impl<'a> Unpacker<'a> { let at4 = stage1.wrapping_add(stage2_off.wrapping_add(216)); let stage5_field = get_u32(&u.decompressed, at4); - // at4 is a (src, src_len, dest, dest_len) quad; only src and dest_len - // are needed here, the other two are consumed by the decrypt below. + let stage5_slen = get_u32(&u.decompressed, at4.wrapping_add(4)); + let stage5_dest = get_u32(&u.decompressed, at4.wrapping_add(8)); let stage5_dlen = get_u32(&u.decompressed, at4.wrapping_add(12)); if verbose { println!(" stage5 = 0x{:08X}", stage5_field); + println!( + " stage5 descriptor = [0x{stage5_field:08X}, 0x{stage5_slen:08X}, 0x{stage5_dest:08X}, 0x{stage5_dlen:08X}]" + ); + println!(" stage5 bytecode = 0x{data_offset:08X}"); + println!(" stage5 accumulator seed = 0x{accum2_seed:08X} at 0x{v6:08X}"); } - if let Err(reason) = - u.decrypt_and_decompress_data(at4, xor_acc ^ chk4 ^ chk5 ^ accum2, Some(&ops1)) - { + let stage5_lo = stage5_field.min(stage5_dest) as usize; + let stage5_hi = stage5_field + .checked_add(stage5_slen) + .zip(stage5_dest.checked_add(stage5_dlen)) + .map(|(source_end, dest_end)| source_end.max(dest_end) as usize) + .filter(|&end| stage5_lo <= end && end <= u.decompressed.len()) + .ok_or(UnpackError::BufferRangeOutOfBounds { + operation: BufferOperation::Read, + offset: stage5_lo, + size: stage5_slen.max(stage5_dlen) as usize, + buffer_len: u.decompressed.len(), + })?; + let stage5_backup = u.decompressed[stage5_lo..stage5_hi].to_vec(); + let mut first_failure = None; + let mut selected_rounds = None; + for rounds in std::iter::once(3u32).chain((0..=8).filter(|&rounds| rounds != 3)) { + u.decompressed[stage5_lo..stage5_hi].copy_from_slice(&stage5_backup); + let candidate_accum = accum2_candidates[rounds as usize].1; + match u.decrypt_and_decompress_data( + at4, + xor_acc ^ chk4 ^ chk5 ^ candidate_accum, + Some(&ops1), + ) { + Ok(()) => { + selected_rounds = Some(rounds); + break; + } + Err(reason) => { + first_failure.get_or_insert(reason); + } + } + } + let Some(selected_rounds) = selected_rounds else { + u.decompressed[stage5_lo..stage5_hi].copy_from_slice(&stage5_backup); return Err(UnpackError::StageDecompressionFailed { stage: DecompressionStage::ExeStage5, - reason, + reason: first_failure.expect("at least one Stage5 candidate was tried"), }); + }; + if verbose { + println!(" selected stage5 accumulator rounds = {selected_rounds}"); } // Inside stage5, the loader stores a table of (ptr, size) pairs at a diff --git a/senbei-pe/src/engine/integrity.rs b/senbei-pe/src/engine/integrity.rs index b49cecb..c768511 100644 --- a/senbei-pe/src/engine/integrity.rs +++ b/senbei-pe/src/engine/integrity.rs @@ -77,6 +77,49 @@ fn rva_to_off(secs: &[Section], file_len: usize, rva: u32, need: u32) -> Option< 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 { @@ -253,6 +296,9 @@ pub fn check(out: &[u8]) -> IntegrityReport { "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); + } } } } @@ -363,3 +409,40 @@ fn looks_like_dll_name(d: &[u8], off: u32) -> bool { } d[start..end].iter().all(|&b| (0x20..0x7F).contains(&b)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn executable_text() -> Vec
{ + 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()); + } +} diff --git a/senbei-pe/src/engine/layout/dd8.rs b/senbei-pe/src/engine/layout/dd8.rs index fc5aca3..b3faa2b 100644 --- a/senbei-pe/src/engine/layout/dd8.rs +++ b/senbei-pe/src/engine/layout/dd8.rs @@ -1,5 +1,7 @@ //! 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 @@ -118,26 +120,29 @@ pub fn select_dd8_formula_pe32(data: &[u8], text_off: u32, text_size: u32) -> Op // // 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: -// two otherwise-unrelated builds can carry byte-identical config-version stamps -// (0x40327253) yet require different shifts, so the only reliable discriminator -// is the .text content itself. +// 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. // -// Detection scoring formula: for each candidate shift, replay decrypt_data8 -// across a few sample pages (25/50/75% of .text) and count how many of the 255 -// mutated positions become 0xCC — the MSVC int3 padding byte. The correct shift -// hits int3 pads disproportionately often (~3-10x the baseline), so the -// highest-scoring shift wins. If neither shift clears 2x the baseline, .text -// is already plaintext → skip (return 99). +// 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. // -// This replaces an earlier entry-stub oracle that matched the 14 fixed CRT-stub -// bytes at the AEP. That oracle false-positived on a newer EXE-64 build: dd8 -// corrupted only the call rel32 (bytes 5-8, the wildcard region), so the stub -// matched under BOTH shifts and the selector defaulted to 0 when the truth was -// 15. The 0xCC statistic samples hundreds of positions per page and is not -// fooled by a stub whose fixed bytes happen to survive. +// Other entry shapes fall back to padding statistics: replay each shift across +// sample pages and count positions restored to the MSVC int3 padding byte. A +// clear gain selects the shift; otherwise .text is treated as already plain. // --------------------------------------------------------------------------- -pub fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32) -> u32 { +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; + } if text_size < 0x1000 { return 0; } @@ -192,6 +197,123 @@ pub fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32) best_shift } +/// 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 { + 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, +) -> Option { + 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) -> Option { + 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 { + 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 { @@ -250,6 +372,42 @@ fn score_dd8_shift( mod tests { use super::*; + fn entry_stub_fixture() -> Vec { + 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