Add code-structure analysis: call graph, jump tables, basic blocks, constant xref

Wave 1 of the code-analysis layer, built on the x86-64 decoder:

- vmie_win32_callgraph walks each .pdata function with the decoder and emits an
  edge for every direct call/jmp whose target lands in the module - the
  intra-module call graph. Indirect edges are left to the IAT and jump tables.
- gva_jumptable recovers a switch's case targets from an indirect jump's table:
  consecutive pointer entries that land in an executable region.
- cfg_blocks splits one function view into basic blocks (a generic handler:
  leaders from intra-function branch targets, cut after jmp/jcc/ret).
- gva_imm_xref finds the instructions whose immediate operand equals a constant
  - the dual of code-xref for magic values, error codes, syscall numbers.

The decoder now also reports imm_off/imm_len so a caller can read or match the
immediate operand. The generic primitives live in the new codeanalysis.h
(jump tables, basic blocks) and scan.h (constant xref); the .pdata-bound call
graph stays on the win32 surface and reuses the existing function/section/decode
primitives - no second PE or instruction parser.
This commit is contained in:
2026-06-16 19:52:25 +03:00
parent c4419964aa
commit 79e82ffc6a
9 changed files with 505 additions and 1 deletions
+80
View File
@@ -0,0 +1,80 @@
/* codeanalysis.h - generic (OS-agnostic) x86-64 code-structure analysis.
*
* Handler layer: built on the generic memory model (memmodel.h: cr3 + VA, the
* region map, gva_read) and the light x86-64 decoder (x86dec.h). It names no
* Windows object - jump-table recovery and basic-block splitting are properties
* of code and the address space, not of any particular OS. The win32-specific
* call graph (which needs .pdata) lives in win32.h instead.
*
* These are the structure-recovery primitives that sit above the decoder and
* gva_code_xref / gva_imm_xref (scan.h): given a function body or an indirect
* jump's table, reconstruct the control flow the linear scanners cannot see.
*/
#ifndef VMIE_CODEANALYSIS_H
#define VMIE_CODEANALYSIS_H
#include <stdint.h>
#include <stddef.h>
#include "memmodel.h" /* vmie_mem, cr3+VA, vregion/VR_*, gva_read/gva_regions */
#include "sigscan.h" /* mem_view_t (the single owner of the view type) */
#include "x86dec.h" /* x86_decode, x86_insn, x86_branch_target */
/* Jump-table recovery. From `table_va`, read consecutive 8-byte entries and
* keep those that point into an EXECUTABLE region under `cr3` (membership tested
* against the live region map, i.e. a VR_X run from gva_regions); stop at the
* first entry that is not a code pointer, at a read failure, or at `max`. The
* entries are absolute 64-bit code VAs (the common /CASE jump-table form a
* compiler emits for a switch). Writes up to `max` recovered targets to
* `targets` (NULL to count only) and returns the number recovered.
*
* Feed it the table address taken from an indirect jump's memory operand - e.g.
* `jmp qword [rip+disp]` => rip+disp (x86_riprel_target), or the base of a
* `jmp qword [base + idx*8]` SIB table - to recover a switch's case targets and
* complete the control-flow graph that the linear decoders (cfg_blocks,
* vmie_win32_callgraph) leave dangling at the indirect jump.
*
* Returns 0 when the first entry is already not a code pointer (an empty/absent
* table), so a 0 return is "no table here", not an error.
*
* Example - resolve a switch reached by `jmp qword [rip+disp]`:
* x86_insn in; x86_decode(code, avail, &in); // the indirect jmp
* uint64_t tbl = x86_riprel_target(jmp_va, &in); // table base VA
* uint64_t cases[64];
* int n = gva_jumptable(m, cr3, tbl, cases, 64); // case target VAs */
int gva_jumptable(vmie_mem* m, uintptr_t cr3, uint64_t table_va,
uint64_t* targets, int max);
/* One basic block inside a function view. The offsets are in the VIEW's own
* coordinate space (mem_view_t.base_va + offset): for a SECTION_LOCAL view they
* are section-local byte offsets, for a MODULE_RVA view they are RVAs.
* start - byte offset of the block's first instruction (inclusive)
* end - byte offset just past the block's last instruction (exclusive), so
* the block spans [start, end) and its length is end - start. */
typedef struct { uint32_t start; uint32_t end; } code_block;
/* Split one function's bytes into basic blocks. `fn` is a view spanning exactly
* one function (e.g. a section-view sub-range covering a func_range from
* vmie_win32_functions): fn.data[0] is the function's first byte and fn.size its
* length. Two linear passes over the bytes with the decoder:
* 1. collect intra-function branch targets (the destinations of jmp/jcc whose
* target lands inside [0, fn.size)) - these are leaders;
* 2. cut a block after every jmp/jcc/ret and before every leader. A CALL is
* treated as fall-through (it returns), so it does NOT end a block. A
* branch whose target is OUTSIDE `fn` (a tail call or inter-procedural jmp)
* ends the block but starts no new one inside `fn`.
*
* Blocks are emitted in ascending start order, partition [0, fn.size) with no
* gaps or overlaps, and are reported in the view's coordinate space (start/end
* are offsets from fn.base_va). Writes up to `max` blocks to `out` (NULL to
* count only) and returns the TOTAL block count, or -1 if the bytes do not
* decode cleanly (a desync: the linear walk hit an undecodable byte). Pure: it
* touches only the view and the decoder, no vmie_mem / no I/O.
*
* Example - block count and extents of one function:
* mem_view_t fn; // a SECTION_LOCAL/RVA sub-view of one function
* code_block bb[256];
* int n = cfg_blocks(fn, bb, 256);
* for (int i = 0; i < n && i < 256; i++)
* printf("block %d: [%#x, %#x)\n", i, bb[i].start, bb[i].end); */
int cfg_blocks(mem_view_t fn, code_block* out, int max);
#endif /* VMIE_CODEANALYSIS_H */
+28
View File
@@ -83,6 +83,34 @@ int gva_sig_scan_multi(vmie_mem* m, uintptr_t cr3, uint64_t lo, uint64_t hi,
int gva_code_xref(vmie_mem* m, uintptr_t cr3, uint64_t lo, uint64_t hi,
uint64_t target_va, uint64_t* out, int max);
/* immediate / constant xref: every instruction in [lo,hi] (kept by the
* protection filter `prot_any`; pass VR_X to restrict to code) whose IMMEDIATE
* operand equals `value`, compared over the low `width` bytes (width is 1, 2, 4,
* or 8). Like gva_code_xref it brute-scans each byte offset with the light
* x86-64 decoder (x86dec.h, NOT a full disassembler) and carries a >=15-byte
* (max x86 instruction length) sweep overlap so no instruction is cut at a
* window seam; the same SEAM and INTERIOR de-duplications apply (a match
* starting in a non-last window's trailing overlap is left to the next window,
* and an interior alias falling inside an already-accepted match is dropped).
*
* An instruction matches when it carries an immediate (imm_len > 0) at least
* `width` bytes wide and its low `width` bytes equal `value & mask(width)`. The
* rel/RIP-relative DISPLACEMENT of a branch is NOT an immediate and never
* matches here - use gva_code_xref for displacement targets.
*
* Records each matching instruction-start VA in the view's coordinate space.
* Writes up to `max` VAs to `out` (NULL to count only) and returns the TOTAL
* number of matches, or -1 on bad input (a NULL m, an unswept range, or a width
* that is not 1/2/4/8). Use it to answer "what code uses the constant N" - error
* codes, magic values, syscall numbers, table sizes, struct sizes.
*
* Example - sites that load the NTSTATUS 0xC0000022 (ACCESS_DENIED) as a dword:
* uint64_t sites[64];
* int n = gva_imm_xref(m, cr3, lo, hi, VR_X, 0xC0000022ull, 4, sites, 64); */
int gva_imm_xref(vmie_mem* m, uintptr_t cr3, uint64_t lo, uint64_t hi,
uint32_t prot_any, uint64_t value, int width,
uint64_t* out, int max);
/* gva bridges to the signature matcher: build mem_view from guest memory and feed sigscan.h */
int gva_sig_scan (vmie_mem* m, uintptr_t cr3, uint64_t lo, uint64_t hi,
uint32_t prot_any, const sig_pattern_t* p, uint64_t* out, int max);
+36
View File
@@ -295,6 +295,42 @@ typedef struct { uint32_t rva; uint32_t size; } func_range;
int vmie_win32_functions(vmie_win32* v, uint64_t cr3, uint64_t module_base,
func_range* out, int max);
/* One call-graph edge, with both endpoints as RVAs relative to the module base
* (absolute VA = module_base + rva).
* from - RVA of the function that contains the call/jmp site (a .pdata
* function start)
* to - RVA of the branch target (inside the same module image)
* kind - 0 = call (E8 / direct CALL), 1 = direct jmp (E9/EB, including a tail
* call to another function). */
typedef struct { uint32_t from; uint32_t to; uint8_t kind; } call_edge;
/* Build the intra-module call graph of the image at `module_base` (in the `cr3`
* address space). Reuses the existing primitives - vmie_win32_functions to
* enumerate the .pdata function starts, vmie_win32_section_view to gather the
* .text bytes, and x86_decode to step each function - and emits one edge for
* every DIRECT call/jmp (has_rel) whose resolved target lands inside the module
* image [module_base, module_base + SizeOfImage). `from` is the containing
* function's RVA, `to` is the target's RVA.
*
* INDIRECT calls/jmps (through a register or memory, e.g. `call [rip+disp]` or
* `jmp rax`) are SKIPPED here - they carry no static rel target. Resolve those
* separately: switch tables via gva_jumptable, import thunks via the IAT (a
* wave-2 concern). A direct branch whose target falls OUTSIDE the image (an
* inter-module jmp/call) is also skipped - the graph is intra-module by
* construction.
*
* Writes up to `max` edges to `out` (NULL to count only) and returns the TOTAL
* edge count, or -1 if the .pdata/.text directory is missing or unreadable.
* Edges are grouped by source function (all of one function's edges are
* contiguous), in ascending function order.
*
* Example - out-degree of each function:
* call_edge e[4096];
* int n = vmie_win32_callgraph(v, pr->cr3, m.base, e, 4096);
* // group by e[i].from to get each function's callees */
int vmie_win32_callgraph(vmie_win32* v, uint64_t cr3, uint64_t module_base,
call_edge* out, int max);
/* One exported symbol from the module export directory (EAT).
* rva - export target RVA (absolute VA = module_base + rva). Forwarder
* exports report the forwarder-string RVA; see `forwarded`.
+29 -1
View File
@@ -46,6 +46,26 @@ typedef struct {
uint8_t disp_len; /* displacement length: 1 (rel8), 4 (rel32 or RIP-rel
* disp32), else 0 (no displacement). The wildcard span is
* [disp_off, disp_off + disp_len). */
uint8_t imm_off; /* byte offset, within the instruction, of the IMMEDIATE
* operand (the trailing constant: imm8/16/32/64 of mov
* reg,imm / cmp r/m,imm / push imm / test / add ...), or
* 0 if the instruction carries no immediate
* (imm_len == 0). This is distinct from disp_off: disp_*
* is the rel/RIP-relative DISPLACEMENT (an address that
* floats with the load address), imm_* is the encoded
* CONSTANT operand. An instruction can have neither, one,
* or - for a few forms (e.g. a RIP-relative store of an
* immediate) - both. The immediate value lives at
* code[imm_off .. imm_off + imm_len), little-endian. */
uint8_t imm_len; /* immediate length in bytes: 1, 2, 4, or 8 (resolved
* against the effective operand size: the 66 prefix and
* REX.W are honoured, so e.g. mov r,imm is 2/4/8 and
* push imm / cmp r/m,imm32 is 2/4). 0 when the
* instruction has no single immediate operand; the rare
* combined-immediate forms (ENTER imm16,imm8; far ptr)
* also report 0 here - they are not a clean constant.
* The constant-xref scanner (gva_imm_xref) reads the low
* `width` bytes at imm_off when imm_len >= width. */
} x86_insn;
/* Decode ONE 64-bit-mode instruction at `code` (`avail` readable bytes). Fills
@@ -59,7 +79,15 @@ typedef struct {
* byte position and length of the rel/RIP-relative displacement field within the
* instruction (0/0 when there is none). These are exactly the bytes that float
* with the load address / relocation, so a signature generator wildcards
* [disp_off, disp_off+disp_len) and keeps the rest as must-match. */
* [disp_off, disp_off+disp_len) and keeps the rest as must-match.
*
* It also reports out->imm_off / out->imm_len: the position and length of the
* trailing IMMEDIATE constant operand (imm8/16/32/64), or 0/0 when there is
* none. The immediate is the encoded literal (a magic value, error code, table
* size, syscall number, ...) - distinct from the rel/RIP displacement. The
* length honours the 66 prefix and REX.W (so mov r,imm is 2/4/8); combined-
* immediate forms (ENTER, far ptr) report imm_len 0. This is what the
* constant-xref scanner (gva_imm_xref) compares against a wanted value. */
int x86_decode(const uint8_t* code, size_t avail, x86_insn* out);
/* Absolute target of a rel branch: ip + insn->len + insn->rel (0 unless has_rel). */