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