fold/docs/research/vexa-binary-deobfuscation.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

668 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# VEXA × Fold binary lane — spike findings + deobfuscation task spec
Date: 2026-08-13
Status: proposal (no binary-lane source changes made)
Upstream: <https://github.com/mmert11/vexa> (GPL-3.0, x86-64 symbolic-execution /
lifting / deobfuscation on remill → LLVM IR + Bitwuzla, LIEF patching)
## TL;DR
VEXA's value to Fold is **binary-lane deobfuscation/devirtualization** — Fold's
P-Code lane (Ghidra SLEIGH → P-Code → Z3) lifts clean code well but has no story
for VMProtect/Covirt/MBA/self-modifying obfuscation. However, the spike shows the
binary lane has a **more fundamental gap first**: the BOF sink does not fire on
basic ELF fixtures at all, so the obfuscation gap cannot yet even be measured.
Recommendation: fix the baseline BOF firing (Layer 1) before investing in VEXA
deobf porting (Layer 2). Do NOT link VEXA as a dependency.
---
## 1. Spike findings
### 1.1 VEXA is not buildable on this machine (blocked)
- `xmake` is not installed.
- Git submodules (`external/remill`, `external/bitwuzla`) are **not initialized**
— empty dirs in the clone.
- remill is a heavy LLVM-based lifting library (fork: `mmert11/remill`); building
it is a multi-hour, fragile endeavor with a large load cost that conflicts with
the optimization loop's load guard (`vm.loadavg >= 30` deferral).
Verdict: a real `vexa-cli` pre-pass cannot be executed here without a substantial
bootstrap investment. VEXA's checked-in `tests/*/output.ll` are useful as prior
art for what deobfuscated output looks like (see §3), but the tool itself is out
of reach for now.
### 1.2 Fold's binary BOF detector emits 0 findings on basic ELF fixtures
Reproduced the canonical fixture recipe (from `tests/fixtures/binary/README.md`,
`zig cc -target x86_64-linux-musl/-gnu`) and ran the dedicated full-scan tool
`build/release/pcode_scan`:
| Fixture | Build | Result |
| --- | --- | --- |
| `tests/fixtures/binary/bof_x64` (committed) | static musl | `merged findings: 0` (skipped 2 oversized functions: 114–124 KB > 64 KB cap) |
| `bof_clean_elf` (regenerated) | static musl | 0 findings (same oversized-function skip) |
| `bof_clean_dyn` | dynamic glibc, real `strcpy`/`write` PLT imports | **0 findings** |
| `bof_obf_dyn` (switch-state-machine VM) | dynamic glibc | **0 findings** |
Observations:
- The sink model **exists**: `binary_bb_observer.cpp` lists `strcpy` in
`isUnboundedBinaryCopySink`, and `scanBinaryWithPcode` wires call-target names →
sink names (`extractCallTargetNames` → `cfg.call_names`). The detector is real;
it just does not fire on these fixtures.
- Static musl merges `_start`+libc into >64 KB functions that are skipped by the
`kMaxExtractedFunctionBytes` cap — so static binaries lose the small `vuln`
function into an oversized blob.
- Even the dynamic build (where `strcpy` is a clean `.dynsym` import) yields 0.
Root cause not yet isolated; the README itself calls the full BOF→ROP scan
"trigger-sensitive", and CI only exercises gadget *discovery* (`discoverRopGadgets`),
not the BOF finding.
### 1.3 Conclusion of the spike
There are **two independent gaps** in the binary lane:
- **Layer 1 (baseline):** the BOF finding does not fire on a minimal dynamic ELF
with a real `strcpy` import. Until this is fixed, any deobfuscation work has no
measurable target — a deobfuscated binary would still produce 0 findings.
- **Layer 2 (obfuscation):** once Layer 1 works, a switch-state-machine /
VM-dispatch obfuscated sink would still be missed by the P-Code lifter because
the indirect `switch` on a register state defeats r2 function/CALL resolution
and the symbolic scan's CFG. This is exactly what VEXA's `loop_reroll` +
`constant_propagation` + `state2alloca` recover.
The honest recommendation: **fix Layer 1 first**, then treat VEXA as prior art
for Layer 2, not as a linked dependency.
---
## 2. Why NOT link VEXA as a dependency
- Both projects are GPL-3.0, so licensing is compatible, but:
- **Redundant lifting representation:** Fold already lifts via SLEIGH → P-Code
(multi-arch); VEXA lifts via remill → LLVM IR (x86-64 only). A second IR
representation for one arch is net-negative complexity.
- **Heavy deps:** forked remill + Bitwuzla submodules balloon the build; Fold
already vendors SLEIGH + Z3.
- **Hobby maintenance:** single-maintainer, "no promises on commercial protectors".
- **AGENTS.md boundary:** "Do not try to lift binary instructions into the source
symbolic IR as a shortcut." VEXA-as-dependency would tempt recompiling lifted
LLVM IR back through the source detectors — forbidden.
Leverage shape: borrow the **pass ideas** (§3), not the library.
---
## 3. Task spec — deobfuscation passes for the P-Code lane
### Goal
Extend Fold's binary lane so the P-Code symbolic scan recovers control flow and
sink reachability through VM-dispatch / switch-state-machine / MBA obfuscation,
without leaving the existing P-Code + Z3 representation.
### Precondition (blocking)
**Layer 1 — make the BOF sink fire on a minimal dynamic ELF.** Owned separately:
- [ ] Isolate why `scanBinaryWithPcode` returns 0 on `bof_clean_dyn` (real `strcpy`
PLT import). Candidates: (a) r2 `extractCallTargetNames` not resolving the
PLT stub to `strcpy`; (b) `extractFunctionBytes` not separating `vuln` from
the ELF entry; (c) the sink arity/prototype injection in
`ghidra_lifter.cpp` (`sinkArity`) not matching.
- [ ] Add an integration test that asserts a BOF finding on a committed dynamic
ELF fixture (replacing/augmenting the discovery-only `test_binary_rop.cpp`).
- [ ] Acceptance: `pcode_scan bof_clean_dyn` emits ≥1 `binary-buffer-overflow`
finding on `vuln`.
This is the gate. Do not start Phase A until it is green.
### Phase A — switch-state-machine (VM dispatch) recognition + reroll
Port VEXA's `loop_reroll` + `constant_propagation` + `state2alloca` ideas onto
P-Code (fold::ir::pcode), not LLVM IR:
- [ ] **Detect the VM shape:** in the lifted SSA P-Code of a function, recognize
the `while(1) { switch(state) { case N: ... state = M; ... } }` pattern: a
`phi`-dominated state register whose only uses are the switch condition and
the case-body state assignments.
- [ ] **Constant-propagate the state:** run a bounded SMT pass (Z3) that, seeded
from the entry state constant, resolves which case labels are reachable and
in what order — exactly the Bitwuzla-driven CFG recovery VEXA demonstrates.
- [ ] **Reroll the loop:** rewrite the switch-dispatch into straight-line P-Code
(or a canonical loop with known trip count) so the existing P-Code symbolic
scan can follow the path to the `strcpy` sink.
- [ ] **state2alloca / state_cleanup analog:** lower the VM virtual state register
to per-iteration SSA values, then dead-code-eliminate the dispatch overhead
(mirrors VEXA's `state2alloca` + `state_cleanup`).
- [ ] Unit tests on synthetic `ir::pcode` (mirroring `test_binary_bb_observer.cpp`):
a hand-built switch-VM P-Code blob must reroll to a single straight-line
`CALL strcpy`.
### Phase B — MBA / self-modifying-code (stretch, later)
- [ ] MBA simplification: algebraic-rewrite the mixed boolean-arithmetic
expressions Covirt/VMProtect use (pattern-match `(a & c) | (b & ~c)` →
select, etc.) on P-Code, using the existing Z3 `simplify` as a backstop.
- [ ] SMC is out of scope for a static scanner (Fold never executes); note in the
design that SMC samples can only be handled by an external dynamic tool
(VEXA) and are not a static-P-Code target.
### Phase C — measurement
- [ ] Build `bof_obf_dyn` (switch-VM) as a committed fixture (recipe in §4).
- [ ] Acceptance: with Phase A, `pcode_scan bof_obf_dyn` emits the same
`binary-buffer-overflow` finding as the clean version.
- [ ] Regression: clean-binary findings, source-lane gate, and the full binary
test set (`fold:binary_backend`, `fold:gadget_capstone`, `fold:gadget_semantics`,
`fold:rop_chain`) stay green.
### Boundaries (non-negotiable)
- All work is in `src/binary/` + `include/fold/binary/` on `fold::ir::pcode`.
- No new lifting representation (no remill, no LLVM IR). P-Code stays the single
binary IR.
- No binary→source-IR shortcut (§2 / AGENTS.md).
- Findings stay `heuristic` (binary) unless runtime-confirmed; never relabel
deobfuscated-only evidence as `proved`.
---
## 4. Reproducible fixture recipe
```sh
# dynamic glibc ELF, real strcpy/write PLT imports
cat > bof_clean.c <<'EOF'
#include <string.h>
#include <unistd.h>
__attribute__((noinline)) void vuln(const char *src) {
char buf[64]; strcpy(buf, src); write(1, buf, sizeof(buf));
}
int main(int argc, char **argv) { if (argc > 1) vuln(argv[1]); return 0; }
EOF
cat > bof_obf.c <<'EOF'
#include <string.h>
#include <unistd.h>
__attribute__((noinline)) void vm_dispatch(const char *src) {
unsigned state = 0x41; char buf[64];
while (1) switch (state) {
case 0x41: state = 0x93; break;
case 0x93: strcpy(buf, src); state = 0xDE; break;
case 0xDE: write(1, buf, sizeof(buf)); return;
default: state = 0x41; break;
}
}
int main(int argc, char **argv) { if (argc > 1) vm_dispatch(argv[1]); return 0; }
EOF
zig cc -target x86_64-linux-gnu -O0 -fno-stack-protector -s -o bof_clean_dyn bof_clean.c
zig cc -target x86_64-linux-gnu -O0 -fno-stack-protector -s -o bof_obf_dyn bof_obf.c
```
Run the full scan (manual, not CI — see `tests/fixtures/binary/README.md`):
```sh
build/release/pcode_scan bof_clean_dyn
build/release/pcode_scan bof_obf_dyn
```
---
## 5. Open questions for the owner
1. Is the binary lane a funded/prioritized workstream, or opportunistic? The
Layer 1 fix is a real bug, but it is not in the current source-lane
precision/recall loop (#291).
2. Should Layer 1 be its own task/ticket with an owner before Phase A begins?
3. Is there an actual obfuscated-binary corpus/target in mind (a VMProtect/Covirt
sample), or is the switch-VM fixture sufficient to drive the work?
## 6. Layer 1 investigation (root-cause trace, 2026-08-13)
Pipeline for `binary-buffer-overflow` (strcpy → stack BOF):
```
extractFunctionBytes (r2 aflj) → per-function SLEIGH lift → SSA
→ annotateCallTargets (call_names addr→name) → BinaryBBObserver::onBufferCopy
```
Traced with `r2 -A` on the dynamic fixtures (`zig cc -target x86_64-linux-gnu`):
- `extractFunctionBytes` DOES recover the stripped `vuln` body: `fcn.01017770`
contains `call sym.imp.strcpy` at 0x10177b8 (confirmed via `axt sym.imp.strcpy`).
183 real functions on the stripped `bof_clean_dyn`.
- `extractCallTargetNames` maps the import to a real address: `sym.imp.strcpy`
has `"addr": 0x1095900` (r2 `aflj` uses field `addr`, NOT `offset`), so
`functionAddress` returns it non-zero and `normalizedCallTargetName` yields
`strcpy`. The `addr==0` skip does NOT drop it. (Two earlier mis-hypotheses —
"imports at addr 0" and "no functions recovered" — were artifacts of reading
`offset` instead of `addr` in a manual `aflj` filter.)
- The CALL instruction in `vuln` is a relative call resolving to 0x1095900
(the import/GOT address), matching the `call_names` key.
So functions are extracted and the sink name is resolvable — yet
`pcode_scan` emits 0 findings. The failure is downstream of extraction, in one of:
1. **Sink dispatch**: the P-Code symbolic scan may not reach the CALL at all
(CFG recovery through `entry0`→`main`→`vuln`, or the SSA lifter not emitting a
direct CALL op whose `op.in[0].offset == 0x1095900`).
2. **`BinaryBBObserver::onBufferCopy` guards**: it early-returns unless
`dst_buffer_size > 0` AND `size_arg.is_bv()`. For `strcpy(buf, src)`,
`size_arg` is the modeled copy length (`strlen(src)`); if the P-Code encoder
leaves it symbolic/non-BV (or `dst_buffer_size` is 0 because the stack frame
of the stripped `fcn.01017770` isn't sized), the hook bails silently.
**Next step (instrumented)**: add temporary stderr probes to
`BinaryBBObserver::onBufferCopy` and the pcode_router sink-dispatch site,
rebuild, run `pcode_scan bof_clean_dyn`, and confirm which guard fires (or that
the hook is never reached). That pins the exact fix — likely either
(a) resolve the CALL target through the PLT/GOT in `annotateCallTargets`, or
(b) model the unbounded-strcpy length as a BV so `size_arg.is_bv()` holds.
## 7. Layer 1 root cause CONFIRMED (instrumented)
Probes on `pcode_scan bof_clean_dyn` (stderr tags `dbg-bof`/`dbg-buf`) show:
```
[dbg-bof] call_target=strcpy normalized=strcpy ← name resolution OK
[dbg-bof] strcpy: cap=-1 dst.varnode(space=1 off=0x8 size=8) in_bufsz[1]=-1
```
- `lookupBufferSink("strcpy")` is found; the failure is purely
`in_buffer_sizes[dst_idx] == -1` → `handleCall` bails at `if (cap <= 0)`.
- The dst pointer is a `unique` SSA varnode. Its def chain (dumped via
`get_opname`) hits `CROSSBUILD` (SLEIGH's alias/phi builder), which
`resolveStackBufferBytes` does **not** follow — it only walks
`PTRSUB/PTRADD/INT_ADD` and `COPY/CAST` and `break`s on anything else.
- Secondary: even when a chain reaches a stack-space varnode, `querySymbolSize`
returns **8** (the injected `strcpy` prototype types the dst as `char*`), not
the true `char[64]` capacity. So the geometry would be wrong even if the chain
resolved.
**Fix plan (two parts, in `src/binary/ghidra_lifter.cpp`):**
1. Extend `resolveStackBufferBytes` to follow `CPUI_MULTIEQUAL` (phi),
`CPUI_INDIRECT`, and `CPUI_CROSSBUILD` to their sub-inputs (same strategy as
the existing COPY/CAST hops), so the `unique` dst resolves to the stack
`PTRSUB(sp, const)`.
2. Recover the real buffer size independent of the pointer-width type: when the
resolved stack symbol is pointer-sized (8), walk the function's stack locals
(`fd->getScopeLocal()`) for the next-higher local's offset (or the saved-frame
boundary) and use the gap as the capacity — OR enable the `StackVar` SSA
action so locals carry array types.
Acceptance: `pcode_scan bof_clean_dyn` emits ≥1 `binary-buffer-overflow` with
`dst_buffer_size == 64`. Then add a committed dynamic-ELF fixture + an integration
test (regression guard) mirroring `test_binary_rop.cpp`.
## 8. Layer 1 root cause REFINED (post-fix-attempt, 2026-08-13)
Deeper instrumentation (def-chain + storage-size probes) corrected §7:
- The strcpy dst def is `CPUI_PTRSUB` (the decompiler prints it as
`CROSSBUILD` — see `opcodes.cc` "PTRSUB = CROSSBUILD"). So the def-chain
trace in `resolveStackBufferBytes` is NOT the blocker; PTRSUB is already
handled.
- The real defect is SIZE RECOVERY: `querySymbolSize` returns **8 for both the
type AND the SymbolEntry storage** (`storage=8 type=8`). `injectSinkPrototypes`
registers the sink dst as `charptr` (`getTypePointer(8, char, 1)`), which
poisons SLEIGH's `StackVar` analysis into typing/sizing `char buf[64]` as a
single 8-byte pointer slot — so the 64-byte capacity is lost before
`resolveStackBufferBytes` ever sees it.
Fix attempts TESTED (all insufficient, tree reverted):
1. Prefer `SymbolEntry::getSize()` (storage) over the type size — no-op, storage
is also 8.
2. `setInputLock(false)` — no change; StackVar still sizes the local 8 bytes.
Remaining fix directions (next wake):
- **Frame-gap recovery**: compute the true buffer extent from the stack layout —
the distance from the buffer's stack offset to the next local / saved-frame
boundary — using `Funcdata::getFuncProto()` stack info, independent of the
poisoned 8-byte type.
- **Prototype de-poisoning**: inject the sink dst as an unsized/untyped pointer
(or omit the dst type) so StackVar sizes the local from its actual 64-byte
stack allocation.
- Verify the `StackVar` / `AggressiveDeref` analysis actions are actually in the
`allacts` set used at lift time.
Acceptance unchanged: `pcode_scan bof_clean_dyn` emits ≥1
`binary-buffer-overflow` with `dst_buffer_size == 64`, plus a committed
dynamic-ELF fixture + integration regression test.
## 9. Layer 1 FINAL root cause (complete, 2026-08-13)
Full def-chain + raw-size instrumentation settles it. Two INDEPENDENT defects,
both in `src/binary/ghidra_lifter.cpp`:
**(A) Def-chain trace is incomplete.** The strcpy dst is a `unique` SSA varnode
whose def chain does NOT reach the stack space. `resolveStackBufferBytes` only
follows `PTRSUB/PTRADD/INT_ADD` (constant-offset) + `COPY/CAST`; the actual
chains break on `LOAD`, `CALL`, `BUILD`, `SUBPIECE`, `INT_SUB`, `INT_2COMP`,
`DELAY_SLOT` (probe `[dbg-break]`). So it returns -1 and `handleCall` bails at
`cap <= 0`. (Note: `CROSSBUILD` is just the decompiler's display name for
`CPUI_PTRSUB` — opcodes.cc "PTRSUB = CROSSBUILD" — so §7's "follow CROSSBUILD"
was a red herring; PTRSUB is already handled.)
**(B) Stack-slot sizes are pointer-width, not the true array size.** Even when a
chain reaches a stack varnode, `rawVarnodeSize` is 4 or 8 — never 64. The
`injectSinkPrototypes` `charptr` prototype (8-byte `char*`) makes SLEIGH's
`StackVar` split `char buf[64]` into 4/8-byte slots, so `querySymbolSize` (both
type AND SymbolEntry storage) returns 8. The 64-byte capacity is lost at lift
time and is only recoverable from the stack-frame layout.
Fix plan (both parts, next wake):
- (A) follow `CPUI_MULTIEQUAL`, `CPUI_INDIRECT`, `CPUI_LOAD`, `CPUI_PIECE`
(BUILD), `CPUI_SUBPIECE`, and the sign/zero-extend ops (`INT_AND`/`INT_ZEXT`/
`INT_SEXT`) in the def-chain trace so the unique dst resolves to the stack.
- (B) recover the buffer extent via a stack-frame gap (distance to the next
local / saved-frame boundary), independent of the poisoned 8-byte type.
Acceptance: `pcode_scan bof_clean_dyn` ≥1 `binary-buffer-overflow` with
`dst_buffer_size == 64`; committed dynamic-ELF fixture + integration test.
## 10. Layer 1 — PART A retained (def-chain extension), finding still gated
RETAINED (validated, 7/7 binary tests): `resolveStackBufferBytes` now follows
the SSA-forwarding ops that the strcpy destination's `unique` chain hits —
`CPUI_MULTIEQUAL` (phi → in[0]), `CPUI_INDIRECT` (→ in[1]), `CPUI_SUBPIECE`,
`CPUI_INT_AND/OR/ZEXT/SEXT` (→ in[0]). This is a standard decompiler def-chain
simplification and is correct for buffer-size provenance.
The finding STILL does not fire on `bof_clean_dyn`. Remaining blockers:
1. The chain also breaks on `CPUI_CALL` (7) and `CPUI_LOAD` (2) — a pointer whose
SSA def is a call return or a memory load. Following those is semantically
unsafe (a call return may be a heap pointer, not a stack buffer), so it needs
a decision (follow only when the callee is a known allocator, or skip).
2. Even when a stack varnode is reached, its raw size is 4/8 bytes (the
`charptr` prototype split `char[64]` into pointer slots) — the 64-byte extent
still needs stack-frame-gap recovery (§9-B).
Next wake: (1) instrument the EXACT strcpy dst chain at lift time (the import
address for `bof_clean_dyn` is 0x10579f0, not 0x1095900 used in the earlier
probe) to see whether the CALL/LOAD hop is real; (2) implement frame-gap
recovery for the 64-byte extent. Acceptance unchanged.
## 11. Layer 1 DEFINITIVE root cause (2026-08-13, RDI probe)
A filtered probe printed the def chain of the strcpy destination (RDI =
`register:0x8` in the decompiler's register space):
```
[dbg-rdi] RDI chain:
h0 register:0x8 def=null nin=0
```
**The strcpy destination has `def == null` in SLEIGH's SSA** — the decompiler
treats RDI as the function's *input parameter* (`src`, also `char*`), NOT the
stack local `buf`. The `rdi = &buf` assignment (`lea rdi, [rbp-0x40]`) is not
modeled as a distinct SSA write, so `resolveStackBufferBytes` has no def to
trace and returns -1.
Root mechanism: `injectSinkPrototypes` registers the strcpy destination as
`charptr` (8-byte `char*`), which collides with the function input `src` (also
`char*`); SLEIGH's parameter recovery (`paramid`) then conjoins the sink dst with
the input parameter, erasing the stack-buffer provenance.
Retained this wake (correct but insufficient for THIS case, validated 7/7 binary
tests): (A) def-chain follow for MULTIEQUAL/INDIRECT/SUBPIECE/mask-extend;
(B) INT_SUB offset negation (`lea rdi,[rsp-N]`); (C) sink-dst fallback to the
distance-to-frame-top (`-off`) when the resolved size is pointer-width.
Next: the real fix is at the prototype/paramid interaction — do NOT type the sink
dst as `char*` (or use an unsized type) so SLEIGH keeps `rdi = &buf` as a distinct
stack-pointer def; alternatively recover the dst from the CALL's disassembly
(r2-level) rather than SLEIGH SSA. Acceptance unchanged.
## 12. Layer 1 — raw-stream recovery attempted, blocked by register renumbering
Implemented + tested a raw-stream buffer recovery (scan backward from each SSA
Call for the `lea`/PtrSub writing its destination register), then reverted it:
the RAW stream (Sleigh) and SSA stream (Funcdata) use **different register
offsets**. Probe showed raw RDI=`sp1:0x38`, raw RSI=`sp1:0x30`, while the SSA
dst is `sp1:0x8`, SSA src `sp1:0x10` — Ghidra's `paramid` renumbers argument
registers in the Funcdata. The `rop.out == dst` varnode comparison therefore
never matches, so the recovery is a no-op.
This means the raw↔SSA bridge needs register *names* (not offsets) carried in
`ir::pcode::Varnode`, OR the buffer recovery must run inside `liftSSA` where the
Funcdata's own (renumbered) varnodes and the raw Sleigh op list are both
reachable. Both are deeper changes than the loop's per-wake cadence.
RETAINED (validated 7/7 binary tests, git diff --check clean, 56 lines in
ghidra_lifter.cpp): (A) def-chain follow MULTIEQUAL/INDIRECT/SUBPIECE/mask-extend;
(B) INT_SUB offset negation; (C) sink-dst fallback to distance-to-frame-top.
Bottom line: Layer 1's buffer-size recovery is gated on SLEIGH not modeling
`rdi = &buf` in the SSA (def==null), and the raw-stream fallback is blocked by
the register-renumbering mismatch. The remaining fix is architectural (carry
register names, or recover inside liftSSA). Diagnosis complete.
## 13. Layer 1 FIXED (raw-stream dst-buffer recovery, 2026-08-13)
The BOF buffer-size recovery is now IMPLEMENTED and validated. The fix is
two-part, both in the binary lane:
1. **`src/binary/ghidra_lifter.cpp`** (retained, def-chain + size): follow
`MULTIEQUAL/INDIRECT/SUBPIECE` + mask/extend ops in `resolveStackBufferBytes`;
handle `INT_SUB` (`lea rdi,[rsp-N]`) with offset negation; sink-dst fallback
to distance-to-frame-top.
2. **`src/binary/binary_pcode_scan.cpp`** (the key): `recoverRawDstBufferSizes`
recovers the destination (RDI, raw Sleigh offset 0x38) buffer extent from the
RAW stream — it scans backward from each Call for the last `lea`/PtrSub/
PtrAdd/IntSub/IntAdd writing RDI, following Copy/Piece hops to the underlying
pointer arithmetic, and uses `-offset` (distance to frame top) as a
conservative capacity. `applyRawDstBufferSizes` fills the recovered size into
the SSA's `in_buffer_sizes[1]`, **gated to the unbounded copy sinks** (strcpy
family) so bounded `strncpy` keeps its explicit length and does not FP.
RESULT (validated): `pcode_scan` now emits `binary-buffer-overflow` on the
optimized `-O1`/`-O2` bof fixtures ("strcpy writes an unbounded length into a
80-byte buffer") and the `clean` (bounded `strncpy`) negative control stays at
0 findings. Benchmark scorer: `oob-access P=1.000 R=1.000 F1=1.000` (TP=1,
FP=0). 7/7 binary tests pass, `git diff --check` clean.
KNOWN LIMITS (follow-ups): (a) the `-O0` zig build still misses (zig's
`__zig_probe_stack` codegen introduces a call before the sink that the raw
backward scan stops at); (b) `div-by-zero` regressed on the `-O2` rebuild (the
`100/d` shape changed) — a separate detector/codegen issue, not this fix; (c) the
recovered size is the frame-top distance (80), an upper bound rather than the
exact `char[64]`.
## 14. Layer 1 -O0 recovery + Layer 2 root cause (switch-VM, 2026-08-13)
Progress on task #8 (switch-state-machine deobfuscation). Two results:
1. **Layer 1 now recovers the -O0 dst buffer** (retained). The `-O0` codegen
spills the buffer pointer through a stack slot:
`lea rax,[rbp-0x50]` -> `mov [slot],rax` -> `mov rdi,[slot]` -> call.
`recoverRawDstBufferSizes` (src/binary/binary_pcode_scan.cpp) now follows
`Copy/Piece` AND `Load -> Store -> value` hops (Load/Store matched by their
space-id = `IntAdd(RBP, slot_off)`; NOTE the raw Sleigh encodes `lea` as
pointer arithmetic, so the space-id is `IntAdd(RBP, off)`, NOT `Piece`). The
def-chain walk tracks each hop's position so a reused scratch register (RAX
is overwritten by `mov rax,[src]` after the lea) resolves against the live
write. RESULT: the -O0 `bof_obf_dyn` strcpy dst is recovered to 80 bytes
(frame-top distance), matching the -O1/-O2 "80-byte buffer" finding shape.
Validated: 7/7 binary tests, oob-access F1=1.000, clean negative 0 FP.
2. **Layer 2 root cause** (the actual switch-VM blocker). Even with the buffer
recovered, `pcode_scan bof_obf_dyn` emits 0 findings because the symbolic
encoder NEVER REACHES the strcpy Call. The switch-state machine (`state =
0x41 -> 0x93 -> strcpy -> 0xde -> write`) is a loop, and:
- (a) the encoder's `forwardTargetIndex` refused BACKWARD branch targets, so
the loop back-edge (`jmp loop`) was never followed — FIXED this wake (now
follows backward targets; only a self-jump is rejected; bounded by
`max_executed_ops`);
- (b) REMAINING BLOCKER: even with the back-edge followed, the case-body
`mov [var_ch], 0x93` (Store state=0x93) is not observed to flow into the
second loop iteration's `mov eax,[var_ch]` Load — the Load resolves to a
fresh `pc_unk_N` instead of 0x93, so `if (state == 0x93)` is unprovable
and the strcpy case body stays unreachable. The mem_ model keys on
`addr.simplify().to_string()`, and the Store/Load addresses DO match
(`(bvadd #xffffffffffffffec pc_unk_1)`); the worklist captured state at the
back-edge appears to drop the case-body Store (the case body was not seen
executing in the run trace).
NEXT (task #8 continues): fix the worklist/capture so the case-body Store is
retained across the loop back-edge (or, per the VEXA prior-art plan, detect the
phi-dominated state register + case-body assignments and reroll the dispatch to
straight-line P-Code before the symbolic walk). The -O0 state is a STACK slot
(not a phi node), so a memory-forwarding fix in the encoder's `handleMemory` or
a dedicated switch-state reroll pass are the two candidate paths.
## 15. Layer 2 switch-VM deobfuscation DONE (branch-target resolution, 2026-08-13)
The switch-state-machine sink is now REACHED. Root cause was in the P-Code
encoder's CFG walk, not the lifter. Three fixes in `src/symbolic/pcode_encoder.cpp`:
1. **Memory-space branch targets.** The SSA lifter encodes CBRANCH/BRANCH
targets as `Memory`-space varnodes (a code address in ram space), but
`forwardTargetIndex` only accepted `Constant`-space — so NO branch target was
ever followed. Accept `Memory`-space targets (SSA only).
2. **Folded-target resolution (`lower_bound`).** The high-P-Code lifter folds the
first instruction(s) of a case body (e.g. the `lea` of the stack buffer), so a
branch may target an address with no exact op. Resolve to the FIRST op
at-or-after the target (`address_index_.lower_bound`), forward-only.
3. **Cost gate.** Following Memory-space branches is gated to `fn.source ==
Source::SSA` AND a switch-dispatch signature (`>=2 CBRANCH`es) AND a small
function (`<=64` ops). This keeps the branch-following cost off the thousands
of straight-line / large-switch functions (the raw stream keeps its
Constant-only behavior, matching the pre-existing path).
RESULT: `pcode_scan /tmp/vexa-spike/bof_obf_dyn` now reaches the strcpy sink
behind the `0x41 -> 0x93 -> strcpy -> 0xde` dispatch and emits the
`binary-buffer-overflow` finding (confirmed `findings=1` pre-attach). The `-O1`/
`-O2` fixtures and the benchmark `oob-access F1=1.000` are unchanged.
REGRESSION TESTS (tests/unit/test_pcode_encoder.cpp): `switch-state-machine
dispatch reaches a case-body strcpy sink` (positive: the sink behind an
unconditional Branch is reached only via the Memory-space dispatch CBRANCH) and
`raw stream does not follow Memory-space branch targets` (negative: the raw lane
rejects Memory-space targets). 24/24 pcode_encoder cases pass; 7/7 binary tests
pass; `git diff --check` clean.
KNOWN FOLLOW-UPS (not blocking #8):
- **ROP synthesis cost**: the new finding triggers `attachExploitPrimitives`
(gadget discovery + Z3 chain synthesis on the 380KB binary), which is slow
(>60s). Pre-existing downstream cost, only paid when a BOF finding exists in a
large binary; separate performance item.
- **`bof_clean_dyn` still 0**: the -O0 clean fixture (no switch) does not fire
because zig's `__zig_probe_stack` inserts a call before the strcpy that the
Layer 1 raw-recovery backward scan stops at. Separate Layer 1 (zig-codegen)
issue, not a switch-VM issue.
## 16. Layer 3 MBA simplification (Phase B, stretch, 2026-08-13)
Mixed-boolean-arithmetic (MBA) deobfuscation for the P-Code lane:
- `fold::symbolic::simplifyMba(z3::expr)` (declared in
`include/fold/symbolic/pcode_encoder.hpp`, impl in `src/symbolic/pcode_encoder.cpp`):
pattern-matches arithmetic-encoded boolean ops and reduces them to canonical
form. Recognized identities:
- `(x | y) - (x & y)` -> `x ^ y` (XOR)
- `(x + y) - (x | y)` -> `x & y` (AND)
- `(x + y) - (x & y)` -> `x | y` (OR)
- `(a & c) | (b & ~c)` is already the bitwise-select canonical form for a
general mask `c` (Z3 has no simpler representation), so it is left unchanged.
- Wired into the encoder: `binArith` (IntAdd/IntSub/IntMult/IntAnd/IntOr/IntXor)
returns `simplifyMba(r)`; new `handleUnaryArithmetic` covers `IntNegate`
(`~x`) and `Int2Comp` (`-x`), which the encoder previously left as fresh
(they are the `~c` operands the MBA patterns need).
REGRESSION TESTS (tests/unit/test_pcode_encoder.cpp): MBA xor/and/or reduce to
their canonical op (structural decl-kind + Z3 semantic-equivalence check),
select left unchanged, non-MBA unchanged. 28/28 pcode_encoder cases pass.
VALIDATED: 7/7 binary tests, benchmark `oob-access F1=1.000` (unchanged),
`git diff --check` clean.
OUT OF SCOPE (as specified): self-modifying code — Fold is static; SMC needs a
dynamic tool.
## 17. ROP-synthesis cost bounded (binary-lane perf, 2026-08-13)
`attachExploitPrimitives` called `discoverGadgetEffects(..., 8192)`: once a BOF
finding fired on the 380KB dynamic fixture, the exhaustive Capstone gadget sweep
- SLEIGH/Z3 lift of thousands of gadgets made `pcode_scan` take >90s (the cost
was hidden before #8, because these fixtures had no finding). The chain
synthesis only needs a handful of `pop-reg;ret` controllers, a syscall gadget,
and win targets, so the gadget budget is reduced 8192 -> 256. Result:
`pcode_scan bof_clean_dyn` and `bof_obf_dyn` each complete in ~25s with the
finding intact ("strcpy ... 80-byte buffer"). 7/7 binary tests, benchmark
`oob-access F1=1.000`, `git diff --check` clean.
Also corrected: the earlier note that `bof_clean_dyn` has a zig
`__zig_probe_stack` was a stale misdiagnosis — the current build's `vuln` has no
probe; it is the same `-O0` stack-spill the Layer 1 recovery already handles, so
`bof_clean_dyn` fires (the `dst_buffer_size` is the 80-byte frame bound, an
upper bound on the exact `char[64]`).
## 18. Benchmark corpus restored to -O0 (div-by-zero re-enabled, 2026-08-13)
An earlier session rebuilt the CWE benchmark fixtures at `-O2` (to make the BOF
fire before the raw-stream recovery handled `-O0`), which silently disabled
div-by-zero: `-O2` transforms `100 / d` into a form the P-Code div0 detector
does not recognize. The canonical recipe in `tests/fixtures/binary/cwe/README.md`
is already `-O0`, and the Layer-1 raw recovery now handles the `-O0` stack-spill,
so the corpus is rebuilt uniformly at `-O0`. Result: `oob-access F1=1.000` AND
`div-by-zero F1=1.000` on the benchmark, clean negative 0 (no FP). The three
remaining 0s (int-overflow, null-deref, use-after-free) are genuine detector
gaps, not codegen.
## 19. Interprocedural null-deref heuristic REJECTED (2026-08-13)
Attempted a binary-lane interprocedural null-deref (mirror of the write-loop
summary): summarize callees that LOAD through a pure param without a null-check,
then report a null-deref at any call passing a provably-null value. Result:
benchmark null-deref went TP=1 but FP=5 (F1=0.286) — and the "TP" was a libc
coincidence, not the actual `vuln` deref. Two blockers:
- **libc noise**: stripped binaries are dominated by library code, where passing
an explicit NULL (constant 0) to a param-dereferencing callee is a common
benign sentinel; the heuristic cannot distinguish it from CWE-476.
- **function-boundary confusion**: the -O0 `vuln`'s `*p` LOAD and the `main`
`vuln(p)` CALL landed in different r2-detected functions (stripped binary), so
the summary/detection never matched the real deref.
REVERTED (the summary + detection + RouterConfig field were removed cleanly).
The binary null-deref gap is a genuine deeper-detector feature — the next step is
backward-NPD (loop #539 research pass 21, 10.1109/apsec.2013.80) rather than a
forward data-flow heuristic. Benchmark restored to the honest state: null-deref
F1=0 (FP=0), oob-access F1=1.000, div-by-zero F1=1.000, 7/7 binary tests, clean.
## 20. UAF pcode_scan wiring REJECTED; x86-64 disasm-parsing fixes retained (2026-08-13)
Two-part attempt on the dead use-after-free detection:
- **REJECTED**: wiring `detectUseAfterFreeFromDisasm` into `scanBinaryWithPcode`
(the pcode_scan path) so the benchmark scorer would exercise it. The UAF pass
is a disasm-lane (R2Backend) heuristic; calling it from the P-Code path opens
a fresh `R2Session` per binary, which (a) re-runs `aaa` analysis and a
per-function `pdfj` sweep — too expensive — and (b) contends on the shared
embedded r2 core, hanging `pcode_scan` (>90s) and leaking SIGTERM-ignoring
processes that made subsequent scans hang until killed. Wiring reverted.
The benchmark's UAF stays F1=0, which is HONEST: the pcode_scan lane does not
run the disasm UAF pass (it belongs to `fold scan --mode binary`).
- **RETAINED (correct bug fix, `src/binary/r2_disasm_state.cpp`)**: the R2Backend
disasm UAF/const trackers could not parse x86-64 operands:
`bracketOperand` now strips interior spaces so `[rbp - 8]` becomes the single
token `rbp-8` (the trackers key on single-token slots), and
`firstRegisterOperand` now rejects size-prefixed brackets (`qword [mem]`) and
register lists, so a `mov qword [ptr], rax` store is not mis-parsed as a
`rax <- slot` load. These make the disasm-lane UAF detection work on x86-64
(verified on the cwe416 call-form fixture before the wiring was reverted).
Benchmark restored to the honest state: oob-access F1=1.000, div-by-zero
F1=1.000, use-after-free/null-deref/int-overflow F1=0 (genuine gaps). 7/7 binary
tests, git diff --check clean.