//! MSVC vtable + RTTI detection. //! //! Heuristic two-pass scan over the binary's read-only data sections. Pass 1 //! finds candidate vtables — runs of ≥3 contiguous big-endian u32 values that //! all land on known function entries. Pass 2 attempts the MSVC RTTI walk //! `vtable[-1] → CompleteObjectLocator → TypeDescriptor → mangled name`. When //! RTTI is stripped (typical for shipped game binaries), each anonymous vtable //! gets a deterministic name `ANON_Class_` keyed by a hash of its //! sorted method PCs (so identical vtables across multiple class instances //! collapse to one entry). //! //! What this module does NOT do: //! - Vtables in heap-allocated memory (built at runtime by ctors) are out of //! scope — only vtables present statically in `.rdata` / `.data`. //! - RTTI inheritance (`BaseClassDescriptor` walk) is best-effort; we record //! the first-level base list when present and leave it NULL otherwise. //! - Multiple-inheritance "extra" vftables (one per base subobject) are //! detected as independent vtables; we don't link them. //! //! Reference: openrce.org "Reversing Microsoft Visual C++" RTTI articles //! (CompleteObjectLocator / TypeDescriptor / BaseClassDescriptor layout). use std::collections::BTreeMap; use xenia_xex::pe::PeSection; use crate::demangle; /// Maximum number of consecutive non-function slots tolerated inside an /// anchor-recovered vtable before the run is considered terminated. MSVC /// vtables can carry null / pure-virtual / unrecognised-thunk slots in their /// head or interior; a small budget lets those through without merging two /// physically-adjacent vtables. Kept small to avoid bridging the gap between /// distinct tables. const MAX_ANCHOR_GAP: usize = 2; /// One detected vtable. #[derive(Debug, Clone)] pub struct Vtable { /// Absolute VA of `vtable[0]` (first method slot). pub address: u32, /// Number of methods in the vtable. pub length: u32, /// Absolute VA of the `CompleteObjectLocator` from `vtable[-1]`, if it /// looked like a valid pointer into `.rdata`. NULL when no RTTI / stripped. pub col_address: Option, /// Class name. Demangled from RTTI when available, otherwise the synthetic /// `ANON_Class_` form. pub class_name: String, /// True when the COL → TypeDescriptor walk succeeded. pub rtti_present: bool, /// First-level base class names from `RTTIClassHierarchyDescriptor`, JSON-encoded. /// `None` when not parseable. pub base_classes_json: Option, /// One entry per slot: function VA in `.text`. pub methods: Vec, } /// Run the vtable scan + RTTI walk. `function_starts` is the set of valid /// `.text` function entry VAs from M1's corrected `functions` table. #[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] pub fn analyze( pe: &[u8], image_base: u32, sections: &[PeSection], function_starts: &std::collections::BTreeSet, ) -> Vec { analyze_with_anchors(pe, image_base, sections, function_starts, &std::collections::BTreeSet::new()) } /// Like [`analyze`], but additionally recovers vtables whose base address is /// known a-priori from a constructor vptr-write store (an "anchor"). The /// contiguity heuristic in pass 1 fragments any vtable whose head region /// contains words that don't resolve to recognised function entries (null / /// pure-virtual / unrecognised thunk slots); those vtables are never emitted /// and the downstream typed-dispatch resolver can't type objects of that /// class. An anchor is a *content-independent* vtable signal — the ctor /// literally installs `vtable_base` into `this+0` via /// `addis/addi (or lis/ori) → stw rX, 0(rThis)` — so for every anchor not /// already covered by a pass-1 run we synthesise a vtable starting at that /// base, reading the fnptr-array run while *tolerating* up to /// [`MAX_ANCHOR_GAP`] consecutive non-function slots before terminating. /// /// `anchors` are absolute VAs of vtable bases (from /// [`scan_vptr_write_constants`]). Existing pass-1 vtables are kept unchanged /// (no regression): an anchor that already coincides with a detected vtable /// base is skipped, and an anchor that lands *inside* an existing run is also /// skipped (it's a sub-object pointer, not a fresh table). #[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))] pub fn analyze_with_anchors( pe: &[u8], image_base: u32, sections: &[PeSection], function_starts: &std::collections::BTreeSet, anchors: &std::collections::BTreeSet, ) -> Vec { let started = std::time::Instant::now(); // Sections we'll scan for vtable bodies. let scan_targets: Vec<&PeSection> = sections .iter() .filter(|s| matches!(s.name.as_str(), ".rdata" | ".data")) .collect(); // Range table for "is this VA in .rdata or .data?" let rdata_ranges: Vec<(u32, u32)> = sections .iter() .filter(|s| s.name == ".rdata") .map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size)) .collect(); let mut candidates: Vec = Vec::new(); for section in scan_targets { let va_start = image_base + section.virtual_address; let va_end = va_start + section.virtual_size; let raw_start = section.virtual_address as usize; let raw_end = (section.virtual_address + section.virtual_size) as usize; if raw_end > pe.len() { continue; } let bytes = &pe[raw_start..raw_end.min(pe.len())]; let mut i = 0usize; while i + 12 <= bytes.len() { // Try to start a run at this 4-aligned offset. if !i.is_multiple_of(4) { i += 1; continue; } let mut run_len = 0usize; let mut methods: Vec = Vec::new(); let mut j = i; while j + 4 <= bytes.len() { let val = u32::from_be_bytes([bytes[j], bytes[j + 1], bytes[j + 2], bytes[j + 3]]); if function_starts.contains(&val) { methods.push(val); run_len += 1; j += 4; } else { break; } } if run_len >= 3 { let address = va_start + (i as u32); candidates.push(Vtable { address, length: run_len as u32, col_address: None, class_name: synth_anon_name(&methods), rtti_present: false, base_classes_json: None, methods, }); i += run_len * 4; } else { i += 4; } } let _ = (va_start, va_end); } // --- Anchor-driven recovery (vptr-write-anchored vtables) --- // // Build a coverage interval set from pass-1 runs so we don't re-emit a // table for an anchor that already lies within an extracted vtable. let mut covered: Vec<(u32, u32)> = candidates .iter() .map(|v| (v.address, v.address + v.length * 4)) .collect(); covered.sort_unstable(); let is_covered = |addr: u32, covered: &[(u32, u32)]| -> bool { covered.iter().any(|&(s, e)| addr >= s && addr < e) }; // Section lookup for "which scan target contains this VA?" let scan_targets_va: Vec<(u32, u32, usize, usize)> = sections .iter() .filter(|s| matches!(s.name.as_str(), ".rdata" | ".data")) .map(|s| { let va = image_base + s.virtual_address; ( va, va + s.virtual_size, s.virtual_address as usize, (s.virtual_address + s.virtual_size) as usize, ) }) .collect(); // Cap a recovered run at the *next anchor* so two physically-adjacent // anchored vtables don't merge. We deliberately do NOT cap at pass-1 // fragments: a fragment is a sub-run the contiguity scan carved out of a // larger table, and the anchor legitimately re-absorbs it (subsumed // fragments are removed afterwards). let anchor_bases: std::collections::BTreeSet = anchors.iter().copied().collect(); let mut recovered = 0usize; let mut newly: Vec = Vec::new(); for &anchor in anchors { if is_covered(anchor, &covered) { continue; } // Locate the containing .rdata/.data section. let Some(&(va_lo, va_hi, raw_lo, raw_hi)) = scan_targets_va.iter().find(|&&(lo, hi, _, _)| anchor >= lo && anchor < hi) else { continue }; if anchor % 4 != 0 { continue; } let raw_hi = raw_hi.min(pe.len()); // Read the fnptr-array run starting at the anchor. Tolerate small // gaps of non-function slots (null / pure-virtual / unrecognised), // but require the run to actually contain at least one real function // (otherwise it's just data, not a vtable). let next_base = anchor_bases.range((anchor + 4)..).next().copied(); let mut methods: Vec = Vec::new(); let mut gap = 0usize; let mut real_fns = 0usize; let mut off = (anchor - va_lo) as usize + raw_lo; let mut va = anchor; while off + 4 <= raw_hi && va < va_hi { if let Some(nb) = next_base && va >= nb { break; } let val = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]); if function_starts.contains(&val) { methods.push(val); real_fns += 1; gap = 0; } else { // A non-function slot. Keep the slot (so downstream slot // indexing stays aligned) but count toward the gap budget. gap += 1; if gap > MAX_ANCHOR_GAP { // Drop the trailing gap slots — they belong past the // table's end. methods.truncate(methods.len().saturating_sub(gap - 1)); break; } methods.push(val); } off += 4; va += 4; } // Trim any trailing non-function slots (the table ends at its last // real method). while methods.last().is_some_and(|&m| !function_starts.contains(&m)) { methods.pop(); } if real_fns == 0 || methods.is_empty() { continue; } let length = methods.len() as u32; newly.push(Vtable { address: anchor, length, col_address: None, class_name: synth_anon_name(&methods), rtti_present: false, base_classes_json: None, methods, }); recovered += 1; } if recovered > 0 { // Drop pass-1 fragments fully subsumed by a recovered (anchored) // vtable — the anchor base is authoritative and the fragment was a // contiguity-scan artifact of the same table. Keep fragments that // only partially overlap (defensive; shouldn't happen for true // sub-runs) so we never lose method coverage. let recovered_spans: Vec<(u32, u32)> = newly.iter().map(|v| (v.address, v.address + v.length * 4)).collect(); candidates.retain(|v| { !recovered_spans .iter() .any(|&(s, e)| v.address >= s && v.address + v.length * 4 <= e) }); candidates.extend(newly); tracing::info!(recovered, "vtables recovered from vptr-write anchors"); } let _ = &covered; // RTTI walk: for each candidate, look at vtable[-1]. let pe_image_base = image_base; for v in &mut candidates { if v.address < 4 { continue; } let col_off = (v.address - pe_image_base - 4) as usize; if col_off + 4 > pe.len() { continue; } let col_ptr = u32::from_be_bytes([pe[col_off], pe[col_off + 1], pe[col_off + 2], pe[col_off + 3]]); if col_ptr == 0 { continue; } if !is_in_ranges(col_ptr, &rdata_ranges) { continue; } // Try to extract the TypeDescriptor mangled-name string. if let Some((td_ptr, hierarchy_ptr)) = read_col(pe, image_base, col_ptr) && let Some(mangled) = read_typedescriptor_name(pe, image_base, td_ptr, &rdata_ranges) && let Some(class) = demangle_rtti_typename(&mangled) { v.col_address = Some(col_ptr); v.class_name = class; v.rtti_present = true; v.base_classes_json = read_class_hierarchy(pe, image_base, hierarchy_ptr, &rdata_ranges); } } let elapsed_ms = started.elapsed().as_millis() as f64; let rtti_count = candidates.iter().filter(|v| v.rtti_present).count(); metrics::histogram!("analysis.phase_ms", "phase" => "vtables").record(elapsed_ms); tracing::info!( vtables = candidates.len(), rtti = rtti_count, anon = candidates.len() - rtti_count, elapsed_ms, "vtable scan complete" ); candidates } fn is_in_ranges(addr: u32, ranges: &[(u32, u32)]) -> bool { ranges.iter().any(|&(s, e)| addr >= s && addr < e) } /// Read 4 big-endian bytes at absolute VA `addr` from the PE image. fn read_be_u32(pe: &[u8], image_base: u32, addr: u32) -> Option { let off = addr.wrapping_sub(image_base) as usize; if off + 4 > pe.len() { return None; } Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) } /// Parse a `CompleteObjectLocator` at VA `col`. Returns /// `(type_descriptor_ptr, class_hierarchy_descriptor_ptr)` on success. /// /// Layout (32-bit MSVC): /// ```text /// +0x00 signature (0 for x86 without /GR-, can be 1) /// +0x04 offset within complete object /// +0x08 cdOffset (this-pointer adjuster) /// +0x0C TypeDescriptor * /// +0x10 RTTIClassHierarchyDescriptor * /// ``` fn read_col(pe: &[u8], image_base: u32, col: u32) -> Option<(u32, u32)> { let td = read_be_u32(pe, image_base, col + 0x0C)?; let chd = read_be_u32(pe, image_base, col + 0x10)?; if td == 0 { return None; } Some((td, chd)) } /// Read a TypeDescriptor's mangled-name string at VA `td`. /// /// Layout: `+0x00` vftable ptr, `+0x04` "spare", `+0x08` zero-terminated /// mangled name (e.g. `.?AVClassName@@`). fn read_typedescriptor_name( pe: &[u8], image_base: u32, td: u32, rdata_ranges: &[(u32, u32)], ) -> Option { if !is_in_ranges(td, rdata_ranges) { return None; } let name_va = td + 0x08; let off = name_va.wrapping_sub(image_base) as usize; if off + 1 > pe.len() { return None; } // Read up to 256 bytes or until NUL. let mut end = off; while end < pe.len().min(off + 256) && pe[end] != 0 { end += 1; } if end == off { return None; } let s = std::str::from_utf8(&pe[off..end]).ok()?; // Sanity: MSVC RTTI names always start with `.?A`. if !s.starts_with(".?A") { return None; } Some(s.to_string()) } /// Demangle an RTTI type-name string of the form `.?AVClassName@ns@@`. /// MSVC convention: leading `.` is the marker for an RTTI string; strip it /// before passing to the demangler. fn demangle_rtti_typename(rtti_name: &str) -> Option { let stripped = rtti_name.strip_prefix('.')?; let raw = msvc_demangler::demangle(stripped, msvc_demangler::DemangleFlags::llvm()).ok()?; // Output looks like `class xe::apu::AudioSystem` or `struct foo::Bar`. let cls = raw .strip_prefix("class ") .or_else(|| raw.strip_prefix("struct ")) .or_else(|| raw.strip_prefix("union ")) .unwrap_or(&raw); Some(cls.to_string()) } /// Best-effort `RTTIClassHierarchyDescriptor` walk: read the /// `BaseClassArray` entries and demangle each base's TypeDescriptor name. /// Returns a JSON array string on success. /// /// Layout: /// ```text /// RTTIClassHierarchyDescriptor: /// +0x00 signature /// +0x04 attributes /// +0x08 numBaseClasses /// +0x0C BaseClassArray * (-> array of BaseClassDescriptor *) /// BaseClassDescriptor: /// +0x00 TypeDescriptor * /// +0x04 numContainedBases /// ... /// ``` fn read_class_hierarchy( pe: &[u8], image_base: u32, chd: u32, rdata_ranges: &[(u32, u32)], ) -> Option { if !is_in_ranges(chd, rdata_ranges) { return None; } let num_bases = read_be_u32(pe, image_base, chd + 0x08)?; if num_bases == 0 || num_bases > 256 { return None; } // sanity cap let bca_ptr = read_be_u32(pe, image_base, chd + 0x0C)?; if !is_in_ranges(bca_ptr, rdata_ranges) { return None; } let mut names: Vec = Vec::new(); for i in 0..num_bases { let bcd_ptr = match read_be_u32(pe, image_base, bca_ptr + i * 4) { Some(p) if is_in_ranges(p, rdata_ranges) => p, _ => return None, }; let td_ptr = match read_be_u32(pe, image_base, bcd_ptr) { Some(p) if is_in_ranges(p, rdata_ranges) => p, _ => return None, }; let mangled = match read_typedescriptor_name(pe, image_base, td_ptr, rdata_ranges) { Some(s) => s, None => return None, }; let cls = demangle_rtti_typename(&mangled).unwrap_or(mangled); names.push(cls); } serde_json::to_string(&names).ok() } /// Pre-pass: discover candidate vtable *bases* from constructor vptr-write /// stores, independent of the static contiguity heuristic. A vptr install is /// the canonical `addis/addi` (or `lis/ori`) immediate build of a constant /// pointing into `.rdata` / `.data`, followed by `stw rX, 0(rThis)` — i.e. the /// ctor writing the vtable pointer to `this+0`. We return the set of such /// constants; these are fed to [`analyze_with_anchors`] so a vtable with /// non-function head words isn't lost. /// /// We only consider stores at displacement 0 (the primary vptr; secondary /// MI vptrs land at non-zero offsets and are handled by the existing /// contiguity scan / typed-dispatch resolver well enough). The register /// tracker mirrors the lis+addi propagation used elsewhere and is reset at /// every basic-block boundary (`block_boundaries`). pub fn scan_vptr_write_constants( pe: &[u8], image_base: u32, functions: &std::collections::BTreeMap, // start -> (end, is_saverestore) sections: &[PeSection], block_boundaries: &std::collections::HashSet, ) -> std::collections::BTreeSet { // Ranges that a vtable base may legitimately live in. let data_ranges: Vec<(u32, u32)> = sections .iter() .filter(|s| matches!(s.name.as_str(), ".rdata" | ".data")) .map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size)) .collect(); let in_data = |a: u32| data_ranges.iter().any(|&(s, e)| a >= s && a < e); const OP_ADDI: u32 = 14; const OP_ADDIS: u32 = 15; const OP_ORI: u32 = 24; const OP_STW: u32 = 36; const OP_X_FORM: u32 = 31; let read = |addr: u32| -> Option { let off = addr.wrapping_sub(image_base) as usize; if off + 4 > pe.len() { return None; } Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]])) }; let mut anchors: std::collections::BTreeSet = std::collections::BTreeSet::new(); for (&fn_start, &(fn_end, is_saverestore)) in functions { if is_saverestore { continue; } let mut reg: [Option; 32] = [None; 32]; let mut pc = fn_start; while pc < fn_end { if pc != fn_start && block_boundaries.contains(&pc) { reg = [None; 32]; } let Some(instr) = read(pc) else { break }; let op = instr >> 26; let rd = ((instr >> 21) & 0x1F) as usize; let ra = ((instr >> 16) & 0x1F) as usize; let simm = ((instr & 0xFFFF) as i16) as i32; let uimm = instr & 0xFFFF; match op { OP_ADDIS if ra == 0 => reg[rd] = Some(uimm << 16), OP_ADDIS => reg[rd] = reg[ra].map(|b| b.wrapping_add(uimm << 16)), OP_ADDI if ra != 0 => reg[rd] = reg[ra].map(|b| b.wrapping_add(simm as u32)), OP_ADDI => reg[rd] = Some(simm as u32), OP_ORI => { let rs = rd; reg[ra] = reg[rs].map(|b| b | uimm); } OP_STW => { // `stw rS, off(rA)` with displacement 0 = primary vptr install. if ra != 0 && simm == 0 && let Some(val) = reg[rd] && in_data(val) { anchors.insert(val); } } 32..=35 | 40..=43 | 48..=51 => reg[rd] = None, OP_X_FORM => { let xo = (instr >> 1) & 0x3FF; if xo != 444 && xo != 467 { reg[rd] = None; } // keep `or`(444=mr)/`mtspr`-ish } 18 | 16 => { if (instr & 1) != 0 { for r in 0..=12 { reg[r] = None; } } } _ => {} } pc = pc.wrapping_add(4); } } anchors } /// Synthetic name for an RTTI-stripped vtable, derived from a stable hash of /// the sorted method-PC list. Two vtables with identical method ordering /// collapse to the same anonymous name. fn synth_anon_name(methods: &[u32]) -> String { // FNV-1a 64-bit on the sorted PC list; we only use 32 bits for brevity. let mut sorted = methods.to_vec(); sorted.sort_unstable(); let mut h: u64 = 0xcbf29ce484222325; for pc in &sorted { for b in pc.to_le_bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); } } format!("ANON_Class_{:08X}", (h as u32)) } /// Build the per-method `(vtable_address, slot, function_address)` list for /// DB insertion, with optional demangled-name lookup for any function that /// has a matching `?…` label. Skips slots whose function isn't in the /// supplied label map. pub fn methods_table( vtables: &[Vtable], labels: &std::collections::HashMap, ) -> Vec<(u32, u32, u32, Option, Option)> { let mut out = Vec::new(); for v in vtables { for (slot, &fn_va) in v.methods.iter().enumerate() { let label = labels.get(&fn_va).cloned(); let demangled = label.as_ref() .and_then(|l| demangle::demangle(l).map(|d| d.raw_demangled)); out.push((v.address, slot as u32, fn_va, label, demangled)); } } out } /// Build a `class_name → Vtable` summary for the `classes` table. Multiple /// vtables sharing the same class name (multiple instances at link time) /// collapse via `BTreeMap` — the first detected vtable wins. pub fn classes_table(vtables: &[Vtable]) -> Vec<(String, u32, bool, Option)> { let mut by_name: BTreeMap = BTreeMap::new(); for v in vtables { by_name.entry(v.class_name.clone()).or_insert(v); } by_name .into_iter() .map(|(name, v)| (name, v.address, v.rtti_present, v.base_classes_json.clone())) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn synth_anon_name_is_stable() { let a = synth_anon_name(&[0x82001000, 0x82001100, 0x82001200]); let b = synth_anon_name(&[0x82001200, 0x82001000, 0x82001100]); assert_eq!(a, b, "anon name must be order-independent"); } #[test] fn synth_anon_name_differs_for_different_methods() { let a = synth_anon_name(&[0x82001000, 0x82001100]); let b = synth_anon_name(&[0x82002000, 0x82002100]); assert_ne!(a, b); } #[test] fn detects_3_method_vtable_in_rdata() { let image_base = 0x82000000u32; let rdata_va = 0x1000u32; let text_va = 0x2000u32; let rdata_size = 16u32; let text_size = 0x100u32; // PE buffer big enough for both sections. let total = (text_va + text_size) as usize; let mut pe = vec![0u8; total]; // Vtable: 3 method PCs at .rdata start, all valid function entries. let m: [u32; 3] = [image_base + text_va, image_base + text_va + 0x10, image_base + text_va + 0x20]; for (i, val) in m.iter().enumerate() { pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4] .copy_from_slice(&val.to_be_bytes()); } let sections = vec![ PeSection { name: ".rdata".into(), virtual_address: rdata_va, virtual_size: rdata_size, raw_offset: rdata_va, raw_size: rdata_size, flags: 0x4000_0040, }, PeSection { name: ".text".into(), virtual_address: text_va, virtual_size: text_size, raw_offset: text_va, raw_size: text_size, flags: 0x6000_0020, }, ]; let mut function_starts = std::collections::BTreeSet::new(); for &pc in &m { function_starts.insert(pc); } let vtables = analyze(&pe, image_base, §ions, &function_starts); assert_eq!(vtables.len(), 1); assert_eq!(vtables[0].length, 3); assert_eq!(vtables[0].address, image_base + rdata_va); assert!(vtables[0].class_name.starts_with("ANON_Class_")); assert!(!vtables[0].rtti_present); } #[test] fn anchor_recovers_vtable_with_nonfn_head() { // A vtable whose head has a null + an unrecognised word, so the // contiguity scan (≥3 contiguous known fns) fragments it. The anchor // (from a ctor vptr-write) must recover the whole table from its base. let image_base = 0x82000000u32; let rdata_va = 0x1000u32; let text_va = 0x2000u32; let rdata_size = 0x40u32; let text_size = 0x100u32; let total = (text_va + text_size) as usize; let mut pe = vec![0u8; total]; let f0 = image_base + text_va; let f1 = image_base + text_va + 0x10; let f2 = image_base + text_va + 0x20; // Slots: [null, NONFN(0xDEAD), f0, f1, f2] let slots: [u32; 5] = [0, 0xDEADBEEF, f0, f1, f2]; for (i, val) in slots.iter().enumerate() { pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4] .copy_from_slice(&val.to_be_bytes()); } let sections = vec![ PeSection { name: ".rdata".into(), virtual_address: rdata_va, virtual_size: rdata_size, raw_offset: rdata_va, raw_size: rdata_size, flags: 0x4000_0040, }, PeSection { name: ".text".into(), virtual_address: text_va, virtual_size: text_size, raw_offset: text_va, raw_size: text_size, flags: 0x6000_0020, }, ]; let mut function_starts = std::collections::BTreeSet::new(); for &pc in &[f0, f1, f2] { function_starts.insert(pc); } // Without an anchor: the head gap (null + nonfn = 2 slots) means the // contiguous run is only [f0,f1,f2]=3 starting at +0x08, so pass-1 // still finds it but at the WRONG base (0x...1008), not the true base. let no_anchor = analyze(&pe, image_base, §ions, &function_starts); assert!( !no_anchor.iter().any(|v| v.address == image_base + rdata_va), "without anchor the table is not recovered at its true base" ); // With the anchor at the true base: let mut anchors = std::collections::BTreeSet::new(); anchors.insert(image_base + rdata_va); let with_anchor = analyze_with_anchors(&pe, image_base, §ions, &function_starts, &anchors); let v = with_anchor .iter() .find(|v| v.address == image_base + rdata_va) .expect("anchor must recover vtable at its true base"); // length spans through f2 (slot 4): 5 slots. assert_eq!(v.length, 5, "table spans null/nonfn head through last fn"); assert_eq!(v.methods[2], f0); assert_eq!(v.methods[4], f2); } #[test] fn scan_vptr_write_constants_finds_ctor_store() { // Encode a ctor: addis r11,r0,0x8201; addi r11,r11,lo; stw r11,0(r31) // installing vtable base 0x8200A908 into this+0. let image_base = 0x82000000u32; let ctor = 0x82001000u32; let mut pe = vec![0u8; 0x4000]; // Lay out a tiny .rdata at 0x...A900 so the constant lands in-range. let vt_base = 0x8200A908u32; // 0x82010000 - 22264 let addis = (15u32 << 26) | (11 << 21) | (0 << 16) | 0x8201; let lo = (vt_base & 0xFFFF) as i16; // -22264 let addi = (14u32 << 26) | (11 << 21) | (0 << 16) | ((lo as u16) as u32); // addi r11,r0,lo would set r11=lo (sign-extended); we need addis+addi // chained. Re-encode addis into r11 from r0, then addi r11,r11,lo. let addi2 = (14u32 << 26) | (11 << 21) | (11 << 16) | ((lo as u16) as u32); let stw = (36u32 << 26) | (11 << 21) | (31 << 16) | 0; // stw r11,0(r31) let at = (ctor - image_base) as usize; pe[at..at + 4].copy_from_slice(&addis.to_be_bytes()); pe[at + 4..at + 8].copy_from_slice(&addi2.to_be_bytes()); pe[at + 8..at + 12].copy_from_slice(&stw.to_be_bytes()); let _ = addi; let sections = vec![PeSection { name: ".rdata".into(), virtual_address: 0xA900, virtual_size: 0x200, raw_offset: 0xA900, raw_size: 0x200, flags: 0x4000_0040, }]; let mut funcs: std::collections::BTreeMap = std::collections::BTreeMap::new(); funcs.insert(ctor, (ctor + 0x40, false)); let anchors = scan_vptr_write_constants( &pe, image_base, &funcs, §ions, &std::collections::HashSet::new(), ); assert!(anchors.contains(&vt_base), "ctor vptr store must yield anchor {vt_base:#x}, got {anchors:?}"); } #[test] fn rejects_2_method_run() { let image_base = 0x82000000u32; let rdata_va = 0x1000u32; let text_va = 0x2000u32; let total = (text_va + 0x100) as usize; let mut pe = vec![0u8; total]; let m: [u32; 2] = [image_base + text_va, image_base + text_va + 0x10]; for (i, val) in m.iter().enumerate() { pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4] .copy_from_slice(&val.to_be_bytes()); } let sections = vec![ PeSection { name: ".rdata".into(), virtual_address: rdata_va, virtual_size: 8, raw_offset: rdata_va, raw_size: 8, flags: 0x4000_0040, }, PeSection { name: ".text".into(), virtual_address: text_va, virtual_size: 0x100, raw_offset: text_va, raw_size: 0x100, flags: 0x6000_0020, }, ]; let mut function_starts = std::collections::BTreeSet::new(); for &pc in &m { function_starts.insert(pc); } let vtables = analyze(&pe, image_base, §ions, &function_starts); assert_eq!(vtables.len(), 0, "runs of 2 must be rejected to keep false-positive rate down"); } }