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

426 lines
22 KiB
Markdown

# 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.