C3 picoCTF 2024 Solution

Published: April 3, 2024

Description

This is the Custom Cyclical Cipher! Download the ciphertext here. Download the encoder here. Enclose the flag in our wrapper for submission. If the flag was "example" you would submit "picoCTF{example}".

Python scripts

Download ciphertext and convert.py to the same directory.

Run the provided script locally with Python 3.

bash
wget https://artifacts.picoctf.net/c_titan/47/ciphertext && \
wget https://artifacts.picoctf.net/c_titan/47/convert.py
This custom cipher challenge requires reversing a Python encryption script. For another custom cryptography challenge, see Custom encryption, which involves Diffie-Hellman and dynamic XOR operations.
  1. Step 1Implement the inverse
    Translate convert.py into a decryptor: swap lookup1/lookup2 roles, replace (cur - prev) with (cur + prev), and keep prev synced with the decrypted index.
    python
    python3 decrypt.py ciphertext > decrypted.txt
    Learn more

    The C3 cipher is a cyclical differential cipher: each character's encoding depends on the previously emitted character. The two lookup tables in convert.py are strings (or lists) that map between plaintext and ciphertext alphabets. lookup1.index(ch) returns the position of a plaintext character; lookup2[cur] returns the ciphertext character at that position.

    # encryption (paraphrased)
    prev = 0
    for ch in plaintext:
        idx = lookup1.index(ch)              # plain -> index
        cur = (idx - prev) % len(lookup2)    # subtract previous
        out.append(lookup2[cur])              # index -> cipher
        prev = cur                            # chain forward (ciphertext index)

    The prev chain variable holds the most recent ciphertext index, not the plaintext index, because the cipher feeds back its own output. To invert, walk the ciphertext left-to-right, swap the table roles, and turn subtraction into addition:

    # decryption
    prev = 0
    for ch in ciphertext:
        cur = lookup2.index(ch)              # cipher -> index
        idx = (cur + prev) % len(lookup2)    # undo the subtraction
        out.append(lookup1[idx])              # index -> plain
        prev = cur                            # same chain variable as encryption

    Modular arithmetic is what makes inversion possible: (x - prev) mod m has a unique inverse (y + prev) mod m because addition mod m is a bijection.

    The provided script is Python 2. When porting to Python 3 watch for: print as a function, range() returning a generator, integer division spelled // (regular / now produces floats), and str versus bytes when reading the ciphertext file.

    In real cryptography this self-feeding construction is formalized in CFB (Cipher Feedback) mode with AES. The toy weakness here: if you know any plaintext-ciphertext pair (or even just the prefix picoCTF), you can verify the lookup tables and decrypt the whole stream.

  2. Step 2Feed the decrypted Python program to itself
    After decryption, the output is a Python 2 program that reads from stdin and samples at cubic indices. Pipe the decrypted Python source back into itself as its own input: cat decrypted.py | python decrypted.py (or pipe convert.py through the decryptor and then into itself). The extracted characters spell out the flag body which you wrap in picoCTF{...}.
    bash
    cat convert.py | python3 decrypt.py | python3 -

    The output is the flag body (e.g., adlibs). Wrap it as picoCTF{adlibs} for submission.

    Learn more

    The decrypted ciphertext is not raw flag text: it is another Python program that samples characters at cubic positions from its own standard input. Piping the decrypted program back into itself provides that input, extracting the hidden phrase embedded at positions 1, 8, 27, 64, 125 (n3).

    Embedding a secret by sampling at cubic indices is a steganographic technique. The message is hidden within a larger body of text, with only specific positions carrying meaningful data. The key lesson is to read the decrypted output carefully rather than assuming it is immediately the flag. The challenge says "self input," meaning the program expects its own source as input.

    In Python 3, iterating with enumerate() and checking if i is a perfect cube (by computing round(i**(1/3))**3 == i) is the idiomatic approach. The provided script may be Python 2; port it or run with Python 2 if available.

Related guides

Flag

picoCTF{adl...}

The cubic sampling script prints the final flag body.

How to prevent this

Custom encoding schemes give the illusion of secrecy. They are not encryption, just obfuscation.

  • If you need confidentiality, use AEAD (AES-GCM, XChaCha20-Poly1305). If you only need to encode binary data for transport, use Base64 + a clearly labeled MAC. Do not mix the two.
  • Assume your encoding will be reverse-engineered. Anything client-side or in shipped code is recoverable; security must rely on a secret key, not a secret algorithm (Kerckhoffs' principle).
  • For sensitive data in URLs, cookies, or QR codes, use a signed token (JWT with HS256/EdDSA, or PASETO) backed by a server-side secret. Plain encoding gives zero security guarantees.

Want more picoCTF 2024 writeups?

Useful tools for Cryptography

Related reading

Do these first

What to try next