← Back to Writeups
CUSTOMEasyReversing

Cinderbound

XESXOR9/10/20263 min read
#reversing#custom#easy#mpy_disassembly#magic_header_identification#bytecode_reconstruction#keystream_inversion#linux

Cinderbound

Platform: CACTF2026 | Category: Reversing | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: mpy_disassembly, magic_header_identification, bytecode_reconstruction, keystream_inversion

Summary

Task: a 221-byte .mpy artifact that file calls 'data' is MicroPython v6.3 bytecode implementing a check function judge(syllable). Solution: fingerprint the magic header, disassemble with MicroPython's mpy-tool.py, reconstruct the rolling XOR/add check, and invert it deterministically to recover the accepted input.

Cinderbound — CACTF2026

Description

The Ash-Vault holds a vow the Cinderbound priesthood pressed into a foreign engine instead — something built outside the Ashguard's own craft, unreadable to any priest trained only on the old chisels. It was built to be misread by exactly the kind of person now holding it; every tool they carried called it "data". Learn the engine's foreign grammar. Find the ward. Give it the syllable it wants, and see if the vow answers back.

English summary: A ZIP contains a single 221-byte file cinderbound.mpy. The lore is a set of thin clues: the "foreign engine" is not CPython but an embedded runtime (MicroPython); file reporting "data" is expected; the "ward" is a check function; the "syllable" is the secret input we must recover. Goal: find the accepted input and wrap it as the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Initial recon of the extracted file:

  • file cinderbound.mpy → data (no recognized signature).
  • xxd / strings reveal qstrings: judge_src.py, judge, syllable, len, ord, list, append, an obfuscated string #$#*2.0# (a red herring), and a run of small integers (57, 129, 154, ...).
  • Header bytes: 4d 06 00 1f → magic 'M' = MicroPython .mpy, version byte 0x06 = mpy v6 (specifically v6.3), arch NONE, source file judge_src.py.

So this is MicroPython bytecode, not CPython .pyc. Standard .pyc tooling won't touch it — the correct engine is MicroPython's own mpy-tool.py disassembler for the matching version. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

1. Fingerprint the exact MicroPython version

bash
python3 -m venv venv
./venv/bin/pip install mpy_cross
./venv/bin/python -m mpy_cross --version   # "emitting mpy v6.3" → MicroPython v1.27.0

mpy v6.3 corresponds to MicroPython release v1.27.0.

2. Fetch the matching disassembler

bash
curl -sO https://raw.githubusercontent.com/micropython/micropython/v1.27.0/tools/mpy-tool.py
curl -sO https://raw.githubusercontent.com/micropython/micropython/v1.27.0/py/makeqstrdata.py

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

3. Disassemble

bash
./venv/bin/python mpy-tool.py -d cinderbound.mpy

This prints:

  • the qstr table (judge, append, syllable, len, ord, list);
  • an obj_table containing the 16-element target tuple (57,129,154,31,199,192,73,243,43,176,255,173,54,203,67,15);
  • the full opcode listing of judge(syllable).

4. Reconstruct the check from opcodes

Reading the stack-based opcodes (LOAD_CONST_OBJ for the target tuple, STORE_FAST, LOAD_GLOBAL len/ord, LOAD_SUBSCR, BINARY_OP variants __xor__ / __and__ / __mul__ / __add__, LOAD_METHOD append, __eq__, RETURN_VALUE) yields: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
def judge(syllable):
    target = (57,129,154,31,199,192,73,243,43,176,255,173,54,203,67,15)
    state = 90                      # LOAD_CONST_SMALL_INT 90
    out = []
    for i in range(len(syllable)):
        out.append((ord(syllable[i]) ^ state) ^ ((i*13) & 255))  # 13, 255 are small ints
        state = (state + ord(syllable[i])) & 255                 # rolling additive state
    return out == list(target)

Each output byte is ord(c) XOR state XOR ((i*13) & 255), and state is a running sum of the input bytes (mod 256). Because state depends only on already-processed bytes, the transform is invertible left-to-right — no brute force needed. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

5. Invert the transform

python
#!/usr/bin/env python3
target = [57,129,154,31,199,192,73,243,43,176,255,173,54,203,67,15]
state = 90
out = []
for i, t in enumerate(target):
    c = t ^ state ^ ((i * 13) & 255)   # recover ord(c)
    out.append(c)
    state = (state + c) & 255          # advance rolling state exactly as judge() does
secret = bytes(out).decode()
print(secret)                          # the accepted syllable

# Verify against the reconstructed judge()
assert judge(secret) is True

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Running this recovers the 16-byte input and judge() returns True, confirming it.

6. Wrap as the flag

The event is HackTheBox-style, so the recovered secret is wrapped in the HTB{...} format to produce the final flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍