$ / 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
download .zip

flags redacted; flag images and local flag.txt files removed