A small set of imports and one spectacularly useful bad opcode string led from a stripped ARM64 executable to the custom virtual machine hiding inside it.
| Event | Hack The Box Cyber Apocalypse 2026 |
|---|---|
| Category | Reversing |
| Focus | Custom VM reverse engineering |
| Difficulty | Medium |
| Points | 950 |
| Outcome | Solved |
The assignment
The challenge supplied one executable with no source code or explanation of the input it expected. I needed to determine how it processed input and recover the value that reached its success path.
The binary turned out to be a native wrapper around an embedded bytecode program. Recovering that inner machine's four-byte instruction format exposed a reversible eight-round transformation.
Executive summary
I began with file, sha256sum, strings, and readelf. The binary was a stripped ARM64 ELF with a very small observable interface: getc, putc, fwrite, and the string bad opcode.
Following that string's cross-reference in Ghidra led to the VM dispatcher. I recovered its registers, memory, instruction format, embedded ROM, and supported opcodes, then built cinder_vm.py to disassemble and emulate the bytecode.
The disassembly revealed a 32-byte input loop followed by an initial key XOR and eight rounds of S-box substitution, XOR mixing, and round-key application. I reversed those operations, recovered the accepted input, and ran it through the Python VM. The emulator executed 13,813 virtual instructions and produced the redacted flag.
Initial evidence
I extracted the archive and identified the binary:
file the_cinder_engine/cinder
We have an ARM64 executable.
I recorded its size and SHA-256 digest so the rest of the analysis remained tied to one artifact:
ls -lh the_cinder_engine/cinder
sha256sum the_cinder_engine/cinder
Next, I checked whether Ghidra was the right tool for this:
strings -a -n 4 the_cinder_engine/cinder
readelf -Ws the_cinder_engine/cinder |
awk '$7 == "UND"'
The imports showed a narrow interface:
getcread individual bytes.putcwrote individual bytes.fwritecould emit a larger message.bad opcodesuggested an interpreter or state machine.
To Ghidra.
Investigation and solve path
Phase 1: Follow bad opcode
I imported the binary using Ghidra's ARM64 little-endian language:
AARCH64:LE:64:v8A
After the initial analysis completed, I opened:
Window
> Defined Strings
> bad opcode
I followed the string label into its only reference:
Right-click s_bad_opcode_00100e70
> References
> Show References To
Opening that location in the Decompiler exposed the correct function:
The first part read from stdin one byte at a time and stored up to 0x1000 bytes. The surrounding control flow repeatedly fetched instructions from an embedded data region.
Oooooh. Juicy. Ghidra exposed a custom virtual machine.
Phase 2: Recover the VM specification
The decompiled switch statement defined the virtual instruction set. Each instruction occupied four bytes, with the opcode in the fourth byte.
| Property | Recovered behavior |
| Input | Up to 4,096 bytes from stdin. |
| Instruction size | Four bytes. |
| Opcode | Fourth instruction byte. |
| Registers | 16 general-purpose registers. |
| Memory | 65,536-byte virtual memory region. |
| Embedded image | Begins at DAT_00100e80. |
0xe0 | Consume one input byte. |
0xe1 | Emit one output byte. |
0x00 | Halt. |
| Unknown opcode | Print bad opcode. |
The remaining cases covered moves, shifts, rotations, arithmetic, bitwise operations, memory access, comparisons, and conditional jumps.
At this point, the native ARM64 code had given me the design of the inner machine. The embedded bytecode would explain the challenge logic.
Phase 3: Build the disassembler and emulator
I built cinder_vm.py to:
- Read
cinderas raw bytes. - Extract its embedded VM image.
- Decode the four-byte instructions.
- Emulate the registers, memory, branches, input, and output.
- Reverse the validation transformation.
- Verify the recovered input through forward execution.
The complete utility belongs on the site's Scripts page. This writeup keeps the portions needed to understand and reproduce the solve.
I tested the disassembler first:
python3 cinder_vm.py \
the_cinder_engine/cinder \
--disassemble |
sed -n '1,35p'
Aha! The disassembler works.
The opening instructions established the input path:
li r8, 0x0020
in r5
store [r4 + 0x0000], r5
store [r4 + 0x0040], r5
addi r4, r4, 0x0001
cmp r4, r8
jnz 0x000c
The VM expected 32 input bytes. It stored each byte in the active state at memory[r4] and preserved a second copy at memory[r4 + 0x40].
The next instructions selected round-key material at ROM offset 0x11c4, applied it with XOR, and substituted each byte through an S-box beginning at 0x10c4. A long sequence of loads and XORs formed the linear mixing layer.
Phase 4: Trace the comparison and success path
I inspected the end of the round structure:
python3 cinder_vm.py \
the_cinder_engine/cinder \
--disassemble |
sed -n '1015,1060p'
There is the complete round structure.
I pulled the next section to see where it led:
python3 cinder_vm.py \
the_cinder_engine/cinder \
--disassemble |
sed -n '1060,1080p'
The two sections showed the complete validation path:
0x1014stored the last mixed byte, completing a 32-byte result at0x80through0x9f.0x1018through0x102ccopied the result into the active state at0x00through0x1f.0x1030through0x1054applied the next 32-byte round key.0x1058through0x1064advanced the round counter until it reached nine.0x1068through0x108ccompared the final state with the 32-byte target at ROM offset0x12e4.- Any mismatch changed
r11from zero to one. jnz 0x10c0halted when the mismatch marker was set.- The success path XORed 26 bytes of preserved input with ROM data at
0x1304and printed the result.
The VM therefore applied:
Input
Initial key XOR
Eight rounds: S-box, linear XOR mixing, round-key XOR
Target comparison
Success-only flag decoding
Phase 5: Reverse the transformation
A 32-byte input allows up to 2256 candidates. The recovered operations gave me a better route.
I started with the stored target and reversed rounds eight through one:
- Remove the round key with XOR.
- Invert the 32 by 32 XOR-mixing matrix.
- Apply the inverse S-box.
After the eighth inverse round, I removed the initial key.
XOR reverses itself:
A XOR K XOR K = A
The S-box was a permutation of all 256 byte values, so I built its inverse by swapping each input-output mapping:
inverse_sbox = [0] * 256
for original, substituted in enumerate(sbox):
inverse_sbox[substituted] = original
The mixing layer used XOR relationships between the 32 state bytes. I represented those relationships as a binary matrix and inverted it with Gaussian elimination over GF(2).
The reverse loop was:
state = bytes(target)
for round_number in range(8, 0, -1):
state = bytes(
value ^ key
for value, key in zip(state, keys[round_number])
)
state = invert_linear(rows, state)
state = bytes(inverse_sbox[value] for value in state)
accepted_input = bytes(
value ^ key
for value, key in zip(state, keys[0])
)
Phase 6: Verify through the VM
I ran the solver against the original binary:
python3 cinder_vm.py \
the_cinder_engine/cinder \
--solve |
tee cinder_solve.txt
The public output remains redacted:
The emulator consumed the recovered input, executed the original embedded program, reached the success-only output path, and halted after 13,813 virtual instructions.
Why the approach worked
The bad opcode reference placed every supported virtual operation in one function. That made it an efficient semantic anchor inside a stripped binary.
Once the dispatcher was documented, the challenge moved into the embedded bytecode. The first instructions exposed the input length and storage pattern. The final instructions exposed the comparison target and output path. The code between them defined the transformation that had to be reversed.
The emulator provided the final validation. It tested instruction decoding, register behavior, memory addressing, signed branches, input handling, transformation order, comparison logic, and output generation against the embedded program.
Security relevance
The challenge demonstrates the limits of client-side obfuscation. The VM increased the cost of analysis, but its interpreter, bytecode, constants, target, and output path were present in the executable.
For offensive security work, the useful lesson is methodological: follow semantic anchors, reconstruct only the machinery needed for the objective, and validate the model against program behavior.
What I learned
This was my first serious Ghidra workflow, and the custom VM made the learning curve worthwhile. I moved from one useful error string to a recovered instruction set, a readable disassembly, an emulator, and an inverted validation routine.
The moment the disassembler produced coherent instructions changed the challenge. Raw bytes had become a program I could reason through.
And yes, I know: reverse engineering is amazing.
Attribution and AI use
I solved and documented The Cinder Engine during Hack The Box Cyber Apocalypse 2026 as a member of the CIAT Cybersecurity Club team.
I used AI to support development and debugging of cinder_vm.py, including translation of the recovered dispatcher and implementation of the GF(2) inversion. I performed the Ghidra analysis, validated the VM model against the bytecode, ran the tooling, and submitted the flag.
Full attack chain
- Identified
cinderas a stripped ARM64 ELF. - Used strings and imports to identify interpreter-like behavior.
- Followed
bad opcodeinto the VM dispatcher in Ghidra. - Recovered the four-byte instruction format, registers, memory, ROM, and opcodes.
- Built a Python disassembler and emulator.
- Decoded the 32-byte input loop and eight-round validation transform.
- Reversed the round keys, S-box, and GF(2) mixing layer.
- Recovered the accepted 32-byte input.
- Executed it through the reconstructed VM.
- Verified the redacted output after 13,813 virtual instructions.