Description
i like peppers. Download: chall.py and output.txt .
Setup
Download chall.py and output.txt.
Read chall.py to understand the modified AES scheme.
cat chall.pycat output.txtSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Read chall.py and inspect output.txt formatObservationI noticed the challenge provides both a Python encryption script and an output file, which suggested reading the source first to identify any modifications to the standard algorithm before attempting to parse the ciphertext pairs.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.bashcat chall.pybashhead -3 output.txtExpected output
picoCTF{bl4ck_c0br4_p3pp3r_...}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 similar tools assume full AES with all four round operations intact. chall.py removes SubBytes, which changes the structure fundamentally - the key schedule is no longer recoverable via S-box differential properties. Attempting to brute-force a 128-bit AES key is computationally infeasible regardless; the correct approach exploits the linearity that the missing SubBytes creates.
Tried: Skipping the output.txt inspection and assuming the first hex line is always the flag ciphertext.
output.txt typically contains one or more known (plaintext, ciphertext) pairs before the flag ciphertext. If you treat the first line as the flag and XOR with a zero-key encryption of all-zeros, you get garbage - the decryption identity requires a confirmed known-PT pair, not just the flag CT. Running head -3 output.txt lets you count the lines and identify which is which before writing any exploit code.
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.
Step 2
Understand the linearity propertyObservationI noticed that chall.py omits the sub_bytes call from every round, which suggested that the entire cipher had become linear over GF(2) and would satisfy the superposition property E_K(A XOR B) = E_K(A) XOR E_K(B).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_bytescall 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 satisfiesE_K(A ⊕ B) = E_K(A) ⊕ E_K(B). SettingA = 0givesE_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.
Step 3
Recover E_K(0) from a known plaintext pairObservationI noticed output.txt contains known plaintext/ciphertext pairs alongside the flag ciphertext, which suggested applying the linearity identity E_K(0) = ct1 XOR E_0(pt1) to extract the key-dependent constant from a single 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).pythonpython3 << '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()) EOFWhat didn't work first
Tried: Encrypting the zero block with the actual key K instead of with a zero key - running encrypt(bytes(16), known_key) - to produce E_K(0) directly.
You do not have the actual key K; that is what you are trying to avoid needing. The whole point of the known-plaintext recovery is to extract the constant E_K(0) without K, by exploiting the linearity E_K(0) = ct1 XOR E_0(pt1). Encrypting with an unknown key requires 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.
pt1 XOR ct1 equals E_K(pt1) XOR pt1, not E_K(0). The cipher is linear but it is not a raw XOR-pad: ShiftRows and MixColumns permute and mix the bytes before AddRoundKey applies. You must compute E_0(pt1) - the modified AES of pt1 under a zero key - and only then XOR with ct1 to cancel out the plaintext contribution and isolate E_K(0).
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 getE_K(0) = E_K(P) ⊕ E_0(P) = ct1 ⊕ E_0(pt1). ComputingE_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.
Step 4
Decrypt the flagObservationI noticed that once E_K(0) is known, XORing it with the flag ciphertext yields E_0(flag_pt), which suggested invoking chall.py's own decrypt routine with a zero key to invert that final layer and recover the plaintext.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.pythonpython3 << '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) EOFWhat didn't work first
Tried: Calling encrypt(e0_flag_pt, zero_key) instead of decrypt to invert E_0, reasoning that a linear cipher should be its own inverse.
A linear cipher is not necessarily involutive. AES's linear layer (ShiftRows plus MixColumns) is invertible via inverse ShiftRows and inverse MixColumns, but applying the forward transform twice does not produce the original input - it applies the linear map twice. Using encrypt again produces E_0(E_0(flag_pt)), not flag_pt. You need the actual decrypt() path, or manually apply inverse MixColumns, inverse ShiftRows, and round-key XOR in reverse order.
Tried: XORing flag_ct directly with e_k_0 and printing the result as the flag, skipping the E_0 inversion step entirely.
flag_ct XOR e_k_0 gives E_0(flag_pt), not flag_pt itself. The zero-key encryption is still a permutation and mixing layer - it is not the identity. You must invert E_0 via decrypt(e0_flag_pt, zero_key) to recover the raw plaintext bytes. Printing the intermediate XOR result gives unreadable binary that looks nothing like a picoCTF flag.
Learn more
The recovery identity is
flag_ct = E_0(flag_pt) ⊕ E_K(0), soE_0(flag_pt) = flag_ct ⊕ E_K(0). InvertingE_0recoversflag_pt. Use chall.py's exposeddecrypt()if it exists; otherwise, apply inverse MixColumns, inverse ShiftRows, and AddRoundKey in reverse round order with a zero key schedule.Why
E_0is 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 thedecryptprimitive, notencrypt.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.
Flag
Reveal 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.