Welcome to the
Crypto Workbook
This workbook guides you through the CryptoPals challenge series and a live classroom CTF. You will implement attacks, not just read about them — from XOR ciphers to breaking CBC-mode encryption in a live network exercise.
Each chapter shows which environment to use. Colab is great for learning the crypto algorithms (no setup, runs in a browser). The Linux terminal is required for the CTF server/client, Wireshark, and network exercises. Most coding chapters work in both — platform tabs show you the right version for each.
What is CryptoPals?
CryptoPals (cryptopals.com) is a set of 64 cryptographic programming challenges written by ex-Matasano Security engineers. Unlike textbooks, it teaches crypto by making you break things: implement a cipher, then attack it. This workbook covers Set 1 (XOR, frequency analysis, ECB) and Set 2 (PKCS#7, CBC mode, bitflipping attacks), then connects those skills directly to the live Classroom CTF.
Environment overview
| Environment | Best for | Setup needed |
|---|---|---|
| Google Colab | Algorithm implementation, learning crypto concepts, Set 1 & Set 2 challenges | Google account only — runs in browser |
| Linux terminal | CTF server/client, Wireshark/tcpdump, socket programming, full pipeline | Python 3 + pycryptodome + Wireshark |
Core vocabulary
| Term | Plain-English meaning |
|---|---|
| plaintext | The original readable message before encryption |
| ciphertext | The scrambled output after encryption |
| key | The secret value used to encrypt or decrypt |
| XOR (⊕) | Bitwise exclusive-or — the foundation of stream ciphers |
| block cipher | Encrypts fixed-size chunks (AES = 16 bytes per block) |
| mode of operation | How a block cipher handles data longer than one block (ECB, CBC, CTR…) |
| IV | Initialization vector — random bytes that seed CBC mode |
| PKCS#7 | A padding scheme that pads plaintext to the next block boundary |
Linux quickstart
& auto-setup script
Run the script below once on any Debian/Ubuntu Linux machine and your entire CryptoPals + CTF environment will be ready: Python dependencies, Wireshark, tcpdump, netcat, tmux, and a project folder structure.
This script uses sudo for system packages. Run it on your own machine or a dedicated lab VM — not a shared production server. Tested on Ubuntu 22.04 and Debian 12.
One-shot setup script
Copy the entire block below into a file called setup_crypto_lab.sh, then run it. Every step is annotated so you know exactly what is being installed and why.
#!/usr/bin/env bash # ───────────────────────────────────────────────────────────────── # CryptoPals + CTF Lab — Auto-setup script # Tested on Ubuntu 22.04 / Debian 12 # Run with: bash setup_crypto_lab.sh # ───────────────────────────────────────────────────────────────── set -e # exit immediately if any command fails echo "[1/7] Updating apt package lists..." sudo apt-get update -q echo "[2/7] Installing system tools..." sudo apt-get install -y \ python3 python3-pip \ # Python runtime + pip wireshark \ # GUI packet capture (for PCAP analysis) tshark \ # CLI version of Wireshark (scriptable) tcpdump \ # Raw packet capture to .pcap files netcat-openbsd \ # nc — test TCP connections, simple clients tmux \ # Split one terminal into multiple panes net-tools \ # ifconfig, netstat (older but useful) curl wget # Download challenge input files echo "[3/7] Allowing Wireshark capture without sudo..." # Add current user to the 'wireshark' group so you don't need sudo for captures sudo usermod -aG wireshark "$USER" echo "[4/7] Installing Python crypto libraries..." pip3 install --user \ pycryptodome \ # AES, RSA, SHA — used in chapters 6-10 requests \ # Download challenge files over HTTP scapy # Advanced packet crafting (optional, CTF bonus) echo "[5/7] Creating project folder structure..." mkdir -p ~/cryptopals/{set1,set2,ctf,pcaps,utils} # set1/ → challenges 1-8 (XOR, frequency, ECB) # set2/ → challenges 9-16 (PKCS7, CBC, bitflip) # ctf/ → server.py, client.py, attacker.py # pcaps/ → captured .pcap files from Wireshark/tcpdump # utils/ → shared helpers (xor, padding, scoring) echo "[6/7] Writing shared utility module..." cat > ~/cryptopals/utils/crypto_helpers.py << 'PYEOF' # ── crypto_helpers.py ────────────────────────────────────────── # Import this in every challenge script: # import sys, os; sys.path.insert(0, os.path.expanduser("~/cryptopals/utils")) # from crypto_helpers import * def xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) def score_english(text: bytes) -> float: freq = {32:13,101:12.7,116:9.1,97:8.2,111:7.5,105:7,110:6.7, 115:6.3,104:6.1,114:6,100:4.3,108:4,99:2.8,117:2.8} lower = text.lower() score = sum(lower.count(b) * w for b, w in freq.items()) printable = sum(1 for b in text if 32 <= b < 127) score += printable * 0.5 return score def break_single_xor(ct: bytes): best = (-1, 0, b"") for k in range(256): plain = bytes(b ^ k for b in ct) s = score_english(plain) if s > best[0]: best = (s, k, plain) return best[1], best[2] def pkcs7_pad(data: bytes, bs: int = 16) -> bytes: n = bs - (len(data) % bs) return data + bytes([n] * n) def pkcs7_unpad(data: bytes) -> bytes: n = data[-1] if n == 0 or n > 16 or data[-n:] != bytes([n]*n): raise ValueError("Bad padding") return data[:-n] def hamming_distance(a: bytes, b: bytes) -> int: return sum(bin(x ^ y).count("1") for x, y in zip(a, b)) PYEOF echo "[7/7] Downloading Set 1 challenge data files..." # Challenge 6 (break repeating-key XOR) curl -s https://cryptopals.com/static/challenge-data/6.txt \ -o ~/cryptopals/set1/challenge6.txt # Challenge 7 (AES-128-ECB decrypt) curl -s https://cryptopals.com/static/challenge-data/7.txt \ -o ~/cryptopals/set1/challenge7.txt # Challenge 8 (detect ECB mode) curl -s https://cryptopals.com/static/challenge-data/8.txt \ -o ~/cryptopals/set1/challenge8.txt # Challenge 10 (CBC decryption) curl -s https://cryptopals.com/static/challenge-data/10.txt \ -o ~/cryptopals/set2/challenge10.txt echo "" echo "✓ Setup complete! Log out and back in for Wireshark group to take effect." echo " Project folder: ~/cryptopals/" echo " Start a tmux session: tmux new -s crypto"
Running the script
# Step 1: Create the script file nano setup_crypto_lab.sh # Paste the script above, then Ctrl+O to save, Ctrl+X to exit # Step 2: Make it executable chmod +x setup_crypto_lab.sh # Step 3: Run it bash setup_crypto_lab.sh # Step 4: Log out and back in (needed for Wireshark group permissions) # Or apply group without logging out: newgrp wireshark
Tmux cheatsheet — working with split terminals
Tmux lets you run the server in one pane and the client in another — essential for the CTF chapters.
tmux new -s crypto # create a new session named "crypto" Ctrl+B then % # split vertically (left/right panes) Ctrl+B then " # split horizontally (top/bottom panes) Ctrl+B then ← → ↑ ↓ # move between panes Ctrl+B then z # zoom current pane to full screen (toggle) Ctrl+B then d # detach session (keeps it running) tmux attach -t crypto # re-attach to the session later exit # close a pane
Useful network commands
# Find your LAN IP address (share with teammates) ip a | grep "inet " | grep -v "127.0.0.1" # or: hostname -I # Check if a port is in use ss -tlnp | grep 9999 # Test connectivity to a teammate's server nc -zv 192.168.1.42 9999 # Kill a process holding a port fuser -k 9999/tcp # Capture all CTF traffic and save to file sudo tcpdump -i eth0 -w ~/cryptopals/pcaps/capture.pcap port 9999 # Read a pcap in hex+ASCII (no Wireshark needed) tcpdump -r ~/cryptopals/pcaps/capture.pcap -XX | head -60
Single-byte XOR
cipher
A single-byte XOR cipher XORs every byte of a message against the same one-byte key. Only 256 possible keys exist — try all of them, score each result against English letter frequencies, and the highest scorer is your plaintext.
How XOR encryption works
plaintext ⊕ key = ciphertext
ciphertext ⊕ key = plaintext
XOR is its own inverse — applying it twice with the same key cancels out.
Go to colab.research.google.com → New notebook. Create one cell per section below. No installs needed for this chapter — it's pure Python.
# ── Cell 1: Shared helpers (run this first in every Set 1 notebook) ── # No pip installs needed — pure Python standard library def score_english(text: bytes) -> float: """Score bytes on how English-like they look. Higher = more likely to be English plaintext.""" freq = { 32: 13.0, # space — most frequent character in English text 101: 12.7, # e 116: 9.1, # t 97: 8.2, # a 111: 7.5, # o 105: 7.0, # i 110: 6.7, # n 115: 6.3, # s 104: 6.1, # h 114: 6.0, # r } lower = text.lower() score = 0.0 for byte_val, weight in freq.items(): score += lower.count(byte_val) * weight # bonus for printable ASCII (penalises random-looking bytes) score += sum(0.5 for b in text if 32 <= b < 127) return score def break_single_xor(ciphertext: bytes): """Try all 256 single-byte keys. Return (key, plaintext) with best score.""" best_score = -1 best_key = 0 best_plain = b"" for key in range(256): candidate = bytes(b ^ key for b in ciphertext) s = score_english(candidate) if s > best_score: best_score = s best_key = key best_plain = candidate return best_key, best_plain print("✓ Helpers loaded")
# ── Cell 2: CryptoPals Set 1 Challenge 3 ── # The hex string below has been XOR-encrypted with a single byte. # Find the key and recover the plaintext. hex_string = "1b37373331363f78151b7f2b783431333d78397828372d363c78373e783a393b3736" # Step 1: decode hex → raw bytes ciphertext = bytes.fromhex(hex_string) print(f"Ciphertext length: {len(ciphertext)} bytes") print(f"Ciphertext (hex): {ciphertext.hex()}") # Step 2: brute-force all 256 keys key, plaintext = break_single_xor(ciphertext) # Step 3: display result print(f"\n✓ Key found: 0x{key:02x} = {key} = chr('{chr(key)}')") print(f"✓ Plaintext: {plaintext.decode(errors='replace')}")
Save the script to ~/cryptopals/set1/challenge3.py then run python3 challenge3.py. The shared helpers from the setup script are imported from ~/cryptopals/utils/.
cd ~/cryptopals/set1
nano challenge3.py # paste the script below, save with Ctrl+O Ctrl+X
python3 challenge3.py
#!/usr/bin/env python3 """CryptoPals Set 1 Challenge 3 — Single-byte XOR cipher.""" import sys, os sys.path.insert(0, os.path.expanduser("~/cryptopals/utils")) from crypto_helpers import break_single_xor hex_string = "1b37373331363f78151b7f2b783431333d78397828372d363c78373e783a393b3736" ciphertext = bytes.fromhex(hex_string) key, plaintext = break_single_xor(ciphertext) print(f"Key: 0x{key:02x} ({key})") print(f"Plaintext: {plaintext.decode(errors='replace')}")
English letter frequencies
Interactive demo
1b37373331363f78151b7f2b783431333d78397828372d363c78373e783a393b3736 has been XORed against a single byte. Find the key and recover the plaintext.bytes.fromhex() first. Then pass the result into break_single_xor(). The plaintext is a recognizable English sentence. You'll know it immediately when you see it.Repeating-key XOR
Repeating-key XOR (Vigenère-like) cycles through a multi-byte key. Each plaintext byte is XORed against the corresponding key byte modulo the key length. Much harder to brute-force — but not unbreakable, as Chapter 4 shows.
ciphertext[i] = plaintext[i] ⊕ key[i mod len(key)]
For key "ICE": byte 0 uses 'I', byte 1 uses 'C', byte 2 uses 'E', byte 3 uses 'I' again…
Add new cells below your Cell 1 helpers from Challenge 3. The score_english helper is reused in later challenges.
# ── CryptoPals Set 1 Challenge 5 ── # Implement repeating-key XOR and verify the output matches the expected hex. def repeating_key_xor(data: bytes, key: bytes) -> bytes: """XOR each byte of data with the corresponding key byte (wrapping).""" return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) # Challenge 5 test vector plaintext = ( b"Burning 'em, if you ain't quick and nimble\n" b"I go crazy when I hear a cymbal" ) key = b"ICE" ct = repeating_key_xor(plaintext, key) expected = ( "0b3637272a2b2e63622c2e69692a23693a2a3c6324202d623d63343c2a26" "22632b204d621b69613b621c484f13" # (first 60 chars shown) ) print("Output (hex):", ct.hex()) print("Matches expected:", ct.hex().startswith(expected[:30]))
cd ~/cryptopals/set1 nano challenge5.py python3 challenge5.py
#!/usr/bin/env python3 """CryptoPals Set 1 Challenge 5 — Repeating-key XOR.""" def repeating_key_xor(data: bytes, key: bytes) -> bytes: return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) plaintext = ( b"Burning 'em, if you ain't quick and nimble\n" b"I go crazy when I hear a cymbal" ) ct = repeating_key_xor(plaintext, b"ICE") print(ct.hex())
repeating_key_xor(data, key). Verify your output matches the expected hex for the "Burning 'em" stanza encrypted with key ICE.enumerate(data) to get both the index i and byte value b. Key index is i % len(key). Call .hex() on the result to get a hex string for comparison.i uses key[i % 6]. So 0, 6, 12, 18 … all use key[0] — these form a "column" you can attack independently with frequency analysis once you know the key length.Breaking repeating-key XOR
The hardest challenge in Set 1. Use Hamming distance to guess the key length, then reduce the multi-byte key problem into several single-byte XOR problems you already know how to solve.
When KEYSIZE equals the true key length, XORing two consecutive KEYSIZE-byte blocks cancels the key: (P1 ⊕ K) ⊕ (P2 ⊕ K) = P1 ⊕ P2. The Hamming distance of two English plaintext blocks is much lower than random bytes — so the correct key size produces the lowest normalized distance.
Colab can fetch the challenge input directly over the internet — no manual file download needed.
# ── Download the challenge 6 ciphertext directly from CryptoPals ── import urllib.request, base64 url = "https://cryptopals.com/static/challenge-data/6.txt" data = urllib.request.urlopen(url).read() ciphertext = base64.b64decode(data) print(f"Downloaded {len(ciphertext)} bytes of ciphertext")
# ── Full repeating-key XOR breaker ── # Assumes score_english(), break_single_xor(), repeating_key_xor() # are already defined from the Cell 1 helpers above. def hamming_distance(a: bytes, b: bytes) -> int: """Count differing bits between two byte strings.""" return sum(bin(x ^ y).count('1') for x, y in zip(a, b)) # Sanity check — should equal 37 assert hamming_distance(b"this is a test", b"wokka wokka!!!") == 37 print("✓ Hamming distance correct") def guess_keysize(ct: bytes, lo=2, hi=40): """Return top-3 key length guesses by normalized Hamming distance.""" scores = [] for k in range(lo, hi + 1): blocks = [ct[i*k:(i+1)*k] for i in range(4)] # average over 3 block-pair comparisons for accuracy dist = ( hamming_distance(blocks[0], blocks[1]) + hamming_distance(blocks[1], blocks[2]) + hamming_distance(blocks[2], blocks[3]) ) / (3 * k) scores.append((dist, k)) scores.sort() return [k for _, k in scores[:3]] def break_repeating_xor(ct: bytes): """Full pipeline: guess key length → transpose → per-column solve.""" best = None for k in guess_keysize(ct): # Slice into k columns: column i = bytes at positions i, i+k, i+2k … columns = [ct[i::k] for i in range(k)] key = bytes(break_single_xor(col)[0] for col in columns) plain = repeating_key_xor(ct, key) s = score_english(plain) if best is None or s > best[0]: best = (s, key, plain) return best[1], best[2] # Run it on the downloaded ciphertext key, plaintext = break_repeating_xor(ciphertext) print(f"\n✓ Key: {key.decode(errors='replace')}") print(f"\nFirst 200 chars of plaintext:\n{plaintext[:200].decode(errors='replace')}")
The setup script downloaded challenge6.txt to ~/cryptopals/set1/. The script below reads it directly from disk.
cd ~/cryptopals/set1 nano challenge6.py python3 challenge6.py
#!/usr/bin/env python3 """CryptoPals Set 1 Challenge 6 — Break repeating-key XOR.""" import sys, os, base64 sys.path.insert(0, os.path.expanduser("~/cryptopals/utils")) from crypto_helpers import score_english, break_single_xor, hamming_distance def repeating_key_xor(data, key): return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) def guess_keysize(ct, lo=2, hi=40): scores = [] for k in range(lo, hi + 1): bl = [ct[i*k:(i+1)*k] for i in range(4)] d = (hamming_distance(bl[0],bl[1])+hamming_distance(bl[1],bl[2])+hamming_distance(bl[2],bl[3]))/(3*k) scores.append((d, k)) return [k for _,k in sorted(scores)[:3]] # Read file downloaded by setup script ct = base64.b64decode(open("challenge6.txt").read()) best = None for k in guess_keysize(ct): cols = [ct[i::k] for i in range(k)] key = bytes(break_single_xor(c)[0] for c in cols) plain = repeating_key_xor(ct, key) s = score_english(plain) if best is None or s > best[0]: best = (s, key, plain) key, plaintext = best[1], best[2] print(f"Key: {key.decode()}") print(plaintext[:300].decode(errors='replace'))
ECB mode detection
AES-ECB encrypts each 16-byte block independently. Identical plaintext blocks produce identical ciphertext blocks — a fatal flaw. Detect ECB by looking for repeated 16-byte blocks in any ciphertext.
ECB: each 16-byte block encrypted independently — same plaintext block always → same ciphertext block.
CBC: each block XORed with the previous ciphertext block before encrypting — identical plaintext blocks → different ciphertext.
This chapter uses AES — run the install cell once at the top of your notebook before importing.
# Run this cell once per Colab session. # pycryptodome provides the AES cipher used from Chapter 5 onward. !pip install pycryptodome -q print("✓ pycryptodome ready")
# ── CryptoPals Set 1 Challenge 8 — Detect AES-ECB ── import urllib.request def detect_ecb(ct: bytes, bs: int = 16) -> bool: """Return True if any 16-byte block is repeated — ECB signature.""" blocks = [ct[i:i+bs] for i in range(0, len(ct), bs)] return len(blocks) != len(set(blocks)) # set removes duplicates # Download the 60 ciphertexts url = "https://cryptopals.com/static/challenge-data/8.txt" lines = urllib.request.urlopen(url).read().decode().strip().splitlines() print(f"Scanning {len(lines)} ciphertexts...\n") for i, line in enumerate(lines): ct = bytes.fromhex(line) if detect_ecb(ct): blocks = [ct[j:j+16] for j in range(0,len(ct),16)] dupes = [b for b in set(blocks) if blocks.count(b) > 1] print(f"✓ Line {i}: ECB detected!") print(f" Repeated block(s): {[d.hex() for d in dupes]}")
The setup script downloaded challenge8.txt to ~/cryptopals/set1/.
cd ~/cryptopals/set1 nano challenge8.py python3 challenge8.py
#!/usr/bin/env python3 """CryptoPals Set 1 Challenge 8 — Detect AES-ECB.""" def detect_ecb(ct: bytes, bs=16) -> bool: blocks = [ct[i:i+bs] for i in range(0, len(ct), bs)] return len(blocks) != len(set(blocks)) lines = open("challenge8.txt").read().strip().splitlines() for i, line in enumerate(lines): ct = bytes.fromhex(line) if detect_ecb(ct): blocks = [ct[j:j+16] for j in range(0,len(ct),16)] dupes = [b for b in set(blocks) if blocks.count(b)>1] print(f"Line {i}: ECB detected! Repeated: {[d.hex() for d in dupes]}")
Many libraries default to ECB — always specify the mode explicitly. Use CBC with a random IV, CTR, or an AEAD mode like AES-GCM for any real system.
PKCS#7 padding
Block ciphers require input to be a multiple of the block size. PKCS#7 is the standard: append N bytes each with value N to reach the next 16-byte boundary. If already aligned, add a full block of 0x10 bytes.
| Message length | Bytes to add | Padding bytes appended |
|---|---|---|
| 20 bytes | 12 | 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c |
| 13 bytes | 3 | 03 03 03 |
| 16 bytes | 16 | 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 |
# ── CryptoPals Set 2 Challenge 9 — PKCS#7 padding ── def pkcs7_pad(data: bytes, bs: int = 16) -> bytes: """Pad data to a multiple of bs bytes using PKCS#7.""" n = bs - (len(data) % bs) # n is always 1..bs, never 0 return data + bytes([n] * n) def pkcs7_unpad(data: bytes) -> bytes: """Strip PKCS#7 padding. Raises ValueError on invalid padding.""" n = data[-1] if n == 0 or n > 16: raise ValueError("Invalid padding length") if data[-n:] != bytes([n] * n): raise ValueError("Padding bytes are inconsistent") return data[:-n] # Challenge 9 example — pad "YELLOW SUBMARINE" to 20-byte block result = pkcs7_pad(b"YELLOW SUBMARINE", 20) print(f"Padded: {result}") print(f"Hex: {result.hex()}") print(f"Unpadded: {pkcs7_unpad(result)}") # Show padding for various lengths for length in [1, 13, 16, 20, 31, 32]: padded = pkcs7_pad(b"A" * length) n = padded[-1] print(f"len={length:2d} pad_bytes={n:2d} total={len(padded)}")
cd ~/cryptopals/set2 nano challenge9.py python3 challenge9.py
#!/usr/bin/env python3 """CryptoPals Set 2 Challenge 9 — Implement PKCS#7 padding.""" import sys, os sys.path.insert(0, os.path.expanduser("~/cryptopals/utils")) from crypto_helpers import pkcs7_pad, pkcs7_unpad result = pkcs7_pad(b"YELLOW SUBMARINE", 20) print(f"Padded: {result}") for l in [1,13,16,20,31,32]: p = pkcs7_pad(b"A"*l) print(f"len={l:2d} pad={p[-1]:2d} total={len(p)}")
If a server raises different errors for valid vs invalid padding — or takes different amounts of time — an attacker can decrypt any ciphertext byte by byte without knowing the key. This is covered in CryptoPals Set 3, Challenge 17.
CBC mode & the IV
CBC fixes ECB's fatal flaw by XORing each plaintext block with the previous ciphertext block before encrypting. The first block uses a random Initialization Vector. Identical plaintexts now produce completely different ciphertexts.
Make sure you ran !pip install pycryptodome -q at the top of your notebook. We use AES.MODE_ECB as the raw block cipher — implementing the CBC chaining ourselves is the whole point of this challenge.
# ── CryptoPals Set 2 Challenge 10 — Implement CBC mode ── # Do NOT use AES.MODE_CBC — implement the chaining manually. import urllib.request, base64 from Crypto.Cipher import AES def xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) def cbc_encrypt(plaintext: bytes, key: bytes, iv: bytes) -> bytes: aes = AES.new(key, AES.MODE_ECB) # raw AES block cipher prev = iv out = b"" for i in range(0, len(plaintext), 16): block = plaintext[i:i+16] block = xor_bytes(block, prev) # XOR with previous ciphertext (or IV) enc = aes.encrypt(block) out += enc prev = enc # chain! return out def cbc_decrypt(ciphertext: bytes, key: bytes, iv: bytes) -> bytes: aes = AES.new(key, AES.MODE_ECB) prev = iv out = b"" for i in range(0, len(ciphertext), 16): block = ciphertext[i:i+16] dec = aes.decrypt(block) out += xor_bytes(dec, prev) # XOR with previous ciphertext prev = block # chain! return out # Download and decrypt Challenge 10 raw = urllib.request.urlopen("https://cryptopals.com/static/challenge-data/10.txt").read() ct = base64.b64decode(raw) key = b"YELLOW SUBMARINE" iv = bytes(16) # 16 zero bytes plaintext = cbc_decrypt(ct, key, iv) # Strip PKCS#7 padding n = plaintext[-1] plaintext = plaintext[:-n] print(plaintext[:300].decode(errors='replace'))
cd ~/cryptopals/set2 nano challenge10.py python3 challenge10.py
#!/usr/bin/env python3 """CryptoPals Set 2 Challenge 10 — Implement CBC mode.""" import sys, os, base64 sys.path.insert(0, os.path.expanduser("~/cryptopals/utils")) from crypto_helpers import pkcs7_unpad from Crypto.Cipher import AES def xor_bytes(a,b): return bytes(x^y for x,y in zip(a,b)) def cbc_decrypt(ct, key, iv): aes=AES.new(key,AES.MODE_ECB); prev=iv; out=b"" for i in range(0,len(ct),16): b=ct[i:i+16]; out+=xor_bytes(aes.decrypt(b),prev); prev=b return out ct = base64.b64decode(open("challenge10.txt").read()) pt = pkcs7_unpad(cbc_decrypt(ct, b"YELLOW SUBMARINE", bytes(16))) print(pt[:300].decode(errors='replace'))
CBC bitflipping attack
CBC is semantically secure, but modifying a ciphertext byte flips the corresponding bit in the next plaintext block. An attacker who can submit and re-submit ciphertexts can inject arbitrary content — without knowing the key.
plaintext[i] = AES_dec(ciphertext[i]) ⊕ ciphertext[i-1]
Flip a bit in ciphertext[i-1] → that exact bit flips in plaintext[i]. Block i-1 decrypts to garbage, but you don't care about that block.
# ── CryptoPals Set 2 Challenge 16 — CBC Bitflipping ── # Goal: make is_admin() return True without knowing the key. # The server blocks ';' and '=' from user input. import os from Crypto.Cipher import AES KEY = os.urandom(16) # random key each run IV = os.urandom(16) def xor_bytes(a,b): return bytes(x^y for x,y in zip(a,b)) def cbc_enc(pt, key, iv): from Crypto.Util.Padding import pad aes=AES.new(key,AES.MODE_ECB); prev=iv; out=b"" for b in [pt[i:i+16] for i in range(0,len(pt),16)]: enc=aes.encrypt(xor_bytes(b,prev)); out+=enc; prev=enc return out def cbc_dec(ct, key, iv): aes=AES.new(key,AES.MODE_ECB); prev=iv; out=b"" for b in [ct[i:i+16] for i in range(0,len(ct),16)]: out+=xor_bytes(aes.decrypt(b),prev); prev=b return out # Prefix = 32 bytes (2 full 16-byte AES blocks) PREFIX = b"comment1=cooking%20MCs;userdata=" SUFFIX = b";comment2=%20like%20a%20pound%20of%20bacon" def encrypt_userdata(userdata: str) -> bytes: # Strip attacker-controlled chars that would give admin directly clean = userdata.replace(";","").replace("=","") pt = PREFIX + clean.encode() + SUFFIX # pad to block boundary n = 16 - (len(pt) % 16) pt += bytes([n]*n) return cbc_enc(pt, KEY, IV) def is_admin(ct: bytes) -> bool: pt = cbc_dec(ct, KEY, IV) return b";admin=true;" in pt # ── THE EXPLOIT ─────────────────────────────────────────── # Prefix is 32 bytes = 2 blocks. Our input starts at block index 2. # We submit 16 'A' bytes as block 2 (our controllable block). # After encryption, we modify bytes in ciphertext block 1 (index 16-31) # to flip specific bits in plaintext block 2 when it decrypts. # # We WANT block 2 plaintext to start with: ;admin=true; # Currently it decrypts to: AAAAAAAAAAAAAAAA # Delta = ord('A') ^ ord(target_char) # ───────────────────────────────────────────────────────── ct = bytearray(encrypt_userdata("AAAAAAAAAAAAAAAA")) target = b";admin=true;" filler = b"A" * len(target) for j in range(len(target)): # Modify ciphertext block 1 at offset j # This flips the corresponding bit in decrypted block 2 ct[16 + j] ^= filler[j] ^ target[j] print("is_admin():", is_admin(bytes(ct))) # → True print("Decrypted block 2:", cbc_dec(bytes(ct),KEY,IV)[32:48])
cd ~/cryptopals/set2 nano challenge16.py python3 challenge16.py
#!/usr/bin/env python3 """CryptoPals Set 2 Challenge 16 — CBC Bitflipping Attack.""" import os from Crypto.Cipher import AES KEY=os.urandom(16); IV=os.urandom(16) def xb(a,b): return bytes(x^y for x,y in zip(a,b)) def enc(pt,k,iv): aes=AES.new(k,AES.MODE_ECB);prev=iv;out=b"" for b in [pt[i:i+16] for i in range(0,len(pt),16)]: e=aes.encrypt(xb(b,prev));out+=e;prev=e return out def dec(ct,k,iv): aes=AES.new(k,AES.MODE_ECB);prev=iv;out=b"" for b in [ct[i:i+16] for i in range(0,len(ct),16)]: out+=xb(aes.decrypt(b),prev);prev=b return out PREFIX=b"comment1=cooking%20MCs;userdata=" SUFFIX=b";comment2=%20like%20a%20pound%20of%20bacon" def make_ct(ud): pt=PREFIX+ud.replace(";","").replace("=","").encode()+SUFFIX n=16-(len(pt)%16);pt+=bytes([n]*n) return enc(pt,KEY,IV) def is_admin(ct): return b";admin=true;" in dec(ct,KEY,IV) ct=bytearray(make_ct("AAAAAAAAAAAAAAAA")) target=b";admin=true;" for j in range(len(target)): ct[16+j]^=ord('A')^target[j] print("is_admin:",is_admin(bytes(ct)))
Use AES-GCM (an AEAD mode) — any modification to the ciphertext is detected before decryption. CBC alone provides confidentiality but not integrity.
is_admin() returns True without the server stripping your semicolons.;admin=true; — for each byte at position j, XOR ct[16 + j] with ord('A') ^ target[j]. This cancels the 'A' and injects the target character into block 2's decryption.Set up your
encrypted channel
Your team sets up an AES-CBC encrypted socket channel. Other teams will capture your traffic and attempt to break it. This chapter walks through the full Linux setup: server, client, traffic capture, and common deliberate weaknesses your instructor may ask you to introduce.
Google Colab does not expose arbitrary TCP ports to the internet, and its network interface is not sniffable by classmates on the same LAN. The CTF requires a real Linux machine (VM, lab PC, or cloud instance) on a shared network.
Project file layout
~/cryptopals/ctf/ server.py # your team's encrypted server client.py # your team's encrypted client attacker.py # tools for breaking opponents (Chapter 10) ~/cryptopals/pcaps/ capture.pcap # traffic you capture from opponents
Server — complete implementation
#!/usr/bin/env python3 """ CTF AES-CBC Encrypted Chat Server ───────────────────────────────── Run: python3 server.py Share your KEY (hex) with your teammate via a secure out-of-band channel (e.g. whisper it in person — do NOT send it over the network!). Port 9999 must be reachable from your teammate's machine. If behind a firewall: sudo ufw allow 9999/tcp """ import socket, os, sys from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad # ── Configuration ────────────────────────────────────────────────── PORT = 9999 KEY = os.urandom(16) # 128-bit random key — share this secretly! BLOCK = 16 # ─────────────────────────────────────────────────────────────────── def encrypt_msg(plaintext: str) -> bytes: """Encrypt a string. Prepend the IV so the receiver can decrypt.""" iv = os.urandom(BLOCK) # fresh random IV every message aes = AES.new(KEY, AES.MODE_CBC, iv) ct = aes.encrypt(pad(plaintext.encode(), BLOCK)) return iv + ct # IV || ciphertext def decrypt_msg(data: bytes) -> str: """Decrypt IV || ciphertext received from client.""" iv, ct = data[:BLOCK], data[BLOCK:] aes = AES.new(KEY, AES.MODE_CBC, iv) return unpad(aes.decrypt(ct), BLOCK).decode() def main(): print(f"[SERVER] Started on port {PORT}") print(f"[SERVER] KEY (hex) = {KEY.hex()}") print(f"[SERVER] Share this key with your teammate IN PERSON.\n") with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("", PORT)) srv.listen(1) print(f"[SERVER] Waiting for connection...") conn, addr = srv.accept() print(f"[SERVER] Connected: {addr}\n") with conn: while True: data = conn.recv(4096) if not data: print("[SERVER] Connection closed.") break try: msg = decrypt_msg(data) print(f"[CLIENT → SERVER] {msg}") reply_text = input("[SERVER reply] ") conn.sendall(encrypt_msg(reply_text)) except Exception as e: print(f"[SERVER] Decrypt error: {e}") conn.sendall(encrypt_msg("ERROR: decryption failed")) if __name__ == "__main__": main()
Client — complete implementation
#!/usr/bin/env python3 """ CTF AES-CBC Encrypted Chat Client ────────────────────────────────── Run: python3 client.py Enter the server IP and the shared KEY (hex) when prompted. """ import socket, os from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad BLOCK = 16 KEY_HEX = input("Enter server KEY (hex): ").strip() SERVER = input("Enter server IP: ").strip() PORT = 9999 KEY = bytes.fromhex(KEY_HEX) def encrypt_msg(plaintext: str) -> bytes: iv = os.urandom(BLOCK) aes = AES.new(KEY, AES.MODE_CBC, iv) return iv + aes.encrypt(pad(plaintext.encode(), BLOCK)) def decrypt_msg(data: bytes) -> str: iv, ct = data[:BLOCK], data[BLOCK:] aes = AES.new(KEY, AES.MODE_CBC, iv) return unpad(aes.decrypt(ct), BLOCK).decode() print(f"\n[CLIENT] Connecting to {SERVER}:{PORT}...") with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect((SERVER, PORT)) print("[CLIENT] Connected. Type messages below.\n") while True: msg = input("[YOU] ") if msg.lower() in ("quit", "exit"): break s.sendall(encrypt_msg(msg)) reply = s.recv(4096) print(f"[SERVER] {decrypt_msg(reply)}\n")
Running the CTF channel in tmux
# ── Terminal 1 (or left tmux pane): Start the server ── cd ~/cryptopals/ctf python3 server.py # Note the KEY (hex) printed — share it with your teammate in person. # Note your IP: hostname -I # ── Terminal 2 (or right tmux pane): Start the client ── cd ~/cryptopals/ctf python3 client.py # Enter the KEY and server IP when prompted # ── Terminal 3 (optional): Capture your own traffic to verify encryption ── sudo tcpdump -i lo -w ~/cryptopals/pcaps/my_traffic.pcap port 9999 # Ctrl+C to stop, then inspect: tcpdump -r ~/cryptopals/pcaps/my_traffic.pcap -XX | head -40 # You should see raw ciphertext — no plaintext visible
Capturing opponent traffic (LAN)
# Find the network interface connected to the lab LAN ip a # look for eth0, ens33, wlan0 etc. # Sniff all TCP on port 9999 across the LAN (replace eth0 with yours) sudo tcpdump -i eth0 port 9999 -w ~/cryptopals/pcaps/opponents.pcap # Let it run while opponents communicate, then Ctrl+C # Quick analysis — show hex payload of each packet tcpdump -r ~/cryptopals/pcaps/opponents.pcap -XX # Or open in Wireshark for GUI analysis: wireshark ~/cryptopals/pcaps/opponents.pcap &
Deliberate weaknesses (instructor-assigned)
Your instructor may ask your team to introduce one of these flaws. Attackers who find and exploit it score bonus points. Only make these changes if explicitly instructed.
| Weakness | Change to make | What it enables |
|---|---|---|
| ECB mode | Change AES.MODE_CBC to AES.MODE_ECB and remove the IV | Block repetition detection, structural leaks |
| Reused IV | Replace os.urandom(16) with bytes(16) (all zeros) | XOR of two ciphertexts cancels the IV — known-plaintext attack |
| Hardcoded key | Set KEY = bytes(16) (16 zero bytes) | Attacker who guesses common weak keys can decrypt everything |
| No padding check | Remove the unpad() call in decrypt | Enables padding oracle attack |
Break your opponents
You have a PCAP file from another team. Work through the attack checklist — each step applies a technique from earlier chapters. The attacker script below combines all tools into one file.
Attack checklist
- Open the PCAP and extract raw payloadsUse tshark (CLI) or Wireshark (GUI). Export each direction's raw bytes.
- Check for ECB modeSplit each payload into 16-byte blocks. Any repeats? → ECB confirmed.
- Check IV handlingIs the first 16 bytes of every message always the same? → IV reuse vulnerability.
- Reused IV attackIf IV is constant, XOR two ciphertexts to cancel the IV. Known-plaintext recovers the other plaintext.
- CBC bitflip probeFlip bytes in a captured ciphertext, replay it, observe server response. Different behavior = bitflip vulnerability.
- Padding oracle probeSend modified ciphertexts to the server. Different errors for valid vs invalid padding = padding oracle.
Full attacker script
#!/usr/bin/env python3 """ CTF Attacker Toolkit ───────────────────────────────────────────────────────────────── Usage: 1. Capture opponent traffic: sudo tcpdump -i eth0 port 9999 -w opponents.pcap 2. Extract raw TCP payloads with tshark: tshark -r opponents.pcap -T fields -e data -Y tcp.payload > payloads.txt 3. Run this script: python3 attacker.py payloads.txt ───────────────────────────────────────────────────────────────── import sys, os # ── 1. DETECT ECB MODE ──────────────────────────────────────────── def detect_ecb(data: bytes, bs=16) -> bool: """True if any 16-byte block repeats — ECB signature.""" blocks = [data[i:i+bs] for i in range(0, len(data), bs)] return len(blocks) != len(set(blocks)) # ── 2. CHECK IV REUSE ───────────────────────────────────────────── def check_iv_reuse(payloads: list[bytes]) -> bool: """True if all messages share the same first 16 bytes (IV).""" ivs = [p[:16] for p in payloads if len(p) >= 16] return len(set(ivs)) == 1 # ── 3. REUSED IV ATTACK ─────────────────────────────────────────── def reused_iv_attack( ct1: bytes, ct2: bytes, known_pt1: bytes ) -> bytes: """ If the IV is reused: ct1 = AES_enc(pt1 XOR IV) ct2 = AES_enc(pt2 XOR IV) XOR of blocks: ct1_b0 XOR ct2_b0 = pt1_b0 XOR pt2_b0 Therefore: pt2_b0 = ct1_b0 XOR ct2_b0 XOR pt1_b0 """ def xb(a,b): return bytes(x^y for x,y in zip(a,b)) # Strip IV prefix if present (first 16 bytes) c1, c2 = ct1[16:], ct2[16:] return xb(xb(c1[:16], c2[:16]), known_pt1[:16]) # ── 4. MAIN ANALYSIS LOOP ───────────────────────────────────────── def main(): if len(sys.argv) < 2: print("Usage: python3 attacker.py payloads.txt") print(" (each line = hex-encoded TCP payload from tshark)") sys.exit(1) lines = open(sys.argv[1]).read().strip().splitlines() payloads = [] for line in lines: line = line.strip() if line: try: payloads.append(bytes.fromhex(line)) except ValueError: pass # skip non-hex lines print(f"\n[*] Loaded {len(payloads)} payloads\n") print("═" * 50) # ── ECB detection ── print("\n[1] ECB Mode Detection") ecb_found = False for i, p in enumerate(payloads): if detect_ecb(p): print(f" ✓ Payload {i}: ECB mode detected! (repeated 16-byte block)") ecb_found = True if not ecb_found: print(" – No ECB detected. Likely CBC or CTR.") # ── IV reuse check ── print("\n[2] IV Reuse Check") if check_iv_reuse(payloads): print(f" ✓ ALL {len(payloads)} payloads share the same IV: {payloads[0][:16].hex()}") print(" ✓ IV REUSE CONFIRMED — known-plaintext attack possible!") # Attempt reused-IV attack if we have ≥2 payloads and know pt1 if len(payloads) >= 2: print("\n[3] Reused-IV Attack (requires known plaintext of msg 1)") # Common CTF scenario: first message is a known greeting known_guesses = [ b"Hello, this is a", b"comment1=cooking", b"GET / HTTP/1.1\r\n", b"user=admin&pass=p", ] for guess in known_guesses: recovered = reused_iv_attack(payloads[0], payloads[1], guess) printable = all(32 <= b < 127 for b in recovered) if printable: print(f" Possible pt2 block 0: {recovered.decode(errors='replace')!r}") else: print(" – IVs differ between messages (correct behaviour).") # ── Payload summary ── print("\n[*] Raw payload hex dump (first 5):") for i, p in enumerate(payloads[:5]): print(f" [{i}] len={len(p):4d} {p[:32].hex()}…") print("\n[*] Done. See Chapter 10 of the workbook for next steps.\n") if __name__ == "__main__": main()
Extracting payloads from a PCAP with tshark
# Extract raw TCP data bytes, one hex string per line tshark -r ~/cryptopals/pcaps/opponents.pcap \ -T fields \ -e data \ -Y "tcp.payload and tcp.dstport==9999" \ > ~/cryptopals/pcaps/payloads.txt # Run the attacker python3 ~/cryptopals/ctf/attacker.py ~/cryptopals/pcaps/payloads.txt
Scoring your break
| Achievement | Points | Technique |
|---|---|---|
| Prove ECB mode was used | 1 pt | Repeated-block detection |
| Recover any plaintext message | 3 pts | Reused IV, known plaintext |
| Recover the secret key | 5 pts | Padding oracle, bitflip + key recovery |
| Send a valid impersonation message | 5 pts | Key recovery or bitflipping |
| Clean, documented attack script | 2 pts | Code quality |
Scoring & debrief
The CTF ends with a structured debrief — the most valuable part. Teams present attacks and defenses, explaining what failed and why. Track your CryptoPals progress below and review the discussion questions before presenting.
CryptoPals progress tracker
Click a cell to mark a challenge complete. Progress is saved in your browser.
Debrief discussion questions
- What mode did your opponents use?ECB, CBC, or something else? How did you determine it from the ciphertext alone?
- What implementation mistakes were most common?IV reuse? Hardcoded keys? No authentication? Which were easiest to exploit?
- Why is CBC still dangerous without a MAC?The bitflipping attack showed this. What real-world protocol vulnerabilities mirror this flaw? (BEAST, POODLE, Lucky13)
- What would it take to make the channel truly secure?Key exchange (Diffie-Hellman), authentication (HMAC), integrity (AES-GCM). Why does TLS bundle all three?
- Confidentiality vs integrity — what's the difference?Can you have one without the other? Give a concrete example from this exercise.
What comes next
| Set | Topics | Difficulty |
|---|---|---|
| Set 3 | CTR mode, MT19937 Mersenne Twister, padding oracle | ⬛⬛⬜⬜ |
| Set 4 | Stream cipher attacks, MD4/SHA-1 length extension | ⬛⬛⬛⬜ |
| Set 5 | Diffie-Hellman, SRP, MITM key fixation | ⬛⬛⬛⬜ |
| Set 6 | RSA, DSA, nonce reuse, Bleichenbacher's attack | ⬛⬛⬛⬛ |
cryptohack.org — best continuation of CryptoPals-style work, browser-based. PicoCTF — beginner to intermediate, free. HackTheBox — closer to real-world pentesting.