Black Cobra Pepper picoCTF 2026 Solution

Published: March 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 - note that SubBytes is removed.

bash
cat chall.py
bash
cat output.txt
  1. Step 1Read chall.py and inspect output.txt format
    Skim chall.py for the round operations (ShiftRows, MixColumns, AddRoundKey present, SubBytes absent), then look at the first few lines of output.txt to see the layout of the (P, C) pairs and the flag ciphertext. See AES for CTF for the math.
    bash
    cat chall.py
    bash
    head -3 output.txt
    Learn more

    output.txt typically lists one or more known (plaintext, ciphertext) pairs followed by the flag ciphertext, one hex blob per line. Confirm visually before pulling values into Python so you know which line is your known PT, which is its CT, and which is the flag CT.

  2. Step 2Understand the linearity property
    chall.py drops SubBytes from AES. 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 omits the sub_bytes call entirely. 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
    Using a known plaintext pt1 and its ciphertext ct1 from output.txt, compute E_0(pt1) with a zero key and XOR with ct1 to extract E_K(0).
    python
    python3 << 'EOF'
    # chall.py implements AES without SubBytes - all ops are linear
    # Property: E_K(P) = E_0(P) XOR E_K(0)
    
    from chall import encrypt  # modified AES (no SubBytes)
    
    # From output.txt: known plaintext/ciphertext pair (the first one shown).
    pt1 = bytes.fromhex("YOUR_KNOWN_PT1")
    ct1 = bytes.fromhex("YOUR_KNOWN_CT1")
    
    zero_key = bytes(16)
    # Verify chall.py's encrypt() accepts a 16-byte zero key. Some implementations
    # expand the key in __init__ or reject all-zero keys; if so, monkey-patch the
    # key-schedule call or instantiate the cipher class directly.
    e0_pt1 = encrypt(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(ct1, e0_pt1))
    print("E_K(0):", e_k_0.hex())
    EOF
    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
    Compute E_K(0) XOR flag_ct = E_0(flag_pt), then run decrypt() with the zero key (if chall.py exposes one) to recover the flag. If only encrypt() is available, fall through to E_0^{-1} via your own decrypt routine.
    python
    python3 << 'EOF'
    flag_ct = bytes.fromhex("YOUR_FLAG_CT")
    
    # Step 1: peel off E_K(0) by XOR. What remains is E_0(flag_pt).
    e0_flag_pt = bytes(a ^ b for a, b in zip(flag_ct, e_k_0))
    
    # Step 2: invert E_0. Prefer chall.py's own decrypt() if it exists; the math
    # is cleanest when you use the same primitives the challenge uses.
    try:
        from chall import decrypt
        flag = decrypt(e0_flag_pt, zero_key)
    except ImportError:
        # Fallback: invert each linear op manually (inverse MixColumns, inverse
        # ShiftRows, AddRoundKey unchanged), in reverse round order.
        raise SystemExit("Implement inverse_E0 from chall.py round structure")
    
    print(flag)
    EOF
    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. Use chall.py's exposed decrypt() if it exists; otherwise, apply inverse MixColumns, inverse ShiftRows, and AddRoundKey in reverse round order with a zero key schedule.

    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 writeups 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 use the decrypt primitive, not encrypt.

    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.

Flag

picoCTF{bl4ck_c0br4_p3pp3r_...}

Removing SubBytes linearises AES: E_K(P) = E_0(P) ⊕ E_K(0). Compute E_0(pt1) with zero key, XOR with known ct1 to get E_K(0), then use this to decrypt any ciphertext.

Want more picoCTF 2026 writeups?

Useful tools for Cryptography

Related reading

What to try next