Practical Crypto Attacks: XOR, ECB, Padding Oracle & CTF Techniques
This is Chapter 7 of the Cryptography series — Notebook 7, and the offensive capstone. The previous six chapters built cryptography up: encoding vs encryption vs hashing, symmetric and asymmetric ciphers, hashing and MACs, password storage, and PKI. This chapter tears it back down — not by breaking AES or RSA (nobody does that; the math is sound) but by exploiting the seams: reused keys, wrong modes, leaky error messages, predictable randomness, missing authentication. Real-world crypto is almost never broken at the algorithm; it’s broken at the usage. A padding-oracle attack recovers your plaintext without ever touching the key. ECB mode leaks the shape of your data. A reused nonce hands over the keystream. These are the bugs that live in production and in every CTF crypto category, and learning to attack them is the fastest way to understand — permanently — why the defensive rules from earlier chapters exist.
Everything here is hands-on and reproducible. Each attack comes with the vulnerable construction, the exact oracle or observation that leaks, working Python you can run, and the one-line fix. Ethics first: run these only against systems you own or are explicitly authorized to test (CTFs, your own lab, disclosed-scope bounty targets). The techniques are dual-use; the intent here is to make you a better defender by making you a competent attacker.
Part 1: The Attacker’s Mindset — Break the Usage, Not the Cipher
Section titled “Part 1: The Attacker’s Mindset — Break the Usage, Not the Cipher”The single most important idea in this chapter: you will almost never attack the primitive. AES-256 and RSA-2048 are, for practical purposes, unbreakable head-on. What breaks is everything around them:
flowchart TD
A[Target: encrypted system] --> B{Where's the weakness?}
B --> C[Reused key/keystream<br/>many-time pad, nonce reuse]
B --> D[Wrong mode<br/>ECB structure leak]
B --> E[No authentication<br/>bit-flipping, padding oracle]
B --> F[Leaky errors/timing<br/>oracle attacks]
B --> G[Bad randomness<br/>ECDSA k-reuse, weak RNG]
B --> H[Textbook/naive asymmetric<br/>small e, common modulus]
C --> Z[Plaintext / key recovery<br/>WITHOUT breaking the cipher]
D --> Z
E --> Z
F --> Z
G --> Z
H --> Z
The attacker’s checklist when handed a ciphertext or a crypto endpoint:
- Is anything reused? Same key for two messages (many-time pad), same IV/nonce (keystream reuse), same ECDSA nonce (key recovery). Reuse is the number-one crypto bug.
- What mode is it? ECB leaks structure. Unauthenticated CBC/CTR is malleable (bit-flipping, padding oracle).
- Is it authenticated? No MAC/AEAD tag means you can tamper. This is where padding oracles and bit-flipping live.
- Does it leak? Different error messages, response times, or behaviors for “bad padding” vs “bad MAC” vs “wrong plaintext” turn the server into an oracle that answers yes/no questions about your guesses.
- Is the randomness real? Predictable IVs, repeated nonces, low-entropy keys, reused ECDSA
k.
Keep this list; every attack below is one of these five turned into a concrete exploit.
The concept of an oracle deserves its own emphasis because it powers so many of these attacks. An oracle is any function the attacker can query that leaks a little information about a secret — even a single bit like “is this padding valid?” or “did this take longer to respond?”. Individually useless; in aggregate devastating. The padding oracle (Part 5), timing oracles (Lucky13), and the RSA/ECDSA leaks all share this shape: turn a tiny, repeatable yes/no leak into full plaintext or key recovery by asking the right sequence of questions. When you audit a crypto endpoint, your sharpest instinct should be “does this thing answer differently — in content, status, or time — depending on my ciphertext?” If yes, you likely have an oracle.
Part 2: XOR — the Foundation of Both Crypto and Its Attacks
Section titled “Part 2: XOR — the Foundation of Both Crypto and Its Attacks”XOR (exclusive-or, the ^ operator) is the atom of symmetric crypto. Recall its algebra, because every
stream cipher and every keystream-reuse attack rides on it:
A ^ A = 0(self-inverse)A ^ 0 = A(identity)A ^ B ^ B = A(XOR twice with the same value cancels — this is how stream ciphers decrypt)- commutative and associative
The one-time pad (OTP) is provably unbreakable: ciphertext = plaintext ^ key, where the key is truly
random, as long as the message, and used exactly once. Because every plaintext maps to some key, the
ciphertext reveals nothing. The catch is in the three conditions — and the attacks come from violating the
“used exactly once” one.
2.1 Single-byte XOR (the CTF warm-up)
Section titled “2.1 Single-byte XOR (the CTF warm-up)”A message XOR’d against a single repeated byte (key is one byte, 0–255) is trivially broken by brute
force: try all 256 keys, and score each candidate plaintext by how English-like it looks (frequency
analysis — spaces and etaoin are common).
import stringct = bytes.fromhex("1b37373331363f78151b7f2b783431333d78397828372d363c78373e783a393b3736")def score(bs): return sum(chr(b) in string.ascii_letters + " " for b in bs)best = max(range(256), key=lambda k: score(bytes(b ^ k for b in ct)))print(best, bytes(b ^ best for b in ct))# 88 b"Cooking MC's like a pound of bacon" <- key byte 0x58, recovered by frequency scoringThis is the classic “Cryptopals Set 1 Challenge 3.” The whole technique: XOR is reversible, the keyspace is tiny (256), so brute-force + a plaintext-plausibility score wins.
2.2 Repeating-key XOR (Vigenère on bytes)
Section titled “2.2 Repeating-key XOR (Vigenère on bytes)”A short key repeated across a long message (key = "SECRET", cycled) is the classic “encryption” amateurs
roll themselves. Break it in two stages:
- Find the key length using the Hamming distance (bit differences): the correct key length minimizes the normalized Hamming distance between adjacent key-length-sized blocks (because same-key- position bytes correlate).
- Solve each key-byte independently as a single-byte XOR (Part 2.1): take every Nth byte (all encrypted under the same key byte) and frequency-attack it.
def hamming(a, b): return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
# 1. guess keysize by lowest normalized hamming distance across blocksdef keysize(ct, lo=2, hi=40): def norm(k): blocks = [ct[i*k:(i+1)*k] for i in range(4)] d = sum(hamming(blocks[i], blocks[j]) for i in range(4) for j in range(i+1,4)) return d / k return min(range(lo, hi), key=norm)
# 2. transpose into columns, single-byte-XOR each column, reassemble keyThis is Cryptopals Challenge 6 and the exact reasoning that breaks classical Vigenère. The lesson: a repeating key is a repeating keystream, and repetition is the vulnerability — which leads directly to the many-time pad.
Why frequency scoring works — the intuition. English text isn’t random: the space character alone is
~18% of typical prose, and e t a o i n s h r dominate the letters. So when you XOR a ciphertext column
against the correct key byte, the result is real text and lands heavily on those characters; against a
wrong key byte, you get near-uniform garbage full of control characters and high bytes. A scoring
function that rewards spaces and common letters (and penalizes non-printable bytes) reliably separates
the one right key byte from the 255 wrong ones. This same “does it look like the expected plaintext
distribution” test is the engine behind single-byte XOR, repeating-key XOR, and many-time-pad recovery —
learn it once, reuse it everywhere.
# A more robust scorer: reward English letter frequency, punish non-printablesFREQ = {' ':13,'e':12,'t':9,'a':8,'o':8,'i':7,'n':7,'s':6,'h':6,'r':6}def score(bs): s = 0 for b in bs: c = chr(b).lower() if b < 9 or (13 < b < 32) or b > 126: # control / non-ascii => almost certainly wrong key s -= 5 s += FREQ.get(c, 0) return sPart 3: The Many-Time Pad — Key/Keystream Reuse
Section titled “Part 3: The Many-Time Pad — Key/Keystream Reuse”Here is the single most important XOR attack, because it recurs everywhere from homemade “OTP” to nonce reuse in AES-CTR/GCM (Part 6). If you use the same keystream for two messages:
C1 = P1 ^ KC2 = P2 ^ KC1 ^ C2 = P1 ^ P2 <- the key CANCELS OUTThe key vanishes, leaving the XOR of the two plaintexts. With enough messages sharing a keystream, you
recover them all via crib dragging: guess a common word (" the ") at each position, XOR it against
C1 ^ C2, and if the result is readable text, you’ve found a fragment of the other plaintext — then extend.
# Given many ciphertexts all XOR'd with the SAME keystream, recover the key byte by byte# assuming plaintext is mostly ASCII: at each position, the true keystream byte is the# one that makes the MOST ciphertexts decrypt to a space/letter.def recover_keystream(cts): ksize = min(len(c) for c in cts) key = bytearray(ksize) for i in range(ksize): best_k, best_score = 0, -1 for k in range(256): score = sum(chr(c[i] ^ k) in " etaoinshrdluETAOIN" for c in cts) if score > best_score: best_k, best_score = k, score key[i] = best_k return bytes(key)flowchart LR
P1[P1] --> X1((XOR))
K[Same keystream K] --> X1 --> C1[C1]
P2[P2] --> X2((XOR))
K --> X2 --> C2[C2]
C1 --> XC((XOR))
C2 --> XC --> R["C1^C2 = P1^P2<br/>key is GONE"]
R --> CD[Crib-drag to recover both]
Where this bites in the real world: WEP Wi-Fi (IV reuse gave repeated keystreams — WEP fell to exactly this), any AES-CTR or GCM implementation that repeats a nonce (Part 6), and every “we built our own OTP but reuse the key” system. Fix: never reuse a keystream — one-time really means one time; use a fresh random IV/nonce per message and never let it repeat.
A note on RC4 — a stream cipher broken by its own biases. RC4 was the dominant stream cipher for years (WEP, early TLS, WPA-TKIP) and is now banned from TLS. Its downfall wasn’t reuse but statistical bias: the second output byte of RC4’s keystream is biased toward zero, and many other positions have detectable biases. Given enough ciphertexts of the same plaintext under different keys (e.g. a session cookie sent in millions of TLS connections), these biases let an attacker recover the plaintext byte by byte — the RC4 NOMORE attack. The lesson generalizes: a keystream must be indistinguishable from random; any statistical structure is an attack surface. Prefer ChaCha20 (a modern, bias-free stream cipher) or AES-GCM. Never RC4.
Part 4: ECB Mode — Structure Leakage and Byte-at-a-Time Decryption
Section titled “Part 4: ECB Mode — Structure Leakage and Byte-at-a-Time Decryption”ECB (Electronic Codebook) encrypts each 16-byte block independently: identical plaintext block → identical ciphertext block. That determinism is fatal for two reasons.
4.1 The ECB Penguin — structure leaks
Section titled “4.1 The ECB Penguin — structure leaks”Encrypt a bitmap of the Linux penguin (Tux) in ECB and you can still see the penguin in the ciphertext, because large areas of identical pixels produce identical ciphertext blocks. ECB hides values, not patterns. Any structured data (a bitmap, a database with repeated fields, a cookie with repeated substrings) leaks its shape.
Detecting ECB is easy: look for repeated 16-byte blocks in the ciphertext. If any two 16-byte blocks are identical, it’s almost certainly ECB (a good mode makes every block look random).
def looks_like_ecb(ct, bs=16): blocks = [ct[i:i+bs] for i in range(0, len(ct), bs)] return len(blocks) != len(set(blocks)) # any duplicate block => ECB
# Cryptopals Challenge 8: find the ECB-encrypted line among many hex ciphertextsdef find_ecb_line(hex_lines): return max(hex_lines, key=lambda h: len(bytes.fromhex(h)) - len(set( bytes.fromhex(h)[i:i+16] for i in range(0, len(bytes.fromhex(h)), 16))))# The line with the most repeated 16-byte blocks is the ECB one.You can see the effect yourself: encrypt a solid-color region and identical input blocks map to identical output blocks, so structure survives:
from Crypto.Cipher import AESkey = b"YELLOW SUBMARINE"plaintext = b"AAAAAAAAAAAAAAAA" * 8 # 8 identical blocks (e.g. a flat image region)ecb = AES.new(key, AES.MODE_ECB).encrypt(plaintext)blocks = [ecb[i:i+16] for i in range(0, len(ecb), 16)]print(len(blocks), len(set(blocks))) # 8 1 -> all 8 ciphertext blocks IDENTICAL# Do the same with MODE_CBC and you get 8 different blocks: structure gone.That “8 blocks in, 1 unique block out” is the ECB penguin in miniature — the mode preserves the pattern of your data even though it hides the values.
4.2 Byte-at-a-time ECB decryption (the beautiful one)
Section titled “4.2 Byte-at-a-time ECB decryption (the beautiful one)”This is the attack that makes ECB genuinely dangerous, not just ugly. Suppose a server encrypts
ECB(your_input ‖ SECRET) and returns the ciphertext — a common shape (e.g. it appends a secret suffix to
attacker-controlled data). You can recover SECRET one byte at a time, without the key, purely by
controlling input length and comparing blocks:
- Send 15
As. The first block isAAAAAAAAAAAAAAA?where?is the first unknown secret byte. Record that ciphertext block. - Now brute-force: send
AAAAAAAAAAAAAAAXfor every possibleX(256 tries) and find which produces the same ciphertext block. ThatXis the first secret byte. - Shift the window: send 14
As so the block isAAAAAAAAAAAAAA?₁?₂, and you already know?₁, so brute-force?₂. Repeat.
# Attacker controls the prefix; oracle returns ECB(prefix || SECRET). Recover SECRET.def crack_ecb(oracle, bs=16): known = b"" for _ in range(64): # up to 64 secret bytes pad = b"A" * (bs - 1 - (len(known) % bs)) block_index = (len(pad) + len(known)) // bs target = oracle(pad)[block_index*bs:(block_index+1)*bs] for b in range(256): guess = pad + known + bytes([b]) if oracle(guess)[block_index*bs:(block_index+1)*bs] == target: known += bytes([b]); break else: break # hit padding; done return knownYou just decrypted a secret with 256 guesses per byte and zero knowledge of the key. This is Cryptopals
Challenge 12, and it’s a real pattern in systems that ECB-encrypt user_data ‖ secret_flag. Fix: never
use ECB. Use an authenticated mode (AES-GCM). If you see repeated blocks in any ciphertext, that’s a
finding.
flowchart TD
A[Oracle: ECB of attacker_prefix + SECRET] --> B[Send 15 A's -> capture target block ...A A A ?]
B --> C[Brute-force last byte: 256 guesses]
C --> D[Match -> 1st secret byte known]
D --> E[Shift window by 1, repeat]
E --> F[Full SECRET recovered, no key]
Part 5: CBC — Bit-Flipping and the Padding Oracle
Section titled “Part 5: CBC — Bit-Flipping and the Padding Oracle”CBC (Cipher Block Chaining) XORs each plaintext block with the previous ciphertext block before
encrypting: C_i = E(P_i ^ C_{i-1}), with C_0 being the IV. Decryption is P_i = D(C_i) ^ C_{i-1}. That
^ C_{i-1} is a gift to attackers when the ciphertext isn’t authenticated.
5.1 CBC bit-flipping
Section titled “5.1 CBC bit-flipping”Because P_i = D(C_i) ^ C_{i-1}, flipping a bit in ciphertext block C_{i-1} flips the same bit in
decrypted plaintext block P_i (while corrupting P_{i-1} into garbage). If the app doesn’t authenticate
the ciphertext, you can surgically edit plaintext you can’t even read. Classic exploit: a cookie
admin=false encrypted in CBC — flip the right bits in the previous block to turn it into admin=true,
sacrificing one earlier block you don’t care about.
# Turn ";admin=true;" into a token by XORing the delta into the previous ciphertext block.# Suppose block N decrypts to "AAAAAAAAAAAAAAAA" and we want ";admin=true;AAAA".target = b";admin=true;AAAA"current = b"AAAAAAAAAAAAAAAA"delta = bytes(t ^ c for t, c in zip(target, current))# XOR delta into the PREVIOUS ciphertext block; block N-1 becomes garbage, block N flips to targetforged_prev = bytes(p ^ d for p, d in zip(ciphertext_block_prev, delta))Full worked bit-flip lab — the classic “become admin” exploit (Cryptopals Challenge 16):
from Crypto.Cipher import AESfrom Crypto.Util.Padding import padimport osKEY, IV = os.urandom(16), os.urandom(16)
def make_token(user_data: bytes): # server wraps your input; quotes out ; and = so you can't inject directly data = b"comment1=cooking%20MCs;userdata=" + user_data.replace(b";", b"%3B").replace(b"=", b"%3D") \ + b";comment2=%20like%20a%20pound%20of%20bacon" return AES.new(KEY, AES.MODE_CBC, IV).encrypt(pad(data, 16))
def is_admin(ct: bytes) -> bool: pt = AES.new(KEY, AES.MODE_CBC, IV).decrypt(ct) return b";admin=true;" in pt # the check we want to satisfy
# We can't type ';admin=true;' (it's escaped). So we submit a block of A's, then# bit-flip the PREVIOUS ciphertext block to transform a controlled block into our target.prefix_blocks = 2 # "comment1=cooking%20MCs;userdata=" = 32 bytes = 2 blocksct = bytearray(make_token(b"A"*16 + b"A"*16))target = b";admin=true;AAAA"have = b"A"*16# XOR the delta into the ciphertext block that precedes our target plaintext blockbase = prefix_blocks*16 # start of the sacrificial blockfor i in range(16): ct[base+i] ^= have[i] ^ target[i] # flip bits: garbles block `base`, fixes block base+16print(is_admin(bytes(ct))) # True -> privilege escalation with no keyThe sacrificial block (base) decrypts to garbage, but nobody parses it; the next block now reads
;admin=true; and the check passes. Fix: authenticate. An AEAD mode (GCM) or encrypt-then-MAC makes
any tampering detected before decryption — the tag check fails and the ciphertext is rejected. Bit-flipping
only works on unauthenticated CBC.
5.2 The Padding Oracle attack — decrypt with no key
Section titled “5.2 The Padding Oracle attack — decrypt with no key”This is the crown jewel of practical crypto attacks and a rite of passage. CBC needs the plaintext to be a
multiple of the block size, so it’s padded — usually PKCS#7, where the value of each pad byte equals the
number of pad bytes (04 04 04 04 for 4 bytes of padding). On decryption, the receiver checks the padding
is valid. If the server behaves differently when padding is valid vs invalid — a different error
message, a different status code, or even just a different response time — it becomes a padding oracle:
a function that tells you, for any ciphertext, whether its decrypted padding is valid.
That single bit of information is enough to decrypt the entire ciphertext, one byte at a time, without the key. Here’s the mechanism for the last byte of a block:
- Recall
P_i = D(C_i) ^ C_{i-1}. You controlC_{i-1}(just send modified bytes). - Take the target block
C_iand a controlled preceding blockC'. Vary the last byte ofC'through all 256 values. For exactly one value, the decrypted last byte ofP_ibecomes0x01— valid padding — and the oracle says “valid.” - Now you know
D(C_i)[15] ^ C'[15] = 0x01, soD(C_i)[15] = C'[15] ^ 0x01. And the real plaintext byte isP_i[15] = D(C_i)[15] ^ C_{i-1}[15](the original preceding block). You’ve recovered one plaintext byte. - To get the next byte, set the last byte so the tail decrypts to
0x02, brute-force the second-to-last to also make0x02, and so on up the block.
def padding_oracle_block(oracle, prev, target, bs=16): """Recover one plaintext block using a valid/invalid-padding oracle.""" dec = bytearray(bs) # intermediate D(target) recovered = bytearray(bs) for pad in range(1, bs + 1): forged = bytearray(bs) for i in range(bs - pad + 1, bs): forged[i] = dec[i] ^ pad # set already-known tail to current pad value for guess in range(256): forged[bs - pad] = guess if oracle(bytes(forged) + target): # oracle: True if padding valid # confirm it's really pad (guard against 0x02 0x01 false positive) omitted for brevity dec[bs - pad] = guess ^ pad recovered[bs - pad] = dec[bs - pad] ^ prev[bs - pad] break return bytes(recovered)sequenceDiagram
participant A as Attacker
participant O as Server (padding oracle)
loop For each of 256 guesses of the last byte
A->>O: modified C' + target block
O->>A: "padding valid" or "padding error"
end
Note over A: The one "valid" reveals D(target)[15]
A->>A: plaintext byte = D(target)[15] XOR original prev[15]
Note over A,O: Repeat up the block, then block by block = FULL plaintext, no key
A concrete numeric walkthrough of the last byte. Say the target block’s intermediate decryption
D(C_i) ends in ...0x3C and the real previous block ends in ...0x6A, so the true plaintext byte is
0x3C ^ 0x6A = 0x56 = 'V'. The attacker doesn’t know any of that yet. They send a forged previous block
and sweep its last byte g from 0x00 to 0xFF. The oracle returns “valid” only when the decrypted last
byte equals 0x01, i.e. when 0x3C ^ g = 0x01, so g = 0x3D. The oracle fires at g = 0x3D; the attacker
computes D(C_i)[15] = g ^ 0x01 = 0x3C, then the real plaintext byte = D(C_i)[15] ^ real_prev[15] = 0x3C ^ 0x6A = 0x56 = 'V'. One byte, ~128 queries on average (256 worst case), zero key knowledge. Repeat
up the block forcing 0x02 0x02, then 0x03 0x03 0x03, and so on.
Guarding against the false positive: for the last byte there’s a rare ambiguity — a “valid” hit might
be because the block decrypted to ...0x02 0x02 rather than ...0x01. The standard fix is, after a hit,
to also perturb the second-to-last byte and confirm the oracle still says valid (which it will only if
the last byte is genuinely 0x01). Production padding-oracle tools like padbuster handle this
automatically.
Real impact: this is CVE-2010-3332 (ASP.NET “Padding Oracle”), which let attackers decrypt view-state
and forge requests against millions of sites, and the same class as POODLE and Lucky13 (a timing
padding oracle, where the leak is response time not an error message). padbuster (sudo apt install padbuster) automates the whole attack against an HTTP endpoint: you give it the encrypted token, the URL,
and the block size, and it recovers plaintext (and can even encrypt chosen plaintext by running the oracle
in reverse). Fixes: (1) use authenticated encryption (AES-GCM) so the tag is checked first and a
tampered ciphertext never reaches the padding check; (2) if you must use CBC, encrypt-then-MAC and
verify the MAC in constant time before decrypting; (3) return an identical error for all failures and
make timing constant. The root cause is always unauthenticated CBC plus a distinguishable failure.
Part 6: Stream Cipher & AEAD Nonce Reuse
Section titled “Part 6: Stream Cipher & AEAD Nonce Reuse”Stream ciphers and counter modes (AES-CTR) produce a keystream from (key, nonce) and XOR it with
plaintext. This is just an OTP with a generated pad — so everything from Part 3 applies: if the nonce
(and key) repeats, the keystream repeats, and C1 ^ C2 = P1 ^ P2. Reused nonce = many-time pad = broken.
AES-GCM makes nonce reuse catastrophic in a second way. GCM is CTR mode plus a Galois-field
authentication tag (GHASH). If you reuse a (key, nonce) pair in GCM, you not only leak P1 ^ P2 (like
any CTR reuse) but also leak enough to recover GHASH’s authentication key H, which lets an attacker
forge valid tags for arbitrary messages — a total break of GCM’s integrity, not just confidentiality.
This is why GCM nonces must never repeat under the same key.
| Mode | Nonce reuse impact |
|---|---|
| AES-CTR | keystream reuse → P1^P2 leaked (confidentiality gone) |
| AES-GCM | above plus auth-key H recovery → forgery (integrity gone too) |
| ChaCha20-Poly1305 | same as GCM: reuse leaks plaintext XOR and enables forgery |
Fixes: use a random 96-bit nonce and track/never repeat it (a counter is fine if it truly never resets); or use a nonce-misuse-resistant mode like AES-GCM-SIV, which degrades gracefully (a reused nonce only reveals whether two plaintexts were equal, not the whole keystream). CTF tell: two GCM ciphertexts with the same nonce field → go for keystream-reuse plaintext recovery and, if needed, forgery.
Part 7: Hash Length Extension — Revisited as an Attack
Section titled “Part 7: Hash Length Extension — Revisited as an Attack”Chapter 4 introduced length extension defensively; here’s the offense. Merkle–Damgård hashes (MD5, SHA-1,
SHA-256) have a structural flaw: given H(secret ‖ message) and the length of secret, an attacker can
compute H(secret ‖ message ‖ padding ‖ anything) — a valid hash for an extended message — without
knowing the secret. This breaks naive hash(secret ‖ data) MACs.
The classic exploit: a URL signed as token = SHA256(secret ‖ "user=guest"). The attacker uses
hashpumpy/hash_extender to produce a valid token for "user=guest" ‖ padding ‖ "&admin=true" without
the secret:
import hashpumpy# known: original hash, known message, appended data, guessed secret lengthnew_hash, new_msg = hashpumpy.hashpump( "0e3f...original_hex", b"user=guest", b"&admin=true", 16) # 16 = secret length guess# new_hash is a VALID SHA256(secret || new_msg) — forged with no secretYou brute-force the secret length (try 8, 16, 24, 32…) since that’s the only unknown. Fix: don’t build MACs by concatenation — use HMAC (its nested construction defeats length extension) or a non-Merkle–Damgård hash (SHA-3/Keccak is a sponge and immune). This is a staple CTF challenge and a real API-signing bug.
Part 8: RSA Attacks — Breaking the Usage, Not the Factoring
Section titled “Part 8: RSA Attacks — Breaking the Usage, Not the Factoring”You won’t factor a 2048-bit modulus. But naive RSA usage falls constantly. The greatest hits:
- Textbook (unpadded) RSA is deterministic and malleable.
c = m^e mod nwith no padding leaks equality (same plaintext → same ciphertext, an ECB-like leak) and is homomorphic (c1·c2 = (m1·m2)^e), enabling blinding attacks. Fix: OAEP padding for encryption, PSS for signatures. - Small
e(e.g. e=3) with smallm: ifm^3 < n, thenc = m^3with no modular wraparound, som = cube_root(c)— just take the integer cube root, no factoring. Common whene=3encrypts a short message with no padding.
from sympy import integer_nthrootc = pow(m, 3) # m small, no padding, e=3, m^3 < nrecovered, exact = integer_nthroot(c, 3)assert exact and recovered == m # plaintext recovered by cube root alone- Håstad’s broadcast attack: the same message sent to
erecipients withe=3and different moduli → CRT + cube root recoversm. - Common modulus attack: the same
nused with two coprime exponentse1, e2on the same message → Bézout coefficients recovermwithout factoring. Worked:
from math import gcddef common_modulus(c1, c2, e1, e2, n): # extended Euclid: find a,b with a*e1 + b*e2 = gcd(e1,e2) = 1 def egcd(a, b): if b == 0: return (a, 1, 0) g, x, y = egcd(b, a % b); return (g, y, x - (a // b) * y) g, a, b = egcd(e1, e2) # handle negative exponents via modular inverse of the ciphertext if a < 0: c1 = pow(c1, -1, n); a = -a if b < 0: c2 = pow(c2, -1, n); b = -b return (pow(c1, a, n) * pow(c2, b, n)) % n # == m, no factoringThe message is recovered purely from two ciphertexts and public exponents. This is why a modulus must never be shared across users — a shockingly common key-management mistake.
- Fermat factorization: if
pandqare too close together,nfactors almost instantly (a real bug in badly-seeded key generators). - Wiener’s attack: a too-small private exponent
dis recovered via continued fractions.
flowchart TD
A[RSA ciphertext/params] --> B{What's naive?}
B --> C[No padding -> malleable/deterministic]
B --> D[e=3 + small m -> cube root]
B --> E[same m to many recipients -> Hastad/CRT]
B --> F[same n, two e -> common modulus]
B --> G[p,q close -> Fermat factor]
B --> H[tiny d -> Wiener]
C --> Z[Plaintext or key recovered]
D --> Z
E --> Z
F --> Z
G --> Z
H --> Z
Fermat factorization when primes are close — a real generator bug:
from sympy import isqrtdef fermat_factor(n): a = isqrt(n) + 1 while True: b2 = a*a - n b = isqrt(b2) if b*b == b2: # a^2 - n is a perfect square => n = (a-b)(a+b) return a - b, a + b # the two primes a += 1# If p and q were generated close together (bad RNG), this returns in microseconds# even for a 2048-bit n — no exponential search needed.The through-line: textbook RSA and bad parameters, not the factoring problem. Even without a math
bug, tools like RsaCtfTool (python3 RsaCtfTool.py --publickey key.pem --uncipher c) automatically try
the whole zoo — Fermat, Wiener, small-e, common-modulus, known-factor databases (factordb), Håstad — so
in a CTF you point it at the public key and it finds whichever weakness applies. Fix: use a vetted
library with OAEP/PSS, a standard exponent (65537), and properly random primes from a good CSPRNG —
never hand-roll RSA, never share a modulus, never use e=3 without padding.
Part 9: ECDSA/DSA Nonce Reuse — Private Key Recovery
Section titled “Part 9: ECDSA/DSA Nonce Reuse — Private Key Recovery”Chapter 6 flagged this; here’s the exploit. ECDSA signs with a per-signature nonce k. If k repeats
across two signatures (r, s1) and (r, s2) (note the same r — the giveaway that k was reused),
the private key falls out algebraically:
s1 = k⁻¹(z1 + r·d) s2 = k⁻¹(z2 + r·d)=> k = (z1 - z2) / (s1 - s2) (recover the nonce)=> d = (s1·k - z1) / r (recover the PRIVATE KEY)def recover_ecdsa_key(r, s1, s2, z1, z2, n): # same r in both signatures signals reused k k = ((z1 - z2) * pow(s1 - s2, -1, n)) % n d = ((s1 * k - z1) * pow(r, -1, n)) % n return d # the signer's private keyThis recovered Sony’s PS3 signing key and is one of the most common “crypto” CTF challenges. Spotting it is
trivial — two signatures with identical r. Even biased (not fully random) nonces are exploitable
via lattice attacks (e.g. Minerva, and the LadderLeak partial-nonce attacks). Fix: RFC 6979
deterministic nonces, or use Ed25519, which derives k deterministically from the message and key so
it can never repeat.
Why partial leakage is enough (the scary part). You don’t even need k to fully repeat. If just a
few bits of each k are known or biased across many signatures — say the top byte is always zero because
of a bad modular reduction, or a side channel leaks the low bits — the private key can be recovered by
framing it as a Hidden Number Problem and solving a lattice with LLL. Real breaks: the ROCA
vulnerability (structured primes in Infineon chips), Minerva (timing leak of nonce bit-length in
smartcards/HSMs), and TPM-Fail. This is why “our RNG is mostly random” is not good enough for ECDSA,
and why deterministic (RFC 6979) or Ed25519 nonces — which remove the RNG from the signing path entirely —
are the robust answer. CTF pattern: a set of signatures where each k shares known high bits →
build the lattice, run LLL (SageMath), recover d.
flowchart LR
A[Many ECDSA sigs] --> B{Nonce quality?}
B -->|k repeats: same r| C[Algebraic: 2 sigs -> d]
B -->|k partially known/biased| D[Hidden Number Problem]
D --> E[Lattice + LLL -> d]
B -->|RFC6979 / Ed25519| F[No RNG in path -> safe]
style F fill:#c8e6c9
Part 10: Hands-On Lab — A Padding Oracle End to End
Section titled “Part 10: Hands-On Lab — A Padding Oracle End to End”Let’s make Part 5.2 concrete with a runnable lab you can build on your own machine.
Tools from scratch:
- Python
pycryptodome(pip install pycryptodome --break-system-packages) — the practical crypto library for building the vulnerable server and the attack. hashpumpy(pip install hashpumpy) — length-extension.sympy— the number theory for RSA attacks.sagemath— heavy lifting for lattice/CRT attacks in harder CTFs.
Step 1 — build the vulnerable oracle (a server that decrypts and leaks padding validity):
from Crypto.Cipher import AESfrom Crypto.Util.Padding import pad, unpadimport os
KEY = os.urandom(16) # attacker never sees this
def encrypt(msg: bytes): iv = os.urandom(16) return iv + AES.new(KEY, AES.MODE_CBC, iv).encrypt(pad(msg, 16))
def padding_oracle(ct: bytes) -> bool: """The vulnerability: leaks whether padding is valid.""" iv, body = ct[:16], ct[16:] try: unpad(AES.new(KEY, AES.MODE_CBC, iv).decrypt(body), 16) return True # valid padding except ValueError: return False # padding error <- the leakStep 2 — attack it (recover plaintext with only padding_oracle, never KEY):
def attack(ct, bs=16): blocks = [ct[i:i+bs] for i in range(0, len(ct), bs)] plaintext = b"" for n in range(1, len(blocks)): prev, target = blocks[n-1], blocks[n] inter = bytearray(bs) recovered = bytearray(bs) for pad_val in range(1, bs+1): forged = bytearray(os.urandom(bs)) for i in range(bs-pad_val+1, bs): forged[i] = inter[i] ^ pad_val for g in range(256): forged[bs-pad_val] = g if padding_oracle(bytes(forged) + target): inter[bs-pad_val] = g ^ pad_val recovered[bs-pad_val] = inter[bs-pad_val] ^ prev[bs-pad_val] break plaintext += bytes(recovered) return plaintext
secret = encrypt(b"admin=false;flag=CTF{padding_oracles_leak_everything}")print(attack(secret)) # recovers the plaintext, byte by byte, with NO keyRun this and watch full plaintext appear from nothing but yes/no padding answers:
$ python3 padding_oracle.pyb'admin=false;flag=CTF{padding_oracles_leak_everything}\x0b\x0b...'# recovered ~200 bytes using only True/False padding responses, KEY never touchedThat visceral result — decryption without the key — is the entire point of the chapter.
Step 3 — the same oracle also ENCRYPTS. A padding oracle isn’t only a decryption tool; you can run it
backwards to forge a ciphertext that decrypts to chosen plaintext (because you can compute the required
previous block from the recovered intermediate D(C_i) values). This is how the ASP.NET attack forged
authenticated view-state — decrypt to learn the intermediate values, then encrypt your own admin=true.
padbuster does both with -plaintext.
Step 4 — now fix it: swap MODE_CBC + unpad for GCM with tag verification:
from Crypto.Cipher import AESdef encrypt_gcm(msg): nonce = os.urandom(12) c = AES.new(KEY, AES.MODE_GCM, nonce=nonce) ct, tag = c.encrypt_and_digest(msg) return nonce + tag + ctdef decrypt_gcm(blob): nonce, tag, ct = blob[:12], blob[12:28], blob[28:] c = AES.new(KEY, AES.MODE_GCM, nonce=nonce) return c.decrypt_and_verify(ct, tag) # raises on ANY tampering, before useThe attack dies because a tampered ciphertext fails decrypt_and_verify’s tag check before any padding
or plaintext logic runs — there’s no padding step to leak, and every failure is the same uniform
authentication error. This is why “AEAD by default” (Part 12) eliminates the entire attack class in one
decision.
Part 11: The CTF Crypto Playbook
Section titled “Part 11: The CTF Crypto Playbook”Pulling the attacks into a decision procedure for when a challenge hands you ciphertext or a crypto endpoint:
flowchart TD
A[Given: ciphertext / oracle] --> B{Symmetric or asymmetric?}
B -->|Symmetric| C{Repeated 16-byte blocks?}
C -->|yes| D[ECB -> penguin / byte-at-a-time]
C -->|no| E{Server errors differ on tamper?}
E -->|yes| F[Padding/timing oracle -> decrypt]
E -->|no| G{Same IV/nonce reused?}
G -->|yes| H[Many-time pad -> crib drag / keystream]
G -->|no| I{Unauthenticated CBC?}
I -->|yes| J[Bit-flip to forge plaintext]
B -->|Asymmetric RSA| K{e small? same n? same m broadcast?}
K --> L[cube root / common modulus / Hastad]
B -->|Asymmetric ECDSA| M{Two sigs same r?}
M -->|yes| N[Nonce reuse -> recover private key]
Fast tells:
- Repeated ciphertext blocks → ECB. Run the penguin/byte-at-a-time.
- Two ciphertexts, same nonce/IV → many-time pad / keystream reuse.
- Server returns different errors or timings on modified ciphertext → padding/timing oracle.
token = md5/sha(secret ‖ data)with attacker-appendable data → length extension (hashpumpy).- Two ECDSA signatures sharing
r→ nonce reuse, recoverd. - RSA with
e=3, tiny message, or shared modulus → cube root / common-modulus / Håstad. admin=falsein unauthenticated CBC cookie → bit-flipping.
Where to grind these: CryptoHack (the best structured crypto-attack trainer, mirrors this chapter section for section), Cryptopals (Sets 1–4 are exactly XOR → ECB → CBC/padding-oracle → nonce reuse), picoCTF Crypto, and pwn.college’s crypto module.
Part 12: Detection & Defense Angle
Section titled “Part 12: Detection & Defense Angle”Every attack maps to a defensive control and a detectable signal — the consolidated blue-team view:
- Kill the whole padding-oracle/bit-flipping class with AEAD. Default to AES-GCM or ChaCha20-Poly1305 everywhere; the authentication tag is checked before decryption, so tampered ciphertext never reaches padding or plaintext logic. If you must use CBC, encrypt-then-MAC with constant-time verification. This single choice eliminates Part 5 entirely.
- Never reuse keystreams/nonces. Enforce random or strictly-counting 96-bit nonces for GCM; alert in code review and with static analysis on any fixed IV/nonce. Consider GCM-SIV where nonce management is hard. Kills Parts 3 and 6.
- Ban ECB. Lint/scan for
MODE_ECB,AES/ECB, and — at runtime — for repeated 16-byte blocks in ciphertext, which is a direct ECB fingerprint (a cheap detection you can run over stored blobs). Kills Part 4. - Uniform errors and constant time. Return one generic error for all decryption/auth failures and make the failure path constant-time (defeats padding and timing oracles like Lucky13). Also defeats the username-enumeration cousin from Chapter 5.
- Use HMAC, not
hash(secret‖data). Kills length extension (Part 7). Prefer SHA-256+ and reject MD5/SHA-1 signatures. - Use vetted asymmetric libraries with OAEP/PSS, e=65537, RFC 6979 or Ed25519. No textbook RSA, no
hand-rolled DSA. Kills Parts 8 and 9. Scan for tiny
e, static nonces, and self-implemented modular math. - Randomness hygiene. CSPRNGs only (
os.urandom,secrets,/dev/urandom); monitor for the Debian- style entropy disasters; enforce key/nonce uniqueness. - Detection signals for the SOC: spikes of decryption/padding errors from one client (a padding-oracle
attack is loud — thousands of malformed ciphertexts probing one endpoint), unusual signature volumes,
and repeated-
rECDSA signatures in logs. Rate-limit and alert on high-volume malformed-crypto requests.
IR use case: if you see a flood of “decryption failed / bad padding” errors from a single source against a CBC endpoint, treat it as an in-progress padding-oracle attack — the fix (migrate to AEAD) and the containment (block the source, rotate keys/sessions the attacker may have decrypted) both follow directly from understanding the attack.
Part 13: Final Revision / Summary
Section titled “Part 13: Final Revision / Summary”- You attack the usage, not the cipher. AES/RSA hold; reuse, wrong mode, missing auth, leaky errors, and bad randomness are what break.
- XOR algebra underlies stream crypto and its attacks. Single-byte XOR (brute 256 + scoring), repeating-key XOR (Hamming keysize + per-column solve).
- Many-time pad: reuse a keystream and the key cancels —
C1^C2 = P1^P2— recoverable by crib dragging. This is WEP, and it’s nonce reuse in CTR/GCM. - ECB leaks structure (the penguin) and enables byte-at-a-time decryption of
ECB(input ‖ secret)with 256 guesses per byte and no key. - CBC unauthenticated is malleable: bit-flipping edits plaintext via the previous block; the padding oracle decrypts the whole message from a valid/invalid-padding signal, one byte at a time, no key (ASP.NET CVE-2010-3332, POODLE, Lucky13).
- Nonce reuse in CTR leaks
P1^P2; in GCM it also leaks the auth key H → forgery. Use unique nonces or GCM-SIV. - Length extension forges
hash(secret‖data)MACs without the secret (hashpumpy). Use HMAC. - RSA falls to textbook/naive usage: small
e+ smallm(cube root), common modulus, Håstad, Fermat, Wiener — not factoring. Use OAEP/PSS, e=65537, random primes. - ECDSA nonce reuse (two sigs, same
r) recovers the private key algebraically (PS3). Use RFC 6979 or Ed25519. - The fixes converge: authenticated encryption (AEAD), never reuse keys/nonces, uniform constant-time errors, HMAC not concatenation, vetted asymmetric libs with proper padding and deterministic/random nonces, and real CSPRNGs.
- The oracle mindset: any endpoint that responds differently — in content, status, or timing — to a modified ciphertext is an oracle, and a single leaked bit, asked enough times, becomes full recovery.
Part 14: Cheat Sheet / Quick Reference
Section titled “Part 14: Cheat Sheet / Quick Reference”Attack → tell → fix:
| Attack | Tell | Fix |
|---|---|---|
| Single/repeating-key XOR | short/repeated key “encryption” | real cipher, random key |
| Many-time pad | same keystream/nonce reused | unique nonce per message |
| ECB structure leak | repeated 16-byte blocks | AES-GCM, never ECB |
| Byte-at-a-time ECB | ECB(input‖secret) oracle | AEAD, don’t append secrets |
| CBC bit-flip | unauthenticated CBC cookie | encrypt-then-MAC / GCM |
| Padding oracle | different error/timing on tamper | AEAD, uniform constant-time errors |
| GCM nonce reuse | two cts, same nonce | unique nonce / GCM-SIV |
| Length extension | hash(secret‖data) MAC | HMAC / SHA-3 |
| RSA e=3 small m | e=3, no padding, short msg | OAEP, e=65537 |
| RSA common modulus | same n, two e | never share modulus |
| ECDSA nonce reuse | two sigs share r | RFC 6979 / Ed25519 |
Detect ECB: any duplicate 16-byte block. Detect padding-oracle attack: flood of bad-padding errors
from one client. Detect nonce reuse: repeated nonce field / repeated r.
The five-question audit (memorize — it triages any crypto target in seconds): (1) Is anything reused —
key, IV, nonce, ECDSA k? (2) What mode — is it ECB, or unauthenticated CBC/CTR? (3) Is it authenticated —
is there an AEAD tag or MAC checked before use? (4) Does it leak — different error/status/timing on
tampering? (5) Is the randomness real — CSPRNG, unique nonces, no biased k? A “bad” answer to any one of
these is usually a full break.
Tooling: pycryptodome (build/attack), hashpumpy (length ext), sympy/sagemath (RSA/lattice),
Cryptopals + CryptoHack (practice).
Golden rules: AEAD by default · unique nonces always · HMAC not hash(a‖b) · vetted libs only ·
OAEP/PSS + e=65537 · Ed25519 / RFC 6979 · CSPRNG only · one generic constant-time error.
Part 15: Common Pitfalls (for defenders)
Section titled “Part 15: Common Pitfalls (for defenders)”- Using CBC (or any unauthenticated mode) and trusting the ciphertext. Malleable — bit-flip and padding oracle. Use AEAD.
- Distinct errors for “bad padding” vs “bad MAC” vs “bad data.” That distinction is the oracle. Return one generic error, constant-time.
- Reusing an IV/nonce “because it’s easier.” Instant many-time pad; in GCM, forgery. Never.
token = sha256(secret + data). Length-extension forgeable. HMAC.- Rolling your own RSA with
e=3and no padding. Cube-rooted in one line. OAEP/PSS + library. - Generating ECDSA nonces with a weak/reused RNG. Two signatures leak the key. Deterministic nonces.
- Assuming “it’s encrypted so it’s safe.” ECB leaks structure; unauthenticated modes leak/forge. Encryption ≠ integrity.
- Ignoring a flood of decryption errors. That’s an active oracle attack, not noise.
- MD5/SHA-1 anywhere security-relevant. Collisions + length extension. SHA-256+/HMAC/SHA-3.
- No key/nonce uniqueness enforcement in code review. The reuse bugs are invisible until exploited.
Part 16: Practice Labs & Resources
Section titled “Part 16: Practice Labs & Resources”- Cryptopals Crypto Challenges (Sets 1–4): the definitive hands-on path — Set 1 (XOR, single/repeating key, ECB detect), Set 2 (byte-at-a-time ECB, CBC bit-flip, PKCS#7), Set 3 (the CBC padding oracle, CTR nonce reuse), Set 4 (length extension, more oracles). This chapter is essentially a guided tour of these; do them.
- CryptoHack: structured, gamified, covers every attack here (XOR, ECB, CBC, padding oracle, RSA small-e/common-modulus/Håstad, ECDSA nonce reuse, GCM nonce reuse) with a helpful community. Best single resource for this chapter’s skills.
- picoCTF — Crypto category: beginner-friendly XOR, RSA, and mode-misuse challenges to build fluency.
- pwn.college — Cryptography module: rigorous, from primitives to oracle attacks, with autograded labs.
- id0-rsa.pub / cryptohack RSA section: focused RSA-attack drilling (Fermat, Wiener, common modulus, Håstad).
- Build the Part 10 padding-oracle lab yourself, then extend it: add a timing-only oracle (remove the
error difference, leak via
time.sleepon the failure path) and attack that — the Lucky13 lesson.
Practice questions / mini-labs:
- You capture two CBC-mode messages that share the same IV, encrypted under the same key. Which attack applies, what exactly leaks, and how do you recover plaintext? Now the messages use AES-CTR with the same nonce instead — does your answer change, and what additional break appears if it’s GCM?
- A server returns HTTP 500 on invalid padding and HTTP 403 on a valid-padding-but-wrong-plaintext. Explain precisely why this is exploitable, sketch how you recover the last byte of a block, and give the two independent fixes.
- Given
ECB(attacker_prefix ‖ SECRET)as an oracle, write the algorithm to recoverSECRETand state how many oracle queries it costs per byte and why block alignment matters. - You have two ECDSA signatures on different messages with the same
r. Derive the equations to recover the private keyd, and name the deterministic-nonce standard that prevents this. - An API signs requests with
token = SHA256(secret ‖ query)and lets you append toquery. Describe the length-extension forgery step by step, what you must brute-force, and the one-line fix.
If you can look at a ciphertext or crypto endpoint and immediately run the checklist — reuse? mode? auth? leak? randomness? — turn each “yes” into the corresponding exploit (many-time pad, ECB byte-at-a-time, padding oracle, bit-flip, nonce-reuse forgery, RSA cube-root, ECDSA key recovery, length extension), and then state the AEAD/HMAC/vetted-library fix and its detection signal, you own this chapter — and you understand, from the attacker’s side, exactly why every defensive rule in this notebook exists.
This chapter closes Notebook 7. You’ve gone from encoding-vs-encryption through symmetric and asymmetric ciphers, hashing and MACs, password storage, PKI and trust, and finally the offensive techniques that exploit every misuse of the above. The next notebook shifts from the mathematics of secrets to the discipline that organizes all of it into defense: Security Foundations — the CIA triad, AAA, threats, vulnerabilities, and risk.
Discussion
Questions, corrections, or something to add? Comment with your GitHub account.