Home > Writeups > DEADROP Rev 4 - VM Executor

DEADROP Rev 4 - VM Executor

A custom stack-based VM interpreter provided without its bytecode. The bytecode that prints the flag is embedded inside the executor itself. Reverse the ISA, extract the embedded bytecode from .rodata, and either run it or trace the arithmetic manually.

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 PRINT 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.

< Back to All Writeups