fix(unpacker): validate executable entry transforms

This commit is contained in:
bfloat16
2026-08-11 19:24:45 +08:00
parent ab1a14c9d1
commit caadbd5325
3 changed files with 308 additions and 25 deletions
+50 -8
View File
@@ -831,14 +831,17 @@ impl<'a> Unpacker<'a> {
// (i.e., the 4 bytes immediately after the last instance of that pattern). // (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) let v6 = find_v_after_pad(&u.decompressed, stage4_field, stage4_dlen)
.unwrap_or_else(|| idb_pos.wrapping_sub(24)); .unwrap_or_else(|| idb_pos.wrapping_sub(24));
let mut accum2 = get_u32(&u.decompressed, v6); let accum2_seed = get_u32(&u.decompressed, v6);
for m in 0..3u32 { 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 bound = (m + 1).wrapping_mul(25) << 2;
let mut i: u32 = 1; let mut i: u32 = 1;
while i <= bound { while i <= bound {
accum2 = accum2.wrapping_add(i); accum2 = accum2.wrapping_add(i);
i = i.wrapping_add(1); i = i.wrapping_add(1);
} }
accum2_candidates.push((m + 1, accum2));
} }
let ops1 = match generate(&u.decompressed, data_offset) { 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 at4 = stage1.wrapping_add(stage2_off.wrapping_add(216));
let stage5_field = get_u32(&u.decompressed, at4); let stage5_field = get_u32(&u.decompressed, at4);
// at4 is a (src, src_len, dest, dest_len) quad; only src and dest_len let stage5_slen = get_u32(&u.decompressed, at4.wrapping_add(4));
// are needed here, the other two are consumed by the decrypt below. let stage5_dest = get_u32(&u.decompressed, at4.wrapping_add(8));
let stage5_dlen = get_u32(&u.decompressed, at4.wrapping_add(12)); let stage5_dlen = get_u32(&u.decompressed, at4.wrapping_add(12));
if verbose { if verbose {
println!(" stage5 = 0x{:08X}", stage5_field); 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) = let stage5_lo = stage5_field.min(stage5_dest) as usize;
u.decrypt_and_decompress_data(at4, xor_acc ^ chk4 ^ chk5 ^ accum2, Some(&ops1)) 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 { return Err(UnpackError::StageDecompressionFailed {
stage: DecompressionStage::ExeStage5, 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 // Inside stage5, the loader stores a table of (ptr, size) pairs at a
+83
View File
@@ -77,6 +77,49 @@ fn rva_to_off(secs: &[Section], file_len: usize, rva: u32, need: u32) -> Option<
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 /// 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. /// loader fault at runtime. `out` is the bytes the unpacker produced.
pub fn check(out: &[u8]) -> IntegrityReport { 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" "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)) 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());
}
}
+175 -17
View File
@@ -1,5 +1,7 @@
//! Validation-driven selection for per-page text transforms. //! 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 /// 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 /// 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 /// 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, // 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 // 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: // shifts are 0 and 15. The shift is NOT stored in any header/config field, so
// two otherwise-unrelated builds can carry byte-identical config-version stamps // the decision must be validated against the resulting .text content.
// (0x40327253) yet require different shifts, so the only reliable discriminator
// is the .text content itself.
// //
// Detection scoring formula: for each candidate shift, replay decrypt_data8 // A recognised CRT entry stub is the strongest oracle: decode skip/0/15 and
// across a few sample pages (25/50/75% of .text) and count how many of the 255 // require both of its direct rel32 branches to land in executable .text. This
// mutated positions become 0xCC — the MSVC int3 padding byte. The correct shift // includes the call/jump displacement bytes themselves; an older entry oracle
// hits int3 pads disproportionately often (~3-10x the baseline), so the // wildcarded those bytes and could accept a stub whose opcodes looked right but
// highest-scoring shift wins. If neither shift clears 2x the baseline, .text // whose branch targets were outside the image.
// is already plaintext → skip (return 99).
// //
// This replaces an earlier entry-stub oracle that matched the 14 fixed CRT-stub // Other entry shapes fall back to padding statistics: replay each shift across
// bytes at the AEP. That oracle false-positived on a newer EXE-64 build: dd8 // sample pages and count positions restored to the MSVC int3 padding byte. A
// corrupted only the call rel32 (bytes 5-8, the wildcard region), so the stub // clear gain selects the shift; otherwise .text is treated as already plain.
// 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.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
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 { if text_size < 0x1000 {
return 0; return 0;
} }
@@ -192,6 +197,123 @@ pub fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32)
best_shift 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<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 // 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. // 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 { fn score_dd8_baseline(data: &[u8], text_off: usize, sample_pages: &[u32]) -> u32 {
@@ -250,6 +372,42 @@ fn score_dd8_shift(
mod tests { mod tests {
use super::*; 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 /// 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 /// the byte that decodes to `0xCC` under the `page+1` formula — i.e. an
/// encrypted `.text` whose plaintext is int3 padding. Positions whose key /// encrypted `.text` whose plaintext is int3 padding. Positions whose key