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
+110
View File
@@ -1,9 +1,11 @@
#include "pe.h"
#include <string.h>
#include <stdlib.h> /* malloc/free (cold call-graph gather only) */
#include "memmodel.h" /* gva_read, VR_* */
#include "sigscan.h" /* mem_sub (pure matcher; engine may use it) */
#include "win32.h" /* public surface: vmie_win32, section_desc, view_base */
#include "x86dec.h" /* x86_decode / x86_branch_target (call-graph step) */
/* IMAGE_SECTION_HEADER: 8-byte Name, then Misc.VirtualSize(+8), VirtualAddress
* (+12), and Characteristics(+36); the header is 40 bytes wide. */
@@ -406,3 +408,111 @@ int vmie_win32_section_view(vmie_win32* v, uint64_t cr3, uint64_t module_base,
out->data = buf; out->size = n; out->base_va = base_va;
return 0;
}
/* ---- public win32 surface: intra-module call graph ----------------------- *
* Reuses the existing primitives only: vmie_win32_functions (.pdata starts),
* vmie_win32_section_view (.text bytes), and x86_decode (the light decoder) -
* there is no second PE parser and no second decoder here. For each function it
* steps the bytes linearly and, on a DIRECT call/jmp (has_rel), resolves the
* target and, if it lands inside the image, emits one {from, to, kind} edge.
* Cold: one-shot directory + section gather, not a hot loop. */
/* SizeOfImage lives in the PE32+ OptionalHeader at +0x38; the OptionalHeader
* begins at NT(base+lfanew)+0x18 (Signature(4)+FileHeader(20)). */
#define OPT_SIZEOFIMAGE_OFF 0x38u
int vmie_win32_callgraph(vmie_win32* v, uint64_t cr3, uint64_t module_base,
call_edge* out, int max) __attribute__((cold));
int vmie_win32_callgraph(vmie_win32* v, uint64_t cr3, uint64_t module_base,
call_edge* out, int max) {
vmie_mem* m = vmie_win32_mem(v);
if (!m) { return -1; }
/* image bounds: [module_base, module_base + SizeOfImage). */
uint32_t lfanew;
if (gva_read(m, cr3, module_base + 0x3C, &lfanew, 4)) { return -1; }
uint32_t size_of_image;
if (gva_read(m, cr3, module_base + lfanew + 0x18 + OPT_SIZEOFIMAGE_OFF,
&size_of_image, 4)) {
return -1;
}
/* locate .text (the executable section the .pdata functions live in). */
section_desc sd[96];
const int ns = vmie_win32_sections(v, cr3, module_base, sd, 96);
if (ns < 0) { return -1; }
const int nsuse = ns < 96 ? ns : 96;
const section_desc* text = NULL;
for (int i = 0; i < nsuse; i++) {
if (strcmp(sd[i].name, ".text") == 0) { text = &sd[i]; break; }
}
if (!text) {
/* fall back to the first executable section */
for (int i = 0; i < nsuse; i++) {
if (sd[i].prot & VR_X) { text = &sd[i]; break; }
}
}
if (!text) { return -1; }
/* gather the executable section once, addressed at its absolute VA so a
* decoded branch target is directly an absolute VA. */
uint8_t* tbuf = malloc(text->vsize);
if (!tbuf) { return -1; }
mem_view_t tv;
if (vmie_win32_section_view(v, cr3, module_base, text, ABSOLUTE_VA,
tbuf, text->vsize, &tv) != 0) {
free(tbuf);
return -1;
}
const uint64_t text_lo = module_base + text->rva; /* tv.base_va */
const uint64_t text_hi = text_lo + tv.size; /* exclusive */
/* function inventory: count, then gather (stack for the common case, heap on
* overflow) so every function is stepped, none silently dropped. */
const int nfn = vmie_win32_functions(v, cr3, module_base, NULL, 0);
if (nfn < 0) { free(tbuf); return -1; }
func_range stack_fr[256];
func_range* fr = stack_fr;
func_range* heap_fr = NULL;
if (nfn > (int)(sizeof stack_fr / sizeof stack_fr[0])) {
heap_fr = malloc((size_t)nfn * sizeof *heap_fr);
if (!heap_fr) { free(tbuf); return -1; }
fr = heap_fr;
}
const int got = vmie_win32_functions(v, cr3, module_base, fr, nfn);
if (got < 0) { free(heap_fr); free(tbuf); return -1; }
int total = 0;
for (int f = 0; f < got; f++) {
const uint64_t fn_lo = module_base + fr[f].rva;
const uint64_t fn_hi = fn_lo + fr[f].size;
/* the function must lie inside the gathered section. */
if (fn_lo < text_lo || fn_hi > text_hi) { continue; }
size_t off = (size_t)(fn_lo - text_lo);
const size_t end = (size_t)(fn_hi - text_lo);
while (off < end) {
x86_insn in;
const int ilen = x86_decode(tv.data + off, end - off, &in);
if (ilen <= 0) { break; } /* desync: stop this fn */
const uint64_t ip = text_lo + off;
if (in.has_rel && (in.flow == X86_CALL || in.flow == X86_JMP)) {
const uint64_t tgt = x86_branch_target(ip, &in);
if (tgt >= module_base &&
tgt < module_base + (uint64_t)size_of_image) {
if (out && total < max) {
out[total].from = fr[f].rva;
out[total].to = (uint32_t)(tgt - module_base);
out[total].kind = (in.flow == X86_CALL) ? 0u : 1u;
}
total++;
}
}
off += (size_t)ilen;
}
}
free(heap_fr);
free(tbuf);
return total;
}
+140
View File
@@ -0,0 +1,140 @@
/* codeanalysis.c - generic x86-64 code-structure analysis (see codeanalysis.h).
*
* gva_jumptable - recover an indirect-jump table (array of absolute code
* pointers) by reading consecutive 8-byte entries and keeping
* those that land in an executable region (region-map tested).
* cfg_blocks - split one function view into basic blocks with the decoder.
*
* Handler boundary: includes only memmodel.h / sigscan.h / x86dec.h (via
* codeanalysis.h) + the standard headers. It names no OS object and reuses the
* generic region map (gva_regions) and the light decoder (x86_decode) - it has
* no PE/Windows knowledge and no second decoder.
*/
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h> /* malloc/free (cfg leader bitmap, pure w.r.t. I/O) */
#include "memmodel.h"
#include "sigscan.h"
#include "x86dec.h"
#include "codeanalysis.h"
/* ---- jump-table recovery ------------------------------------------------- *
* Snapshot the executable runs once (cold setup), then read 8-byte entries from
* table_va and keep each that lands inside one of those VR_X runs, stopping at
* the first non-code-pointer / read failure / max. */
#define JT_MAXX 256 /* executable runs sampled for membership */
/* Is `va` inside one of the `n` executable runs `xr`? Linear scan: a module has
* a handful of X runs, and a switch table is short. */
static int in_x_region(const vregion* xr, int n, uint64_t va) {
for (int i = 0; i < n; i++) {
if (va >= xr[i].va && va < xr[i].va + xr[i].len) { return 1; }
}
return 0;
}
int gva_jumptable(vmie_mem* m, uintptr_t cr3, uint64_t table_va,
uint64_t* targets, int max) __attribute__((cold));
int gva_jumptable(vmie_mem* m, uintptr_t cr3, uint64_t table_va,
uint64_t* targets, int max) {
if (!m) { return -1; }
/* Executable runs under cr3, clamped to whichever canonical half the table
* sits in (gva_regions requires lo/hi in a single half). Code pointers in a
* jump table point into the same image, hence the same half as the table. */
const int kern = (table_va >= KERN_MIN);
const uint64_t lo = kern ? KERN_MIN : USER_MIN;
const uint64_t hi = kern ? ~0ull : USER_MAX;
vregion xr[JT_MAXX];
const int nx = gva_regions(m, cr3, lo, hi, VR_X, xr, JT_MAXX);
if (nx < 0) { return -1; }
const int nuse = nx < JT_MAXX ? nx : JT_MAXX;
int n = 0;
for (uint64_t va = table_va; ; va += 8) {
uint64_t entry;
if (gva_read(m, cr3, va, &entry, 8)) { break; } /* read failure */
if (!in_x_region(xr, nuse, entry)) { break; } /* not a code ptr */
if (targets && n < max) { targets[n] = entry; }
n++;
}
return n;
}
/* ---- basic-block split --------------------------------------------------- *
* Two linear passes with the decoder. A "leader" is the first instruction of a
* block: offset 0, the fall-through after any jmp/jcc/ret, and the in-function
* target of any jmp/jcc. A block runs from one leader up to (exclusive) the next
* leader. Pure: only the view and x86_decode, no vmie_mem. */
/* A terminator ends a block: an unconditional/conditional jump or a return. A
* CALL is fall-through (it returns), so it is NOT a terminator. */
static int is_terminator(x86_flow f) {
return f == X86_JMP || f == X86_JCC || f == X86_RET;
}
int cfg_blocks(mem_view_t fn, code_block* out, int max) {
if (!fn.data || fn.size == 0) { return -1; }
if (fn.size > 0xFFFFFFFFu) { return -1; } /* offsets are u32 */
const size_t size = fn.size;
/* leader[off] != 0 marks the start of a basic block. */
uint8_t* leader = calloc(1, size);
if (!leader) { return -1; }
leader[0] = 1; /* entry is a leader */
/* Pass 1: mark fall-through-after-terminator and intra-function targets. A
* desync (undecodable byte in the linear walk) aborts with -1. */
for (size_t off = 0; off < size; ) {
x86_insn in;
const int ilen = x86_decode(fn.data + off, size - off, &in);
if (ilen <= 0) { free(leader); return -1; }
const size_t next = off + (size_t)ilen;
if (is_terminator(in.flow)) {
if (next < size) { leader[next] = 1; } /* fall-through start */
if (in.has_rel && (in.flow == X86_JMP || in.flow == X86_JCC)) {
/* branch target, in the view's coordinate space -> view offset. */
const uint64_t tgt = x86_branch_target(fn.base_va + off, &in);
if (tgt >= fn.base_va && tgt < fn.base_va + size) {
leader[(size_t)(tgt - fn.base_va)] = 1;
}
}
}
off = next;
}
/* Pass 2: walk again, emitting one block per leader run. A block ends at the
* instruction after a terminator, or just before the next leader. */
int total = 0;
size_t blk_start = 0;
for (size_t off = 0; off < size; ) {
x86_insn in;
const int ilen = x86_decode(fn.data + off, size - off, &in);
if (ilen <= 0) { free(leader); return -1; }
const size_t next = off + (size_t)ilen;
const int ends = is_terminator(in.flow) ||
(next < size && leader[next]); /* leader starts next */
if (ends) {
if (out && total < max) {
out[total].start = (uint32_t)blk_start;
out[total].end = (uint32_t)next;
}
total++;
blk_start = next;
}
off = next;
}
/* a trailing run with no terminator (off ran off the end) is its own block. */
if (blk_start < size) {
if (out && total < max) {
out[total].start = (uint32_t)blk_start;
out[total].end = (uint32_t)size;
}
total++;
}
free(leader);
return total;
}
+56
View File
@@ -123,3 +123,59 @@ int gva_code_xref(vmie_mem* m, uintptr_t cr3, uint64_t lo, uint64_t hi,
}
return c.n;
}
/* ---- decoder-driven constant (immediate) xref ---------------------------- *
* Same +1 brute-scan skeleton as gva_code_xref, but the predicate compares the
* decoded IMMEDIATE operand (x86_insn.imm_off/imm_len) to a wanted value over
* its low `width` bytes - not a branch/RIP target. The SEAM and INTERIOR
* de-duplications are identical to code-xref (a +1 brute-scan invariant): the
* predicate is the only thing that differs, so the two stay as two narrow
* passes rather than a forced common skeleton. */
struct imm_cb {
uint64_t want; /* value masked to `width` bytes */
uint64_t mask; /* low-`width`-byte mask */
int width; /* 1/2/4/8 */
uint64_t cover; /* VA just past the last accepted match */
uint64_t* out; int max, n;
};
__attribute__((hot))
static int imm_sweep_cb(void* u, const uint8_t* data, size_t len,
uint64_t base, size_t ov, int last) {
struct imm_cb* c = u;
const size_t limit = last ? len : (len > ov ? len - ov : 0);
for (size_t off = 0; off < len; off++) {
if (!last && off >= limit) { break; }
x86_insn in;
const int ilen = x86_decode(data + off, len - off, &in);
if (ilen <= 0) { continue; }
if (in.imm_len < (uint8_t)c->width) { continue; } /* no imm wide enough */
/* read the low `width` bytes of the immediate, little-endian. */
uint64_t v = 0;
for (int b = 0; b < c->width; b++) {
v |= (uint64_t)data[off + in.imm_off + b] << (8 * b);
}
if ((v & c->mask) != c->want) { continue; }
const uint64_t va = base + off;
if (va < c->cover) { continue; } /* interior alias of a prior hit */
c->cover = va + (uint64_t)ilen;
if (c->out && c->n < c->max) { c->out[c->n] = va; }
c->n++;
}
return 0;
}
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) {
if (width != 1 && width != 2 && width != 4 && width != 8) { return -1; }
struct imm_cb c; memset(&c, 0, sizeof c);
c.width = width;
c.mask = (width == 8) ? ~0ull : ((1ull << (8 * width)) - 1);
c.want = value & c.mask;
c.out = out; c.max = max;
if (gva_sweep(m, cr3, lo, hi, prot_any, X86_MAX_INSN, imm_sweep_cb, &c) < 0) {
return -1;
}
return c.n;
}
+25
View File
@@ -232,8 +232,12 @@ static int decode_vex(const uint8_t* code, size_t avail, x86_insn* out) {
n += m;
/* 0F3A map is the imm8 map: every opcode carries a trailing imm8. */
int has_imm8 = 0;
size_t imm8_at = 0;
if (mmmmm == 3u) {
if (avail < n + 1) { return 0; }
imm8_at = n; /* the trailing imm8 starts here */
has_imm8 = 1;
n += 1;
}
@@ -246,6 +250,8 @@ static int decode_vex(const uint8_t* code, size_t avail, x86_insn* out) {
out->riprel = rip;
out->disp_off = rip_present ? (uint8_t)(modrm_at + rip_off) : 0;
out->disp_len = rip_present ? 4u : 0u;
out->imm_off = has_imm8 ? (uint8_t)imm8_at : 0;
out->imm_len = has_imm8 ? 1u : 0u;
return (int)n;
}
@@ -262,6 +268,11 @@ static void read_rel(const uint8_t* p, size_t off, size_t bytes, x86_insn* out)
out->has_rel = 1;
out->disp_off = (uint8_t)off; /* rel field begins here */
out->disp_len = (uint8_t)bytes; /* rel8 -> 1, rel32 -> 4 */
/* The branch's trailing `bytes` are its rel DISPLACEMENT, not a constant
* immediate: the main path provisionally tagged them as imm (E8/E9/EB/Jcc
* read their rel via the immediate-class table), so undo that here. */
out->imm_off = 0;
out->imm_len = 0;
}
/* ---- main decode --------------------------------------------------------- */
@@ -274,6 +285,7 @@ int x86_decode(const uint8_t* code, size_t avail, x86_insn* out) {
out->has_rel = 0; out->rel = 0;
out->has_riprel = 0; out->riprel = 0;
out->disp_off = 0; out->disp_len = 0;
out->imm_off = 0; out->imm_len = 0;
}
return 0;
}
@@ -283,6 +295,7 @@ int x86_decode(const uint8_t* code, size_t avail, x86_insn* out) {
out->has_rel = 0; out->rel = 0;
out->has_riprel = 0; out->riprel = 0;
out->disp_off = 0; out->disp_len = 0;
out->imm_off = 0; out->imm_len = 0;
const size_t cap = avail < 15u ? avail : 15u; /* never decode past 15 */
size_t n = 0;
@@ -345,6 +358,7 @@ int x86_decode(const uint8_t* code, size_t avail, x86_insn* out) {
out->has_riprel = rip_present; out->riprel = rip;
out->disp_off = rip_present ? (uint8_t)(modrm_at + rip_off) : 0;
out->disp_len = rip_present ? 4u : 0u;
/* 0F38 opcodes carry no immediate. imm_off/imm_len stay 0/0. */
if (n < 1 || n > 15 || n > avail) { return 0; }
out->len = (uint8_t)n;
return (int)n;
@@ -360,10 +374,13 @@ int x86_decode(const uint8_t* code, size_t avail, x86_insn* out) {
if (m == 0) { return 0; }
n += m;
if (n >= cap) { return 0; } /* trailing imm8 */
const size_t imm8_at = n; /* the imm8 starts here */
n += 1;
out->has_riprel = rip_present; out->riprel = rip;
out->disp_off = rip_present ? (uint8_t)(modrm_at + rip_off) : 0;
out->disp_len = rip_present ? 4u : 0u;
out->imm_off = (uint8_t)imm8_at; /* the 0F3A trailing imm8 */
out->imm_len = 1u;
if (n < 1 || n > 15 || n > avail) { return 0; }
out->len = (uint8_t)n;
return (int)n;
@@ -409,6 +426,14 @@ int x86_decode(const uint8_t* code, size_t avail, x86_insn* out) {
if (im) {
if (cap < n + im) { return 0; }
/* Record the immediate field position/length for a clean single
* immediate (imm8/16/32/64). The combined-immediate forms - ENTER
* (imm16+imm8, im==3) and the legacy far pointer (IM_P) - are not a
* single constant operand, so they leave imm_off/imm_len at 0/0. */
if (im == 1u || im == 2u || im == 4u || im == 8u) {
out->imm_off = (uint8_t)n;
out->imm_len = (uint8_t)im;
}
n += im;
}