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

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
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 1
    Implement the inverse
    Observation
    I noticed that convert.py was provided in source and used a chained subtraction with a 'prev' feedback variable, which suggested the cipher was invertible by reversing the arithmetic and tracking the same chain variable on the ciphertext side.
    Translate convert.py into a decryptor: swap lookup1/lookup2 roles, replace (cur - prev) with (cur + prev), and keep prev synced with the ciphertext index (cur, not idx).
    python
    python3 decrypt.py ciphertext > decrypted.txt
    What didn't work first

    Tried: Replacing (cur + prev) with (cur - prev) in the decryptor, mirroring the encoder exactly.

    The encoder subtracts prev before writing, so the decryptor must add prev back to recover the original index. Using subtraction in both directions compounds the error on every character and produces garbled output with no recognizable prefix like 'picoCTF'.

    Tried: Updating prev with the decrypted plaintext index (idx) instead of the ciphertext index (cur).

    The encoder chains off the ciphertext index, so the decryptor must also track cur - not idx - to stay in sync with the original feedback state. Using idx shifts the chain variable by the recovered offset and corrupts every subsequent character after the first.

    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 2
    Feed the decrypted Python program to itself
    Observation
    I noticed that the decrypted ciphertext was Python source code rather than readable flag text, and the challenge description hinted at self-referential input, which suggested running the decrypted program with its own source as stdin to extract characters at cubic indices.
    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 -

    Expected output

    adlibs

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

    What didn't work first

    Tried: Reading the decrypted output as the flag directly and submitting it without running it as a program.

    The decrypted ciphertext is a second Python script, not the flag. Opening it in a text editor shows Python source code, not the flag body. The script must be executed with its own source as stdin so it can sample characters at cubic indices (1, 8, 27, 64, ...) to extract the hidden phrase.

    Tried: Piping the already-decrypted output file into itself instead of piping convert.py through the decryptor and then into the sampler.

    The pipeline must decrypt convert.py first and then feed that Python source into the resulting program in a single pipe: cat convert.py | python3 decrypt.py | python3 -. Running cat decrypted.txt | python3 decrypted.txt treats the file as both script and stdin simultaneously, which causes Python to raise a file-not-found error or read an empty stream depending on shell buffering.

    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.

Interactive tools
  • Cipher Identifier & Auto-DecoderPaste any ciphertext and the tool auto-runs every common decoder (base64, hex, Morse, ROT, Atbash, Bacon, binary, decimal, URL) and ranks the results by English-likeness.
  • Frequency AnalysisAnalyze letter frequencies in a substitution cipher and interactively build the decryption mapping with auto-filled guesses.
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.

Flag

Reveal flag

picoCTF{adl...}

The cubic sampling script prints the final flag body.

Key takeaway

Any cipher whose transformation rule and lookup tables are provided in source is trivially invertible by reversing the arithmetic; a feedback construction that chains each ciphertext output into the next encoding step is undone by walking the ciphertext in the same direction with the inverse operation while tracking the same chain variable. Kerckhoffs's principle states that security must rely on a secret key, not a secret algorithm - providing the encoder source makes the algorithm public and leaves nothing protecting the ciphertext.

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.

Related reading

Want more picoCTF 2024 writeups?

Useful tools for Cryptography

Do these first

What to try next