← Back to CoderGeek
Native RE VMP Devirtualization AArch64 Authorized Research

VMP on Android Native Libraries:
Internals, Tooling & a Devirtualization Lab

Updated 2026-07-30

This is an authorized-research / educational write-up. It explains how Virtual Machine Protection works and how analysts approach it, with a build-it-then-break-it lab. It provides no tools for unauthorized access. Do not apply any of this to software you are not authorized to examine.
Virtual Machine Protection replaces a function's native code with a private bytecode run by an embedded interpreter, destroying the original control-flow graph. The recovery loop, though, is constant: locate the dispatcher, enumerate handlers, recover opcode semantics, trace a real run, lift back to C, and differential-test. In 2026 the winning stack is QBDI/Unicorn traces plus Triton symbolic lifting plus LLM-assisted annotation — not pure static symbolic execution.

1. What VMP Actually Does

A VMP protector performs three transformations on a function:

StageInputOutput
LiftingNative IR / machine code (e.g. ARM64)Custom bytecode for a private ISA
EmbeddingThe bytecodeA data blob + a dispatcher linked into the binary
DispatchingA call to the protected functionRuntime 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

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.

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:

PhaseGoalTools (2026)
TriageIdentify VM, dispatcher, handlersIDA Pro 9.x, Ghidra, Binary Ninja, JEB Native
Static liftingRecover opcode semantics symbolicallyTriton, MAAT, angr, Miasm
Trace / emulationExecute the VM under instrumentationQBDI, Unicorn, Qiling, Frida + Stalker
Native debuggingStep the dispatcher on-deviceLLDB (NDK), GDB + gdbserver
Hooking / bypassSkip anti-analysis, snapshot contextFrida, LSPosed, objection
AI-assistedAnnotate handlers, suggest ISALLM-assisted lifting (see §4.4)

3.1 Recommended workflow

  1. Find the dispatcher. A tight loop reading a byte/halfword from a buffer indexed by a virtual IP, followed by a computed jump.
  2. Enumerate handlers. Map every dispatch target → handler entry. Handler count ≈ ISA width.
  3. Symbolize one handler at a time. Triton/angr computes its effect on the virtual context as a transfer function — the semantics of one opcode.
  4. Trace a real execution. Drive the VM under QBDI/Unicorn/Frida and record the opcode sequence — a program in the private ISA.
  5. 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:

OpcodeMnemonicEncodingSemantics
0x01LOAD01 reg immr[reg] = imm (magic 0xFF=arg0, 0xFE=arg1)
0x02ADD02 dst srcr[dst] += r[src]
0x03MUL03 dst immr[dst] *= imm
0x04CMP04 reg immr[3] = (r[reg] == imm)
0x05JE05 offif (r[3]) ip = off
0x06RET06 regreturn 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

  1. Instrument the dispatcher with QBDI or Frida-Stalker to log (ip, opcode, ctx_before, ctx_after) per instruction across many inputs.
  2. Cluster handlers by effect. Identical transfer functions = same opcode in disguise (commercial VMs replicate handlers to defeat counting).
  3. Synthesize semantics with Triton: concretize, lift to AST, simplify with MBA-aware rewriting, emit one C statement.
  4. Emit recovered C and differential-test against the protected .so on a shared fuzz corpus.

6. State of the Art (2026)

7. Advanced Exercises (Bonus)

  1. Stack & calls. Add PUSH/POP, a virtual stack pointer, CALL/RET with a virtual return address.
  2. Threaded dispatch. Replace the switch with direct threading; watch the CFG dissolve.
  3. Handler replication & randomization. Rebuild your disassembler to cluster rather than enumerate — exactly what defeats naïve analysis on real protectors.
  4. Opaque predicates inside handlers. Confirm symbolic execution now needs predicate resolution before clustering.
  5. Anti-emulation. A handler that reads cntvct_el0 and diverges under Unicorn; then the Frida shim that pins it.
Bottom line. VMP replaces native code with private bytecode and destroys the original CFG, but the recovery loop is constant and learnable: locate dispatcher → enumerate handlers → recover opcode semantics → trace → lift to C → differential-test. Build the lab in §4, break it in §5, harden it in §7. If you can lift your own hardened VM, you can lift most of what you will meet in the field.

Hardened native lib in your way?

Authorized VMP / OLLVM / RASP analysis and devirtualization. Quote within 24 hours.

Get a Quote