The public xvmp tree ships a compiler half that was never compiled (no TU includes the virtualizer headers; on dev trees they do not build), a weak empty vm_exec stub, silent skips for unsupported IR, and a companion article describing an earlier prototype that is not in the tree. This change completes and measures the chain: - Completed toolchain, merged into all four omp branches of that tree: gated new-PM pass wiring (-mllvm -irobf-irvm, section .irvm), FORMAT v3 bytecode (8-byte aligned slots, explicit load/store widths, stable CC_ cast kinds, per-index GEP byte scales, branch condition = operand 0), a strong-symbol vm_exec interpreter with a 23-check unit suite, and a VMP.h user header. Virtualized vs native output identical at -O0/-O1/-O2 on all four branches. One branch's ungated IRVM call in Obfuscation::runOnModule (rewrote .irvm functions in plain native builds) removed; IRVM is strictly flag-gated. - Fixture measured end to end (fco+bcfobf+subobf+IRVM build): native-identical output; 17,961-step guest trace via VMP_TRACE; 24.6M-instruction host trace via a ptrace single-stepper; vm_handler_slice yields 95,184 segments / 18 unique signatures; the fla outer state machine separates as a shuttle class, round-boundary segments split the trajectory, positional alignment names every handler segment (59/59 in a round; counts match the guest histograms exactly), zero unmapped with --opcode-map. - Tooling hardening measured on the real fixture: opcode maps key by signature (first-block keys collide under a shared dispatch successor); apply_map keeps first-block keys as fallback, regression tests added. - Environment boundary recorded: frida stalker does not work in these containers (measured past the syscall layer: primitives pass, agent loads, zero denied syscalls; the failure is inside the injected agent's hook machinery), an outer ptrace tracer conflicts with frida spawn, and the ptrace stepper is the working host capture path. Design lesson: a pure virtualized call can still be LICM-hoisted by the compiling optimizer. Records: EXTENSION-sampling-crash.md SS5-SS6 (final-state rewrite), known-limitations.md, ete-diff-tracing.md SS5, vm_handler_slice.py header, capability matrix.
14 KiB
Extension verification — sampling, crash forensics, symbolization, de-virtualization
Covers the second hardware-layer pass: references/hardware-sampling-and-crash.md,
scripts/trace_addr2sym.py, scripts/vm_handler_slice.py, and the source-reading
corrections to the flattening recipes. Strength labels as this directory's README defines
them.
Reference environment: Linux container host, Python 3.13, cross gcc 16.2.0 targeting
aarch64-linux-gnu (the toolchain that makes the symbolizer's "real ELF" half testable).
Network access was used once, read-only, to fetch the two public obfuscator implementations
the calibration cites (below). No Android device, no coresight/SPE/BRBE hardware.
Headline: the address→symbol loop is measured against a real cross-compiled library; the de-virtualization slicer is measured on synthetic traces AND end to end on a real IR-virtualized + flattened binary (built by the completed toolchain, host-traced, sliced, and fully opcode-named); the SPE/BRBE producer side remains documented-and-gated. The flattening-recipe corrections are read from public source, and that source is named.
1. trace_addr2sym.py against a real library — observed
$ gcc -shared -fPIC -O2 -o libtlib.so tlib.c # cross gcc -> aarch64 ELF64
$ readelf --syms -W libtlib.so
... 00000000000005e0 132 FUNC GLOBAL DEFAULT 9 encrypt_block
... 0000000000000664 4 FUNC GLOBAL DEFAULT 9 decrypt_block
$ python skills/apk-reverse/scripts/trace_addr2sym.py --elf libtlib.so --offs 0x610
0x610 -> encrypt_block+0x30 [vaddr 0x610 file 0x610 .text+0x130]
The resolution agrees with readelf exactly (0x610 − 0x5e0 = 0x30, inside the sized symbol);
a point outside any function falls to the nearest-below symbol with its section shown
(0x700 -> _fini+0x98 [.eh_frame+0x40]), which is GNU-addr2line-shaped behaviour and is
pinned by unit test together with the PT_LOAD vaddr≠offset conversion on a hand-built
minimal ELF. That minimal-ELF fixture also covers the stripped-library fallback
(section+offset, no symbols).
2. vm_handler_slice.py on the calibrated shape — observed
A synthetic interpreter trace in the shape the public IR virtualizer emits (dispatcher D, handler blocks, one const-materialization segment, an unclosed tail):
$ python skills/apk-reverse/scripts/vm_handler_slice.py --log vm.log --opcode-map map.json
dispatcher: libvm.so+0x1000 (41.7% of 24 blocks, 10 hits)
segments: 10 total, 7 unique, avg len 2.00 [last segment UNCLOSED: exit path or truncated window]
opcode trajectory (first 20): OP_LOAD OP_LOAD OP_STORE OP_BR OP_LOAD OP_LOAD OP_BR OP_CALL OP_LOAD OP_RET
What the output pins, and why each part is the part a de-virtualization needs: the
dispatcher share (with the diagnostic that separates a real dispatch loop from a spin
loop), the segment table (one row per handler chain, first block = handler entry =
the --opcode-map key), the trajectory (the guest program, one signature per executed
guest opcode), the OP_CALL materialization segment appearing as its own signature (the
virtualizer's const dispatch — the second observation point the calibration names), and the
UNCLOSED tail flagged rather than silently dropped. The auto-detection rule is
top-of-histogram plus predecessor diversity (≥3 distinct predecessors), not a naive
count margin: in a stack-shaped VM the hottest handler executes on every guest instruction,
exactly as often as the dispatcher. That refusal-to-accept is the load-bearing part of the
auto rule. Section 6 measures all of this against a real hardened binary.
3. The flattening-recipe corrections — observed source, inferred runtime
Fetched read-only from the public implementations the user supplied
(github.com/dpkg123/ollvm-aosp-toolchain: branch ollvm-r614150-clang23-rebase+omp
[Hikari family] and ollvm-r614150-clang23+omp [Pluto family]; commits 63fec41f and
9b7cf75e for the IR virtualizer; 2026-10):
- Hikari
Flattening.cpp:switchVaris an entry-block alloca written through aswitchVarAddrdouble pointer (indirect store per transition); case values arescramble32-permuted; EH-bearing functions are skipped. - Pluto
Flattening.cpp: two allocas (switchVar,switchXor) with a rolling XOR (enc^=delta; key^=delta; cond=enc^keyper dispatch); case values fully random; the true case exists only in theswitchCondregister for one instruction; EH/callbr/indirectbrfunctions skipped. - Hikari
AntiDebugging.cpp: on Darwin/AArch64 it injects inline-assembly rawsvcwith a randomized register-setup order and a randomized svc immediate; elsewhere it links precompiled anti-debug IR. Consequence recorded in the reference: byte signatures cannot survive the randomization; semantic site-location (svc_scan.py) and hardware observation do not care. - The IR virtualizer:
vm_exec(code, size, vm_args, num)stubs, boxed-argument array, 11 one-byte opcodes + 19 arithmetic sub-ops, const/call materialization dispatch, switch-to-if lowering.
These corrected hardware-watchpoints.md's state-variable recipe (the old text assumed a
global and a readable value — wrong for both families as shipped) and added the
family-difference table to ete-diff-tracing.md §5. The fetches live under
tools/_work/refs/ (git-ignored); runtime behaviour of these passes is inferred from
source — except the IR virtualizer's runtime, which this pass built, executed and
measured (§5–§6).
4. What this pass deliberately did not do
- No SPE capture, no BRBE record read, no
perf -j anyon any device: those routes ship as documented-and-gated, and the reference's §5 failure modes say exactly how each gate fails silently. - No hardened closed-source VM was sliced: the fixture in §6 is an open, self-built virtualizer; against a closed VMP the same slicing yields the trajectory, and the opcode map becomes a manual labelling task over the (usually few dozen) unique segments — which is exactly the workflow the script documents.
5. The xvmp toolchain, completed and verified — observed (workbench clone)
The public tree ships the compiler half of its IR virtualizer only, and that half was never compiled: no TU includes the virtualizer headers, and on dev trees they do not build. Three facts to keep when working with that tree:
getOrCreateVMExecemits a weak stub with no implementation whenvm_execis absent — a build from that tree as-is links and then falls into the empty stub at runtime.- Unsupported IR is skipped with an
errs()line and no failure — "compiled clean" is not "every function was rewritten": read stderr for[irvm] skiplines, or a silently skipped function stays native and quietly skews a trace pair. - The companion write-up (vmp.md) describes a different, earlier prototype — 32-bit fixed-width ISA, thunk bridges, an LVMP container — none of which is in the tree. Trust the code, not the article.
This repository completed the missing half. On the workbench clone of that tree (SSH
environment, 64-core), all four omp branches now carry:
- Pass wiring:
IRVM.cpp/.h(new-PM pass beside the obfuscation manager, enabled by-mllvm -irobf-irvm, functions opt in viasection(".irvm")) — the first TU that ever compiled the virtualizer headers, which required fixing per-tree upstream breakage (the revivedADT/Optional.hlacksNoneType;DataLayout(Module*)removed;Brsplit intoUncondBr/CondBron newer trees;CastOpsrenumbered —FPExtmoved 17→47; the Hikaricompat/CallSite.hcalledDenseMapInfomembers dev-main deleted). One branch additionally had an ungatedIRVMModuleToolcall at the top ofObfuscation::runOnModule— plain native builds rewrote.irvmfunctions into thunks over the weak stub (and native -O1/-O2 escaped only because small functions get inlined away); removing it made IRVM strictly flag-gated. - FORMAT v3 bytecode: 8-byte aligned zero-extended slot words; explicit
OP_LOAD/OP_STOREaccess widths; src/dst bit widths plus a stable CC_ kind enum forOP_CAST(raw LLVM opcode numbers break across trees); operand width forOP_CMP; per-index byte scales forOP_GEP(a single element size mis-addresses the multi-index GEPs-O0emits verbatim). The branch condition is operand 0, never the last operand (that is the false-successor label). - Runtime:
vm_exec.cstrong-symbol interpreter (23 hand-packed bytecode unit checks on host) andVMP.huser header (IRVM_SECTION, weakhandle_callfallback,VMP_TRACE=1per-step interpreter trace). - Verification (
test_irvm.c: loops, globals, arrays via GEP, fpu, casts, external printf): virtualized vs native output identical at -O0/-O1/-O2 on all fourompbranches (upstream commits9b7cf75..ee23d66,cb1e2c1..63f100d,8a85cce..ee23d66,b6b6578..7929a03).
6. The fixture, built, traced, sliced, and named — measured (host)
End to end on the workbench clone with the §5 toolchain:
- Sample (
tools/_work/irvm_fixture/fixture_sample.c): three.irvmfunctions —checksum(fnv1a loop over 32 bytes),stats(fpu + branches),mix(byte-shuffle casts) — each called 200× from main.statsstays native: at -O1 the optimizer's if-conversion produces aselectbefore the IRVM pass sees it (the documented skip hazard; avolatileinterlock does not survive either). The fixture ships 2/3 virtualized — as-is, since that is the real workflow. - Hardened build:
-O1 -mllvm -irobf-irvm -mllvm -enable-fco -mllvm -enable-bcfobf -mllvm -enable-subobfplusruntime/vm_exec.c; output identical to native. This tree's Hikari flag is-enable-fco(not-enable-fla).handle_callneeds no user-side bridge — the pass emits the real const-materialization dispatch into the module — but only when at least one function was rewritten: with all candidates inlined away (-O1 + single call site) the link fails on the weakvm_execreference chain.noinlineon the.irvmfunctions keeps candidates (and the emittedhandle_call) alive. - Guest ground truth:
VMP_TRACE=1→ 17,961[vm] step=lines (this build traces CAST/ARITH/CMP/RET steps only). - Host capture: frida stalker does not work in these containers (measured on both a
Docker host and an Android-kernel sandbox, frida 16.7.19 and 17.23.3): execution
primitives pass (anonymous RWX mmap, W^X flip, memfd), the agent loads and runs the
script, a ptrace syscall-error tracer around the injection tree shows zero denied
syscalls — the failure is inside the injected agent's hook/trampoline machinery, past
the syscall layer. Two operational notes: an outer ptrace tracer conflicts with frida
spawn (frida ptrace-attaches the child it spawns → NotSupportedError — do not nest this
repo's ptrace tooling and frida against the same tree); and the working host capture is
a 33-line ptrace single-stepper (
stepper.c): 24,582,6990x<rip>lines in 36 s, filtered to the main image (23.97 M lines). On Android devices the stalker route keeps its own verification status per the skill's evidence rules. - Slicing:
vm_handler_slice.py --dispatcher 0x55b98249c470(manual — with an instruction-per-line stream the dispatcher block's instructions tie in count, soautocorrectly refuses) → 95,184 segments, 18 unique signatures,RESULT=ok. The diagnostics fire as designed: the tail stays open (exit path), and the nested-dispatch warning marks the outer fla state machine — one guest step crosses the chosen dispatcher ~5.3× (95,184 / 17,961), and the len-20 segment (83,286 hits) is the flat state-machine shuttle, not a handler. - Per-level separation and naming: the 18 signatures split into the shuttle class and
16 handler segments (~59 per round). No single host block fires once per guest step
(fla scattered the switch into per-state entries), so the 200-count round-boundary
segments split the trajectory into per-call rounds, and each round's signature sequence
aligns positionally against the VMP_TRACE guest sequence. Result (round 2, all 59
handler segments named):
loop1 x8: [ARITH_OR_SHL, OP_CAST, ARITH_ADD_CMP_latch]→LOOP_BOUNDARY_BR LOOP2_ENTRY_BR LOOP2_PROLOGUE→loop2 x8: [ARITH_OR, ARITH_SUB_INDEX, OP_CAST, ARITH11_ADD_CMP_latch]→OP_RET— positionally isomorphic to the guest ops ([or, shl, CAST, add, cmp]/[or, sub, CAST(zext), arith11, add, cmp], many ops per segment under fla), with the latch firing 7/8 times (the last iteration exits via the boundary segments) and the segment counts matching the guest histograms exactly (3232/1600/1400/200).--opcode-mapyields a fully named trajectory, zero unmapped (map attools/_work/irvm_fixture/opcode_map.json). - Two findings that carry beyond this fixture:
-O1LICM hoisted the pure virtualized call out of the 200× driver loop —checksumexecuted once (c == h*200 mod 2^32). A virtualized function is not protected against being treated as pure by the optimizer that virtualized it; defeat hoisting with per-round argument changes when designing fixtures.apply_maporiginally keyed handler names by the segment's first block — under an instruction stream (or an outer flattening pass) every segment shares the dispatch-successor block, and only the signature distinguishes handlers. Signature keys are tried first; first-block keys remain supported.
Artifacts under tools/_work/irvm_fixture/ (git-ignored): sample, stepper, the frida
attempt, guest trace, host excerpt, opcode map. The full host trace stays on the workbench
clone.