HTB Cyber Apocalypse 2026 - The Salt Crown: Reversing / The Cinder Engine (Medium)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

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.

1. Download

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

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 Find the VM and its memory (success)

Decompile main in Ghidra and follow what it reads and writes.

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.

3.2 Work out the instruction format (success)

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.

3.3 Recover the opcode semantics (success)

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.

RangeClass
0x00control
0x11register move
0x29-0x2Cshifts and rotates
0x3Aload immediate
0x52-0x54bitwise
0x6B, 0x7C, 0x7D, 0x80arithmetic
0x90compare
0xA0-0xA2branches
0xC4-0xC6memory
0xE0-0xE1I/O

The complete map, 23 opcodes:

OpMnemonicOperandsEffectFlag
0x00halt-stop, exit 0-
0x11movrd, rs1rd = rs1-
0x29rolrd, rs1, immrd = rs1 rotated left by imm & 31-
0x2Arorrd, rs1, immrd = rs1 rotated right by imm & 31-
0x2Bshlrd, rs1, immrd = rs1 << (imm & 31)-
0x2Cshrrd, rs1, immrd = rs1 >> (imm & 31), logical-
0x3Aldird, immrd = imm, zero-extended-
0x52xorrd, rs1, rs2rd = rs1 ^ rs2yes
0x53andrd, rs1, rs2rd = rs1 & rs2yes
0x54orrd, rs1, rs2rd = rs1 | rs2yes
0x6Bmulrd, rs1, rs2rd = rs1 * rs2, low 32 bitsyes
0x7Caddrd, rs1, rs2rd = rs1 + rs2yes
0x7Dsubrd, rs1, rs2rd = rs1 - rs2yes
0x80addird, rs1, immrd = rs1 + immyes
0x90cmprs1, rs2flag only, no result writtenyes
0xA0jmptargetunconditional-
0xA1jetargettaken when the flag is set-
0xA2jnetargettaken when the flag is clear-
0xC4ldbrd, [rs1+imm]rd = data[(rs1 + imm) & 0xFFFF]-
0xC5stb[rs1+imm], rddata[(rs1 + imm) & 0xFFFF] = rd, byte-
0xC6ldcrd, [rs1+imm]rd = code[(rs1 + imm) % 0x131E]-
0xE0getcrdnext input byteyes, at EOF
0xE1putcrs1write rs1 as a byte to stdout-

Three findings from that table shaped everything afterwards:

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.

3.4 Validate the decoding before trusting the listing (success)

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.

3.5 Read the VM program (success)

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:

  1. read 0x20 bytes, store them twice - at r4+0 and r4+0x40
  2. XOR the input against a key table at 0x11c4
  3. substitute each byte through a table at 0x10c4, repeatedly (loop in loop)
  4. a long bit-mixing stage, different for each of the 32 bytes
  5. compare against a table at 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.

3.6 Try to XOR the two tables together (failed)

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.

3.7 Invert the layers (success)

Three layers, three inverses - "first remove your shoes, then the socks".

LayerInverse
XOR with a round keyXOR with the same key - self-inverse
sbox substitutionthe inverse permutation table (verified: the sbox is a permutation)
bit mixinginverse 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.

4. Solution

4.1 The disassembler

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()

4.2 The solver

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()

5. Run it

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.

Flag

HTB{c1nd3r_3ng1n3_unw0und}

6. Summary of how the exploit works

#StageMechanism
1Two 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.
2Recover 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).
3Identify 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.
4Recover 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.
5Run 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.