← Back to Writeups
CUSTOMMediumCrypto

numerology

XESXOR9/10/20263 min read
#crypto#custom#medium#known_plaintext_attack#pyc_reconstruction#quarter_round_inversion

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.json
  • init.sh
  • readme.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:

  1. The 4 ChaCha constants are replaced with zeros.
  2. 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 = 0
  • b = 0
  • c = K
  • d = counter

After one quarter-round plus feedforward, the first output word simplifies to:

text
out0 = rotl32(K + rotl32(counter, 16), 12)

So the first 4 bytes of keystream already determine the counter.

Solution

Use the known flag prefix CTF{.

  1. XOR the first 4 ciphertext bytes with CTF{ to recover the first keystream word.
  2. Interpret that word as little-endian out0.
  3. Invert the formula:
text
counter = rotr32(rotr32(out0, 12) - K, 16) mod 2^32

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

  1. This yields counter = 32279 = 0x00007e17.
  2. Generate the first keystream block with the public key and recovered counter.
  3. XOR the 42-byte flag ciphertext with that keystream block.

The recovered plaintext is:

text
CTF{REDACTED}

Solver script (solve.py):

python
import 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()

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