M5+M7: indirect-dispatch reachability + .rdata string detection

Two MEDIUM milestones bundled (both opportunistic per plan; both small).

## M5 — indirect-dispatch reachability

- `xenia_analysis::indirect`: per-basic-block register tracker over each
  detected function. Recognises the canonical static-vtable pattern
  `lis+addi → lwz off(rA) → mtctr → bcctrl` where rA holds a known M3
  vtable address. Emits one `Xref { kind: IndirectCall }` per resolvable
  bcctrl site.
- PowerPC ABI awareness: `bl`-style calls clobber volatile r0..r12 + ctr
  but preserve non-volatile r13..r31, so a vtable pointer parked in r30/r31
  before a call survives.
- Label-based basic-block boundaries kill register state — bounds
  false-positive risk for jump-IN paths.
- New `XrefKind::IndirectCall` variant (DB tag `'ind_call'`).
- New SQL view `v_indirect_reachability_from_entry` — strict superset of
  `v_reachability_from_entry`, taking `ind_call` edges in the BFS.

Sylpheed yield: 0 edges detected. The binary's 1,001 static lis+addi
references into vtables are nearly all constructor-side vptr writes, not
dispatches; real method dispatch goes through `this->vptr` which requires
alias analysis we explicitly don't do. Documented in SCHEMA.md as the
expected limitation. Three unit tests cover the synthetic-correctness path.

## M7 — string / constant-pool detection

- `xenia_analysis::strings`: scans `.rdata` for runs of ≥ 6 printable
  ASCII bytes (NUL-terminated) and ≥ 6 UTF-16LE code units (basic-plane
  printable ASCII, NUL u16 terminator).
- New `strings(address PK, encoding, length, content)` table + encoding index.
- Implicit cross-ref via existing `xrefs.kind='ref'` rows whose target
  matches a strings.address.

Sylpheed yield: 6,311 ASCII strings (including embedded HLSL shader source
and AS_CB_SURFACE_SWIZZLE_* assertion strings). 9,132 lis+addi sites
cross-reference detected strings — names source PCs near each string in
one query. Four unit tests cover encoding detection, NUL termination, and
short-run rejection.

Tests 626→633 (+3 indirect, +4 strings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-08 21:22:50 +02:00
parent 0209e88f0a
commit ab4fe211e5
9 changed files with 852 additions and 19 deletions

View File

@@ -0,0 +1,468 @@
//! Indirect-dispatch reachability for vtable-bound `bcctrl`/`bctrl` sites.
//!
//! Walks each detected function with a tiny per-basic-block register tracker,
//! recognising the canonical MSVC PowerPC pattern that loads a slot from a
//! statically-addressed vtable into CTR and indirectly calls it:
//!
//! ```text
//! lis rA, hi
//! addi rA, rA, lo ; rA = vtable_address
//! lwz rB, slot*4(rA) ; rB = vtable[slot]
//! mtctr rB ; CTR = vtable[slot]
//! bcctrl ; indirect call → vtable[slot]
//! ```
//!
//! Pattern hits are emitted as `(source_pc, target_pc)` pairs that callers
//! insert into the `xrefs` table with `kind='ind_call'`.
//!
//! ### What this does NOT cover
//!
//! - Vtable pointer loaded from a `this`-pointer field (`lwz rA, off(this)`)
//! is the dominant pattern in real C++ code; resolving it requires
//! alias / points-to analysis that's far beyond this layer's scope.
//! - Indirect calls via function-pointer fields (callbacks) are similarly
//! unresolvable without object-flow analysis.
//! - Register state is intentionally killed at every label (basic-block
//! boundary) — we don't try to do flow-sensitive merging across joins.
//!
//! Reference: IBM PowerPC ABI on register-save convention, plus the
//! `xenia_analysis::xref` `lis+addi`/`lis+ori` tracker which we mirror
//! conceptually.
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::func::FuncAnalysis;
use crate::vtables::Vtable;
/// One detected indirect-call edge: `bcctrl` at `source` jumps to `target`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IndirectEdge {
pub source: u32,
pub target: u32,
/// Vtable the source resolved through.
pub via_vtable: u32,
/// Method slot index within the vtable.
pub slot: u32,
}
#[derive(Debug, Clone, Copy)]
enum RegVal {
/// Register holds a known constant (e.g. after `lis+addi`).
Const(u32),
/// Register holds a method pointer loaded from a known vtable slot.
MethodPtr {
vtable_addr: u32,
slot: u32,
method_pc: u32,
},
}
const OP_ADDI: u32 = 14;
const OP_ADDIS: u32 = 15;
const OP_BCCTR: u32 = 19; // also covers blr — distinguish via XO
const OP_LWZ: u32 = 32;
const OP_ORI: u32 = 24;
const OP_X_FORM: u32 = 31; // mtspr / mr / etc.
/// Run the static indirect-dispatch scan. Returns one edge per resolvable
/// `bcctrl` site.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(
pe: &[u8],
image_base: u32,
func_analysis: &FuncAnalysis,
vtables: &[Vtable],
labels: &HashMap<u32, String>,
) -> Vec<IndirectEdge> {
let started = std::time::Instant::now();
// Index vtables by their start VA so the lwz handler can decide
// whether a given Const(addr) is "really" a vtable.
let vtable_by_addr: BTreeMap<u32, &Vtable> =
vtables.iter().map(|v| (v.address, v)).collect();
// Set of all "label"-bearing PCs in the analyzed binary. We treat each
// label as a basic-block boundary (anything `loc_*` is a jump target,
// so register state arriving at it is unreliable).
let mut block_boundaries: HashSet<u32> = HashSet::with_capacity(labels.len());
for &addr in labels.keys() {
block_boundaries.insert(addr);
}
let mut edges: Vec<IndirectEdge> = Vec::new();
for (&fn_start, fi) in &func_analysis.functions {
if fi.is_saverestore { continue; }
let mut reg: [Option<RegVal>; 32] = [None; 32];
let mut ctr: Option<RegVal> = None;
let mut pc = fn_start;
while pc < fi.end {
// Reset register state on basic-block entry. We don't reset on
// the function entry itself (PC == fn_start) because labels and
// function-starts coincide; the initial state is already None.
if pc != fn_start && block_boundaries.contains(&pc) {
reg = [None; 32];
ctr = None;
}
let instr = match read_instr(pe, image_base, pc) {
Some(i) => i,
None => 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 {
// lis rD, IMM (== addis rD, r0, IMM)
OP_ADDIS if ra == 0 => {
reg[rd] = Some(RegVal::Const(uimm << 16));
}
// addis rD, rA, IMM
OP_ADDIS => {
if let Some(RegVal::Const(b)) = reg[ra] {
reg[rd] = Some(RegVal::Const(b.wrapping_add(uimm << 16)));
} else {
reg[rd] = None;
}
}
// addi rD, rA, IMM
OP_ADDI if ra != 0 => {
if let Some(RegVal::Const(b)) = reg[ra] {
reg[rd] = Some(RegVal::Const(b.wrapping_add(simm as u32)));
} else {
reg[rd] = None;
}
}
// li rD, IMM (== addi rD, 0, IMM)
OP_ADDI => {
reg[rd] = Some(RegVal::Const(simm as u32));
}
// ori rA, rS, IMM — note operand order: bits 21..25 = rS, 16..20 = rA
OP_ORI => {
let rs = rd; // bits 21..25 = source
if let Some(RegVal::Const(b)) = reg[rs] {
reg[ra] = Some(RegVal::Const(b | uimm));
} else {
reg[ra] = None;
}
}
// lwz rD, off(rA) — try to resolve as vtable slot load.
OP_LWZ => {
if ra != 0
&& let Some(RegVal::Const(base)) = reg[ra]
{
let target = base.wrapping_add(simm as u32);
// Two-step lookup so we accept both:
// (a) base = exact vtable head, simm/4 = slot
// (b) base + simm = exact vtable head (rare;
// compiler hoists the slot offset into addi)
let resolved = resolve_vtable_slot(target, &vtable_by_addr)
.or_else(|| resolve_vtable_slot_via_off(base, simm, &vtable_by_addr));
reg[rd] = resolved.map(|(vt, slot, pc)| RegVal::MethodPtr {
vtable_addr: vt, slot, method_pc: pc,
});
} else {
reg[rd] = None;
}
}
// X-form: mtspr/mtctr, bcctrl, mr, etc.
OP_X_FORM => {
let xo = (instr >> 1) & 0x3FF;
match xo {
467 => {
// mtspr SPR, rS — PPC SPR field is split: high 5 bits
// in PPC bits 16:20 (= Rust bits 11..15), low 5 bits
// in PPC bits 11:15 (= Rust bits 16..20). Mirrors
// the convention in `func.rs::is_mfspr_lr`.
let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F);
if spr == 9 {
ctr = reg[rd];
}
// Otherwise no observable effect on tracked state.
}
// Anything that writes rD (most arithmetic, loads, etc.) clobbers it.
// Conservative: invalidate rD on any X-form that has rD in bits 21..25
// and is NOT a comparison or branch.
_ => {
// Heuristic: most X-form ops with non-zero RC encode rD; we
// invalidate to avoid stale Const propagation past arithmetic.
// This is over-eager but safe (false negatives on edges, never
// false positives).
reg[rd] = None;
}
}
}
// bcctr/bcctrl — opcode 19, XO=528. LK in low bit.
OP_BCCTR => {
let xo = (instr >> 1) & 0x3FF;
if xo == 528 {
let lk = (instr & 1) != 0;
if lk
&& let Some(RegVal::MethodPtr { vtable_addr, slot, method_pc }) = ctr
{
edges.push(IndirectEdge {
source: pc,
target: method_pc,
via_vtable: vtable_addr,
slot,
});
}
// After the call, CTR is preserved but rD register
// values across the call boundary are not trustworthy.
// Don't touch reg state — most ABIs preserve only
// some regs anyway.
}
}
// op 18: b / bl / ba / bla. LK=1 is a call; LK=0 is an
// unconditional branch with no fall-through (next PC is
// reached only via a different basic block, which the
// label-based reset already handles). On a call, the
// PowerPC ABI marks r0..r12 + ctr as volatile and
// r13..r31 as non-volatile (callee-saved); preserve the
// non-volatile half so vtable pointers loaded into r30/r31
// before a `bl` survive the call.
18 => {
let lk = (instr & 1) != 0;
if lk {
for r in 0..=12 { reg[r] = None; }
ctr = None;
}
// LK=0 (`b`) makes fall-through unreachable; nothing to do —
// any next reachable PC will hit a label boundary.
}
// Conditional branches (op 16) fall through; preserve all reg
// state for the fall-through path. The label-based join-point
// invalidation bounds false-positive risk for jump-IN paths.
16 => {
let lk = (instr & 1) != 0;
if lk {
for r in 0..=12 { reg[r] = None; }
ctr = None;
}
}
// Stores and loads we don't track explicitly clobber rD only
// when rD is on the destination side; the conservative rule
// is "any non-recognised opcode that may write rD invalidates it".
36..=55 => {
// Loads write rD; stores don't. The safe pessimisation is
// to invalidate rD for the load family (32..=35, 40..=43, etc.)
// and leave it alone for stores. We've already handled lwz
// above; for the rest, invalidate rD.
if matches!(op, 32..=35 | 40..=43 | 48..=51) {
reg[rd] = None;
}
}
_ => {}
}
pc = pc.wrapping_add(4);
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("analysis.phase_ms", "phase" => "indirect").record(elapsed_ms);
tracing::info!(
edges = edges.len(),
elapsed_ms,
"indirect-dispatch scan complete"
);
edges
}
fn read_instr(pe: &[u8], image_base: u32, addr: u32) -> Option<u32> {
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]]))
}
/// `target = base + simm` where `target` is an exact vtable head (rare,
/// compiler hoists the slot offset into the addi).
fn resolve_vtable_slot_via_off(
base: u32,
simm: i32,
vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Option<(u32, u32, u32)> {
let target = base.wrapping_add(simm as u32);
if let Some(v) = vtable_by_addr.get(&target)
&& !v.methods.is_empty()
{
return Some((v.address, 0, v.methods[0]));
}
None
}
/// `target` is an absolute address. If it falls inside a known vtable's
/// `[address, address + length*4)` range AND is 4-aligned to a slot,
/// return `(vtable_addr, slot, method_pc)`.
fn resolve_vtable_slot(
target: u32,
vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Option<(u32, u32, u32)> {
// BTreeMap range search for the largest key ≤ target.
let (&vt_addr, vt) = vtable_by_addr.range(..=target).next_back()?;
if target < vt_addr { return None; }
let off = target - vt_addr;
if !off.is_multiple_of(4) { return None; }
let slot = off / 4;
if slot >= vt.length { return None; }
let method_pc = *vt.methods.get(slot as usize)?;
Some((vt_addr, slot, method_pc))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::func::FuncInfo;
use std::collections::BTreeMap;
fn mk_vtable(addr: u32, methods: Vec<u32>) -> Vtable {
Vtable {
address: addr,
length: methods.len() as u32,
col_address: None,
class_name: "ANON_test".into(),
rtti_present: false,
base_classes_json: None,
methods,
}
}
/// Encode the canonical pattern at PC `start`:
/// lis r3, hi
/// addi r3, r3, lo ; r3 = vtable_addr
/// lwz r4, slot*4(r3) ; r4 = vtable[slot]
/// mtctr r4
/// bcctrl
fn encode_pattern(buf: &mut [u8], offset: usize, vtable_addr: u32, slot_off: i32) {
let hi = (vtable_addr >> 16) as u16;
let lo = (vtable_addr & 0xFFFF) as i16;
let lis = (15u32 << 26) | (3 << 21) | (0 << 16) | (hi as u32);
// addi r3, r3, lo (signed) — note: addi is treated as signed
let addi = (14u32 << 26) | (3 << 21) | (3 << 16) | ((lo as u16) as u32);
let lwz = (32u32 << 26) | (4 << 21) | (3 << 16) | ((slot_off as u16) as u32);
// mtctr r4 = mtspr CTR(=9), r4. SPR_low (=9) → Rust bits 16-20;
// SPR_high (=0) → Rust bits 11-15. Rc bit 0.
let mtctr = (31u32 << 26) | (4 << 21) | (9 << 16) | (0 << 11) | (467 << 1);
let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1; // bcctrl 20, 0
let words = [lis, addi, lwz, mtctr, bcctrl];
for (i, w) in words.iter().enumerate() {
buf[offset + i * 4..offset + i * 4 + 4].copy_from_slice(&w.to_be_bytes());
}
}
#[test]
fn detects_canonical_lis_addi_lwz_mtctr_bcctrl() {
let image_base = 0x82000000u32;
let text_va = 0x1000u32;
let pc_start = image_base + text_va;
let vtable_addr = 0x82010000u32;
// PE: just the .text we'll write the pattern into.
let mut pe = vec![0u8; 0x1100];
encode_pattern(&mut pe, text_va as usize, vtable_addr, 8); // slot 2
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
functions.insert(pc_start, FuncInfo {
start: pc_start,
end: pc_start + 5 * 4,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
});
let func_analysis = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB, 0xCC, 0xDD])];
let labels: HashMap<u32, String> = HashMap::new();
let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].source, pc_start + 4 * 4); // bcctrl at 5th instruction
assert_eq!(edges[0].target, 0xCC); // slot 2
assert_eq!(edges[0].via_vtable, vtable_addr);
assert_eq!(edges[0].slot, 2);
}
#[test]
fn out_of_range_slot_yields_no_edge() {
let image_base = 0x82000000u32;
let text_va = 0x1000u32;
let pc_start = image_base + text_va;
let vtable_addr = 0x82010000u32;
let mut pe = vec![0u8; 0x1100];
// Encode slot 12, but vtable only has 4 methods.
encode_pattern(&mut pe, text_va as usize, vtable_addr, 48);
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
functions.insert(pc_start, FuncInfo {
start: pc_start,
end: pc_start + 5 * 4,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
});
let func_analysis = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB, 0xCC, 0xDD])];
let labels: HashMap<u32, String> = HashMap::new();
let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels);
assert_eq!(edges.len(), 0);
}
#[test]
fn label_in_middle_kills_state() {
let image_base = 0x82000000u32;
let text_va = 0x1000u32;
let pc_start = image_base + text_va;
let vtable_addr = 0x82010000u32;
let mut pe = vec![0u8; 0x1100];
encode_pattern(&mut pe, text_va as usize, vtable_addr, 0);
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
functions.insert(pc_start, FuncInfo {
start: pc_start,
end: pc_start + 5 * 4,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
});
let func_analysis = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB])];
// Label between addi and lwz — must kill the Const tracking.
let mut labels: HashMap<u32, String> = HashMap::new();
labels.insert(pc_start + 8, "loc_mid".to_string());
let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels);
assert_eq!(edges.len(), 0, "label in middle of pattern must kill register state");
}
}