01 / Challenge Overview
The challenge provided two files: a stripped 64-bit ELF executable and a binary file named program.bin. There were no symbols and no hints, other than the description implying it was a custom architecture.
02 / Initial Triage
As always, I started with basic static and dynamic analysis to understand the lay of the land.
$ file challenge
challenge: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, stripped
$ strings challenge | head -20
/lib64/ld-linux-x86-64.so.2
libc.so.6
fopen
fread
malloc
...
The presence of fopen and fread strongly suggested the executable was reading the program.bin file.
03 / Identifying the VM Dispatcher
Loading the binary into Ghidra, I navigated to the main function. The setup was typical for a VM challenge: an initialization block allocating memory for a "virtual stack" and "registers", followed by a massive switch/case statement inside a while loop.
This central loop is the VM Dispatcher. It fetches the next byte from the loaded program.bin buffer, advances the Instruction Pointer (IP), and executes the corresponding case.
04 / Reconstructing the Instruction Set
By analyzing the operations within each switch case, I mapped out the custom instruction set. The VM was stack-based, meaning most operations occurred on the top of the virtual stack.
0x01 (PUSH): Read the next 4 bytes, push onto the stack.0x02 (POP): Pop the top value off the stack.0x03 (ADD): Pop two values, add them, push the result.0x04 (XOR): Pop two values, XOR them, push the result.0x0A (CMP): Compare top two values, set a virtual flag register.0x0B (JMP): Jump to address if the flag is set.
05 / Writing the Disassembler
Analyzing raw hex bytecode is tedious. To understand the logic in program.bin, I wrote a quick Python disassembler based on my instruction set mapping.
# vm_disasm.py — Custom VM bytecode disassembler
OPCODES = {
0x01: ('PUSH', 4),
0x02: ('POP', 0),
0x03: ('ADD', 0),
0x04: ('XOR', 0),
0x0A: ('CMP', 0),
0x0B: ('JMP', 4),
}
def disassemble(bytecode):
pc = 0
while pc < len(bytecode):
op = bytecode[pc]
if op in OPCODES:
name, size = OPCODES[op]
operand = bytecode[pc+1:pc+1+size]
print(f'{pc:04x}: {name} {operand.hex()}')
pc += 1 + size
else:
print(f'{pc:04x}: UNKNOWN {hex(op)}')
pc += 1
06 / Bytecode Analysis
Running the disassembler over program.bin yielded readable assembly. The logic was a classic flag checker: it read input, looped over each character, XORed it against a hardcoded static array, and compared the result to an expected buffer.
07 / Flag Extraction
Since the XOR operation is reversible, I extracted the expected buffer and the static XOR key from the bytecode. A simple Python script performed the inverse operation, revealing the flag.
Key Takeaways: VM challenges require identifying the architecture type (stack vs register), mapping the dispatcher loop, and building tooling to translate the custom bytecode into a readable format.
Tools Used
Practice Questions
- How does identifying whether a VM is stack-based or register-based change your reverse engineering approach?
- What patterns in a
switchstatement suggest a VM dispatcher in Ghidra? - How can you automate the mapping of opcodes if the VM is highly obfuscated?