Complete the xvmp IR virtualizer end to end and measure the de-virtualization chain on a real fixture

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.
This commit is contained in:
dabao1955
2026-10-16 14:45:03 +08:00
committed by dabao1955
parent e151d7d605
commit cd1df50a2a
20 changed files with 1151 additions and 151 deletions
@@ -0,0 +1,70 @@
# Extension verification — absorption pass: four public write-ups (Kanxue, 2026)
Covers the absorption of four public reverse-engineering write-ups the user supplied
(2026-08/09 postings; titles, authors and every sample identity anonymized in this record —
the write-ups themselves were already anonymized by their authors). Following the
absorption-pass convention (`EXTENSION-desensitization.md`): **only phase, checklist and
command patterns cross over**, nothing target-shaped, and each absorbed claim carries its own
strength label.
**Headline: two write-ups produced a new reference (`references/native-vm-recovery.md`, every
step `inferred`), one produced four pitfalls (P43–P46) and one tool fix; one write-up's
dispatcher-margin lesson was already absorbed in the previous pass. The sources are public
postings; no sample, tool output or target fact from them entered the repository.**
---
## 1. What was read
| Source (anonymized) | Shape | Absorbed into |
|---|---|---|
| W1: full arm64 custom-VM recovery (long-form) | 0x00–0x1A method narrative | `references/native-vm-recovery.md` (existence signals, dispatcher separation, partial deflattening, relocation tables, PC proofs, descriptor/ISA cautions, clean-re-VM loop) |
| W2: VMP interpreter recovery with trace-driven IR lifting | anchor/trace/lifting narrative | `references/native-vm-recovery.md` (register anchoring + triple verification, nested segment stacks, STORE-backward slicing, byte provenance, IR shape), `vm_handler_slice.py` nested-loop diagnostic |
| W3: login-capture war story (packed + MADP/Weex + gomobile) | route-by-route复盘 | `references/pitfalls.md` P43–P46; the stack-ABI note in `references/hardware-watchpoints.md` |
| W4: manual OLLVM deflattening | predicate algebra + IDA scripting | cross-checked against existing content; its MOV/MOVK constant-propagation state mapping is referenced as the static counterpart in `native-vm-recovery.md` §11 — no new file |
## 2. The one code change, and why — observed
`vm_handler_slice.py` gained `nested_loop_segments()`: a block repeating 4+ times **inside one
segment** flags a nested dispatch loop. Reasoning absorbed from W2: handler chains repeat
across segments, never within one — a within-segment repeat is a second dispatcher running
under the first, and W2 measured 45-of-46 entries nested with 57–1997 instructions lost per
nesting under a flat accumulator. Host-tested on a synthetic nested fixture:
```
$ python vm_handler_slice.py --log nested.log --dispatcher libvm.so+0x1000
note: segments 2 contain a block repeating 4+ times inside one segment: a nested dispatch
loop ... slice per level; the segment-stack fix is documented in
references/native-vm-recovery.md
```
The same edit fixed a latent bug this pass's review found: `diagnostics()` had been measuring
`len()` of `(run, closed)` tuples (always 2) since the open-tail change, quietly disabling the
spin-loop note on the real code path. Both regressions are now pinned by tests.
## 3. The judgement calls — what did NOT cross over
- W1/W2's Ghidra/SLEIGH-specific mechanics (p-code slicing internals, seed definitions bound to
their toolchain): only the **method** crossed (store-backward, provenance-over-value); the
repository ships no Ghidra script and none is implied.
- W3's working exploit details (hook source, injection directory, SELinux labels of the
delivery mechanism): the delivery route already has its own file
(`references/lsposed-and-modules.md`, `kernel-event-injection.md`); what crossed is the
**layer-identification discipline** (each dead end names the next layer; prefer the highest
plaintext boundary; a RegisterNative inventory is one run for a full method table).
- W4's full IDA script: the technique is named (state-register MOV/MOVK constant propagation,
CMP-site collection, predecessor walk past blocks with no CMP on the state register) and
pointed at from `native-vm-recovery.md` §11; writing an IDA-independent implementation is a
future pass, not an absorbed claim.
- Every sample identity in all four sources: anonymized by the authors, kept anonymized here;
`.ref/` (the local copies) is untracked and git-ignored in effect (never added).
## 4. Strength accounting
- `native-vm-recovery.md`: every method step `inferred` (absorbed, not executed here) — stated
in the file's own header, mirrored in `evidence-summary.md` and the capability matrix.
- `vm_handler_slice.py` nested diagnostic: `observed` on the host fixture (this pass).
- Pitfalls P43–P46: absorbed as `inferred` lessons with their mechanism stated; each names the
check that would have caught it, per this repository's pitfall format.
- No claim from the write-ups was upgraded to `observed` by absorption. That is the point of
the convention: a method someone else paid for is a lead, not a measurement.
@@ -10,10 +10,11 @@ Reference environment: Linux container host, Python 3.13, cross `gcc 16.2.0` tar
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 interpreter traces, and the SPE/BRBE
producer side remains documented-and-gated. The flattening-recipe corrections are read from
public source, and that source is named.**
**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.**
---
@@ -55,9 +56,9 @@ guest opcode), the **OP_CALL materialization segment** appearing as its own sign
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: the first implementation used a 4x margin and refused this very fixture —
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.
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
@@ -86,15 +87,123 @@ These corrected `hardware-watchpoints.md`'s state-variable recipe (the old text
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, never executed here.**
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 any` on any device: those routes ship as
documented-and-gated, and the reference's §5 failure modes say exactly how each gate
fails silently.
- No xvmp-hardened binary was compiled or traced: the slicer's calibration is source-read,
and its first run against a real sample is a measurement, not a confirmation.
- The `--opcode-map` a real target needs is a manual labelling task over the segment table;
this pass generated the fixture's map by hand, which is exactly the workflow the script
documents.
- 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:
- `getOrCreateVMExec` emits a **weak stub with no implementation** when `vm_exec` is 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] skip` lines, 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 via `section(".irvm")`) — the first TU that ever
compiled the virtualizer headers, which required fixing per-tree upstream breakage (the
revived `ADT/Optional.h` lacks `NoneType`; `DataLayout(Module*)` removed; `Br` split into
`UncondBr`/`CondBr` on newer trees; `CastOps` renumbered — `FPExt` moved 17→47; the
Hikari `compat/CallSite.h` called `DenseMapInfo` members dev-main deleted). One branch
additionally had an **ungated** `IRVMModuleTool` call at the top of
`Obfuscation::runOnModule` — plain native builds rewrote `.irvm` functions 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_STORE` access widths; src/dst bit widths plus a **stable CC_ kind enum** for
`OP_CAST` (raw LLVM opcode numbers break across trees); operand width for `OP_CMP`;
**per-index byte scales** for `OP_GEP` (a single element size mis-addresses the
multi-index GEPs `-O0` emits verbatim). The branch condition is operand 0, never the
last operand (that is the false-successor label).
- **Runtime**: `vm_exec.c` strong-symbol interpreter (23 hand-packed bytecode unit checks
on host) and `VMP.h` user header (`IRVM_SECTION`, weak `handle_call` fallback,
`VMP_TRACE=1` per-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 four `omp`
branches** (upstream commits `9b7cf75..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 `.irvm` functions —
`checksum` (fnv1a loop over 32 bytes), `stats` (fpu + branches), `mix` (byte-shuffle
casts) — each called 200× from main. `stats` stays native: at -O1 the optimizer's
if-conversion produces a `select` before the IRVM pass sees it (the documented skip
hazard; a `volatile` interlock 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-subobf` plus `runtime/vm_exec.c`; output **identical to native**.
This tree's Hikari flag is `-enable-fco` (not `-enable-fla`). `handle_call` needs 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 weak `vm_exec` reference chain.
`noinline` on the `.irvm` functions keeps candidates (and the emitted `handle_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,699 `0x<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, so `auto`
correctly 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-map` yields a
fully named trajectory, zero unmapped (map at `tools/_work/irvm_fixture/opcode_map.json`).
- **Two findings that carry beyond this fixture**:
1. `-O1` LICM **hoisted the pure virtualized call** out of the 200× driver loop —
`checksum` executed 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.
2. `apply_map` originally 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.
+1
View File
@@ -101,6 +101,7 @@ kernel toolchain is an explicitly-labelled template whose gate says where it clo
| `EXTENSION-ete-diff.md` | The differential half measured on synthetic flattened-dispatcher fixtures (first divergence, unique-per-side sets), the gate/capture plumbing measured to the no-device boundary, and what was deliberately not done: no perf-with-CORESIGHT build, no cs_etm stream, no overhead claim |
| `EXTENSION-kernel-instrument.md` | The kernel-side scaffold's generate/verify round trip, the spec-refusal rules shared with `hw_watch.py`, the capability map whose `no` cells do the gatekeeping, and the three-time-fenced experimental register-rewrite action |
| `EXTENSION-sampling-crash.md` | The second hardware pass: `trace_addr2sym.py` verified against a real cross-compiled `.so` (readelf-cross-checked), the interpreter-trace slicer measured on the calibrated xvmp shape (including the 4x-margin bug it found in itself), and the flattening-recipe corrections read from the two public obfuscator implementations the user supplied |
| `EXTENSION-absorption-kx.md` | The absorption pass over four public write-ups: the native-VM-recovery procedure (every step `inferred`), pitfalls P43–P46, the slicer's nested-loop diagnostic (with the latent `len(tuple)` bug this pass's review caught), and what deliberately did not cross over |
## Adding a run