Cinderbound
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/stringsreveal 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 byte0x06= mpy v6 (specifically v6.3), archNONE, source filejudge_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 read-only • copy allowedpython3 -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 read-only • copy allowedcurl -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 read-only • copy allowed./venv/bin/python mpy-tool.py -d cinderbound.mpy
This prints:
- the qstr table (
judge,append,syllable,len,ord,list); - an
obj_tablecontaining 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 read-only • copy alloweddef 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 read-only • copy allowed#!/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.