Step 232. Cryptopals Set 2: ECB/CBC, the Padding Oracle — Piercing the Block Cipher’s Cracks
Level 3 — Real-World CTF & Advanced Offensive Skills | Difficulty ★★★★☆ | Estimated time: 6 hours
Prerequisites: Step 231 (Set 1) — every oracle attack you build today stands on last chapter’s scoring function and block instincts.
⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- What you need: Python 3 (measured: 3.12.14), pycryptodome, Step 231’s
xorlib.py. - Caution: Cryptopals (cryptopals.com) is a public, legal study problem set. Every oracle in this chapter (the server role that encrypts for you) is implemented by you inside your own Python — you’re not attacking a real server; today’s method is "standing up a vulnerable server in my lab" and breaking it. All output measured 2026-09-09.
If Set 1 was "XOR and statistics," Set 2 is "the structural flaws of block-cipher modes." ECB’s single property — the same plaintext block becoming the same ciphertext block — permits an attack that "siphons a secret string one character at a time," and CBC collapses to the padding-oracle attack, where one true/false answer to "is the padding valid?" decrypts an entire ciphertext. Today you reproduce all of cryptography’s masterpiece attacks by hand.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Implement PKCS#7 padding and explain why the true/false of validity checking leaks information
- Implement CBC mode yourself using only the ECB primitive, and match it against the library’s result
- Extract a secret string with an ECB/CBC detection oracle and the byte-at-a-time attack
- Manipulate a chosen plaintext block to a desired value with CBC bit-flipping
- Implement the padding-oracle attack and decrypt a CBC ciphertext without the key
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 (measured: 3.12.14) + pycryptodome (AES primitive) |
| Today’s commands | AES.new(key, AES.MODE_ECB/CBC), hand-rolled pkcs7_pad/unpad, cbc_encrypt/decrypt |
| Concepts needed | The chaining structure of block-cipher modes (ECB/CBC), XOR chaining, oracle attacks |
| Today’s deliverable | Implementations of 4 oracle attacks (detection, byte-at-a-time, bit-flipping, padding) |
2-1. PKCS#7 Padding — The Last Byte Is the Rule
A block cipher requires input in multiples of 16 bytes, so the gap gets filled. PKCS#7’s rule: fill with N bytes of value N, where N is the shortfall — 3 bytes short means \x03\x03\x03; an exact multiple means a whole new block of 16 \x10 bytes. After decryption, this rule gets checked — and the true/false response of that check itself becomes an oracle.
2-2. CBC’s Chaining Structure
encryption: C[i] = Enc(P[i] XOR C[i-1]) (C[0]'s slot holds the IV)
decryption: P[i] = Dec(C[i]) XOR C[i-1]
The key intuition: change ciphertext block C[i] and plaintext block P[i+1] changes predictably (in exchange, P[i] becomes garbage). That property is the bit-flipping attack, and the observation "know Dec(C[i]) and you know the plaintext" is the padding oracle’s starting point.
2-3. Oracle Attacks — Systems That Answer When Asked
An oracle is "a system that answers questions with partial information." An encryption oracle encrypts my input for me; a padding oracle tells me whether a tampered ciphertext’s padding is valid. Information-theoretically each answer is about a bit, but accumulated over repeated queries, the entire plaintext leaks out — in today’s measurement, the padding oracle used 6,105 queries to decrypt 48 bytes.
3. Follow Along
All output in this chapter was measured 2026-09-09 on Python 3.12.14 + pycryptodome. Keys and IVs are random every run, so specific values will differ.
3-1. Implementing PKCS#7 Padding (Challenge 9)
BS = 16
def pkcs7_pad(data, bs=BS):
n = bs - (len(data) % bs)
return data + bytes([n]) * n
def pkcs7_unpad(data):
n = data[-1]
if n == 0 or n > BS or data[-n:] != bytes([n]) * n:
raise ValueError("invalid padding")
return data[:-n]
padded = pkcs7_pad(b"YELLOW SUBMARINE")
print("after padding:", padded, "-> length", len(padded))
try:
pkcs7_unpad(b"ICE ICE BABY\x05\x05\x05\x05")
except ValueError as e:
print("bad padding:", repr(e))
after padding: b'YELLOW SUBMARINE\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10' -> length 32
bad padding: ValueError('invalid padding')
How to read the output: even an input that’s exactly 16 bytes gets a new block of sixteen \x10 bytes — "skip padding on exact multiples" is forbidden. And note that unpad raises an exception instead of returning false — that exception becomes the server’s error response, which becomes the padding oracle.
3-2. Implementing CBC by Hand (Challenge 10)
Without using the library’s CBC, build the chain by hand from the ECB primitive.
from Crypto.Cipher import AES
import secrets
def cbc_encrypt(pt, key, iv):
c = AES.new(key, AES.MODE_ECB)
out, prev = b"", iv
for i in range(0, len(pt), BS):
blk = bytes(x ^ y for x, y in zip(pt[i:i+BS], prev)) # XOR, then encrypt
prev = c.encrypt(blk)
out += prev
return out
def cbc_decrypt(ct, key, iv):
c = AES.new(key, AES.MODE_ECB)
out, prev = b"", iv
for i in range(0, len(ct), BS):
blk = ct[i:i+BS]
out += bytes(x ^ y for x, y in zip(c.decrypt(blk), prev)) # decrypt, then XOR
prev = blk
return out
key, iv = secrets.token_bytes(16), secrets.token_bytes(16)
pt = b"CBC mode chains every block to the previous one!!"
ct = cbc_encrypt(pkcs7_pad(pt), key, iv)
print("my CBC round trip:", pkcs7_unpad(cbc_decrypt(ct, key, iv)) == pt)
ref = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(ct)
print("matches library CBC:", pkcs7_unpad(ref) == pt)
my CBC round trip: True
matches library CBC: True
How to read the output: the hand-built chain matches the library bit for bit. Now the formula for how P[i+1] changes when you alter C[i] is in your hands — the two attacks ahead use this structure directly.
3-3. The ECB/CBC Detection Oracle (Challenge 11)
Feed 64 identical bytes to an oracle that encrypts under an unknown mode.
def encryption_oracle(data):
k = secrets.token_bytes(16)
mode = secrets.choice(["ECB", "CBC"])
if mode == "ECB":
return AES.new(k, AES.MODE_ECB).encrypt(pkcs7_pad(data)), mode
return AES.new(k, AES.MODE_CBC, iv=secrets.token_bytes(16)).encrypt(pkcs7_pad(data)), mode
def detect_mode(ct):
blocks = [ct[i:i+BS] for i in range(0, len(ct), BS)]
return "ECB" if len(set(blocks)) < len(blocks) else "CBC"
ct3, real = encryption_oracle(b"A" * 64)
print("detected:", detect_mode(ct3), "| actual mode:", real, "| correct:", detect_mode(ct3) == real)
detected: CBC | actual mode: CBC | correct: True
How to read the output: 64 As make 4 completely identical 16-byte blocks — under ECB the ciphertext has 4 identical blocks too; under CBC the chain makes them all different. Run it repeatedly and both ECB and CBC come up, and detection is right every time (confirmed in measurement). Step 231’s repeated-block detection became the discriminator as-is.
3-4. ECB Byte-at-a-Time (Challenge 12)
The oracle encrypts my input + a secret string under ECB. Siphon the secret one character at a time.
import base64
target = base64.b64decode(b"Um9sbGluJyBpbiBteSA1LjA=") # a secret that exists only inside the oracle
okey = secrets.token_bytes(16)
def oracle_ecb(user):
return AES.new(okey, AES.MODE_ECB).encrypt(pkcs7_pad(user + target))
recovered = b""
for i in range(len(target)):
pad_len = BS - 1 - (len(recovered) % BS)
prefix = b"A" * pad_len
base = oracle_ecb(prefix)[:pad_len + len(recovered) + 1]
for g in range(256):
trial = oracle_ecb(prefix + recovered + bytes([g]))[:pad_len + len(recovered) + 1]
if trial == base:
recovered += bytes([g]); break
print("byte-at-a-time extraction:", recovered)
byte-at-a-time extraction: b"Rollin' in my 5.0"
How to read the output: the principle goes like this. Feed 15 As and the first block is A×15 + secret[0] — the secret’s first character sits at the block’s end. Try 256 guesses of A×15 + guess and find the one producing the same ciphertext block — first character confirmed. Next comes 14 As + the characters recovered so far + a guess. ECB’s same input = same ciphertext property serves as a lookup table. The 16-character secret was extracted exactly.
3-5. CBC Bit-Flipping (Challenge 16)
Goal: plant ;admin=true; in the decryption result. Semicolons are stripped from user input, so the legitimate path is impossible.
bkey, biv = secrets.token_bytes(16), secrets.token_bytes(16)
def enc_userdata(u):
u = u.replace(b";", b"").replace(b"=", b"")
pt = b"comment1=cooking%20MCs;userdata=" + u + b";comment2=%20like%20a%20pound%20of%20bacon"
return AES.new(bkey, AES.MODE_CBC, iv=biv).encrypt(pkcs7_pad(pt))
ct5 = enc_userdata(b"A" * 32)
print("admin check before tampering:", b";admin=true;" in pkcs7_unpad(
AES.new(bkey, AES.MODE_CBC, iv=biv).decrypt(ct5)))
mod = bytearray(ct5)
inject = b";admin=true;AAAA" # exactly 16 bytes
orig = b"A" * 16 # the 3rd block's plaintext (a value we know)
for i in range(BS):
mod[BS + i] ^= orig[i] ^ inject[i] # tamper the previous block -> replace the next block's plaintext
pt2 = pkcs7_unpad(AES.new(bkey, AES.MODE_CBC, iv=biv).decrypt(bytes(mod)))
print("admin check after tampering:", b";admin=true;" in pt2)
print("part of tampered plaintext:", pt2[32:64])
admin check before tampering: False
admin check after tampering: True
part of tampered plaintext: b';admin=true;AAAAAAAAAAAAAAAAAAAA'
How to read the output: since P[3] = Dec(C[3]) XOR C[2], XORing orig XOR inject into C[2] replaces P[3] with inject. The price is that P[2] becomes garbage (the spot where the chain was cut), but the check passed. Without knowing key or IV, you rewrote one plaintext block to a value of your choosing.
3-6. The Padding Oracle (Challenge 17)
The oracle answers only true/false to "is this ciphertext’s padding valid?"
pkey, piv = secrets.token_bytes(16), secrets.token_bytes(16)
pct = AES.new(pkey, AES.MODE_CBC, iv=piv).encrypt(pkcs7_pad(b"Attack at dawn!! The padding oracle talks."))
def padding_oracle(ct):
try:
pkcs7_unpad(AES.new(pkey, AES.MODE_CBC, iv=piv).decrypt(ct))
return True
except ValueError:
return False
def attack_block(prev, cur):
inter = bytearray(BS); plain = bytearray(BS)
for pos in range(BS - 1, -1, -1):
pad = BS - pos
crafted = bytearray(prev)
for j in range(pos + 1, BS):
crafted[j] = inter[j] ^ pad
for g in range(256):
if g == prev[pos] and pos == BS - 1:
continue # the original as-is → a trap candidate where the real padding is valid
crafted[pos] = g
if padding_oracle(bytes(crafted) + cur):
if pos > 0: # verify: if flipping the previous byte keeps it valid, it's real
crafted[pos - 1] ^= 1
ok = padding_oracle(bytes(crafted) + cur)
crafted[pos - 1] ^= 1
if not ok: continue
inter[pos] = g ^ pad
plain[pos] = prev[pos] ^ inter[pos]
break
return bytes(plain)
blocks = [piv] + [pct[i:i+BS] for i in range(0, len(pct), BS)]
out = b"".join(attack_block(blocks[i-1], blocks[i]) for i in range(1, len(blocks)))
print("padding oracle recovery:", pkcs7_unpad(out))
padding oracle recovery: b'Attack at dawn!! The padding oracle talks.'
How to read the output: you attack from the last byte. Manipulate prev[15] and find the g that makes the decrypted last byte \x01 (valid padding); then the intermediate value is inter[15] = g ^ 1 and the plaintext is prev[15] ^ inter[15]. Next, target \x02\x02 for the second-to-last byte. At most 256 queries per byte; the measured total was 6,105 queries for 48 bytes — one-bit true/false answers leaked the entire plaintext. Without the two lines handling the trap candidate (the original as-is) and the verification query, you’ll misjudge on the last block — mind the comments’ positions.
4. Missions & Exercises
Mission — The Oracle Attack Compilation Set
- Organize today’s four attacks into functions in
oracle_attacks.py—detect_mode,ecb_byte_at_a_time(oracle),cbc_bit_flip(ct, orig, inject, offset),padding_oracle_attack(ct, iv, oracle) - Count each function’s call count (number of oracle queries) and print it — confirm the padding oracle averages ~128 queries per byte (measured 6,105 queries / 48 bytes)
- Pair each of the four attacks with "one line of defense code" in a comment — e.g., padding oracle → "verify a MAC before decrypting (Encrypt-then-MAC)"
- (Optional) Apply the same functions to the original Cryptopals Set 2 challenges 12, 16, 17
Exercises
Exercise 1. Why does PKCS#7 add a full 16 bytes even when the input is an exact multiple of 16? What ambiguity would arise on the decryption side if it were skipped?
Exercise 2. Explain why byte-at-a-time works on ECB but not CBC, in terms of the presence or absence of the "same input block → same output block" property.
Exercise 3. In bit-flipping, when you tampered with C[2] to change P[3], why did P[2] become garbage? Show it with the CBC decryption formula.
Exercise 4. Explain why the padding-oracle attack pins down "the last byte of the decryption result" first, via the observation that padding validation starts from the end.
5. Model Answers & Completion Criteria
Mission Model Answer
def padding_oracle_attack(ct, iv, oracle):
blocks = [iv] + [ct[i:i+BS] for i in range(0, len(ct), BS)]
return b"".join(attack_block(blocks[i-1], blocks[i], oracle)
for i in range(1, len(blocks)))
Grading criteria: ① all four functions produce the same results as the text’s measurements (secret extraction, admin planting, plaintext recovery) ② the query counter actually increments (thousands for the padding oracle) ③ the defense comments aim precisely at each attack’s premise — e.g., byte-at-a-time’s defense is "never mix the secret and user input in the same block + retire ECB."
How to verify: draw fresh keys and run twice. Extraction must hold regardless of the key to be a real attack; if it works only for a particular key, that’s an implementation bug.
Exercise Answers
Answer 1. If skipping padding were allowed, decrypting a 16-byte-multiple message that ends in \x01 would leave no way to tell "is this \x01 padding or content?" Always adding padding establishes the rule "the last N bytes, given by the final byte’s value, are unconditionally padding," making unpad deterministic.
Answer 2. The attack’s core device is a lookup table that checks "does a ciphertext block matching my guessed plaintext block appear?" ECB keeps blocks independent, so the comparison works; CBC mixes the previous block (or IV) into the chain every time, so the same plaintext block yields different ciphertexts and no table can be built.
Answer 3. Since P[2] = Dec(C[2]) XOR C[1] and C[2] was tampered with, Dec(C[2]) becomes a random value unrelated to the original — the block cipher’s avalanche effect flips the whole output over a one-bit difference. Meanwhile, in P[3] = Dec(C[3]) XOR C[2], Dec(C[3]) is untouched and only C[2] changed, so the change is reflected exactly. One block breaks while the next block gets steered — that’s CBC tampering’s cost structure.
Answer 4. The minimal form of valid padding is a final single byte \x01. So targeting only the last byte (the rest can be anything) yields a true/false signal from single-byte manipulation. Once the last byte is pinned, the next target is \x02\x02 — the target padding grows one byte forward at a time, so the attack necessarily starts at the end and walks forward.
Completion Criteria Checklist
- [ ] I implemented PKCS#7 pad/unpad and confirmed ValueError on bad padding
- [ ] I implemented CBC from the ECB primitive and saw it match the library
- [ ] I distinguished ECB/CBC by repeated-block count
- [ ] I extracted a 16-byte secret string byte-at-a-time
- [ ] I planted
;admin=true;via bit-flipping and passed the check - [ ] I recovered the full plaintext via the padding oracle without the key
- [ ] I can explain with formulas "tamper ciphertext block i → steer plaintext block i+1, block i collapses"
- [ ] Mission: 4 attacks turned into functions + query counts confirmed in output
6. Common Pitfalls & Fixes
Wall 1. ValueError: Data must be aligned to block boundary in ECB mode
Symptom: ValueError: Data must be aligned to block boundary in ECB mode (measured 2026-09-09).
Cause: pycryptodome does not pad automatically — you fed encrypt input that isn’t a multiple of 16 bytes.
Fix: always go through pkcs7_pad. This error is the signal telling you "the library won’t pad for you," and it’s why 3-1 comes first.
Wall 2. ValueError: Incorrect AES key length (5 bytes)
Symptom: ValueError: Incorrect AES key length (5 bytes) (measured 2026-09-09).
Cause: AES keys must be 16/24/32 bytes. Check that something like "YELLOW SUBMARINE" is exactly 16 bytes.
Fix: print len(key) to check, and use secrets.token_bytes(16) for arbitrary keys.
Wall 3. Forgetting the IV in CBC
Symptom: AES.new(key, AES.MODE_CBC) runs without error, but decryption is garbled from the first block.
Cause: when the IV is omitted, pycryptodome auto-generates a random IV — without knowing the IV used at encryption, the first decrypted block is garbage.
Fix: always specify the IV for CBC, and adopt the convention of prepending the IV to the ciphertext for storage (iv + ct). The 3-2 hand implementation is exactly this structure.
Wall 4. The padding oracle misjudges on the last block
Symptom: the tail of the recovery is \x00 or garbled characters.
Cause: in the last-byte attack, "the original as-is" (g == prev[pos]) is a trap candidate that returns True because the real padding is valid — it passes at the actual padding length, not the target \x01.
Fix: don’t skip the two devices in 3-6 — ① skipping the original-as-is candidate on the last byte, ② the verification query that flips the previous byte. Verification queries inflate the query count but guarantee accuracy (included in the measured 6,105).
Wall 5. recovered stalls midway in byte-at-a-time
Symptom: only some characters get extracted and the loop ends without break.
Cause: the pad_len computation is wrong when crossing a block boundary, or the comparison range ([:pad_len + len(recovered) + 1]) reaches past the boundary into the next block.
Fix: comparisons must cover "only up to the block currently being worked" — use 3-4’s slice range as-is, and be sure to test boundary-crossing with a secret of 17+ bytes (the measured secret is exactly 17 bytes, so it crosses a boundary).
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| PKCS#7 | Padding that fills a shortfall of N with N bytes of value N — its validity check becomes an oracle |
| CBC chaining | C[i] = Enc(P[i] ⊕ C[i-1]) — the connection is the manipulation point |
| Byte-at-a-time | Lookup-table extraction of a secret one character at a time, via ECB’s determinism |
| Bit-flipping | Tamper C[i] → steer P[i+1] exactly (in exchange, P[i] collapses) |
| Padding oracle | Back-compute intermediate values from a 1-bit padding true/false; full plaintext recovery (measured ~128 queries/byte) |
Today’s Commands & Code
| Command | What it does |
|---|---|
pkcs7_pad / pkcs7_unpad |
Attach/validate padding — the oracle’s heart |
AES.new(key, AES.MODE_CBC, iv=iv) |
Library CBC (for cross-checking) |
crafted[j] = inter[j] ^ pad |
A crafted block dressed to the target padding |
bytes(x ^ y for x, y in zip(a, b)) |
Byte-string XOR — the base operation of every attack today |
oracle(prefix + recovered + bytes([g])) |
One-character-guess table query |
An Instinct More Important Than Commands
Today’s four attacks all share one structure — organizing "what the system carelessly answers" into questions. ECB’s determinism, CBC’s chaining, padding validation’s true/false each look harmless, but combined with repeated queries, the whole plaintext leaks. That’s why modern cryptographic design’s answer is "make questioning impossible" — with authenticated encryption (AEAD), tampered ciphertexts are discarded before decryption. The structure of that answer is the subject of next chapter’s AES grand review.
Once every box is checked, Step 232 is complete. Click the checkbox in the sidebar to save your progress.