- 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
34 KiB
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)
xmakeis 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 >= 30deferral).
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.cpplistsstrcpyinisUnboundedBinaryCopySink, andscanBinaryWithPcodewires 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 thekMaxExtractedFunctionBytescap — so static binaries lose the smallvulnfunction into an oversized blob. - Even the dynamic build (where
strcpyis a clean.dynsymimport) 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
strcpyimport. 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
switchon a register state defeats r2 function/CALL resolution and the symbolic scan's CFG. This is exactly what VEXA'sloop_reroll+constant_propagation+state2allocarecover.
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
scanBinaryWithPcodereturns 0 onbof_clean_dyn(realstrcpyPLT import). Candidates: (a) r2extractCallTargetNamesnot resolving the PLT stub tostrcpy; (b)extractFunctionBytesnot separatingvulnfrom the ELF entry; (c) the sink arity/prototype injection inghidra_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_dynemits ≥1binary-buffer-overflowfinding onvuln.
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: aphi-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
strcpysink. - 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(mirroringtest_binary_bb_observer.cpp): a hand-built switch-VM P-Code blob must reroll to a single straight-lineCALL 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 Z3simplifyas 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_dynemits the samebinary-buffer-overflowfinding 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/onfold::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 asproved.
4. Reproducible fixture recipe
# 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):
build/release/pcode_scan bof_clean_dyn
build/release/pcode_scan bof_obf_dyn
5. Open questions for the owner
- 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).
- Should Layer 1 be its own task/ticket with an owner before Phase A begins?
- 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):
extractFunctionBytesDOES recover the strippedvulnbody:fcn.01017770containscall sym.imp.strcpyat 0x10177b8 (confirmed viaaxt sym.imp.strcpy). 183 real functions on the strippedbof_clean_dyn.extractCallTargetNamesmaps the import to a real address:sym.imp.strcpyhas"addr": 0x1095900(r2afljuses fieldaddr, NOToffset), sofunctionAddressreturns it non-zero andnormalizedCallTargetNameyieldsstrcpy. Theaddr==0skip does NOT drop it. (Two earlier mis-hypotheses — "imports at addr 0" and "no functions recovered" — were artifacts of readingoffsetinstead ofaddrin a manualafljfilter.)- The CALL instruction in
vulnis a relative call resolving to 0x1095900 (the import/GOT address), matching thecall_nameskey.
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:
- 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 whoseop.in[0].offset == 0x1095900). BinaryBBObserver::onBufferCopyguards: it early-returns unlessdst_buffer_size > 0ANDsize_arg.is_bv(). Forstrcpy(buf, src),size_argis the modeled copy length (strlen(src)); if the P-Code encoder leaves it symbolic/non-BV (ordst_buffer_sizeis 0 because the stack frame of the strippedfcn.01017770isn'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 purelyin_buffer_sizes[dst_idx] == -1→handleCallbails atif (cap <= 0).- The dst pointer is a
uniqueSSA varnode. Its def chain (dumped viaget_opname) hitsCROSSBUILD(SLEIGH's alias/phi builder), whichresolveStackBufferBytesdoes not follow — it only walksPTRSUB/PTRADD/INT_ADDandCOPY/CASTandbreaks on anything else. - Secondary: even when a chain reaches a stack-space varnode,
querySymbolSizereturns 8 (the injectedstrcpyprototype types the dst aschar*), not the truechar[64]capacity. So the geometry would be wrong even if the chain resolved.
Fix plan (two parts, in src/binary/ghidra_lifter.cpp):
- Extend
resolveStackBufferBytesto followCPUI_MULTIEQUAL(phi),CPUI_INDIRECT, andCPUI_CROSSBUILDto their sub-inputs (same strategy as the existing COPY/CAST hops), so theuniquedst resolves to the stackPTRSUB(sp, const). - 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 theStackVarSSA 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 asCROSSBUILD— seeopcodes.cc"PTRSUB = CROSSBUILD"). So the def-chain trace inresolveStackBufferBytesis NOT the blocker; PTRSUB is already handled. - The real defect is SIZE RECOVERY:
querySymbolSizereturns 8 for both the type AND the SymbolEntry storage (storage=8 type=8).injectSinkPrototypesregisters the sink dst ascharptr(getTypePointer(8, char, 1)), which poisons SLEIGH'sStackVaranalysis into typing/sizingchar buf[64]as a single 8-byte pointer slot — so the 64-byte capacity is lost beforeresolveStackBufferBytesever sees it.
Fix attempts TESTED (all insufficient, tree reverted):
- Prefer
SymbolEntry::getSize()(storage) over the type size — no-op, storage is also 8. 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/AggressiveDerefanalysis actions are actually in theallactsset 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:
- The chain also breaks on
CPUI_CALL(7) andCPUI_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). - Even when a stack varnode is reached, its raw size is 4/8 bytes (the
charptrprototype splitchar[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:
src/binary/ghidra_lifter.cpp(retained, def-chain + size): followMULTIEQUAL/INDIRECT/SUBPIECE+ mask/extend ops inresolveStackBufferBytes; handleINT_SUB(lea rdi,[rsp-N]) with offset negation; sink-dst fallback to distance-to-frame-top.src/binary/binary_pcode_scan.cpp(the key):recoverRawDstBufferSizesrecovers the destination (RDI, raw Sleigh offset 0x38) buffer extent from the RAW stream — it scans backward from each Call for the lastlea/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.applyRawDstBufferSizesfills the recovered size into the SSA'sin_buffer_sizes[1], gated to the unbounded copy sinks (strcpy family) so boundedstrncpykeeps 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:
-
Layer 1 now recovers the -O0 dst buffer (retained). The
-O0codegen 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 followsCopy/PieceANDLoad -> Store -> valuehops (Load/Store matched by their space-id =IntAdd(RBP, slot_off); NOTE the raw Sleigh encodesleaas pointer arithmetic, so the space-id isIntAdd(RBP, off), NOTPiece). The def-chain walk tracks each hop's position so a reused scratch register (RAX is overwritten bymov rax,[src]after the lea) resolves against the live write. RESULT: the -O0bof_obf_dynstrcpy 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. -
Layer 2 root cause (the actual switch-VM blocker). Even with the buffer recovered,
pcode_scan bof_obf_dynemits 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
forwardTargetIndexrefused 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 bymax_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'smov eax,[var_ch]Load — the Load resolves to a freshpc_unk_Ninstead of 0x93, soif (state == 0x93)is unprovable and the strcpy case body stays unreachable. The mem_ model keys onaddr.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).
- (a) the encoder's
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:
- Memory-space branch targets. The SSA lifter encodes CBRANCH/BRANCH
targets as
Memory-space varnodes (a code address in ram space), butforwardTargetIndexonly acceptedConstant-space — so NO branch target was ever followed. AcceptMemory-space targets (SSA only). - Folded-target resolution (
lower_bound). The high-P-Code lifter folds the first instruction(s) of a case body (e.g. theleaof 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. - Cost gate. Following Memory-space branches is gated to
fn.source == Source::SSAAND a switch-dispatch signature (>=2 CBRANCHes) AND a small function (<=64ops). 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_dynstill 0: the -O0 clean fixture (no switch) does not fire because zig's__zig_probe_stackinserts 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 ininclude/fold/symbolic/pcode_encoder.hpp, impl insrc/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 maskc(Z3 has no simpler representation), so it is left unchanged.
- Wired into the encoder:
binArith(IntAdd/IntSub/IntMult/IntAnd/IntOr/IntXor) returnssimplifyMba(r); newhandleUnaryArithmeticcoversIntNegate(~x) andInt2Comp(-x), which the encoder previously left as fresh (they are the~coperands 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_scantake >90s (the cost was hidden before #8, because these fixtures had no finding). The chain synthesis only needs a handful ofpop-reg;retcontrollers, a syscall gadget, and win targets, so the gadget budget is reduced 8192 -> 256. Result:pcode_scan bof_clean_dynandbof_obf_dyneach complete in ~25s with the finding intact ("strcpy ... 80-byte buffer"). 7/7 binary tests, benchmarkoob-access F1=1.000,git diff --checkclean.
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*pLOAD and themainvuln(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
detectUseAfterFreeFromDisasmintoscanBinaryWithPcode(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 freshR2Sessionper binary, which (a) re-runsaaaanalysis and a per-functionpdfjsweep — too expensive — and (b) contends on the shared embedded r2 core, hangingpcode_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 tofold 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:bracketOperandnow strips interior spaces so[rbp - 8]becomes the single tokenrbp-8(the trackers key on single-token slots), andfirstRegisterOperandnow rejects size-prefixed brackets (qword [mem]) and register lists, so amov qword [ptr], raxstore is not mis-parsed as arax <- slotload. 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.