Skip to main content

C3 picoCTF 2024 Solution

Reverse and invert a custom cyclical cipher to recover a flag from encoded output.

Published: April 3, 2024Updated: August 25, 2026

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 1Implement the inverse
    Observation
    convert.py comes with the challenge, and it encodes with a chained subtraction that carries a 'prev' feedback variable. Reverse the arithmetic and keep the same chain variable, which holds the plaintext-alphabet index.
    Translate convert.py into a decryptor: swap lookup1/lookup2 roles, replace (cur - prev) with (cur + prev) mod 40, and set prev to the recovered index in lookup1, which is exactly what the encoder chained on.
    python
    python3 decrypt.py ciphertext > decrypted.py
    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 ciphertext index (the position in lookup2) instead of the recovered lookup1 index.

    Read convert.py closely: it sets prev = cur where cur is lookup1.index(char), the plaintext index, and only the emitted character is drawn from lookup2. Chain on the ciphertext index instead and the feedback state diverges immediately, so everything after the first character is wrong.

    Learn more

    The C3 cipher is a cyclical differential cipher: each character's encoding depends on the character before it. The two lookup tables in convert.py are 40-character strings that map between plaintext and ciphertext alphabets, lookup1 covering newline, space, punctuation, the digit 1 and the lowercase letters, and lookup2 covering A through T then a through t. lookup1.index(ch) returns the position of a plaintext character; the emitted character is the one lookup2 holds at the differenced position.

    # encryption (as shipped)
    prev = 0
    for ch in plaintext:
        cur = lookup1.index(ch)            # plain -> index
        out += lookup2[(cur - prev) % 40]  # emit the difference
        prev = cur                         # chain on the PLAINTEXT index

    The prev chain variable holds the most recent plaintext index. Only the difference is mapped through lookup2 on the way out, so the feedback state is a value the decryptor recovers as it goes. 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) + prev) % 40  # undo the subtraction
        out += lookup1[cur]                    # 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.

    convert.py itself was written for Python 2 but runs unchanged under Python 3, since it uses sys.stdout.write rather than a print statement. The script it decrypts to is not so lucky, as the next step shows. When porting Python 2 code generally, watch for print as a function, range() returning a lazy object, integer division spelled // (regular / now produces floats), and str versus bytes when reading files.

    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
    Observation
    The decryption produces Python source rather than a flag, and the description hints at self-referential input. Run the decrypted program with its own source on stdin and it pulls out characters at cubic indices.
    After decryption, the output is a Python 2 program whose header comments read #asciiorder, #fortychars, #selfinput, #pythontwo. It reads its input with fileinput and prints the character at every cubic index. Feed it its own source: python2 decrypted.py decrypted.py. The printed characters spell out the flag body, which you wrap in picoCTF{...}.
    python
    python3 decrypt.py ciphertext > decrypted.py
    python
    python2 decrypted.py decrypted.py

    Expected output

    a
    d
    l
    i
    b
    s

    The sampler prints one character per line; joined together they are the flag body 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.

    What comes out is a second Python script, not the flag, so a text editor just shows you source code. Execute it with its own source on stdin and it samples characters at cubic indices (1, 8, 27, 64, and so on) to build the phrase.

    Tried: Running decrypted.py with no input at all, or with convert.py as its input.

    The #selfinput comment is literal: the sampler has to read its own source, because that is the text the cubic indices point into. Give it nothing and fileinput blocks on an empty stdin; give it convert.py and the cubic positions land on unrelated characters, producing noise instead of a word.

    Learn more

    The decrypted ciphertext is not raw flag text: it is another Python program that samples characters at cubic positions from its own input. Passing the decrypted program its own source 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.

    The decrypted script really is Python 2: its print chars[i] statement is a syntax error under Python 3. Either run it with python2, or port it by parenthesizing the print. In Python 3, iterating with enumerate() and checking whether i is a perfect cube (round(i**(1/3))**3 == i) is the idiomatic rewrite.

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.
  • Rail Fence CipherEncrypt or decrypt rail fence (zigzag) transposition ciphers. Brute-force across rail counts and offsets to find the right setting fast.

Flag

Reveal flag

picoCTF{adl...}

The cubic sampling script prints the final flag body.

Key takeaway

A cipher whose rule and lookup tables ship in source is invertible by running the arithmetic backwards. Even a feedback construction, where each ciphertext byte feeds the next encoding step, unwinds if you walk the ciphertext in the same direction with the inverse operation and the same chain variable. Kerckhoffs's principle is the point: security has to rest on a secret key, not a secret algorithm, and here nothing is secret at all.

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

Useful tools for Cryptography

Where to go next