Skip to main content

Black Cobra Pepper picoCTF 2026 Solution

Reverse engineer a custom block cipher to decrypt the flag. Understanding the cipher's structure is the key.

Published: March 20, 2026Updated: September 20, 2026

Description

i like peppers. Download: chall.py and output.txt.

Download chall.py and output.txt.
Read chall.py to understand the modified AES scheme.
bash
cat chall.py
bash
cat output.txt

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Read chall.py and inspect output.txt format
    Observation
    The challenge gives both an encryption script and its output. Read the source first and look for changes to the standard algorithm before parsing any ciphertext.
    Skim chall.py for the round operations. ShiftRows, MixColumns and AddRoundKey are real, while sub_bytes, sub_word and rcon are defined as identity stubs that return their argument untouched. Then read output.txt: it is two hex lines, the ciphertext of the known plaintext pt1 that chall.py hardcodes and the ciphertext of the flag. See AES for CTF for the math.
    bash
    cat chall.py
    bash
    head -3 output.txt

    Expected output

    d7481d89f1aaf5a857f56edd2ae8994c
    8c7d66558130eb5796d131beb43c9934
    What didn't work first

    Tried: Treating this as standard AES and trying to crack the key with a known-plaintext tool like aeskeyfind or brute-forcing the key schedule.

    aeskeyfind and its relatives assume all four round operations are intact. Removing SubBytes changes the structure, so the key schedule is no longer recoverable through S-box differential properties. Brute-forcing a 128-bit key is hopeless anyway. Exploit the linearity the missing step created.

    Tried: Assuming the first hex line of output.txt is the flag ciphertext.

    chall.py prints AES(pt1, key) first and AES(flag, key) second, so line 1 is the known-plaintext ciphertext and line 2 is the flag. Swap them and the identity has nothing to cancel against, so you get garbage. Match the two print() calls at the bottom of chall.py to the two lines before writing any code.

    Learn more

    output.txt holds only two hex blobs, one per line, because chall.py prints only two values. The matching known plaintext is not in output.txt at all: it is the literal pt1 = "72616e646f6d64617461313131313131" (the ASCII string randomdata111111) near the bottom of chall.py. Line 1 is its ciphertext, line 2 is the flag ciphertext.

  2. Step 2Understand the linearity property
    Observation
    chall.py defines sub_bytes as a stub that returns the state untouched, which makes the whole cipher linear over GF(2). Encryption then distributes over XOR.
    chall.py neuters SubBytes: sub_bytes returns the state unchanged, and sub_word and rcon in the key schedule do the same. Every remaining op (ShiftRows, MixColumns, AddRoundKey) is linear over GF(2), so E_K(P) = E_0(P) XOR E_K(0). One known plaintext breaks the cipher.
    Learn more

    AES is built from four round operations: SubBytes (non-linear S-box), ShiftRows (byte permutation), MixColumns (linear transform over GF(28)), AddRoundKey (XOR). Only SubBytes is non-linear; it is the entire source of resistance to linear and differential cryptanalysis.

    In chall.py the round function still calls sub_bytes, but that function is return state, so the S-box never happens. ShiftRows is a fixed byte permutation (linear), MixColumns is a constant matrix multiplication over GF(28) (linear), and AddRoundKey is XOR. Composition of linear maps is linear, so the whole cipher satisfies E_K(A ⊕ B) = E_K(A) ⊕ E_K(B). Setting A = 0 gives E_K(P) = E_K(0) ⊕ E_0(P), the identity used below.

    Without SubBytes, AES degenerates to a linear cipher equivalent to a large XOR with a key-derived pad. Linear ciphers fall to one known plaintext.

  3. Step 3Recover E_K(0) from a known plaintext pair
    Observation
    chall.py hardcodes a known plaintext and output.txt gives its ciphertext on line 1. That one pair plus the linearity identity gives you the key-dependent constant.
    Using the known plaintext pt1 from chall.py and its ciphertext ct1 from line 1 of output.txt, compute E_0(pt1) by calling chall.py's own AES() with an all-zero key, then XOR with ct1 to extract E_K(0). Delete the two [redacted] assignments and the print() calls at the bottom of chall.py first so it imports cleanly, and note AES() takes hex strings, not bytes.
    python
    python3 << 'EOF'
    # chall.py's sub_bytes/sub_word/rcon are identity stubs, so every remaining
    # operation is linear: AES(P, K) = E_0(P) XOR E_K(0).
    
    from chall import AES  # after deleting the [redacted] lines at the bottom
    
    pt1 = "72616e646f6d64617461313131313131"   # hardcoded in chall.py
    ct1 = "d7481d89f1aaf5a857f56edd2ae8994c"   # output.txt line 1
    zero_key = "00" * 16                       # AES() wants a 32-char hex key
    
    # With an all-zero key every round key is zero, so E_0(0) = 0 and AES() with
    # that key is exactly the linear map E_0.
    e0_pt1 = AES(pt1, zero_key)
    
    # Extract E_K(0): since E_K(pt1) = E_0(pt1) XOR E_K(0)
    e_k_0 = bytes(a ^ b for a, b in zip(bytes.fromhex(ct1), bytes.fromhex(e0_pt1)))
    print("E_K(0):", e_k_0.hex())
    EOF

    Expected output

    E_K(0): 84d227bdd96f3e00e39b40f4be22594d
    What didn't work first

    Tried: Encrypting the zero block with the actual key K instead of with a zero key - running AES("00" * 16, key) - to produce E_K(0) directly.

    You do not have the key, and avoiding it is the whole point. The known-plaintext step extracts the constant without ever recovering K. Encrypting under an unknown key would require the key, which is circular.

    Tried: XORing pt1 with ct1 directly and treating the result as E_K(0), skipping the E_0(pt1) computation step.

    XOR-ing the plaintext with its ciphertext gives you the encryption XOR the plaintext, not the constant. The cipher is linear but it is not a raw XOR pad: ShiftRows and MixColumns permute and mix before the round key applies. Compute the modified AES of that plaintext under a zero key first, then XOR with the ciphertext to isolate the constant.

    Learn more

    A known-plaintext attack (KPA) is one where the attacker has access to both a plaintext and its corresponding ciphertext. With a linear cipher, one known plaintext/ciphertext pair is all you need to break the system completely. This is in stark contrast to AES with SubBytes, which is designed to resist even chosen-plaintext attacks requiring millions of oracle queries.

    The algebra here is straightforward: since E_K(P) = E_0(P) ⊕ E_K(0), you can rearrange to get E_K(0) = E_K(P) ⊕ E_0(P) = ct1 ⊕ E_0(pt1). Computing E_0(pt1) is free - you have the source code and can run the modified AES with a zero key. XORing with ct1 recovers the key-dependent offset E_K(0).

    This E_K(0) term is effectively a universal decryption key for the linear cipher: knowing it lets you decrypt any ciphertext without knowing the actual key K. It acts like a one-time pad for this particular cipher, but unlike a true OTP, it is recoverable from a single known plaintext - which is why linearity is catastrophically insecure.

  4. Step 4Decrypt the flag
    Observation
    With that constant known, XOR it into the flag ciphertext and what remains is E_0(flag). chall.py ships no decrypt routine, so invert the linear map yourself.
    Compute E_K(0) XOR flag_ct = E_0(flag_pt). chall.py only encrypts, so build the 128x128 GF(2) matrix of E_0 by encrypting each of the 128 unit vectors under the zero key, then solve the system for the input that maps to E_0(flag_pt). That input is the flag.
    python
    python3 << 'EOF'
    from chall import AES
    
    zero_key = "00" * 16
    e_k_0 = bytes.fromhex("84d227bdd96f3e00e39b40f4be22594d")
    flag_ct = bytes.fromhex("8c7d66558130eb5796d131beb43c9934")   # output.txt line 2
    
    # Step 1: peel off E_K(0) by XOR. What remains is E_0(flag).
    target = bytes(a ^ b for a, b in zip(flag_ct, e_k_0))
    
    def bits(b):
        return int.from_bytes(b, "big")
    
    # Step 2: E_0 is linear, so encrypt each of the 128 unit vectors under the
    # zero key to get its matrix, reduced straight into row echelon form.
    pivots = {}
    for i in range(128):
        e = bytearray(16)
        e[i // 8] = 1 << (7 - i % 8)
        v, mask = bits(bytes.fromhex(AES(bytes(e).hex(), zero_key))), 1 << i
        while v:
            h = v.bit_length() - 1
            if h not in pivots:
                pivots[h] = (v, mask)
                break
            pv, pm = pivots[h]
            v ^= pv
            mask ^= pm
    
    # Step 3: solve E_0(x) = target. The mask records which unit vectors went in.
    v, mask = bits(target), 0
    while v:
        h = v.bit_length() - 1
        pv, pm = pivots[h]
        v ^= pv
        mask ^= pm
    
    flag = bytearray(16)
    for i in range(128):
        if mask >> i & 1:
            flag[i // 8] |= 1 << (7 - i % 8)
    print(bytes(flag).decode())
    EOF

    Expected output

    picoCTF{spi1...}
    What didn't work first

    Tried: Calling AES(target, zero_key) again to invert E_0, reasoning that a linear cipher should be its own inverse.

    Linear does not mean self-inverse. AES's linear layer inverts through inverse ShiftRows and inverse MixColumns, but running the forward transform twice just applies the map twice. Encrypting again gives you a doubly-encrypted value, not the plaintext. Invert the map properly: either solve the GF(2) system, or apply the inverses in reverse round order by hand.

    Tried: XORing flag_ct directly with e_k_0 and printing the result as the flag, skipping the E_0 inversion step entirely.

    That XOR gives the plaintext still encrypted under a zero key, not the plaintext. A zero key zeroes every round key, but the permutation and mixing layers still ran. Invert E_0 to get the raw bytes. Printing the intermediate value gives unreadable binary.

    Learn more

    The recovery identity is flag_ct = E_0(flag_pt) ⊕ E_K(0), so E_0(flag_pt) = flag_ct ⊕ E_K(0). Inverting E_0 recovers flag_pt. chall.py exposes no decrypt routine, so either apply inverse MixColumns and inverse ShiftRows in reverse round order with a zero key schedule, or treat E_0 as a 128x128 matrix over GF(2) and solve the system, which needs no knowledge of the round structure at all.

    Why E_0 is invertible at all: each component (ShiftRows, MixColumns, AddRoundKey-with-zero-key) is invertible. ShiftRows is a permutation. MixColumns is a fixed invertible matrix over GF(28) with a known inverse matrix. AddRoundKey with a zero key is the identity, so it's its own inverse. Full linear ciphers are not always self-inverse: some cipher discussions loosely claim "linear over GF(2) means self-inverse," but that's only true for pure XOR-pad ciphers. AES's linear layer is invertible, not involutive, so you have to invert it rather than re-apply it.

    This challenge echoes the AES competition (1997-2001), which rejected proposals with insufficient non-linearity. Linear-looking complexity is not security; SubBytes is the entire reason AES holds.

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{spi1...}

Stubbing out SubBytes linearises AES: E_K(P) = E_0(P) XOR E_K(0). Compute E_0(pt1) with a zero key, XOR with the known ct1 to get E_K(0), peel that off the flag ciphertext, then invert the linear map E_0 to recover the 16-byte flag. The flag is shown abbreviated on this page; work the steps above to recover the full value.

Key takeaway

SubBytes is the only non-linear step in AES, and it alone is what defeats linear and differential cryptanalysis. Remove it and the cipher becomes a linear map over GF(2), which is to say a glorified XOR with a key-derived constant, broken by one known plaintext. The AES competition required candidates to demonstrate non-linearity for exactly this reason, and a custom or reduced-round cipher is always worth checking first for a missing non-linear component.

Related reading

Useful tools for Cryptography

Where to go next