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 - note that SubBytes is removed.
cat chall.pycat output.txtSolution
Walk me through it- Step 1Read chall.py and inspect output.txt formatSkim 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.pybashhead -3 output.txtLearn 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 2Understand the linearity propertychall.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 3Recover E_K(0) from a known plaintext pairUsing 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()) EOFLearn 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 4Decrypt the flagCompute 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) EOFLearn 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.
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.