The first time you hit a VM-protected binary it feels like the author cheated.
There's no real control flow — just one enormous function that reads bytes from
an array and switches on them forever. Your decompiler gives up. Your
breakpoints land in the same handler every time.
Then it clicks: it's an interpreter. Someone wrote a tiny CPU in software and compiled the real logic down to bytecode for it. Reverse the CPU, and the bytecode is just another program waiting to be disassembled. This is the writeup of doing exactly that against a toy I built to teach myself the workflow.
recognizing the shape#
VM obfuscators all have the same skeleton, and once you've seen it you can't unsee it:
Three tells, every time: a program counter that only ever indexes one buffer, a
dispatch table or giant switch, and a struct that's clearly a register file.
Find those and you've found the machine. The "code" it runs is data sitting
somewhere in the binary.
mapping the registers#
Before the opcodes mean anything, you need the VM's state layout. I rename fields in the disassembler as I confirm them — guesswork at first, then pinned down by watching the handlers use them.
|
The handler that clears running is your HALT. The handlers that move sp
are your stack ops. You're not guessing anymore — you're reading.
recovering the opcode table#
Now the grind: open each handler, figure out what it does, write it down. Most are two or three instructions. I keep a table as I go.
| opcode | handler does | I call it |
|---|---|---|
0x01 |
push imm8 |
PUSH |
0x04 |
pop b, pop a, push a+b | ADD |
0x05 |
pop b, pop a, push a^b | XOR |
0x0A |
pop val, store to regs[operand] |
STORE |
0x0B |
load regs[operand], push |
LOAD |
0x10 |
pop cond, pop target, jump if cond | JNZ |
0xFF |
clear running |
HALT |
the operand encoding is its own little puzzle
Some handlers read extra bytes after the opcode (immediates, register indices); some pack a register number into the low bits of the opcode itself. Get this wrong and your disassembly desyncs one byte and turns to garbage three instructions later. When output suddenly goes nonsense, suspect your instruction lengths before you suspect your semantics.
writing the disassembler#
Once the table is solid, the bytecode disassembler is fifty lines of Python. The point isn't elegance — it's turning an opaque blob into something you can read.
|
Run it over the code buffer you found at 0xB0 and the obfuscation
evaporates. What was one unreadable interpreter loop becomes a linear listing of
a stack machine doing — in my toy — a rolling XOR check against an embedded
key. The "protection" was a layer of indirection, and you just removed it.
the takeaway#
VM obfuscation trades one hard problem (reading native code) for two easier ones (reverse the interpreter, then disassemble the bytecode). It looks scarier than packing or anti-debug, but it's more mechanical: there's a finite set of handlers, each one is small, and once you've named them all the program has nowhere left to hide.1
Build your own VM obfuscator before you fight someone else's. Twenty handlers and an afternoon, and the genre stops being scary forever.
-
Commercial protectors (VMProtect, Themida) make every step here harder — handler obfuscation, duplicated/virtualized handlers, mutated bytecode between runs. The workflow is identical; you just spend days on each phase instead of minutes. Build the toy first so the workflow is muscle memory before the target is fighting back. ↩