VM Executor
Overview
A custom stack-based VM interpreter. It requires a bytecode file as an
argument, but none is provided. The bytecode that produces the flag is
embedded inside the executor itself as a byte array. Players must reverse
the VM's instruction set, locate the embedded bytecode in .rodata, then
either extract it to a file to run, or trace the arithmetic manually.
Step 1: Run it, observe the problem
./vm_executor.bin
# Usage: ./vm_executor.bin <bytecode_file>
# DEADROP Pigeon Range Calculation Unit - bytecode required
./vm_executor.bin anything
# anything: No such file or directory
No bytecode file is provided. The binary won't execute without one.
Step 2: strings, find the lead
strings vm_executor.bin
Notable output:
DRVM
DEADROP Pigeon Range Calculation Unit - bytecode required
ILLEGAL OPCODE: 0x%02x at ip=%u
STACK OVERFLOW
STACK UNDERFLOW
DRVM is the bytecode format magic, it appears as a string constant in
the binary. That means the bytecode header is embedded somewhere in .rodata.
Step 3: Reverse the instruction set
Load in Ghidra. Find the execute() function, it contains a switch on
a byte opcode. Each case reveals one instruction:
| Opcode | Mnemonic | Behaviour |
|---|---|---|
| 0x01 | PUSH n | Push 4-byte little-endian int onto stack |
| 0x02 | ADD | a=pop, b=pop, push(b+a) |
| 0x03 | MUL | a=pop, b=pop, push(b*a) |
| 0x04 | SUB | a=pop, b=pop, push(b-a) |
| 0x05 | DUP | Duplicate top of stack |
| 0x06 | POP | Discard top of stack |
| 0x07 | Pop, print as decimal | |
| 0x08 | HALT | Stop execution |
| 0x09 | MOD | a=pop, b=pop, push(b%a) |
| 0x0A | SWAP | Swap top two stack values |
| 0x0B | PRINTCHAR | Pop, print as ASCII character |
Note the SUB operand order: a=pop(top), b=pop(second), result=b-a.
So PUSH 50, PUSH 8, SUB gives 50-8=42, not 8-50.
Step 4: Find and extract the embedded bytecode
In Ghidra, search for the byte sequence 44 52 56 4D 01 00 (DRVM + version)
in the binary, or look for the embedded_bytecode array in .rodata.
The format is:
Offset 0-3: "DRVM" (magic)
Offset 4-5: 01 00 (version)
Offset 6-9: payload length (LE uint32)
Offset 10+: instructions
Solve path A: extract and run:
Pull the bytes out of .rodata and write them to a file:
import struct
data = open('vm_executor.bin', 'rb').read()
idx = data.find(b'DRVM\x01') # \x01 = version, avoids the string literal
plen = struct.unpack_from('<I', data, idx + 6)[0]
bytecode = data[idx : idx + 10 + plen]
open('program.bytecode', 'wb').write(bytecode)
Then run it:
./vm_executor.bin program.bytecode
Solve path B: disassemble and trace:
Write a disassembler using the ISA table above:
import struct
data = open('vm_executor.bin', 'rb').read()
idx = data.find(b'DRVM\x01') # \x01 = version, avoids the string literal
plen = struct.unpack_from('<I', data, idx + 6)[0]
code = data[idx + 10 : idx + 10 + plen]
MNEMONICS = {
0x01:'PUSH', 0x02:'ADD', 0x03:'MUL', 0x04:'SUB',
0x05:'DUP', 0x06:'POP', 0x07:'PRINT', 0x08:'HALT',
0x09:'MOD', 0x0A:'SWAP', 0x0B:'PRINTCHAR'
}
ip = 0
while ip < len(code):
op = code[ip]; ip += 1
if op == 0x01:
n = struct.unpack_from('<i', code, ip)[0]; ip += 4
print(f' PUSH {n}')
else:
print(f' {MNEMONICS.get(op, f"UNK_{op:02x}")}')
The disassembly shows each flag character computed through different arithmetic before being printed. For example, the first few characters:
PUSH 115
PUSH 47
SUB ; 115 - 47 = 68 = 'D'
PRINTCHAR
PUSH 19
PUSH 3
MUL
PUSH 12
ADD ; 19*3 + 12 = 69 = 'E'
PRINTCHAR
PUSH 9
DUP
MUL
PUSH 16
SUB ; 9*9 - 16 = 65 = 'A'
PRINTCHAR
...
Trace each block to recover the full flag character by character, or simulate the stack in Python:
stack = []
output = []
ip = 0
while ip < len(code):
op = code[ip]; ip += 1
if op == 0x01: n=struct.unpack_from('<i',code,ip)[0]; ip+=4; stack.append(n)
elif op == 0x02: a=stack.pop(); b=stack.pop(); stack.append(b+a)
elif op == 0x03: a=stack.pop(); b=stack.pop(); stack.append(b*a)
elif op == 0x04: a=stack.pop(); b=stack.pop(); stack.append(b-a)
elif op == 0x05: stack.append(stack[-1])
elif op == 0x0B: output.append(chr(stack.pop()))
elif op == 0x08: break
print(''.join(output))
Flag: DEADROP{bytecode_not_included_read_the_vm}
Key Takeaway
"Bytecode not included, read the VM." The flag is self-referential: the solve requires understanding the VM well enough to either extract the embedded bytecode and feed it back in, or simulate execution yourself. Real-world VM protectors (Themida, VMProtect) use the same principle at much greater scale and complexity.