- 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
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
0through 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 (
scanInstructionsForConstHazardsin 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 aCopy 0or 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 whensign(a)==sign(b) && sign(a+b)!=sign(a)(signed); model it inhandleIntegerArithmeticand 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_MAXwith a constant RHS may be provable directly without a solver.
Potential paths.
- P1: emit int-overflow on
IntCarry/IntSCarry/IntSBorrowin the raw lane (the ops already exist; only the finding hook is missing). - P2: signed-overflow SMT check in
handleIntegerArithmetic(binArith), firingonBinaryArith(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/freecallees 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 binarydetect 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_memoryas 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):
-
Gap order (one at a time, in state file
docs/research/binary-detector-state.md): null-deref → int-overflow → use-after-free. -
Signal-to-noise pre-check (before any implementation). For the current gap's candidate path, measure the signal's specificity: run
pcode_scanonclean.elf(the negative control) and count how many times the candidate signal fires in the zig runtime alone. If it fires onclean.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. -
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.
-
Bounded implementation + validate + retain/reject (unchanged): implement one sub-hypothesis,
ninja -C build/release, benchmarkbench/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):
-
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-onlyclang -Sstill works for inspection.) -
clang/gcc remove the zig-specific carry-flag signal.
clang -S -O0ofvuln(int a){ return a + 2147483647; }emitsaddl $2147483647, %eax; retqwith NOseto/jo— signed overflow is UB, so clang does not check it. zig emitsadd+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 aif (d == 0) panicguard, so the encoder's CFG walk reaches theidivon 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 impliesd == 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
ais stored to a stack slot then re-loaded into a WRITTEN register (mov eax,[var_4h]) beforeadd 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:
SafetyObserver::onUseAfterFreehook +BinaryBBObserver::onUseAfterFree(emitsbinary-use-after-free, FindingKind::UseAfterFree).- Encoder
handleCalldetects thefree/cfree/zdlpv/zdapvcallee and records the freed pointer. KEY: the free arg is in the arg REGISTER (raw Sleigh RDI=0x38), NOTop.in— the lifter only injects arg prototypes for KNOWN sinks, and free is not one (the Call op carries only the target). - Encoder
handleMemory(Load/Store) checks the address against the freed set and firesonUseAfterFreeon 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.