$ / 4 min read/crypto
Taking on the Temple
Four chained stages — autokey cipher, SHA-256 counter keystream, chained digest, AES-GCM vault — where each answer is the next stage key.
- section
- CSAW26
- event
- ctf.csaw.io ↗
- category
- crypto
- status
- ● solved
- target
https://temple.ctf.csaw.io/
A body of rock, a body of ice, a body of steel… When you have the three Pokémon, the king shall appear.
Regirock / Regice / Registeel unlock Regigigas — three gated stages feeding a fourth. Each stage is a different primitive, and each stage’s answer is the next stage’s key.
Markers are 12-digit random suffixes specifically so the submit forms cannot be used as a correctness oracle — you have to break each layer.
Stage 1 — Granite (Regirock): autokey cipher
stone.json holds a ciphertext produced by an autokey cipher primed with
STONE. Autokey feeds the plaintext back in as key material after the primer,
so it decrypts sequentially:
pt[i] = ct[i] - (primer[i] if i < len(primer)
else pt[i-len(primer)])
def autokey_decrypt(ct, primer="STONE"):
key = "".join(c for c in primer.upper() if c.isalpha())
pt = []
for i, ch in enumerate(ct):
k = ord(key[i]) - 65 if i < len(key) else ord(pt[i - len(key)]) - 65
pt.append(chr((ord(ch) - 65 - k) % 26 + 65))
return "".join(pt)
The plaintext spells the marker suffix as concatenated digit-words
(NINENINETWO...). Since autokey_encrypt strips all non-alpha, the word
boundaries are gone — parse with backtracking against the 12-word length.
→ GRANITE-992220037417
Stage 2 — Frost (Regice): SHA-256 counter keystream
frost.bin is XORed with SHA256(material ‖ counter) where the material is
SHA256(K1).
def stream_xor(data, material):
out = bytearray(); pos = counter = 0
while pos < len(data):
block = hashlib.sha256(material + counter.to_bytes(8, "big")).digest()
out.extend(x ^ y for x, y in zip(data[pos:pos+32], block))
pos += 32; counter += 1
return bytes(out)
This is the “follow the origin” hint — stage 2’s key is stage 1’s answer.
→ CRYO-664128208716
Stage 3 — Iron (Registeel): chained digest
Same keystream construction, but the material is a three-link chain, spelled out
in the artifact’s own plate_note:
h0 = SHA256(K2)
h1 = SHA256(h0 ‖ K1)
h2 = SHA256(reverse(h1) ‖ K2) <- byte-reversal
The reversal is the “the shape of the answer is not the answer” inscription.
→ FERRUM-345246166574
Stage 4 — The vault (Regigigas): AES-GCM
key = sha256(f"{k1}|{k2}|{k3}".encode()).digest()
nonce = base64decode(vault["nonce_b64"])
aad = "NORTHERN-RELIQUARY-V1"
flag = AESGCM(key).decrypt(nonce, ciphertext, aad.encode())
The altar’s “the order is not decorative” matters: stone ‖ frost ‖ iron, pipe-joined, in that order.
Solver: ~/ctf/work/temple/solve.py — walks the session gates and re-derives
everything live, so it works against a fresh instance.
## challenge files
14 files · 11 KB- .dockerignore
- Dockerfile
- README.md
- app/
- data/
- docker-compose.yml
- lib.py
- requirements.txt
- solve.py
flags redacted; flag images and local flag.txt files removed