VMP on Android Native Libraries:
Internals, Tooling & a Devirtualization Lab
Updated 2026-07-30
1. What VMP Actually Does
A VMP protector performs three transformations on a function:
| Stage | Input | Output |
|---|---|---|
| Lifting | Native IR / machine code (e.g. ARM64) | Custom bytecode for a private ISA |
| Embedding | The bytecode | A data blob + a dispatcher linked into the binary |
| Dispatching | A call to the protected function | Runtime interpretation by the VM loop |
At runtime the original function is gone. In its place is a stub that pushes a virtual context (virtual registers, virtual IP, virtual flags) and enters a dispatcher — a loop that fetches the next opcode, decodes it, and jumps to the matching handler. Each handler mutates the virtual context, just as real instructions mutate real registers. The defender's win is asymmetry: the analyst must reconstruct both the ISA and the program before the original logic is recoverable.
1.1 Dispatcher patterns
switch-dispatch — onewhile(1) { switch(op) {...} }loop. Easiest to spot (one big jump table) and easiest to trace.if/elsechain — functionally identical but harder to pattern-match; common in hand-rolled VMs.- Threaded dispatch (direct threading, Duff's-device, or computed
br/jmp) — each handler tail-jumps to the next; no central loop exists, so the CFG is a tangled web. The hardest variant, and what separates toy VMs from commercial protectors.
1.2 Why static analysis collapses
A protected function has no recoverable CFG. The source's CMP/JE becomes two VM opcodes whose control-flow effect is hidden inside a handler. IDA, Ghidra, and Binary Ninja produce one huge basic block or a degenerate CFG — loops, branches, and calls are deliberately destroyed.
2. The Protection Landscape (2026)
VMP rarely travels alone. A serious native target stacks multiple transforms — know the stack before choosing a strategy.
- VMProtect / Themida — desktop lineage, mature VM with multiple dispatch modes + mutation.
- Tigress — academic, source-to-source, diverse configurable VMs; common in research and CTF.
- OLLVM lineage —
ollvm, Hikari, Arkari, goron, pluto. Many "VMP" samples on Android are actually OLLVM control-flow flattening plus a partial interpreter, not a full VM. - Mobile-first protectors — Promon SHIELD, DexGuard/Guardsquare, Arxan, Bangcle, Ijiami, Tencent Legu, Alibaba Jiagu, 360 Jiagu. Their native VMP is the hardest tier.
Companion obfuscations you will find alongside VMP: opaque predicates, control-flow flattening (often the precursor to virtualization), instruction substitution / MBA, string & constant encryption, anti-debug / anti-Frida, and anti-emulation checks that distinguish real silicon from Unicorn/QEMU.
3. Analysis & Devirtualization Toolkit
Choose tools by phase:
| Phase | Goal | Tools (2026) |
|---|---|---|
| Triage | Identify VM, dispatcher, handlers | IDA Pro 9.x, Ghidra, Binary Ninja, JEB Native |
| Static lifting | Recover opcode semantics symbolically | Triton, MAAT, angr, Miasm |
| Trace / emulation | Execute the VM under instrumentation | QBDI, Unicorn, Qiling, Frida + Stalker |
| Native debugging | Step the dispatcher on-device | LLDB (NDK), GDB + gdbserver |
| Hooking / bypass | Skip anti-analysis, snapshot context | Frida, LSPosed, objection |
| AI-assisted | Annotate handlers, suggest ISA | LLM-assisted lifting (see §4.4) |
3.1 Recommended workflow
- Find the dispatcher. A tight loop reading a byte/halfword from a buffer indexed by a virtual IP, followed by a computed jump.
- Enumerate handlers. Map every dispatch target → handler entry. Handler count ≈ ISA width.
- Symbolize one handler at a time. Triton/angr computes its effect on the virtual context as a transfer function — the semantics of one opcode.
- Trace a real execution. Drive the VM under QBDI/Unicorn/Frida and record the opcode sequence — a program in the private ISA.
- Lift to readable IR, then C, and re-run against the original to prove equivalence.
4. Lab: Build a Mini-VMP, Then Break It
The fastest way to understand VMP is to build one. We translate a trivial C function into private bytecode, implement an interpreter, then in §5 write the lifter that recovers the original logic.
4.1 The target function
int logic(int x, int y) {
int z = x * 2 + y;
if (z == 10) return 1;
else return 0;
}
4.2 Design the private ISA
Four virtual registers r0–r3 (r3 doubles as the comparison flag), six opcodes:
| Opcode | Mnemonic | Encoding | Semantics |
|---|---|---|---|
0x01 | LOAD | 01 reg imm | r[reg] = imm (magic 0xFF=arg0, 0xFE=arg1) |
0x02 | ADD | 02 dst src | r[dst] += r[src] |
0x03 | MUL | 03 dst imm | r[dst] *= imm |
0x04 | CMP | 04 reg imm | r[3] = (r[reg] == imm) |
0x05 | JE | 05 off | if (r[3]) ip = off |
0x06 | RET | 06 reg | return r[reg] |
4.3 Hand-compile to bytecode
LOAD r0, x ; 01 00 FF
LOAD r1, y ; 01 01 FE
MUL r0, 2 ; 03 00 02
ADD r0, r1 ; 02 00 01
CMP r0, 10 ; 04 00 0A
JE 0x0D ; 05 0D -> jump to "return 1" if equal
LOAD r2, 0 ; 01 02 00
RET r2 ; 06 02 (false branch)
LOAD r2, 1 ; 01 02 01 (at offset 0x0D)
RET r2 ; 06 02 (true branch)
4.4 The interpreter (dispatcher)
#include <stdio.h>
int run_vm(unsigned char *code, int arg0, int arg1) {
int r[4] = {0}; // virtual register file (r3 = flag)
int ip = 0; // virtual instruction pointer
for (;;) {
unsigned char op = code[ip++];
switch (op) {
case 0x01: { // LOAD
int reg = code[ip++];
int val = code[ip++];
if (val == 0xFF) val = arg0;
if (val == 0xFE) val = arg1;
r[reg] = val;
break;
}
case 0x02: { // ADD
int dst = code[ip++], src = code[ip++];
r[dst] += r[src];
break;
}
case 0x03: { // MUL
int dst = code[ip++], imm = code[ip++];
r[dst] *= imm;
break;
}
case 0x04: { // CMP
int reg = code[ip++], imm = code[ip++];
r[3] = (r[reg] == imm); // flag
break;
}
case 0x05: { // JE
int off = code[ip++];
if (r[3]) ip = off;
break;
}
case 0x06: { // RET
int reg = code[ip++];
return r[reg];
}
default:
return -1; // illegal opcode
}
}
}
Build native with gcc -O2 main.c vm.c -o vmtest, or cross-compile for Android with the NDK (aarch64-linux-android24-clang -O2 -shared -fPIC ...) and run on-device under lldb-server / Frida.
5. Breaking the VM: Writing the Lifter
The interpreter above is what you find inside a protected .so. The reverse engineer's job is to recover §4.1 from the dispatcher plus the bytecode blob.
5.1 Recover the opcode table
From the dispatcher's switch, every case reveals one opcode's intent:
INSTR_TABLE = {
0x01: "LOAD",
0x02: "ADD",
0x03: "MUL",
0x04: "CMP",
0x05: "JE",
0x06: "RET",
}
In a real target you recover this by symbolically executing each handler in Triton/angr and naming the opcode after its symbolic effect — not by reading mnemonics off the page.
5.2 The disassembler (bytecode → assembly)
def disasm(bytecode):
i = 0
while i < len(bytecode):
op = bytecode[i]
if op == 0x01:
print(f"{i:02X}: LOAD r{bytecode[i+1]}, {bytecode[i+2]}"); i += 3
elif op == 0x02:
print(f"{i:02X}: ADD r{bytecode[i+1]}, r{bytecode[i+2]}"); i += 3
elif op == 0x03:
print(f"{i:02X}: MUL r{bytecode[i+1]}, {bytecode[i+2]}"); i += 3
elif op == 0x04:
print(f"{i:02X}: CMP r{bytecode[i+1]}, {bytecode[i+2]}"); i += 3
elif op == 0x05:
print(f"{i:02X}: JE {bytecode[i+1]}"); i += 2
elif op == 0x06:
print(f"{i:02X}: RET r{bytecode[i+1]}"); i += 2
else:
print(f"{i:02X}: ??? 0x{op:02X}"); i += 1
The LOAD/MUL/ADD/CMP/JE sequence then collapses back to if (x*2 + y == 10) return 1; else return 0;. That final collapse — from VM assembly to C — is the whole game at scale.
5.3 From manual to automatic: symbolic lifting
- Instrument the dispatcher with QBDI or Frida-Stalker to log
(ip, opcode, ctx_before, ctx_after)per instruction across many inputs. - Cluster handlers by effect. Identical transfer functions = same opcode in disguise (commercial VMs replicate handlers to defeat counting).
- Synthesize semantics with Triton: concretize, lift to AST, simplify with MBA-aware rewriting, emit one C statement.
- Emit recovered C and differential-test against the protected
.soon a shared fuzz corpus.
6. State of the Art (2026)
- Trace-driven devirtualization is standard. The trace → cluster → lift pipeline beats pure symbolic execution on large VMs because angr-style paths explode on threaded dispatchers.
- AArch64 is the default battlefield. Dispatchers use
br xN/blr xN; PAC, BTI, and MTE add real constraints (and MTE is useful when you fuzz handler semantics). - OLLVM descendants blur CFF vs VMP. Modern forks do partial virtualization — treat each function independently.
- LLM-assisted RE. Models reliably annotate single handlers and collapse MBA, but the correct pattern is LLM proposes, SMT disposes — never trust a transfer function without symbolic or differential verification.
7. Advanced Exercises (Bonus)
- Stack & calls. Add
PUSH/POP, a virtual stack pointer,CALL/RETwith a virtual return address. - Threaded dispatch. Replace the
switchwith direct threading; watch the CFG dissolve. - Handler replication & randomization. Rebuild your disassembler to cluster rather than enumerate — exactly what defeats naïve analysis on real protectors.
- Opaque predicates inside handlers. Confirm symbolic execution now needs predicate resolution before clustering.
- Anti-emulation. A handler that reads
cntvct_el0and diverges under Unicorn; then the Frida shim that pins it.
Hardened native lib in your way?
Authorized VMP / OLLVM / RASP analysis and devirtualization. Quote within 24 hours.
Get a QuoteThis document is for technical demonstration and authorized research only. It provides no tools or methods for unauthorized access. © CoderGeek.