//! 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 { let num_pages_total = text_size / 0x1000; let mut sample_pages: Vec = 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 = 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 { 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 { 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 { 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 { 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" ); } }