Before the white fire, exactly one Cinderbound scribe understood how the Cinder Engine thought. [...] He kept the opcode map in his head and nowhere else, a habit the order called caution and everyone else called paranoia. He died the night Maelor reached for the First Mark [...] The machine still runs. It still takes a vow, still churns it through whatever logic survives inside that scorched firmware, still says true or false at the end. Nobody alive can tell you why. [...] Work out its opcode map from how it moves. Pull the firmware apart and find the cipher hiding in its tables. Then feed it something worth saying, and see if a dead man's machine still remembers how to keep a promise.
A 14 KB aarch64 binary containing a bytecode VM with an undocumented instruction set. The VM program implements an 8-round Substitution-Permutation Network (SPN) block cipher over a 32-byte state. Two tools are needed: a disassembler to read the bytecode at all, and a solver that inverts the cipher.
$ unzip -l a2524d8e-6b8d-45d3-b7d9-cd4c19db3a79-1784743721.zip
Length Date Time Name
--------- ---------- ----- ----
0 06-23-2026 21:51 the_cinder_engine/
14248 06-23-2026 21:37 the_cinder_engine/cinder
--------- -------
14248 2 files
$ file cinder
cinder: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked,
interpreter /lib/ld-linux-aarch64.so.1, stripped
One stripped aarch64 binary, and nothing else - no source, no server, no hint about what it expects on stdin.
Neither. A local binary that reads from stdin and prints nothing useful for wrong
input. strings gives away the shape of the thing:
$ strings cinder
...
bad opcode
...
Conclusion: "bad opcode" plus the challenge text asking for an opcode map says this is an interpreter. The aarch64 code is just the VM; the actual logic is bytecode somewhere in the binary.
Decompile main in Ghidra and follow what it reads and writes.
0x1000 bytes of instructions, then runs themDAT_00113018 - a register fileDAT_00100e80, four bytes per instruction
Conclusion: 16 registers plus a zero flag, fixed 4-byte instructions, bytecode blob
at .rodata+0x18 (vaddr 0xE80, 0x131E bytes). Each
opcode's meaning has to be worked out from what the interpreter does with it.
Before any opcode can be identified, the four bytes have to be split into fields. The
interpreter shows how: at the top of the dispatch loop it slices the instruction word
apart, and each slice is then used as an index into the register file at
DAT_00113018 or fed to an ALU operation.
Following which byte ends up where gives the layout. The register file is indexed with 4-bit values, so a byte holding two register numbers splits into a high and a low nibble:
byte3 byte2 byte1 byte0
+------------+------+------+------------+------------+
| opcode | rd | rs1 | imm high | imm low |
+------------+------+------+------------+------------+
8 bit 4 b 4 b 8 bit 8 bit
rs2 = byte0 & 0x0F
\_________________________/
imm16 = byte1 << 8 | byte0
Note that rs2 and the immediate overlap - they occupy the same byte. That is
not a conflict, because no instruction uses both: three-register forms read
rs2, immediate forms read imm16, and which one applies is
decided by the opcode.
Conclusion: 16 registers addressable, a 16-bit immediate, one opcode byte. What is still missing is what each opcode does - and, crucially, how the immediate is interpreted, which turns out to differ between instruction classes.
The interpreter dispatches on byte3, so every opcode has its own handler
block in the aarch64 code. What a handler does with the decoded fields is the
semantics - read the block, write down the operation.
The opcode values themselves help: they are not arbitrary but grouped, which makes the handlers easy to keep apart and gives a cross-check when a block is ambiguous.
| Range | Class |
|---|---|
0x00 | control |
0x11 | register move |
0x29-0x2C | shifts and rotates |
0x3A | load immediate |
0x52-0x54 | bitwise |
0x6B, 0x7C, 0x7D, 0x80 | arithmetic |
0x90 | compare |
0xA0-0xA2 | branches |
0xC4-0xC6 | memory |
0xE0-0xE1 | I/O |
The complete map, 23 opcodes:
| Op | Mnemonic | Operands | Effect | Flag |
|---|---|---|---|---|
0x00 | halt | - | stop, exit 0 | - |
0x11 | mov | rd, rs1 | rd = rs1 | - |
0x29 | rol | rd, rs1, imm | rd = rs1 rotated left by imm & 31 | - |
0x2A | ror | rd, rs1, imm | rd = rs1 rotated right by imm & 31 | - |
0x2B | shl | rd, rs1, imm | rd = rs1 << (imm & 31) | - |
0x2C | shr | rd, rs1, imm | rd = rs1 >> (imm & 31), logical | - |
0x3A | ldi | rd, imm | rd = imm, zero-extended | - |
0x52 | xor | rd, rs1, rs2 | rd = rs1 ^ rs2 | yes |
0x53 | and | rd, rs1, rs2 | rd = rs1 & rs2 | yes |
0x54 | or | rd, rs1, rs2 | rd = rs1 | rs2 | yes |
0x6B | mul | rd, rs1, rs2 | rd = rs1 * rs2, low 32 bits | yes |
0x7C | add | rd, rs1, rs2 | rd = rs1 + rs2 | yes |
0x7D | sub | rd, rs1, rs2 | rd = rs1 - rs2 | yes |
0x80 | addi | rd, rs1, imm | rd = rs1 + imm | yes |
0x90 | cmp | rs1, rs2 | flag only, no result written | yes |
0xA0 | jmp | target | unconditional | - |
0xA1 | je | target | taken when the flag is set | - |
0xA2 | jne | target | taken when the flag is clear | - |
0xC4 | ldb | rd, [rs1+imm] | rd = data[(rs1 + imm) & 0xFFFF] | - |
0xC5 | stb | [rs1+imm], rd | data[(rs1 + imm) & 0xFFFF] = rd, byte | - |
0xC6 | ldc | rd, [rs1+imm] | rd = code[(rs1 + imm) % 0x131E] | - |
0xE0 | getc | rd | next input byte | yes, at EOF |
0xE1 | putc | rs1 | write rs1 as a byte to stdout | - |
Three findings from that table shaped everything afterwards:
ldb/stb
reach a 64 KB scratch memory that starts out zeroed, while ldc reads the
bytecode blob itself - modulo its length. That is where the program keeps its
constants, and it is why the tables are findable at all.
pc + 4 + simm16 - relative to the following instruction, with the
immediate sign-extended. Every other instruction zero-extends, so its immediate is never
negative. Getting this wrong produces a listing that looks plausible and is wrong.
je/jne, so
cmp means "equal" and getc sets it at end of input. Shifts and
rotates deliberately leave it alone - which matters when following control flow, because
only the last flag-setting instruction before a branch counts.
Conclusion: The map is complete enough to write a disassembler. Anything outside the 23 known opcodes should then be the constant pool rather than a decoding failure - a prediction that the next step can test.
A disassembler built on a wrong field layout does not fail loudly - it emits a listing that reads like code and means nothing. Cheap self-checks first, reading afterwards.
Two invariants must hold if the format is right:
unaligned = [t for t in targets if t % 4] # every branch target
outside = [t for t in targets if not 0 <= t < len(code)] # is a valid instruction
Every branch has to land on a 4-byte boundary and inside the blob. If the immediate
endianness were swapped, or targets computed from pc instead of
pc + 4, both counts would immediately be non-zero.
; 4894 bytes, 1223 instructions, 10 branch targets
; misaligned targets: 0 out of range: 0
Conclusion: 10 branch targets, all aligned, all in range - the format is right. And the "bad opcode" lines cluster in one contiguous region at the end rather than being scattered, exactly as predicted for a constant pool. Those are the tables.
With mnemonics in place the program becomes readable. Annotating it by hand
(vmdis.out-commented) shows the structure.
; 4894 bytes, 1223 instructions, 10 branch targets
; misaligned targets: 0 out of range: 0
## loop to read input:
# initizlize some registers:
0000 0000003a ldi r0, 0x0
0004 2000803a ldi r8, 0x20
0008 0000403a ldi r4, 0x0
# loop to read 0x20 chars
L000c:
000c 000050e0 getc r5 ; flag
# copy1:
0010 000054c5 stb [r4 + 0x0], r5
# copy2:
0014 400054c5 stb [r4 + 0x40], r5
0018 01004480 addi r4, r4, 0x1 ; flag
001c 08000490 cmp r4, r8 ; flag
0020 e8ff00a2 jne L000c
## XOR loop with key at 0x11c4
0024 c411703a ldi r7, 0x11c4
0028 0000403a ldi r4, 0x0
L002c:
002c 0400977c add r9, r7, r4 ; flag
0030 000019c6 ldc r1, [r9 + 0x0]
0034 000054c4 ldb r5, [r4 + 0x0]
# xor input ^ fixed key:
0038 01005552 xor r5, r5, r1 ; flag
003c 000054c5 stb [r4 + 0x0], r5
0040 01004480 addi r4, r4, 0x1 ; flag
0044 08000490 cmp r4, r8 ; flag
0048 e0ff00a2 jne L002c
## substitution and mixing:
The program does five things:
0x20 bytes, store them twice - at r4+0 and
r4+0x400x11c40x10c4, repeatedly (loop in loop)0x12e4; only on a full match, XOR the input
with a table at 0x1304 and print it
Conclusion: This is a substitution-permutation network - 8 rounds of
sbox, mix, add-round-key. The flag is not stored anywhere: it is
input ^ mask, so the correct input has to be reconstructed, not extracted.
If 0x10c4 were the expected value and 0x11c4 the key, then
target XOR key would be the answer in one line.
It is not. 0x10c4 holds 256 entries - one per possible byte value, not one per
input position. It is a substitution box (mem[i] = table[mem[i]]), not a
comparison table.
Conclusion: No shortcut. The cipher has to be inverted properly - which is fine, because every stage of an SPN is invertible by construction.
Three layers, three inverses - "first remove your shoes, then the socks".
| Layer | Inverse |
|---|---|
| XOR with a round key | XOR with the same key - self-inverse |
| sbox substitution | the inverse permutation table (verified: the sbox is a permutation) |
| bit mixing | inverse of a 32x32 matrix over GF(2) |
The mixing layer is the awkward one: it is fully unrolled in the bytecode, roughly a
thousand instructions. Rather than reading it by hand, the solver executes it
symbolically - each register holds the set of state positions feeding into it,
and xor becomes symmetric difference. Every store then yields one row of the
matrix.
Conclusion: Extract sbox, keys and target from the blob, build the matrix
symbolically, invert it over GF(2), then run the 8 rounds backwards from the stored target
value. The result is the accepted 32-byte input, and XORing it with the mask at
0x1304 gives the flag - without ever running the binary.
vmdis.py - reads the blob straight out of the ELF and prints mnemonics,
resolved branch labels and a constant pool.
#!/usr/bin/env python3
"""
Disassembler for the bytecode VM inside ./the_cinder_engine/cinder.
The aarch64 binary is a tiny interpreter; all of the actual logic lives in a
0x131e-byte blob at .rodata+0x18. Instructions are four bytes:
byte0 immediate low / rs2 = byte0 & 0x0F
byte1 immediate high imm16 = byte1<<8 | byte0
byte2 rd = byte2 >> 4, rs1 = byte2 & 0x0F
byte3 opcode
Branch targets are relative to the *following* instruction and use the
sign-extended immediate, so a target is pc + 4 + simm16. Only jumps get the
sign-extended form; every other immediate is zero-extended and therefore
never negative.
The VM keeps two separate address spaces, which is why there are two load
instructions. ldb/stb reach a 64 KB scratch memory that starts out zeroed;
ldc reads the bytecode blob itself, where the program hides its constants.
Anything the disassembler reports as "bad opcode" is that constant pool
rather than a decoding failure.
The state is 16 32-bit registers plus a single zero flag. The flag is set by
the arithmetic and compare instructions and read only by je/jne, so when
following control flow only the last flag-setting instruction before a
branch matters.
Usage: python3 vmdis.py [path-to-cinder]
"""
import signal
import struct
import sys
BYTECODE_VADDR = 0xE80
BYTECODE_SIZE = 0x131E
# opcode -> (mnemonic, operand kind, sets the zero flag)
#
# Operand kinds control the printing only:
# rrr rd, rs1, rs2 rri rd, rs1, imm ri rd, imm
# rr rs1, rs2 rr2 rd, rs1 jmp target
# mem rd, [rs1+imm] memw [rs1+imm], rd rdo rd r rs1
#
# The flag column says whether the instruction writes the zero flag; it is
# always "result == 0", so cmp means "equal" and getc means "end of input".
# Shifts and rotates deliberately leave the flag alone.
OPCODES = {
0x00: ("halt", "none", False), # stop, exit 0
0x11: ("mov", "rr2", False), # rd = rs1
0x29: ("rol", "rri", False), # rd = rs1 rotated left by imm & 31
0x2A: ("ror", "rri", False), # rd = rs1 rotated right by imm & 31
0x2B: ("shl", "rri", False), # rd = rs1 << (imm & 31)
0x2C: ("shr", "rri", False), # rd = rs1 >> (imm & 31), logical
0x3A: ("ldi", "ri", False), # rd = imm, zero-extended to 32 bits
0x52: ("xor", "rrr", True), # rd = rs1 ^ rs2
0x53: ("and", "rrr", True), # rd = rs1 & rs2
0x54: ("or", "rrr", True), # rd = rs1 | rs2
0x6B: ("mul", "rrr", True), # rd = rs1 * rs2, low 32 bits only
0x7C: ("add", "rrr", True), # rd = rs1 + rs2
0x7D: ("sub", "rrr", True), # rd = rs1 - rs2
0x80: ("addi", "rri", True), # rd = rs1 + imm; imm is never negative
0x90: ("cmp", "rr", True), # flag = (rs1 == rs2), no result written
0xA0: ("jmp", "jmp", False), # unconditional
0xA1: ("je", "jmp", False), # taken when the flag is set
0xA2: ("jne", "jmp", False), # taken when the flag is clear
0xC4: ("ldb", "mem", False), # rd = data[(rs1 + imm) & 0xFFFF]
0xC5: ("stb", "memw", False), # data[(rs1 + imm) & 0xFFFF] = rd, byte
0xC6: ("ldc", "mem", False), # rd = code[(rs1 + imm) % 0x131E]
0xE0: ("getc", "rdo", True), # rd = next input byte, flag set at EOF
0xE1: ("putc", "r", False), # write rs1 as a byte to stdout
}
def read_bytecode(path):
with open(path, "rb") as handle:
data = handle.read()
shoff = struct.unpack("<Q", data[0x28:0x30])[0]
shentsize = struct.unpack("<H", data[0x3A:0x3C])[0]
shnum = struct.unpack("<H", data[0x3C:0x3E])[0]
for index in range(shnum):
off = shoff + index * shentsize
addr = struct.unpack("<Q", data[off + 0x10:off + 0x18])[0]
file_off = struct.unpack("<Q", data[off + 0x18:off + 0x20])[0]
size = struct.unpack("<Q", data[off + 0x20:off + 0x28])[0]
if addr <= BYTECODE_VADDR < addr + size:
start = file_off + (BYTECODE_VADDR - addr)
return data[start:start + BYTECODE_SIZE]
raise SystemExit("bytecode blob not found in any section")
def decode(code, pc):
"""Split one instruction into its fields."""
byte0, byte1, byte2, opcode = code[pc:pc + 4]
imm = (byte1 << 8) | byte0
simm = imm - 0x10000 if imm & 0x8000 else imm
return {
"opcode": opcode,
"rd": byte2 >> 4,
"rs1": byte2 & 0x0F,
"rs2": byte0 & 0x0F,
"imm": imm,
"simm": simm,
"target": (pc + 4 + simm) & 0xFFFFFFFF,
}
def format_operands(kind, f):
rd, rs1, rs2, imm = f["rd"], f["rs1"], f["rs2"], f["imm"]
if kind == "rrr":
return "r%d, r%d, r%d" % (rd, rs1, rs2)
if kind == "rri":
return "r%d, r%d, %#x" % (rd, rs1, imm)
if kind == "ri":
return "r%d, %#x" % (rd, imm)
if kind == "rr":
return "r%d, r%d" % (rs1, rs2)
if kind == "rr2":
return "r%d, r%d" % (rd, rs1)
if kind == "mem":
return "r%d, [r%d + %#x]" % (rd, rs1, imm)
if kind == "memw":
return "[r%d + %#x], r%d" % (rs1, imm, rd)
if kind == "jmp":
return "L%04x" % f["target"]
if kind == "rdo":
return "r%d" % rd
if kind == "r":
return "r%d" % rs1
return ""
def collect_targets(code):
"""Gather every branch target so they can be printed as labels.
Doubles as the sanity check on the whole decoding: see main().
"""
targets = set()
for pc in range(0, len(code) - 3, 4):
f = decode(code, pc)
if OPCODES.get(f["opcode"], ("", "", False))[1] == "jmp":
targets.add(f["target"])
return targets
def main():
path = (sys.argv[1] if len(sys.argv) > 1
else "the_cinder_engine/cinder")
code = read_bytecode(path)
targets = collect_targets(code)
# Every branch must land on an instruction boundary and inside the blob.
# If either count is non-zero the field layout is wrong - most likely the
# immediate endianness, or a target computed from pc instead of pc + 4 -
# and the listing below is fiction. Checking this first is far cheaper
# than discovering the mistake halfway through reading the output.
unaligned = [t for t in targets if t % 4]
outside = [t for t in targets if not 0 <= t < len(code)]
print("; %d bytes, %d instructions, %d branch targets"
% (len(code), len(code) // 4, len(targets)))
print("; misaligned targets: %d out of range: %d"
% (len(unaligned), len(outside)))
if unaligned or outside:
print("; WARNING: instruction format is probably wrong")
print()
for pc in range(0, len(code) - 3, 4):
f = decode(code, pc)
entry = OPCODES.get(f["opcode"])
raw = code[pc:pc + 4].hex()
if pc in targets:
print("L%04x:" % pc)
if entry is None:
print(" %04x %s .word %#010x ; bad opcode %#04x"
% (pc, raw, int.from_bytes(code[pc:pc + 4], "little"),
f["opcode"]))
continue
mnemonic, kind, sets_flag = entry
text = "%-5s %s" % (mnemonic, format_operands(kind, f))
print(" %04x %s %-28s%s"
% (pc, raw, text, " ; flag" if sets_flag else ""))
if __name__ == "__main__":
# Restore the default SIGPIPE handling that Python replaces, so piping
# the listing into head or less exits quietly instead of raising.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
main()
solve.py - extracts the tables, recovers the mixing matrix by symbolic
execution, inverts it over GF(2) and unwinds the 8 rounds.
#!/usr/bin/env python3
"""
Recover the 32-byte input the cinder VM asks for, and with it the flag.
The bytecode implements a small SPN block cipher over a 32-byte state:
state ^= key[0]
for round in 1..8:
state[i] = sbox[state[i]] # substitution
state = mix(state) # XOR of fixed byte positions
state ^= key[round]
It then compares the result against a stored value and, only on a match,
prints input[i] ^ mask[i] for the first 26 bytes. The flag therefore cannot
be read out of the binary; the correct input has to be reconstructed.
Every stage is invertible, so the cipher runs backwards:
xor with a key -> xor with the same key (self-inverse)
sbox -> inverse permutation table
mix -> inverse of a 32x32 matrix over GF(2)
The mixing layer is fully unrolled in the bytecode, roughly a thousand
instructions. Rather than reading it by hand it is executed symbolically:
each register holds the *set* of state positions that feed into it, and xor
becomes symmetric difference. Each store then yields one row of the matrix.
Usage: python3 solve.py [path-to-cinder]
"""
import struct
import sys
BYTECODE_VADDR = 0xE80
BYTECODE_SIZE = 0x131E
BLOCK = 32 # state size in bytes
ROUNDS = 8
# Layout of the constant pool, all verified against the disassembly.
SBOX_OFF = 0x10C4 # 256 bytes
KEYS_OFF = 0x11C4 # 9 * 32 bytes: whitening key plus one per round
EXPECTED_OFF = 0x12E4 # 32 bytes the final state must equal
MASK_OFF = 0x1304 # 26 bytes xored with the input to form the flag
FLAG_LEN = 26
# The unrolled mixing layer, from the first load to the last store.
MIX_START = 0x0070
MIX_END = 0x1018
STATE_BASE = 0x00 # mixing reads state[0x00..0x1f]
SCRATCH_BASE = 0x80 # and writes out[0x80..0x9f]
# opcodes needed to follow the mixing layer
OP_XOR = 0x52
OP_LDB = 0xC4
OP_STB = 0xC5
def read_bytecode(path):
"""Pull the VM bytecode blob out of the ELF."""
with open(path, "rb") as handle:
data = handle.read()
shoff = struct.unpack("<Q", data[0x28:0x30])[0]
shentsize = struct.unpack("<H", data[0x3A:0x3C])[0]
shnum = struct.unpack("<H", data[0x3C:0x3E])[0]
for index in range(shnum):
off = shoff + index * shentsize
addr = struct.unpack("<Q", data[off + 0x10:off + 0x18])[0]
file_off = struct.unpack("<Q", data[off + 0x18:off + 0x20])[0]
size = struct.unpack("<Q", data[off + 0x20:off + 0x28])[0]
if addr <= BYTECODE_VADDR < addr + size:
start = file_off + (BYTECODE_VADDR - addr)
return data[start:start + BYTECODE_SIZE]
raise SystemExit("bytecode blob not found")
def extract_matrix(code):
"""Recover the mixing layer as 32 sets of contributing state positions.
Symbolic execution: a register holds a frozenset of state indices rather
than a value. ldb from the state seeds a singleton, xor combines two sets
with symmetric difference (a position xored in twice cancels out), and a
store into the scratch area completes one output byte.
"""
regs = [frozenset() for _ in range(16)]
rows = {}
for pc in range(MIX_START, MIX_END, 4):
byte0, byte1, byte2, opcode = code[pc:pc + 4]
imm = (byte1 << 8) | byte0
rd, rs1, rs2 = byte2 >> 4, byte2 & 0x0F, byte0 & 0x0F
if opcode == OP_LDB:
# rd = state[imm]; the base register r0 is zero throughout
regs[rd] = frozenset([imm - STATE_BASE])
elif opcode == OP_XOR:
regs[rd] = regs[rs1] ^ regs[rs2]
elif opcode == OP_STB:
rows[imm - SCRATCH_BASE] = regs[rd]
else:
raise SystemExit("unexpected opcode %#x at %#x" % (opcode, pc))
if sorted(rows) != list(range(BLOCK)):
raise SystemExit("mixing layer did not yield %d rows" % BLOCK)
# row j as a bitmask over the input positions
return [sum(1 << i for i in rows[j]) for j in range(BLOCK)]
def invert_matrix(rows):
"""Invert a 32x32 GF(2) matrix by Gauss-Jordan elimination.
Each row is a bitmask; "adding" two rows is xor, and there are no
fractions to worry about. The identity is carried along on the right and
ends up holding the inverse.
"""
size = len(rows)
left = list(rows)
right = [1 << i for i in range(size)]
for col in range(size):
pivot = next((r for r in range(col, size) if left[r] >> col & 1), None)
if pivot is None:
raise SystemExit("mixing matrix is singular - not invertible")
left[col], left[pivot] = left[pivot], left[col]
right[col], right[pivot] = right[pivot], right[col]
for r in range(size):
if r != col and (left[r] >> col & 1):
left[r] ^= left[col]
right[r] ^= right[col]
return right
def apply_matrix(rows, state):
"""out[j] = xor of state[i] for every i set in row j."""
out = bytearray(BLOCK)
for j, mask in enumerate(rows):
acc = 0
for i in range(BLOCK):
if mask >> i & 1:
acc ^= state[i]
out[j] = acc
return out
def main():
path = (sys.argv[1] if len(sys.argv) > 1
else "the_cinder_engine/cinder")
code = read_bytecode(path)
sbox = code[SBOX_OFF:SBOX_OFF + 256]
inv_sbox = bytearray(256)
for value, image in enumerate(sbox):
inv_sbox[image] = value
keys = [code[KEYS_OFF + r * BLOCK:KEYS_OFF + (r + 1) * BLOCK]
for r in range(ROUNDS + 1)]
expected = code[EXPECTED_OFF:EXPECTED_OFF + BLOCK]
mask = code[MASK_OFF:MASK_OFF + FLAG_LEN]
forward = extract_matrix(code)
inverse = invert_matrix(forward)
taps = [bin(row).count("1") for row in forward]
print("[*] sbox is a permutation : %s" % (sorted(sbox) == list(range(256))))
print("[*] mixing taps per output: %d..%d" % (min(taps), max(taps)))
# Sanity check: the recovered inverse really does undo the forward map.
probe = bytes(range(BLOCK))
assert apply_matrix(inverse, apply_matrix(forward, probe)) == probe
print("[*] matrix inverse verified")
# Run the cipher backwards, starting from the value it wants to see.
state = bytearray(expected)
for rnd in range(ROUNDS, 0, -1):
state = bytearray(a ^ b for a, b in zip(state, keys[rnd]))
state = apply_matrix(inverse, state)
state = bytearray(inv_sbox[b] for b in state)
state = bytearray(a ^ b for a, b in zip(state, keys[0]))
# Replay it forwards to prove the recovered input is accepted.
check = bytearray(a ^ b for a, b in zip(state, keys[0]))
for rnd in range(1, ROUNDS + 1):
check = bytearray(sbox[b] for b in check)
check = apply_matrix(forward, check)
check = bytearray(a ^ b for a, b in zip(check, keys[rnd]))
print("[*] forward re-check matches: %s" % (bytes(check) == expected))
print("\n[+] input (%d bytes):\n %s" % (len(state), state.hex()))
flag = bytes(a ^ b for a, b in zip(state[:FLAG_LEN], mask))
print("\n[+] flag:\n")
print(flag.decode("latin-1"))
if __name__ == "__main__":
main()
The disassembler, on the shipped binary:
$ python3 vmdis.py the_cinder_engine/cinder | head -12
; 4894 bytes, 1223 instructions, 10 branch targets
; misaligned targets: 0 out of range: 0
0000 0000003a ldi r0, 0x0
0004 2000803a ldi r8, 0x20
0008 0000403a ldi r4, 0x0
L000c:
000c 000050e0 getc r5 ; flag
0010 000054c5 stb [r4 + 0x0], r5
0014 400054c5 stb [r4 + 0x40], r5
0018 01004480 addi r4, r4, 0x1 ; flag
001c 08000490 cmp r4, r8 ; flag
Zero misaligned branch targets and zero out of range is the sanity check that the instruction format was decoded correctly.
The solver:
$ python3 solve.py
[*] sbox is a permutation : True
[*] mixing taps per output: 11..20
[*] matrix inverse verified
[*] forward re-check matches: True
[+] input (32 bytes):
83efaaac9b9a46fbfe0cb665e082127080782320d51c1d134f5bcd157abaf50f
[+] flag:
HTB{c1nd3r_3ng1n3_unw0und}
Four self-checks pass before any result is printed: the sbox really is a permutation, the matrix inverse verifies, and re-running the cipher forwards over the recovered input reproduces the stored target.
Finally, feeding that input to the actual engine - the binary is aarch64, so in a matching container:
$ python3 -c 'import sys; sys.stdout.buffer.write(bytes.fromhex(
"83efaaac9b9a46fbfe0cb665e082127080782320d51c1d134f5bcd157abaf50f"))' > vow.bin
$ docker run --rm --platform linux/arm64 -v "$PWD:/w" -w /w ubuntu:24.04 \
sh -c "./cinder < vow.bin"
HTB{c1nd3r_3ng1n3_unw0und}
The dead man's machine still keeps its promise.
HTB{c1nd3r_3ng1n3_unw0und}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Two layers of code | The aarch64 binary is only an interpreter. The real program is a
0x131E-byte bytecode blob at .rodata+0x18: 16 registers, a
zero flag, 4-byte instructions. |
| 2 | Recover the opcode map | Derived from what the interpreter does per opcode. Two traps: branch offsets are
sign-extended and relative to the next instruction, and there are two address spaces
(ldb scratch memory vs ldc bytecode). |
| 3 | Identify the cipher | 8 rounds of add-round-key, sbox substitution and bit mixing over a 32-byte state,
compared against a stored target. The flag is input ^ mask, so it does
not exist in the binary at all. |
| 4 | Recover the mixing matrix | The mixing layer is unrolled across ~1000 instructions. Executing it symbolically -
registers hold sets of source positions, xor is symmetric difference -
yields the 32x32 GF(2) matrix without reading the code by hand. |
| 5 | Run it backwards | Every layer inverts: XOR is self-inverse, the sbox is a permutation, the matrix is invertible over GF(2). Unwinding 8 rounds from the stored target gives the accepted input - no search involved. |
The challenge is rated medium, and the maths supports that - an SPN inverts mechanically. The work is in getting there: the instruction set has to be reconstructed before a single line can be read, and the one genuinely clever move is refusing to read the mixing layer at all. A thousand unrolled XOR instructions are unreadable by hand and trivial to run symbolically, which turns the hardest-looking part of the bytecode into a matrix extraction.