callgraph_verify/disasm.rs
1//! Recursive-descent function disassembly over real PE bytes (iced-x86).
2//!
3//! Given a function entry VA, we decode instructions and follow the function's
4//! *intra-function* control flow (conditional branches + their fallthrough,
5//! unconditional `JMP rel`) until every reachable path terminates at a `RET` /
6//! interrupt / undecodable byte. Along the way we harvest, purely from the
7//! decoded operands, every fact that lets us catch a rebuild hallucination:
8//!
9//! * `direct_calls` — targets of `CALL rel16/rel32` (the call graph proper)
10//! * `tail_calls` — `JMP rel` leaving the body (a tail call into another fn)
11//! * `indirect_calls`/`indirect_jumps` — counts of register/memory dispatch
12//! * `vcall_slots` — displacements of `CALL/JMP [reg+0xNN]` (vtable slots)
13//! * `iat_calls` — absolute targets of `CALL [0x00a28xxx]` (IAT imports)
14//! * `field_offsets` — displacements of `[reg+0xNN]` accesses (struct fields),
15//! excluding ESP/EBP (those are stack locals/args)
16//! * `globals` — absolute data addresses referenced as `[0x00bXXXXX]`
17//! * `imm_addrs` — in-image immediates (`MOV [esi],0x00a7e38c` vtable VAs,
18//! function pointers, string-literal addresses)
19//!
20//! This is *structural* ground truth: no decompiler in the loop.
21
22use crate::pe::Pe;
23use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
24use std::collections::{BTreeMap, BTreeSet};
25
26/// Upper bound on how far past the entry we follow control flow. Oblivion's
27/// largest functions are a few KB; 64 KB stops a mis-decoded `JMP` from walking
28/// the whole section.
29const MAX_FN_SPAN: u64 = 0x10000;
30/// `[reg+disp]` with disp below this is treated as a struct-field access; larger
31/// displacements are almost certainly absolute addresses or noise.
32const MAX_FIELD_OFFSET: u64 = 0x8000;
33
34/// The recovered "kind" of a single call argument, harvested from the instruction that set it
35/// up (a register load or a `PUSH`). Coarse on purpose — only the distinctions a faithful port
36/// must preserve, and only those a short local scan can recover without false positives.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ArgKind {
39 /// An immediate constant (`PUSH 0x10`, `MOV ECX,0`, `XOR ECX,ECX`).
40 Imm(u64),
41 /// A struct-field load `[reg+disp]` off a non-stack base (`PUSH [ESI+0xc]`).
42 Field(u64),
43 /// An absolute in-image data reference `[0x00bXXXXX]` (a global).
44 Global(u64),
45 /// Address of a stack local (`LEA ECX,[EBP-8]` / `LEA EAX,[ESP+4]`) — an out-param / temp.
46 LocalAddr,
47 /// Anything not confidently classified (a register value, computed expression, prior result).
48 Other,
49}
50
51/// One direct call site with the argument set-up recovered by a short backward scan of its basic
52/// block. `ecx`/`edx` are the fastcall/thiscall register args (the receiver lands in `ecx`); `stack`
53/// is the pushed args in source (left-to-right) order. `None`/empty means "not recovered in-block".
54#[derive(Debug, Clone, Default)]
55pub struct CallSite {
56 pub target: u64,
57 pub ecx: Option<ArgKind>,
58 pub edx: Option<ArgKind>,
59 pub stack: Vec<ArgKind>,
60}
61
62#[derive(Debug, Default)]
63pub struct CallGraph {
64 pub entry: u64,
65 /// Direct `CALL` targets → number of call sites hitting that target.
66 pub direct_calls: BTreeMap<u64, u32>,
67 /// Per-call-site recovered argument shapes (one entry per direct `CALL` instruction).
68 pub call_sites: Vec<CallSite>,
69 /// Direct `JMP rel` targets that leave the walked body (tail calls).
70 pub tail_calls: BTreeSet<u64>,
71 pub indirect_calls: u32,
72 pub indirect_jumps: u32,
73 /// vtable slot displacements seen in `CALL/JMP [reg+0xNN]`.
74 pub vcall_slots: BTreeSet<u64>,
75 /// IAT import slot addresses seen in `CALL [0x00a28xxx]`.
76 pub iat_calls: BTreeSet<u64>,
77 /// Struct-field displacements off a non-stack base register.
78 pub field_offsets: BTreeSet<u64>,
79 /// Absolute data addresses referenced as memory operands (globals).
80 pub globals: BTreeSet<u64>,
81 /// In-image immediate values (vtable VAs, fn pointers, string addresses).
82 pub imm_addrs: BTreeSet<u64>,
83 /// Conditional-branch polarity (by condition class): signed (`JL/JLE/JG/JGE`),
84 /// unsigned (`JB/JBE/JA/JAE`), equality (`JE/JNE`). Other conditions
85 /// (`JP/JS/JO/...`) are not counted. Ground truth for the polarity gap:
86 /// the gate checks CFG *structure* but never `==` vs `!=` or signed vs
87 /// unsigned, so an inverted mirror passes. These counts let ports and
88 /// tests pin which class the binary actually uses.
89 pub signed_branches: u32,
90 pub unsigned_branches: u32,
91 pub equality_branches: u32,
92 /// Highest instruction-end VA reached — the function's apparent extent.
93 pub max_end: u64,
94 pub decode_errors: u32,
95 /// First few decode failures as `(ip, raw_bytes)`. Decoders hitting
96 /// invalid opcodes is normal at padding/alignment/data boundaries, but a
97 /// long run inside what is supposed to be a function usually means the
98 /// entry VA is wrong, or the function crosses into embedded data. The
99 /// hex bytes here are enough to tell which.
100 pub decode_error_samples: Vec<(u64, Vec<u8>)>,
101}
102
103impl CallGraph {
104 pub fn call_targets(&self) -> BTreeSet<u64> {
105 self.direct_calls.keys().copied().collect()
106 }
107
108 /// Every in-image address this function references — call targets, tail-call
109 /// JMPs, IAT slots, global data refs and baked immediates (vtable VAs / fn
110 /// pointers / string addresses). Used to detect inlining: if callee C's whole
111 /// footprint also appears in caller F's footprint, the binary pasted C's body
112 /// into F, so a Rust call F→C is faithful (un-inlined), not hallucinated.
113 pub fn footprint(&self) -> BTreeSet<u64> {
114 let mut s: BTreeSet<u64> = self.direct_calls.keys().copied().collect();
115 s.extend(&self.tail_calls);
116 s.extend(&self.iat_calls);
117 s.extend(&self.globals);
118 s.extend(&self.imm_addrs);
119 s
120 }
121}
122
123/// Harvest every memory/immediate operand fact from one decoded instruction.
124fn scan_operands(pe: &Pe, instr: &Instruction, cg: &mut CallGraph) {
125 for i in 0..instr.op_count() {
126 match instr.op_kind(i) {
127 OpKind::Memory => {
128 let base = instr.memory_base();
129 let index = instr.memory_index();
130 let disp = instr.memory_displacement64();
131 if base == Register::None && index == Register::None {
132 // absolute `[0x...]` — a global/static data reference.
133 // Skip LEA: it computes an address without dereferencing, so
134 // the operand isn't a memory reference. (BUG #14)
135 if pe.section_of(disp).is_some() && instr.mnemonic() != Mnemonic::Lea {
136 cg.globals.insert(disp);
137 }
138 } else if pe.section_of(disp).is_some()
139 && instr.mnemonic() != Mnemonic::Lea
140 {
141 // Indexed `[base + index*scale + disp]` (or `[reg+disp]`)
142 // off a large in-image displacement — a table base, not a
143 // struct field (e.g. FALL.EXE `[eax*4+0x18F01]`). Record it
144 // as a global so bodies may bake the true address without
145 // tripping HALLUCINATED-ADDR. Small offsets miss
146 // `section_of` and fall through to the field branch below;
147 // LEA never dereferences (BUG #14), so it stays excluded.
148 cg.globals.insert(disp);
149 } else if base != Register::None
150 && base != Register::ESP
151 && base != Register::EBP
152 && index == Register::None
153 && disp > 0
154 && disp < MAX_FIELD_OFFSET
155 // LEA computes an address (pointer math / &field), it doesn't
156 // dereference a struct field — skip it to avoid e.g. the
157 // `LEA EDX,[EAX+1]` of a strlen loop looking like field 0x1.
158 && instr.mnemonic() != Mnemonic::Lea
159 {
160 // `[reg+0xNN]` off a non-stack base — a struct field access.
161 cg.field_offsets.insert(disp);
162 }
163 }
164 OpKind::Immediate8
165 | OpKind::Immediate8to16
166 | OpKind::Immediate8to32
167 | OpKind::Immediate16
168 | OpKind::Immediate32 => {
169 let v = instr.immediate(i);
170 // In-image immediate = a baked address (vtable VA, fn ptr, string).
171 if pe.section_of(v).is_some() {
172 cg.imm_addrs.insert(v);
173 }
174 }
175 _ => {}
176 }
177 }
178}
179
180/// Classify operand `i` of `instr` as an argument kind (immediate / field / global / other).
181fn classify_operand(pe: &Pe, instr: &Instruction, i: u32) -> ArgKind {
182 match instr.op_kind(i) {
183 OpKind::Immediate8
184 | OpKind::Immediate8to16
185 | OpKind::Immediate8to32
186 | OpKind::Immediate16
187 | OpKind::Immediate32 => ArgKind::Imm(instr.immediate(i)),
188 OpKind::Memory => {
189 let base = instr.memory_base();
190 let index = instr.memory_index();
191 let disp = instr.memory_displacement64();
192 if base == Register::None && index == Register::None {
193 if pe.section_of(disp).is_some() {
194 ArgKind::Global(disp)
195 } else {
196 ArgKind::Other
197 }
198 } else if base != Register::ESP
199 && base != Register::EBP
200 && base != Register::None
201 && index == Register::None
202 && disp > 0
203 && disp < MAX_FIELD_OFFSET
204 {
205 ArgKind::Field(disp)
206 } else {
207 ArgKind::Other
208 }
209 }
210 _ => ArgKind::Other,
211 }
212}
213
214/// Classify the *source* of a register-setup instruction (`MOV reg,src` / `LEA reg,src` /
215/// `XOR reg,reg`). Returns the arg kind the register holds going into the following `CALL`.
216fn classify_reg_src(pe: &Pe, instr: &Instruction) -> ArgKind {
217 match instr.mnemonic() {
218 Mnemonic::Lea => {
219 let base = instr.memory_base();
220 if base == Register::EBP || base == Register::ESP {
221 ArgKind::LocalAddr
222 } else {
223 ArgKind::Other // lea of a field address — not distinguished in v1
224 }
225 }
226 Mnemonic::Xor => ArgKind::Imm(0), // only called when op0==op1 (zero idiom)
227 Mnemonic::Mov | Mnemonic::Movzx | Mnemonic::Movsx => classify_operand(pe, instr, 1),
228 _ => ArgKind::Other,
229 }
230}
231
232/// True if `instr` writes register `reg` as its destination (op0) via a recognized setup form.
233fn writes_reg(instr: &Instruction, reg: Register) -> bool {
234 if instr.op0_kind() != OpKind::Register || instr.op0_register() != reg {
235 return false;
236 }
237 match instr.mnemonic() {
238 Mnemonic::Mov | Mnemonic::Lea | Mnemonic::Movzx | Mnemonic::Movsx => true,
239 // XOR reg,reg is the zero idiom only when both operands are the same register.
240 Mnemonic::Xor => instr.op1_kind() == OpKind::Register && instr.op1_register() == reg,
241 _ => false,
242 }
243}
244
245/// Recover the argument shape of a `CALL` from the instructions preceding it in its basic block
246/// (`buf` excludes the CALL itself). Scans backward: the first `PUSH`es encountered are the
247/// left-to-right stack args (cdecl/stdcall push right-to-left, so backward order = source order),
248/// and the first writes to ECX/EDX are the fastcall/thiscall register args.
249pub(crate) fn recover_call_site(pe: &Pe, buf: &[Instruction], target: u64) -> CallSite {
250 let mut cs = CallSite {
251 target,
252 ..Default::default()
253 };
254 for instr in buf.iter().rev().take(64) {
255 // Stop at a prologue / frame boundary: an explicit ESP adjust (`sub esp,N`) or any
256 // FS-relative access (SEH frame set-up). Argument pushes never precede these, so this
257 // keeps callee-saved / SEH-cookie pushes out of the recovered argument list.
258 if is_frame_boundary(instr) {
259 break;
260 }
261 if instr.mnemonic() == Mnemonic::Push {
262 cs.stack.push(classify_operand(pe, instr, 0));
263 } else if cs.ecx.is_none() && writes_reg(instr, Register::ECX) {
264 cs.ecx = Some(classify_reg_src(pe, instr));
265 } else if cs.edx.is_none() && writes_reg(instr, Register::EDX) {
266 cs.edx = Some(classify_reg_src(pe, instr));
267 }
268 }
269 cs
270}
271
272/// A prologue / stack-frame boundary that argument set-up never precedes: an `ADD/SUB ESP,imm`
273/// or any instruction touching FS-relative memory (the SEH frame).
274fn is_frame_boundary(instr: &Instruction) -> bool {
275 if matches!(instr.mnemonic(), Mnemonic::Add | Mnemonic::Sub)
276 && instr.op0_kind() == OpKind::Register
277 && instr.op0_register() == Register::ESP
278 {
279 return true;
280 }
281 (0..instr.op_count())
282 .any(|i| instr.op_kind(i) == OpKind::Memory && instr.memory_segment() == Register::FS)
283}
284
285/// If `addr` is a one-instruction thunk (`JMP target`), return `target`. Callers often invoke a
286/// function through such a thunk (a tail-jump stub, or `/OPT:ICF`-folded forwarder), so the byte
287/// call edge points at the thunk, not the real callee — following it one level lets the call-graph
288/// check see the true target.
289pub fn resolve_thunk(pe: &Pe, addr: u64) -> Option<u64> {
290 let bytes = pe.read(addr, 16)?;
291 let mut dec = Decoder::with_ip(32, bytes, addr, DecoderOptions::NONE);
292 if !dec.can_decode() {
293 return None;
294 }
295 let instr = dec.decode();
296 if instr.flow_control() == FlowControl::UnconditionalBranch {
297 let t = instr.near_branch_target();
298 if t != 0 {
299 return Some(t);
300 }
301 }
302 None
303}
304
305/// Does `instr` *read* `reg` (as a source register or a memory base/index)? Excludes the
306/// destination register operand (op0) so a pure write like `MOV ECX,EAX` doesn't count as a read.
307fn reads_reg(instr: &Instruction, reg: Register) -> bool {
308 for i in 0..instr.op_count() {
309 match instr.op_kind(i) {
310 OpKind::Memory => {
311 if instr.memory_base() == reg || instr.memory_index() == reg {
312 return true;
313 }
314 }
315 OpKind::Register if i > 0 && instr.op_register(i) == reg => {
316 return true;
317 }
318 _ => {}
319 }
320 }
321 false
322}
323
324/// Heuristically determine whether the function at `addr` takes register parameters in ECX / EDX
325/// (i.e. is thiscall/fastcall). Scans the entry block for a READ of the register before any write:
326/// thiscall reads ECX (the `this`), fastcall also reads EDX, cdecl/stdcall read neither. Used to
327/// align a caller's recovered register args to the callee's actual parameters (so a stray EDX in a
328/// caller is not mistaken for an argument to a thiscall callee). Conservative: under-detection just
329/// means a position is skipped, never a false mismatch.
330pub fn reg_params(pe: &Pe, addr: u64) -> (bool, bool) {
331 let Some(bytes) = pe.read(addr, 32 * 32) else {
332 return (false, false);
333 };
334 let mut dec = Decoder::with_ip(32, bytes, addr, DecoderOptions::NONE);
335 let mut instr = Instruction::default();
336 let (mut ecx_written, mut edx_written) = (false, false);
337 let (mut ecx_param, mut edx_param) = (false, false);
338 let mut n = 0;
339 while dec.can_decode() && n < 24 {
340 dec.decode_out(&mut instr);
341 if instr.is_invalid() {
342 break;
343 }
344 if !ecx_written && reads_reg(&instr, Register::ECX) {
345 ecx_param = true;
346 }
347 if !edx_written && reads_reg(&instr, Register::EDX) {
348 edx_param = true;
349 }
350 if instr.op0_kind() == OpKind::Register {
351 match instr.op0_register() {
352 Register::ECX => ecx_written = true,
353 Register::EDX => edx_written = true,
354 _ => {}
355 }
356 }
357 // A leaf that immediately forwards its incoming registers to another call is still
358 // thiscall/fastcall even though iced-x86 cannot represent the implicit register use on
359 // the CALL itself. Preserve untouched ECX/EDX on the first instruction so constructors
360 // such as BSScissorTriShape (which forwards both registers to its base ctor) align stack
361 // arguments correctly. Later calls retain the caller-saved rule below.
362 if n == 0
363 && matches!(instr.flow_control(), FlowControl::Call)
364 && instr.near_branch_target() != instr.next_ip()
365 {
366 if !ecx_written {
367 ecx_param = true;
368 }
369 if !edx_written {
370 edx_param = true;
371 }
372 }
373 // Per the x86 cdecl/fastcall/thiscall conventions, ECX and EDX are caller-saved:
374 // any CALL in the entry block clobbers them, so a later READ of ECX/EDX is not
375 // evidence that the callee takes that register as a parameter. Mark them written
376 // across the CALL — without this, a stray `MOV EAX, [ECX]` after a prior CALL
377 // would falsely set `ecx_param = true` and pollute ARG-SHAPE diffs.
378 if matches!(instr.flow_control(), FlowControl::Call) {
379 ecx_written = true;
380 edx_written = true;
381 }
382 if !matches!(instr.flow_control(), FlowControl::Next | FlowControl::Call) {
383 break;
384 }
385 n += 1;
386 }
387 (ecx_param, edx_param)
388}
389
390/// Walk the function at `entry` and return its facts, sourced purely from bytes.
391pub fn analyze(pe: &Pe, entry: u64) -> Result<CallGraph, String> {
392 if !pe.is_executable_va(entry) {
393 return Err(format!(
394 "{entry:#010x} is not inside any PE section of this image\n{}",
395 pe.describe_sections(entry)
396 ));
397 }
398 let mut cg = CallGraph {
399 entry,
400 ..Default::default()
401 };
402 let lo = entry;
403 let hi = entry.saturating_add(MAX_FN_SPAN);
404
405 let mut work = vec![entry];
406 let mut seen: BTreeSet<u64> = BTreeSet::new();
407 // Per-branch-target seed: when we encounter `Jcc target` / `JMP target` /
408 // indirect-jump case, the args set up before the branch (PUSHes, MOV-to-ECX,
409 // etc.) also apply to the target block's first CALL — the predecessor and
410 // target share the same register/stack state at the branch instruction. The
411 // outer loop resets `block_buf` when it pops a new `start`, so without this
412 // seed map the target's calls would be processed with an empty `block_buf`
413 // and `recover_call_site` would undercount arguments. (BUG #8 fix.)
414 //
415 // Multiple predecessors may push the same target — `entry().or_insert_with`
416 // keeps the FIRST push, since the args are equivalent along either path up
417 // to the branch instruction itself (the differing branches only affect the
418 // value of flags, which `recover_call_site` doesn't read).
419 let mut branch_seed: std::collections::HashMap<u64, Vec<Instruction>> =
420 std::collections::HashMap::new();
421
422 while let Some(start) = work.pop() {
423 if !seen.insert(start) {
424 continue;
425 }
426 let Some(bytes) = pe.read(start, 32 * 64) else {
427 continue;
428 };
429 let mut dec = Decoder::with_ip(32, bytes, start, DecoderOptions::NONE);
430 let mut instr = Instruction::default();
431 // Straight-line instruction buffer for the current block, used to recover the argument
432 // set-up preceding each CALL. Reset at block boundaries (branches) and after each call.
433 // For non-entry `start`s, seed from the predecessor branch (see `branch_seed`).
434 let mut block_buf: Vec<Instruction> = branch_seed.remove(&start).unwrap_or_default();
435
436 loop {
437 if !dec.can_decode() {
438 break;
439 }
440 let ip = dec.ip();
441 if ip != start && seen.contains(&ip) {
442 break; // block already covered by another path
443 }
444 if ip > start {
445 seen.insert(ip);
446 }
447 dec.decode_out(&mut instr);
448 if instr.is_invalid() {
449 cg.decode_errors += 1;
450 // Keep up to 5 samples so the user can tell at a glance
451 // whether the failures are padding (single 0x00/0xCC/0x90),
452 // an aligned jump table (4 distinct bytes), or something
453 // structural (random bytes that decode to nothing — entry
454 // VA is probably wrong).
455 if cg.decode_error_samples.len() < 5 {
456 let bytes = pe.read(ip, 8).unwrap_or(&[]).to_vec();
457 cg.decode_error_samples.push((ip, bytes));
458 }
459 break;
460 }
461 cg.max_end = cg.max_end.max(instr.next_ip());
462 scan_operands(pe, &instr, &mut cg);
463 block_buf.push(instr);
464
465 match instr.flow_control() {
466 FlowControl::Call => {
467 let t = instr.near_branch_target();
468 if t != 0 {
469 *cg.direct_calls.entry(t).or_insert(0) += 1;
470 let prior = &block_buf[..block_buf.len() - 1]; // exclude the CALL itself
471 let cs = recover_call_site(pe, prior, t);
472 cg.call_sites.push(cs);
473 }
474 block_buf.clear(); // args after a call start fresh
475 }
476 FlowControl::IndirectCall => {
477 cg.indirect_calls += 1;
478 record_indirect_target(&instr, pe, &mut cg);
479 block_buf.clear(); // an indirect call's args must not leak into the next call
480 }
481 FlowControl::ConditionalBranch => {
482 // Polarity ground truth (one count per branch instruction;
483 // each ip is visited once via `seen`, so no double-count).
484 match instr.mnemonic() {
485 Mnemonic::Jl | Mnemonic::Jle | Mnemonic::Jg | Mnemonic::Jge => {
486 cg.signed_branches += 1;
487 }
488 Mnemonic::Jb | Mnemonic::Jbe | Mnemonic::Ja | Mnemonic::Jae => {
489 cg.unsigned_branches += 1;
490 }
491 Mnemonic::Je | Mnemonic::Jne => {
492 cg.equality_branches += 1;
493 }
494 _ => {}
495 }
496 let t = instr.near_branch_target();
497 if (lo..hi).contains(&t) {
498 branch_seed.entry(t).or_insert_with(|| block_buf.clone());
499 work.push(t);
500 } else {
501 cg.tail_calls.insert(t);
502 }
503 // Don't clear `block_buf` here: the args set up before the
504 // conditional apply to BOTH the taken and the fallthrough
505 // paths (they share register/stack state at the branch).
506 // Clearing would make the fallthrough's first CALL recover
507 // an empty argument set. The buffer is reset by the next
508 // CALL we encounter (Call/IndirectCall arms above clear
509 // post-call) or by an explicit boundary.
510 }
511 FlowControl::UnconditionalBranch => {
512 let t = instr.near_branch_target();
513 if (lo..hi).contains(&t) {
514 // Single-JMP entry stub: a function whose entire
515 // entry block is one unconditional JMP (sibling-call
516 // `JMP tail_target`, ICF forwarder, `jmp` over no
517 // bytes) transfers control elsewhere instead of
518 // containing its own body. Following it would merge
519 // the callee's body (and globals) into the caller,
520 // hiding the tail edge the gate checks. Scoped to
521 // exactly one instruction so a `JMP` that skips an
522 // inline jump table after a prologue (multi-instr
523 // entry block) keeps the follow behavior below.
524 if start == lo && block_buf.len() == 1 {
525 cg.tail_calls.insert(t);
526 } else {
527 branch_seed.entry(t).or_insert_with(|| block_buf.clone());
528 work.push(t);
529 }
530 } else {
531 cg.tail_calls.insert(t);
532 }
533 break;
534 }
535 FlowControl::IndirectBranch => {
536 cg.indirect_jumps += 1;
537 record_indirect_target(&instr, pe, &mut cg);
538 let mut cases: Vec<u64> = Vec::new();
539 // Indirect-jump target recovery, by operand shape:
540 // * SIB-indexed `[base+idx*s+TABLE]` (MSVC-style): the
541 // existing follow_jump_table read below. Unchanged.
542 // * Unindexed `[reg+TABLE]` with NO base register, i.e.
543 // an absolute `[disp]` (IAT thunks, import jumps):
544 // NEVER followed. The disp names data, not code, and
545 // the fallthrough past a non-returning jump is dead
546 // code -- decoding it attributes neighboring
547 // functions' calls here (verified: 9KB/20-call blowup
548 // on noivilbo 0x006ff480, whose IAT tail-jmps sit
549 // after cmp/jcc).
550 // * Unindexed `[reg+TABLE]` WITH a base register: Watcom
551 // switch lowering (`shl reg,2` then `jmp [reg+TABLE]`,
552 // e.g. FALL.EXE 0x602c0). The table entries are
553 // pre-fixup bytes on LE, so the read below yields
554 // nothing; instead continue decoding at the fallthrough
555 // address -- the case bodies tile the bytes right after
556 // the dispatch (verified: 602c0's five hidden 7d6ae
557 // calls, previously mis-reported as HALLUCINATED).
558 // Gated on switch evidence (a compare/test plus a
559 // conditional branch in this block) so vtable tail-jumps
560 // like `jmp [ecx+0x44]` keep today's behavior.
561 let has_index = instr.memory_index() != Register::None;
562 // The base must be produced locally (e.g. the scaled
563 // index): an incoming register like a vtable `this`
564 // is never a switch table.
565 let mut base_written = false;
566 if instr.memory_base() != Register::None {
567 // CMP/TEST only write flags, not their operands --
568 // without this carve-out the evidence check itself
569 // (`cmp reg,...`) would trivially satisfy "written".
570 for b in block_buf.iter() {
571 match b.mnemonic() {
572 Mnemonic::Cmp | Mnemonic::Test => continue,
573 _ => {}
574 }
575 if b.op0_kind() == OpKind::Register
576 && b.op0_register() == instr.memory_base()
577 {
578 base_written = true;
579 break;
580 }
581 }
582 }
583 let unindexed_switch =
584 !has_index && base_written && is_switch_dispatch(&block_buf);
585 follow_jump_table(&instr, pe, lo, hi, unindexed_switch, &mut cases);
586 // GCC -O0 switch lowering keeps the scale in a separate
587 // `shl`, adds the table base as an immediate, loads the
588 // entry, then jumps through a register:
589 // shl $2,%eax; add $TABLE,%eax; mov (%eax),%eax; jmp *%eax
590 // The JMP itself has no memory operand, so the readers
591 // above see nothing (MinGW `c_callgraph_od` classify:
592 // 1 indirect jump, 0 cases, 0 calls). Recover the table
593 // base from the `add` and read it like any other table,
594 // gated on the same switch evidence so a vtable
595 // `mov eax,[ecx+slot]; jmp eax` (no bound check, `this`
596 // never `add`ed with an in-image base) stays untouched.
597 if cases.is_empty()
598 && instr.op_count() > 0
599 && instr.op_kind(0) == OpKind::Register
600 && is_switch_dispatch(&block_buf)
601 {
602 if let Some(table) =
603 register_table_base(&block_buf, pe, instr.op_register(0))
604 {
605 read_table_entries(pe, table, lo, hi, &mut cases);
606 }
607 }
608 for c in &cases {
609 branch_seed.entry(*c).or_insert_with(|| block_buf.clone());
610 work.push(*c);
611 }
612 if unindexed_switch {
613 let ft = instr.next_ip();
614 if (lo..hi).contains(&ft) {
615 branch_seed.entry(ft).or_insert_with(|| block_buf.clone());
616 work.push(ft);
617 }
618 }
619 break;
620 }
621 FlowControl::Return | FlowControl::Exception => break,
622 // NOTE: `Interrupt` (`int n`) is deliberately NOT terminal:
623 // DOS `int 21h` / DPMI `int 31h` are API calls that return
624 // normally, so decoding must continue at `next_ip()`.
625 // Treating them like `ret` truncated listings and hid
626 // calls reachable only via post-`int` fallthrough.
627 FlowControl::Next | FlowControl::Interrupt | FlowControl::XbeginXabortXend => {}
628 }
629 }
630 }
631 Ok(cg)
632}
633
634/// Addresses a tail-call target references (its globals, baked immediates,
635/// and IAT slots), unioned over every `JMP`-out edge of `cg`.
636///
637/// A tail-call `JMP G` transfers control to G, so G's data references execute
638/// as part of THIS function. The call-graph diff (`diff_body_calls`) already
639/// counts a tail target's *callees* as acceptable spellings; this is the
640/// address-side mirror for the HALLUCINATED-ADDR check: a port that
641/// duplicates a shared tail body (e.g. a predicate tail-jumped into from two
642/// origins) bakes the tail's addresses faithfully. Only ever ADDS acceptance
643/// — a target that fails to analyze is skipped, never an error.
644pub fn tail_referenced(pe: &Pe, cg: &CallGraph) -> BTreeSet<u64> {
645 let mut out = BTreeSet::new();
646 for t in &cg.tail_calls {
647 if let Ok(tcg) = analyze(pe, *t) {
648 out.extend(&tcg.globals);
649 out.extend(&tcg.imm_addrs);
650 out.extend(&tcg.iat_calls);
651 }
652 }
653 out
654}
655
656/// Follow an MSVC-style `switch` jump table: `JMP dword ptr [idx*4 + TABLE_VA]`.
657/// `TABLE_VA` (the operand displacement, an in-image .rdata address) holds an
658/// array of u32 case addresses. We read entries until one falls outside the
659/// function `[lo, hi)` span -- switch cases always point back into the function,
660/// so that's a reliable terminator. Recovers the case bodies (and their `CALL`s)
661/// that a plain recursive descent, unable to follow an indirect jump, would miss.
662///
663/// `allow_unindexed` covers Watcom-style lowering where the scale lives in a
664/// separate `shl` and the dispatch is a plain `[reg+TABLE]` with no SIB index
665/// (e.g. FALL.EXE `0x602c0`: `jmp dword ptr cs:[eax+0x502D8]`). Only set it
666/// when the block already looks like a switch (see `is_switch_dispatch`):
667/// without that evidence a vtable tail-jump would read arbitrary words as
668/// "cases". On LE binaries the table entries are pre-fixup bytes, so the
669/// read yields no in-range targets and the fallthrough arm below carries
670/// the recovery instead.
671pub(crate) fn follow_jump_table(
672 instr: &Instruction,
673 pe: &Pe,
674 lo: u64,
675 hi: u64,
676 allow_unindexed: bool,
677 work: &mut Vec<u64>,
678) {
679 for i in 0..instr.op_count() {
680 if instr.op_kind(i) != OpKind::Memory {
681 continue;
682 }
683 let disp = instr.memory_displacement64();
684 if instr.memory_index() == Register::None && !allow_unindexed {
685 return;
686 }
687 if pe.section_of(disp).is_none() {
688 return;
689 }
690 read_table_entries(pe, disp, lo, hi, work);
691 return;
692 }
693}
694
695/// Read a u32 VA jump table at `table_va`, pushing entries while they point
696/// back into the function `[lo, hi)` (the terminator shared by every table
697/// shape: switch cases always target the function's own bodies).
698fn read_table_entries(pe: &Pe, table_va: u64, lo: u64, hi: u64, work: &mut Vec<u64>) {
699 let mut k = 0u64;
700 while k < 1024 {
701 let Some(b) = pe.read(table_va + k * 4, 4) else {
702 break;
703 };
704 if b.len() < 4 {
705 break;
706 }
707 let target = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as u64;
708 if !(lo..hi).contains(&target) {
709 break; // first entry outside the function ends the table
710 }
711 work.push(target);
712 k += 1;
713 }
714}
715
716/// Table base for a register-indirect switch dispatch (`jmp *%reg`).
717/// Finds the entry load `mov %reg,[%base]` nearest the jump, then the
718/// `add %base,$TABLE` / `lea %base,[...]` / `mov %base,$TABLE` that
719/// established it with an in-image table address. Returns `None` when the
720/// jump register comes straight from memory with no locally-built table
721/// (e.g. a vtable `mov %eax,[%ecx+slot]; jmp *%eax`, whose `%ecx` is an
722/// incoming `this` never `add`ed with an in-image base).
723fn register_table_base(block: &[Instruction], pe: &Pe, jump_reg: Register) -> Option<u64> {
724 // Nearest load into the jump register: `mov %reg,[%base]`.
725 let mut load_base: Option<Register> = None;
726 for b in block.iter().rev() {
727 if matches!(
728 b.mnemonic(),
729 Mnemonic::Mov | Mnemonic::Movzx | Mnemonic::Movsx
730 ) && b.op0_kind() == OpKind::Register
731 && b.op0_register() == jump_reg
732 && b.op1_kind() == OpKind::Memory
733 {
734 load_base = Some(b.memory_base());
735 break;
736 }
737 }
738 let base = load_base?;
739 // The table address built into the base register further up.
740 for b in block.iter().rev() {
741 match b.mnemonic() {
742 Mnemonic::Add => {
743 if b.op0_kind() == OpKind::Register && b.op0_register() == base {
744 for i in 0..b.op_count() {
745 if matches!(
746 b.op_kind(i),
747 OpKind::Immediate8
748 | OpKind::Immediate8to16
749 | OpKind::Immediate8to32
750 | OpKind::Immediate16
751 | OpKind::Immediate32
752 ) && pe.section_of(b.immediate(i)).is_some()
753 {
754 return Some(b.immediate(i));
755 }
756 }
757 }
758 }
759 Mnemonic::Lea => {
760 if b.op0_kind() == OpKind::Register
761 && b.op0_register() == base
762 && b.op1_kind() == OpKind::Memory
763 && pe.section_of(b.memory_displacement64()).is_some()
764 {
765 return Some(b.memory_displacement64());
766 }
767 }
768 Mnemonic::Mov | Mnemonic::Movzx | Mnemonic::Movsx => {
769 if b.op0_kind() == OpKind::Register && b.op0_register() == base {
770 for i in 0..b.op_count() {
771 if matches!(
772 b.op_kind(i),
773 OpKind::Immediate8
774 | OpKind::Immediate8to16
775 | OpKind::Immediate8to32
776 | OpKind::Immediate16
777 | OpKind::Immediate32
778 ) && pe.section_of(b.immediate(i)).is_some()
779 {
780 return Some(b.immediate(i));
781 }
782 }
783 }
784 }
785 _ => {}
786 }
787 }
788 None
789}
790
791/// Switch-dispatch evidence: a compare/test plus a conditional branch in
792/// the current straight-line block (e.g. `cmp edx,6; ja default` ahead of
793/// `jmp [reg+TABLE]`). Used to license fallthrough decoding past an
794/// indirect jump only where a switch table is plausible, so vtable
795/// tail-jumps (no bound check) never gain fallthrough coverage.
796fn is_switch_dispatch(block: &[Instruction]) -> bool {
797 let mut has_cmp = false;
798 let mut has_jcc = false;
799 for i in block {
800 match i.mnemonic() {
801 Mnemonic::Cmp | Mnemonic::Test => has_cmp = true,
802 _ => {}
803 }
804 if matches!(i.flow_control(), FlowControl::ConditionalBranch) {
805 has_jcc = true;
806 }
807 }
808 has_cmp && has_jcc
809}
810
811/// Classify an indirect `CALL`/`JMP [...]` target: `[reg+disp]` is a vtable slot
812/// (record the displacement); `[abs]` in the IAT range is an import (record addr).
813fn record_indirect_target(instr: &Instruction, pe: &Pe, cg: &mut CallGraph) {
814 // The branch target operand is the (single) memory operand, if any.
815 for i in 0..instr.op_count() {
816 if instr.op_kind(i) == OpKind::Memory {
817 let base = instr.memory_base();
818 let disp = instr.memory_displacement64();
819 if base == Register::None && instr.memory_index() == Register::None {
820 if pe.section_of(disp).is_some() {
821 cg.iat_calls.insert(disp); // CALL [0x00a28xxx]
822 }
823 } else if base != Register::None && disp < MAX_FIELD_OFFSET {
824 cg.vcall_slots.insert(disp); // CALL [reg+slot]
825 }
826 return;
827 }
828 }
829}