mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-15 20:44:22 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user