Skip to main content

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