Some CTF reversing challenges don’t check a password. Instead, they load your input into a custom virtual machine and run bytecode. The bytecode is designed to be hard to follow: obfuscated instruction sets, indirect jumps, data manipulation that makes no sense until you understand the VM’s design. But once you reverse the instruction set, you can write a solver that runs backwards or brute-forces the flag.
This post covers how to spot a custom VM, how to reverse its instruction set, and how to write a solver.
What is a custom VM?
A virtual machine in this context is a program that:
- Reads bytecode (an array of bytes) from somewhere: the binary itself, a file, or hardcoded in the data section.
- Interprets each byte as an instruction using a switch/case or jump table.
- Executes the instruction using registers, a stack, or memory that only the VM knows about.
It’s the same idea as Python or Java’s VM, but the instruction set is invented for this one challenge. The flag is usually encoded by the VM executing a specific program; your input must make the VM reach a “success” state.
How to spot a VM
When you open a binary in Ghidra and see this pattern, it’s probably a VM:
A big switch statement in main (or a called function):
while (pc < code_length) {
switch (bytecode[pc]) {
case 0x01: /* instruction 1 */ break;
case 0x02: /* instruction 2 */ break;
case 0x03: /* instruction 3 */ break;
// ... 20-50 cases
}
pc++;
}
The pc is a program counter: an index into the bytecode array. The
switch dispatches on the opcode (first byte of each instruction). The
number of cases tells you how many instructions the VM has.
Other telltale signs:
- A
whileorforloop that indexes into a byte array and dispatches on the value. - Registers that aren’t CPU registers: global variables used as VM state
(
vm_reg[0],vm_stack,vm_pc). - A function that looks like it has no purpose other than “run this program.”
Reversing the instruction set
Once you’ve found the VM loop, the job is to figure out what each opcode does. Work through the switch cases one at a time.
Start with the obvious ones
Some opcodes are easy to identify:
Stack push:
case 0x10:
vm_stack[vm_sp] = vm_reg[arg];
vm_sp++;
break;
This pushes a register value onto the stack. You can tell from the
vm_sp++ (stack pointer increment) and the write to the stack array.
Stack pop:
case 0x11:
vm_sp--;
vm_reg[arg] = vm_stack[vm_sp];
break;
The reverse: decrement the stack pointer, read into a register.
Input:
case 0x20:
vm_reg[0] = user_input[input_index];
input_index++;
break;
Reads a byte from user input into a register. This is how your input gets into the VM.
Compare and jump:
case 0x30:
if (vm_reg[arg1] != vm_reg[arg2]) {
vm_pc = jump_target;
}
break;
Conditional jump: if two registers don’t match, jump to a different offset. This is the “check” opcode.
Print success:
case 0xFF:
puts("Correct!");
break;
The exit opcode.
Map the full instruction set
Create a table (even just a text file) mapping each opcode to what it does:
0x00 = nop
0x01 = push reg
0x02 = pop reg
0x03 = mov reg, imm
0x04 = add reg, reg
0x05 = sub reg, reg
0x06 = xor reg, reg
0x10 = read_input reg
0x20 = cmp reg, reg
0x21 = je offset
0x22 = jne offset
0xFF = halt
The exact opcodes depend on the challenge. But the categories are consistent: data movement, arithmetic, logic, control flow, I/O. Every VM implements some subset of these.
Follow the data flow
Once you know the instructions, trace what happens to user input:
- It enters via the
read_inputopcode. - It gets pushed onto the stack or moved into a register.
- It gets transformed: XOR, ADD, SUB, lookup table, bit shifts.
- The result is compared against something: the expected flag bytes.
The transformation chain is the “encryption” on the flag. If you can read each step, you can reverse it.
Writing a solver
Once you know the instruction set, you don’t need to trace through the VM manually. Write a solver.
Approach 1: emulate the VM
Reimplement the VM in Python (or any language). Run the bytecode through your emulator with candidate inputs:
class VM:
def __init__(self, bytecode):
self.bytecode = bytecode
self.pc = 0
self.reg = [0] * 8
self.stack = []
self.input_idx = 0
def run(self, user_input):
self.user_input = user_input
while self.pc < len(self.bytecode):
op = self.bytecode[self.pc]
self.pc += 1
if op == 0x10: # read_input
arg = self.bytecode[self.pc]; self.pc += 1
self.reg[arg] = self.user_input[self.input_idx]
self.input_idx += 1
elif op == 0x06: # xor reg, reg
a = self.bytecode[self.pc]; self.pc += 1
b = self.bytecode[self.pc]; self.pc += 1
self.reg[a] ^= self.reg[b]
elif op == 0x20: # cmp
a = self.bytecode[self.pc]; self.pc += 1
b = self.bytecode[self.pc]; self.pc += 1
if self.reg[a] != self.reg[b]:
self.pc = self.bytecode[self.pc]
else:
self.pc += 1
elif op == 0xFF:
return True
return False
Then brute-force or constraint-solve the input.
Approach 2: reverse the operations
If the VM does simple transformations, reverse them step by step:
# VM does: input[i] ^ 0x42 + 3 == expected[i]
# Reverse: flag[i] = (expected[i] - 3) ^ 0x42
flag = bytes([(b - 3) ^ 0x42 for b in expected])
This only works when the transformations are simple and reversible. If the VM does a hash or a complex loop, you need the emulator approach.
Approach 3: use angr
angr is a binary analysis framework that can solve constraining problems. If you can identify the VM’s check function, angr can find an input that makes it return “success”:
import angr
proj = angr.Project('./crackme', auto_load_libs=False)
state = proj.factory.entry_state()
simgr = proj.factory.simulation_manager(state)
# find the "correct" state, avoid the "wrong" state
simgr.explore(find=0x401234, avoid=0x401256)
if simgr.found:
found = simgr.found[0]
print(found.posix.dumps(0)) # stdin that reaches the find address
angr treats the binary as a set of constraints and uses a constraint solver to find an input that reaches a specific address. It’s slow but it works, even on VMs you don’t fully understand.
Common VM patterns in CTF
Stack-based VMs look like Forth or JVM bytecode. Instructions push and pop values, operations work on the top of the stack:
push 0x41
push 0x42
xor // stack = [0x03]
Register-based VMs look like assembly. Instructions move data between registers and perform operations:
mov r0, 0x41
mov r1, 0x42
xor r0, r1 // r0 = 0x03
Obfuscated VMs hide the instruction set. Instead of a clean switch,
they use computed jumps (goto *(&jump_table + opcode * 8)), so the
decompiler can’t show you the switch directly. You need to follow the
jump table manually. In Ghidra, look for arrays of function pointers in
the data section.
Multi-stage VMs run one VM, then use its output as input to another VM. You need to reverse both stages.
Custom VMs look intimidating but they follow patterns. Every VM has a program counter, an instruction stream, and a dispatch mechanism. Find those three things and you can reverse the instruction set. The next post covers anti-debugging: techniques binaries use to detect that you’re debugging them, and how to get around it.