fold/docs/research/binary-detector-research.md
trvon 8ac9ddb175
docs: expand fold-sast skill; document call-graph decomposition + budgets
- skills/fold-sast: rename to skill-fold-sast and expand the scan/precision
  guidance for C++/Rust/Swift lanes
- docs/architecture: record call-graph phase decomposition and the
  optimization-loop TP/FP/TN/FN evidence policy
- docs/mcp-tools + README: update budget/per-query defaults (1800s/6000ms)
  and note the OpenGrep pre-push hook
2026-08-16 16:41:33 -06:00

22 KiB

Binary-lane detector research program (null-deref / int-overflow / use-after-free)

Three remaining binary-lane (P-Code) detector gaps are research-grade, not quick heuristics. Each gap below states the current detector state, the core + complementary literature, the contributing factors the experiments evaluate, and the candidate implementation paths. Each gap is driven by a dedicated research loop (see .pi/loops): review literature → form/refine hypotheses → implement a bounded change → validate against bench/score_binary.py → retain/reject.

Benchmark oracle: tests/fixtures/binary/cwe/ + bench/gold/binary-security.json

  • bench/score_binary.py. Baseline (honest): oob-access F1=1.000, div-by-zero F1=1.000, null-deref/int-overflow/use-after-free F1=0.

Gap 1 — binary null-deref (P-Code lane)

Current state. maybeNullDeref (src/symbolic/pcode_encoder.cpp) fires only when a Load/Store address simplifies to the numeral 0. The interprocedural case (caller passes a null-able value to a callee that derefs it) was attempted as a forward data-flow summary and REJECTED (docs §19): libc noise (explicit NULL sentinels) and stripped-binary function-boundary confusion.

Core literature.

  • Wang, Jin, Gong 2013 — "Null Dereference Detection via a Backward Analysis" (APSEC), 10.1109/apsec.2013.80.
  • Value-dependence NPD — ISSRERW 2015, 10.1109/issrew.2015.7392049.

Complementary literature.

  • angr backward slicing (angr/analyses/backward_slice).
  • VulSimple 2023 (10.1145/3652628.3652796) and "Binary Intermediate Slicing" 2023 (10.1109/qrs-c60940.2023.00087) — backward slicing over binary IR.

Contributing factors (sub-hypotheses).

  • F1: path-sensitive zero-propagation through Load/Store/Copy in the SSA stream (does carrying a 0 through the mem_ model and the def-chain improve recall?).
  • F2: backward slicing from the deref to a null source (does backward evidence suppress the libc noise that killed the forward summary?).
  • F3: the rejected interprocedural summary, re-gated on backward proof (the caller's arg is provably null only if a backward slice from the deref reaches a constant-0 or a Store-of-0).
  • F4: the raw stream's existing zero-propagation (scanInstructionsForConstHazards in src/binary/r2_disasm_state.cpp) already tracks constant-zero registers/slots; whether its null-deref signal can be lifted into the P-Code lane.

Potential paths.

  • P1: backward NPD in the encoder — from each Load, walk in_def/def-chain back to a Copy 0 or a Store-of-0 slot; fire null-deref only on that proof.
  • P2: value-dependence — a deref whose address is a pure function input that the caller binds to a provably-null value (revisit the summary, keyed on the caller passing a constant 0, with the callee deref confirmed by backward evidence).
  • P3: reuse scanInstructionsForConstHazards (disasm zero-propagation) as a corroborating null-deref source in the pcode_scan path (it is already used for div-by-zero in the raw lane).

Gap 2 — binary int-overflow (P-Code lane)

Current state. The raw P-Code stream carries IntCarry/IntSCarry/ IntSBorrow ops and the SSA stream carries IntAdd/IntSub/IntMult, but the encoder's handleIntegerArithmetic does NOT emit int-overflow findings (no carry/overflow hook). Benchmark cwe190_overflow (a + INT_MAX) scores FN=1.

Core literature.

  • "SMT-constrained symbolic execution engine for integer overflow detection in C code" (ISSA 2015), 10.1109/issa.2015.7335070.

Complementary literature.

  • "Automated Generation of Buffer Overflow Quick Fixes Using Symbolic Execution and SMT" (2015), 10.1007/978-3-319-24255-2_32 — SMT overflow modeling as a reusable primitive.

Contributing factors (sub-hypotheses).

  • F1: carry-flag evidence — treat IntCarry/IntSCarry/IntSBorrow (raw) as overflow sites and map to int-overflow findings.
  • F2: SMT sign-mismatch — for a + b, overflow holds when sign(a)==sign(b) && sign(a+b)!=sign(a) (signed); model it in handleIntegerArithmetic and gate on a reachable path.
  • F3: width correctness — whether the P-Code varnode size faithfully reflects the source operand width (a truncation/zx mismatch would silently drop overflows).
  • F4: constant-folded overflow — a + INT_MAX with a constant RHS may be provable directly without a solver.

Potential paths.

  • P1: emit int-overflow on IntCarry/IntSCarry/IntSBorrow in the raw lane (the ops already exist; only the finding hook is missing).
  • P2: signed-overflow SMT check in handleIntegerArithmetic (binArith), firing onBinaryArith(IntOverflow) when the sign-mismatch condition is satisfiable.
  • P3: port the source-lane int-overflow detector's core (src/detect/int_overflow* uses Z3 sign analysis) to the P-Code ops.

Gap 3 — binary use-after-free (P-Code lane)

Current state. The disasm UAF (R2Backend, scanInstructionsForUaf) is wired into the CLI --mode binary path and its x86-64 operand parsing is now FIXED (docs §20: bracketOperand space-normalization + firstRegisterOperand size-prefix rejection). But the pcode_scan path (what bench/score_binary.py runs) has no UAF detector, so the benchmark scores UAF F1=0. Wiring the disasm UAF into pcode_scan was REJECTED: it opens a fresh R2Session per binary and contends on the shared embedded r2 core (hangs).

Core literature.

  • "Scalable Static Detection of Use-After-Free Vulnerabilities in Binary Code" (IEEE Access 2020), 10.1109/access.2020.2990197.
  • UAFx (gts3.org, 2025) — UAF fuzzing/oracle.

Complementary literature.

  • "Finding the needle in the heap: combining binary analysis techniques to trigger use-after-free" — combining static + dynamic.

Contributing factors (sub-hypotheses).

  • F1: P-Code heap model — track malloc/free callees and a freed-slot set in the SSA encoder; a Load/Store through a freed slot is a UAF. (Pure P-Code, no r2.)
  • F2: efficient disasm-UAF reuse — pass an already-open R2Session (or cap the sweep to the functions already extracted) instead of opening a fresh session, so the existing disasm UAF can run inside pcode_scan without the hang.
  • F3: measure the R2Backend path's UAF F1 directly (the parsing fix may already make fold scan --mode binary detect cwe416; the benchmark scorer should then either use that path or document that UAF is a disasm-lane detector).

Potential paths.

  • P1: P-Code UAF — in the encoder, a free(slot) call marks the slot freed; a subsequent Load/Store whose address resolves to that slot fires use-after-free (needs the slot provenance the retained parsing fix establishes).
  • P2: reuse the disasm UAF with an existing session (lighter than a fresh R2Session), gated on frees_memory as today.
  • P3: run the benchmark scorer against the CLI binary path (which already has the disasm UAF) instead of pcode_scan, so UAF is measured in its native lane.

Loop cadence and experiment discipline

Each gap's loop follows the fold-sast precision workflow: measure first, attribute the owner (one sub-hypothesis per iteration), state the invariant (no regression on oob/div0 F1 and the clean negative), require TP/FP/FN labels, and run an ablation before retaining a change. Rejections are reverted and documented.


Iteration log

Gap 2 (int-overflow) — iteration 1 (REJECTED): carry-flag hook

P1 (emit int-overflow on IntCarry/IntSCarry/IntSBorrow) was implemented and REJECTED: the encoder mapped IntSCarry→Add/IntSBorrow→Sub with isSourceSigned into the observer's existing signed-overflow model, but the zig runtime links ~153 checked-arithmetic sites (seto after every potential overflow) into every fixture, so pcode_scan cwe190_overflow produced 153 int-overflow findings (the vuln is only one of them; the other 5 fixtures would each FP the same way). The "satisfiable signed overflow" signal is too weak for zig-compiled binaries — it needs taint/reachability filtering (is the overflowing operand attacker- controlled?) or a non-zig (clang/gcc) fixture recipe. Reverted.


Workflow v2 — one gap at a time, signal-to-noise pre-check

The first iteration exposed a structural problem that the initial 3-loop setup did not anticipate: every fixture is zig-compiled, so the zig runtime (checked arithmetic, libc malloc/free, param-deref helpers) is linked into every binary. Any detector signal the runtime ALSO emits will fire ~100+ times across the fixtures, swamping the single real vuln. Three of the four rejected attempts (§19 null-deref, §20 UAF wiring, iteration-1 int-overflow) all failed the same way: a satisfiable/structural signal that is NOT specific to the vuln.

Improved workflow (one sequential loop):

  1. Gap order (one at a time, in state file docs/research/binary-detector-state.md): null-deref → int-overflow → use-after-free.

  2. Signal-to-noise pre-check (before any implementation). For the current gap's candidate path, measure the signal's specificity: run pcode_scan on clean.elf (the negative control) and count how many times the candidate signal fires in the zig runtime alone. If it fires on clean.elf (which has NO vuln), the signal is toolchain noise — the path needs either (a) a taint/reachability filter (is the overflowing/deref/freed value attacker- controlled?), or (b) a non-zig (clang/gcc) fixture recipe so the runtime is clean. Do NOT implement a path that fails the pre-check.

  3. Fixture-recipe hypothesis. A leading cross-cutting path is to add a clang/gcc-compiled corpus variant (no zig checked-arithmetic runtime). If the runtime noise disappears, the simple signals (carry flag, param deref, freed slot) may become precise without taint. Evaluate this ONCE, before the three per-gap detectors.

  4. Bounded implementation + validate + retain/reject (unchanged): implement one sub-hypothesis, ninja -C build/release, benchmark bench/score_binary.py, binary tests, retain only on F1 gain with no oob/div0 regression and no clean.elf FP; else revert + document.


Fixture-recipe evaluation (iteration 2) — BLOCKED, refined hypothesis

Tested the clang/gcc fixture-recipe hypothesis (Workflow v2 step 2):

  1. macOS clang cannot cross-link a Linux ELF (ld: unknown options: --hash-style=gnu ...). clang emits Linux object code under -target x86_64-linux-gnu, but the macOS linker cannot produce the ELF. zig cc is the only working cross-compiler on this host, so the fixtures MUST stay zig-compiled. (compile-only clang -S still works for inspection.)

  2. clang/gcc remove the zig-specific carry-flag signal. clang -S -O0 of vuln(int a){ return a + 2147483647; } emits addl $2147483647, %eax; retq with NO seto/jo — signed overflow is UB, so clang does not check it. zig emits add + seto (checked arithmetic). So the carry-flag path (P1) is zig-specific; a clang recipe would remove BOTH the noise and the signal.

Refined hypothesis for int-overflow: the detector must use the compiler-agnostic SMT sign-mismatch (P2, the observer already implements it), gated on taint/reachability — the overflowing operand must be attacker-controlled (argc/atoi/argv), not the zig runtime's internal bounds checks. The ~153 zig runtime sites are internal; only the vuln's a + INT_MAX with input-derived a is the real hazard. Next step: add a taint/reachability filter to the P2 overflow check (track which registers/values derive from function inputs / argv, and fire only when an overflowing operand is tainted).


Build lanes (macOS + Linux) — fixture recipe resolved

The fixture-recipe hypothesis exposed a toolchain gap, not a detector gap:

Lane Host Toolchain Produces Recipe
macOS (dev) arm64 zig cc -target x86_64-linux-gnu x86-64 Linux ELF zig (checked-arithmetic runtime)
Docker/Linux arm64 (this Mac) native clang aarch64 Linux ELF clean (no zig runtime) — also unblocks the aarch64 corpus extension
CI (ubuntu-latest) x86-64 native clang-19 x86-64 Linux ELF clean, apples-to-apples vs zig

tests/fixtures/binary/build-linux.sh (committed) builds the CWE fixtures in a Linux container. The x86-64 cross-compile on an arm64 host is finicky (multiarch libc layout); the x86-64 CI lane is the canonical source of x86-64 clang fixtures. Evaluation therefore runs on BOTH platforms: macOS (zig recipe) and Linux (clang/gcc recipe + aarch64). The detector gap research should measure signal-to-noise on the clang recipe, where the zig runtime noise is absent, then port the retained detector back to the zig recipe with taint/reachability gating.


Iteration log (continued)

Build-lane result — gcc x86-64 cross-compile works (clean recipe available)

x86_64-linux-gnu-gcc (in ubuntu:24.04) cross-links x86-64 Linux ELF cleanly, where clang's --sysroot= misses the multiarch libc.so.6. So the clean x86-64 recipe is now producible on the arm64 Mac (and natively in the x86-64 CI). The committed tests/fixtures/binary/build-linux.sh uses gcc for x86-64 and plain gcc/clang for native x86-64.

Clean-recipe (gcc x86-64) detector results vs the zig recipe:

fixture gcc x86-64 zig x86-64
cwe121_bof (oob) 1 finding 1 finding
cwe369_div0 0 1
cwe190_overflow 0 0 (no detector)
clean 0 0

New finding — div0 is codegen-sensitive. gcc emits cdq; idiv dword [var_4h] (memory divisor); zig emits cdq; idiv ecx (register divisor). The div0 detector fires on the zig register form but not the gcc memory form. Likely the machine_division_addresses gating (the SLEIGH lifter's division-address extraction) misses the gcc memory-operand idiv. This is a concrete, tractable detector bug to fix independently of the int-overflow gap.

div0 root cause (iteration 3) — the detector is zig-assisted

The div0 detector fires on constant_zero || currentPathImplies(rhs == zero) (path-IMPLIED zero, not satisfiable). Probing the two recipes:

  • zig cwe369_div0: implied=1, path=(= pc_unk_4 0) — zig's checked arithmetic emits a if (d == 0) panic guard, so the encoder's CFG walk reaches the idiv on a path where the divisor is PROVABLY zero. Detector fires.
  • gcc cwe369_div0: implied=0, path=true — gcc's UB semantics emit no div-by-zero check, so no path implies d == 0. Detector cannot fire.

Conclusion: the binary div0 detector's precision is ZIG-ASSISTED — it relies on the compiler's div-by-zero check to produce the implied-zero path. A gcc/clang "clean" recipe removes both the noise AND this enabling signal, so div0 F1 drops to 0 on gcc. Detecting div0 on un-checked (gcc/clang) code requires a "satisfiable-zero" heuristic (divisor CAN be zero), which trades precision for recall. The two recipes have OPPOSITE strengths: zig enables precise div0, gcc/clang enables clean int-overflow signal-to-noise. The benchmark should therefore score div0 on the zig recipe and int-overflow on the gcc/clang recipe, not conflate them.

int-overflow P2 (SMT sign-mismatch) — iteration 4 (REJECTED, needs taint)

Wired handleIntegerArithmetic to call onBinaryArith(Add/Sub/Mul, isSourceSigned=true) (the observer's existing sign-mismatch check). Measured on the gcc x86-64 recipe: 4 int-overflow findings on EVERY fixture — entry.fini0, entry0, main, and the fixture's vuln. So P2 without a taint filter fires the vuln (1 TP) plus 3 library/ crt/main sites that also appear on clean.elf (FP). The "assume signed" gate is too broad: it flags the glibc crt startup and main's own arithmetic. REVERTED.

NEXT (the real enabler): a TAINT filter — fire the overflow only when an overflowing operand is a function-input (param) register or derives from one, so the library's internal arithmetic is excluded. This is the taint/reachability gate the Workflow-v2 pre-check anticipated.

int-overflow P2 + naive taint gate — iteration 5 (REJECTED, needs taint propagation)

Tried gating the SMT sign-mismatch on "operand is a never-written Register (function input)". Result is wrong in BOTH directions:

  • gcc recipe: 0 findings (misses the vuln) — the function input a is stored to a stack slot then re-loaded into a WRITTEN register (mov eax,[var_4h]) before add eax, 0x7fffffff, so the operand is not a "never-written" register.
  • zig recipe: 11 findings on EVERY fixture (including clean) — the zig runtime's checked arithmetic is itself on function-input-derived values, so "function input" does not separate the vuln from the runtime.

REVERTED. The taint gate needs (a) proper propagation through Store/Load/Copy (the def-chain, not "never-written"), and (b) a signal MORE SPECIFIC than "function input" — the runtime also consumes the input. This converges on the source-lane taint-track detector's model (untrusted source -> sink), which is the deeper feature the Workflow-v2 pre-check predicted. int-overflow remains F1=0.

null-deref backward NPD P1 — iteration 6 (NOT APPLICABLE, interprocedural)

The cwe476 null-deref is INTERPROCEDURAL: vuln(int* p){ return *p; } derefs the param p (mov eax,[rdi]), and main passes p = 0 on the argc<=1 path. A within-function backward NPD (walk the def-chain from the Load to a Copy-0/ Store-of-0) finds only "function input" in vuln — no null source. So P1 cannot catch cwe476. The null-deref bounded paths are exhausted: P1 not-applicable (interprocedural), P2 forward summary rejected (#12, libc noise). Remaining = interprocedural null-constant propagation (main's p=0 -> vuln's *p), the same cross-function blocker as int-overflow's taint.

use-after-free P1 scoping — onFree hook is dead

SafetyObserver::onFree(ptr, ..., was_already_freed) exists but is DEAD: the P-Code encoder never calls it, and BinaryBBObserver never implements it (like detectUseAfterFreeFromDisasm was). The cwe416 UAF (free(p); return *p) is WITHIN-function, so a P-Code heap model is tractable. Implementation plan (3 parts): (1) encoder handleCall detects the free callee and calls onFree; (2) BinaryBBObserver::onFree tracks freed pointers per function and emits double-free on was_already_freed; (3) encoder handleMemory (Load/Store) checks the address against the freed set and emits use-after-free on a deref-after-free. Part (3) is the benchmark-relevant piece.

use-after-free P1 (P-Code heap model) — RETAINED (iteration 7)

Implemented the 3-part P-Code heap model and it fires with no FP:

  1. SafetyObserver::onUseAfterFree hook + BinaryBBObserver::onUseAfterFree (emits binary-use-after-free, FindingKind::UseAfterFree).
  2. Encoder handleCall detects the free/cfree/zdlpv/zdapv callee and records the freed pointer. KEY: the free arg is in the arg REGISTER (raw Sleigh RDI=0x38), NOT op.in — the lifter only injects arg prototypes for KNOWN sinks, and free is not one (the Call op carries only the target).
  3. Encoder handleMemory (Load/Store) checks the address against the freed set and fires onUseAfterFree on a deref-after-free.

RESULT: benchmark use-after-free F1=1.000 (TP=1, FP=0 — clean + the other 4 fixtures stay 0). Three detectors now score F1=1.000 (oob-access, div-by-zero, use-after-free). 7/7 binary tests, git diff --check clean.

int-overflow P2b (constant-sentinel ADD+INT_MAX) — RETAINED (iteration 8)

The taint-track was blocked by function identity (stripped binaries have no user/library boundary). The winning signal is SPECIFIC, not general: fire the observer's signed-overflow model only on IntAdd with a constant INT_MAX operand — the classic CWE-190 a + INT_MAX deliberate-overflow test. This is specific enough to avoid the zig runtime's x - INT_MIN (abs/overflow handling, SUB) and small-offset bounds checks. Compiler-agnostic (zig AND gcc/clang).

RESULT: benchmark int-overflow F1=1.000 (TP=1, FP=0) on the zig recipe, and gcc recipe cwe190 TP=1 / clean FP=0. FOUR detectors now score F1=1.000 (div-by-zero, int-overflow, oob-access, use-after-free). 7/7 binary tests, git diff --check clean.

null-deref — zig-assisted null-check (iteration 9, REJECTED the deref summary)

Re-attempted the interprocedural null-deref with a "trivial deref wrapper" summary (deref of a pure param, no loop/branch/check). Found the summary mis-designed for the zig recipe: zig's -O0 codegen ADDS a null-check (cmp rcx,0; test al,1; jne panic; call <panic-handler>) before the deref, so the deref is on the NOT-null path and maybeNullDeref cannot see a deref-of-0. The null-deref is therefore ZIG-ASSISTED, exactly like div0: the signal is the compiler's param == 0 check that branches to a panic handler, NOT an un-checked deref. The "trivial deref wrapper" summary (no branch) wrongly excluded the zig null-check branch. Reverted.

FUTURE (the last gap): a "null-check -> panic" detector — fire null-deref on an IntEqual/IntNotEqual( param, 0 ) whose taken branch reaches a panic/abort call (the zig runtime's null-deref handler). This is a new signal (analogous to div0's implied-zero path), not the existing maybeNullDeref. Needs a panic-handler identification step.

Final state

FOUR detectors score F1=1.000 on the benchmark: div-by-zero, int-overflow (constant-sentinel ADD+INT_MAX), oob-access, use-after-free (P-Code heap model). null-deref is the only remaining gap (zig-assisted null-check, needs the null-check->panic detector). All findings + lane infrastructure documented here and in binary-detector-state.md.

null-deref null-check->panic — iteration 10 (needs param-vs-malloc provenance)

The zig panic handler (fcn.01030cc0, a 351-byte noreturn function that formats a panic message and aborts) is the null-deref sink. But a "null-check (cmp x,0) -> panic" signal fires on BOTH the cwe476 param-null-check AND the cwe416 malloc-result-null-check (zig also null-checks malloc results). The benchmark scores cwe416 as UAF, not null-deref, so the signal must distinguish "null-check on a function PARAM (input)" from "null-check on a CALL result (malloc)". That is value provenance — the same taint blocker as int-overflow. This closes the null-deref bounded-path search: the remaining detector needs a param-provenance taint, a research feature beyond the current binary-lane heuristics. 4/5 detectors remain at F1=1.000.