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.

EventHack The Box Cyber Apocalypse 2026
CategoryReversing
FocusCustom VM reverse engineering
DifficultyMedium
Points950
OutcomeSolved
The Cinder Engine challenge overview on Hack The Box

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
file command output identifying ARM64 ELF executable

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
Binary file size and SHA-256 digest

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"'
strings output showing bad opcode and minimal interface readelf showing undefined symbols including getc putc fwrite

The imports showed a narrow interface:

  1. getc read individual bytes.
  2. putc wrote individual bytes.
  3. fwrite could emit a larger message.
  4. bad opcode suggested 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
Ghidra import with ARM64 little-endian language selected

After the initial analysis completed, I opened:

Window
> Defined Strings
> bad opcode
Ghidra Defined Strings window showing bad opcode

I followed the string label into its only reference:

Right-click s_bad_opcode_00100e70
> References
> Show References To
Ghidra cross-reference to bad opcode string

Opening that location in the Decompiler exposed the correct function:

Ghidra decompiler showing VM dispatcher 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.

Decompiled switch statement revealing the VM instruction set

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.

PropertyRecovered behavior
InputUp to 4,096 bytes from stdin.
Instruction sizeFour bytes.
OpcodeFourth instruction byte.
Registers16 general-purpose registers.
Memory65,536-byte virtual memory region.
Embedded imageBegins at DAT_00100e80.
0xe0Consume one input byte.
0xe1Emit one output byte.
0x00Halt.
Unknown opcodePrint 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:

  1. Read cinder as raw bytes.
  2. Extract its embedded VM image.
  3. Decode the four-byte instructions.
  4. Emulate the registers, memory, branches, input, and output.
  5. Reverse the validation transformation.
  6. 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'
Disassembler output showing decoded VM instructions

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'
Disassembly showing end of round structure and comparison

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'
Disassembly showing target comparison and success output path

The two sections showed the complete validation path:

  1. 0x1014 stored the last mixed byte, completing a 32-byte result at 0x80 through 0x9f.
  2. 0x1018 through 0x102c copied the result into the active state at 0x00 through 0x1f.
  3. 0x1030 through 0x1054 applied the next 32-byte round key.
  4. 0x1058 through 0x1064 advanced the round counter until it reached nine.
  5. 0x1068 through 0x108c compared the final state with the 32-byte target at ROM offset 0x12e4.
  6. Any mismatch changed r11 from zero to one.
  7. jnz 0x10c0 halted when the mismatch marker was set.
  8. The success path XORed 26 bytes of preserved input with ROM data at 0x1304 and 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:

  1. Remove the round key with XOR.
  2. Invert the 32 by 32 XOR-mixing matrix.
  3. 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:

Solver output showing verified recovery after 13813 VM instructions

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

  1. Identified cinder as a stripped ARM64 ELF.
  2. Used strings and imports to identify interpreter-like behavior.
  3. Followed bad opcode into the VM dispatcher in Ghidra.
  4. Recovered the four-byte instruction format, registers, memory, ROM, and opcodes.
  5. Built a Python disassembler and emulator.
  6. Decoded the 32-byte input loop and eight-round validation transform.
  7. Reversed the round keys, S-box, and GF(2) mixing layer.
  8. Recovered the accepted 32-byte input.
  9. Executed it through the reconstructed VM.
  10. Verified the redacted output after 13,813 virtual instructions.