numerology
numerology
Platform: GoogleCTF2025 | Category: Crypto | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-07 | Status: Solved Techniques: known_plaintext_attack, pyc_reconstruction, quarter_round_inversion
Summary
Task: a package ships a public key, learning ciphertext pairs, and only a Python bytecode implementation of a homebrew stream cipher. Solution: reconstruct the reduced-round ChaCha-like design, invert the first quarter-round with the known CTF{ prefix, recover the counter, and decrypt the flag.
numerology — GoogleCTF2025
Description
I made a new cipher, can you help me test it? I'll give you the key, please use it to decrypt my ciphertext.
English summary: the archive contains a public structured key, a learning dataset of plaintext/ciphertext pairs, and a hidden flag ciphertext. The goal is to understand the custom cipher well enough to recover the missing secret parameters and decrypt the flag.
Analysis
The package contained these main artifacts:
ctf_challenge_package.jsoninit.shreadme.md__pycache__/crypto_numerology.cpython-312.pyc
The important clue was that the actual implementation was only present as Python bytecode. Reconstructing __pycache__/crypto_numerology.cpython-312.pyc into reconstructed_crypto_numerology.py revealed the exact generator.
The cipher is a ChaCha-like stream cipher with two major weaknesses:
- The 4 ChaCha constants are replaced with zeros.
- The challenge uses exactly 1 quarter-round, not full ChaCha rounds.
The 32-byte key is also highly structured and public. Only key-word positions 1, 3, 4, and 6 are populated; the rest are zero.
The reconstructed state layout is:
- words
0..3: zero constants - words
4..11: eight key words - word
12: counter - words
13..15: nonce
With ROUNDS=1, only the first quarter-round runs, on indices (0,4,8,12). For the target flag this is enough to break everything, because the ciphertext is only 42 bytes long. That means decryption depends only on the first 64-byte keystream block, and the nonce bytes never affect any flag bytes that are actually used.
Let K = key_word[4] and counter = d. For the first quarter-round, the initial values are:
a = 0b = 0c = Kd = counter
After one quarter-round plus feedforward, the first output word simplifies to:
text read-only • copy allowedout0 = rotl32(K + rotl32(counter, 16), 12)
So the first 4 bytes of keystream already determine the counter.
Solution
Use the known flag prefix CTF{.
- XOR the first 4 ciphertext bytes with
CTF{to recover the first keystream word. - Interpret that word as little-endian
out0. - Invert the formula:
text read-only • copy allowedcounter = rotr32(rotr32(out0, 12) - K, 16) mod 2^32
- This yields
counter = 32279 = 0x00007e17. - Generate the first keystream block with the public key and recovered counter.
- XOR the 42-byte flag ciphertext with that keystream block.
The recovered plaintext is:
text read-only • copy allowedCTF{REDACTED}
Solver script (solve.py):
python read-only • copy allowedimport json import struct from pathlib import Path def rotl32(v, c): return ((v << c) & 0xFFFFFFFF) | (v >> (32 - c)) def rotr32(v, c): return ((v >> c) | ((v << (32 - c)) & 0xFFFFFFFF)) & 0xFFFFFFFF def make_block(key_words, counter): k = key_words[4] a = 0 b = 0 c = k d = counter & 0xFFFFFFFF d = rotl32(d, 16) c = (c + d) & 0xFFFFFFFF b = rotl32(b ^ c, 12) a = (a + b) & 0xFFFFFFFF d = rotl32(d ^ a, 8) c = (c + d) & 0xFFFFFFFF b = rotl32(b ^ c, 7) state = [0] * 16 state[5] = (2 * key_words[1]) & 0xFFFFFFFF state[7] = (2 * key_words[3]) & 0xFFFFFFFF state[10] = (2 * key_words[6]) & 0xFFFFFFFF state[0] = a state[4] = b state[8] = (c + k) & 0xFFFFFFFF state[12] = (d + counter) & 0xFFFFFFFF return b"".join(struct.pack("<I", w) for w in state) def main(): pkg = json.loads(Path("crypto-numerology/ctf_challenge_package.json").read_text()) key = bytes.fromhex(pkg["cipher_parameters"]["key"]) key_words = struct.unpack("<8I", key) flag_ct = bytes.fromhex(pkg["flag_ciphertext"]) known_prefix = b"CTF{" ks0 = int.from_bytes(bytes(c ^ p for c, p in zip(flag_ct[:4], known_prefix)), "little") counter = rotr32((rotr32(ks0, 12) - key_words[4]) & 0xFFFFFFFF, 16) keystream = make_block(key_words, counter) flag = bytes(c ^ k for c, k in zip(flag_ct, keystream)).decode() print(f"counter = {counter} (0x{counter:08x})") print(flag) if __name__ == "__main__": main()