Breaking AES-CBC with a Padding Oracle
A server that only tells you whether decrypted padding was valid hands you the whole plaintext. This guide runs the real CBC padding-oracle attack against full AES-128, recovers a secret message one byte at a time, and explains why POODLE and Lucky 13 made authenticated encryption the rule.
Pashalis Laoutaris 16 min read
Try it
Interactive AES-CBC Padding Oracle Breaker
🔐 AES-CBC Padding Oracle Attack
0Breaking AES-CBC with a Padding Oracle
Introduction
AES is not broken here. The cipher stays exactly as strong as ever. What breaks is the system around it, a server that decrypts AES-CBC ciphertext and leaks one tiny fact: whether the padding came out valid.
That single bit of feedback is enough. An attacker who can submit ciphertext and learn “padding good” or “padding bad” recovers the entire plaintext, without the key, one byte at a time. The attack is the padding oracle, described by Serge Vaudenay in 2002. It later powered real breaks like POODLE and Lucky 13, and it is the main reason the modern rule is to authenticate first and decrypt second.
Table of Contents
- Padding, and Why It Leaks
- The Oracle
- Recovering One Byte
- From One Byte to the Whole Block
- Interactive Visualizer
- A Worked Example
- Python Implementation
- For Fun: The Attack Loop in ~20 Lines
- Limitations
- Security Status
- FAQ
- References
Padding, and Why It Leaks
CBC encrypts data one 16-byte block at a time. Each plaintext block is XORed with the previous ciphertext block before AES encrypts it, and the first block uses a random IV. Decryption runs the chain backward. The server AES-decrypts a block to an intermediate value, then XORs it with the previous ciphertext block to get the plaintext.
Messages are rarely an exact multiple of 16 bytes, so they are padded first. The usual scheme is PKCS#7. If three bytes are missing, append three bytes each holding the value 03. If the message already fills a block, add a whole block of 16 bytes. On decryption the server reads the last byte, say 04, and checks that the final four bytes are all 04. Valid padding is removed, bad padding is an error.
The vulnerability is simple. The intermediate value I from AES comes out fixed once the ciphertext block is chosen. The plaintext is P = I XOR C_prev, where C_prev is the previous ciphertext block. An attacker who controls C_prev controls what the plaintext decrypts to, byte for byte, even without knowing I. Change C_prev, and you change P in a way you can predict exactly, because XOR is reversible.
The Oracle
An oracle is any part of a system that answers one question for free. Here the question is “does this ciphertext decrypt to valid padding?” A server answers it in many accidental ways. It might return a distinct “padding error” message, throw a different exception, respond a few milliseconds faster, or simply close the connection. Any observable difference between valid and invalid padding is the leak.
Give the attacker a function oracle(iv, ciphertext) that returns True for valid padding and False otherwise. That is the only access needed. The key never leaves the server, and the attacker never sees it. Only the one-bit answer crosses the gap.
Recovering One Byte
Take a target ciphertext block C and the block before it. To decrypt C, the attacker places a crafted block C' of 16 chosen bytes in front of it and submits (C', C) to the oracle. The server computes I = AES-decrypt(C), then outputs P = I XOR C'. The attacker chose C', so they can steer P.
Start with the last byte. Set the first 15 bytes of C' to anything, then try all 256 values for the final byte. For exactly one value, the decrypted block ends in a valid 01, which means the last plaintext byte equals 01. At that moment I[15] XOR C'[15] = 0x01, so the intermediate byte is I[15] = C'[15] XOR 0x01. One oracle hit reveals one intermediate byte, with no key involved.
There is a subtlety on that first byte. A block could end in a valid 02 02 by luck, not just 01, giving a false positive. The fix is to perturb the second-to-last byte of C' and ask again. If the padding is truly 01, changing an earlier byte leaves it valid; if it was a longer run, the answer flips. That check pins down the real 01 case.
From One Byte to the Whole Block
Once I[15] is known, go for I[14], aiming at padding 02 02. Set the last byte of C' to I[15] XOR 0x02, which forces the final plaintext byte to 02. Then sweep the second-to-last byte of C' until the oracle accepts, which happens when the plaintext ends in 02 02. That gives I[14] = C'[14] XOR 0x02.
Keep walking left. For padding length n, fix every already-known tail byte of C' to produce the value n, then brute-force the next unknown byte through 256 values. Each position costs at most 256 oracle queries, so a full 16-byte block falls in at most 4096 queries. Compare that to guessing the block directly, which is 256 to the 16th power, a number with 39 digits. The intermediate value comes out completely.
The last step is free. The real plaintext is P = I XOR C_real, where C_real is the genuine previous ciphertext block (or the IV for the first block). XOR the recovered intermediate with the real previous block, and the plaintext appears. Repeat for every block, and the entire message is laid bare.
Interactive Visualizer
The visualizer above runs the real attack against full AES-128. Click to watch it recover the hidden message one byte at a time, from the last byte of each block to the first, exactly as the Python below does. It shows the forged previous block it is testing, the intermediate bytes as they fall, and the plaintext filling in from right to left, along with the running count of oracle queries.
The AES inside is the complete cipher, verified against the official FIPS-197 test vector. Nothing is faked and no shortcut touches the key. The only thing the attacker’s code ever learns from the server is the single valid-or-invalid bit, the same as a real padding oracle in the wild.
A Worked Example
The server holds a random-looking 128-bit key and encrypts a secret message with AES-128 in CBC mode under a fresh IV. The attacker sees only the IV and the ciphertext, plus the oracle. The secret is:
The access code is 7591. Keep it to yourself!
That message is 45 bytes, which pads to 48 bytes, three AES blocks. The intercepted values are:
iv : 4ca00ff4c898d61e1edbf1800618fb28
ciphertext : 981b31a05553448e66850e0aa0cb7bb9
bae28be5a7662a0d30186534696a0b39
432f8ec9f128ec113057d51b8f187904
Running the padding-oracle attack on these three blocks recovers the plaintext completely:
oracle calls : 6269
recovered : The access code is 7591. Keep it to yourself!
The attacker never touched the key. It took 6,269 oracle queries, a few thousandths of the 256^16 guesses that blindly brute-forcing a single block would need, and the message came back whole. Each block took at most 4,096 queries, usually fewer, because most bytes are found well before the 256th guess.
Python Implementation
The code below is self-contained and runnable. It builds AES-128 from scratch, checks itself against the FIPS-197 test vector, wraps AES in CBC with PKCS#7 padding, and defines a server that leaks only the padding bit. The attack uses nothing but that server.
# cbc_padding_oracle.py
#
# Padding-oracle attack against real AES-128-CBC. AES itself is never broken. The server leaks ONE bit
# per query, whether the PKCS#7 padding was valid after decryption, and that bit is enough to recover
# the whole plaintext without the key, one byte at a time. This is the attack behind POODLE and Lucky 13,
# and the reason modern systems authenticate the ciphertext (encrypt-then-MAC, or AES-GCM) before
# decrypting it. The AES core below is standard and self-checks against the FIPS-197 test vector.
# ---- AES-128 core ----
def _build_sbox():
p = q = 1
sbox = [0] * 256
while True:
p = p ^ ((p << 1) & 0xFF) ^ (0x1B if p & 0x80 else 0)
q ^= (q << 1) & 0xFF
q ^= (q << 2) & 0xFF
q ^= (q << 4) & 0xFF
q ^= 0x09 if q & 0x80 else 0
q &= 0xFF
x = q ^ ((q << 1) & 0xFF | q >> 7) ^ ((q << 2) & 0xFF | q >> 6) \
^ ((q << 3) & 0xFF | q >> 5) ^ ((q << 4) & 0xFF | q >> 4)
sbox[p] = (x ^ 0x63) & 0xFF
if p == 1:
break
sbox[0] = 0x63
return sbox
SBOX = _build_sbox()
INV_SBOX = [0] * 256
for _i, _v in enumerate(SBOX):
INV_SBOX[_v] = _i
RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]
def _xtime(a):
a <<= 1
return (a ^ 0x11B) & 0xFF if a & 0x100 else a & 0xFF
def _gmul(a, b):
r = 0
for _ in range(8):
if b & 1:
r ^= a
b >>= 1
a = _xtime(a)
return r
def key_expansion(key):
w = [list(key[i:i + 4]) for i in range(0, 16, 4)]
for i in range(4, 44):
t = list(w[i - 1])
if i % 4 == 0:
t = t[1:] + t[:1]
t = [SBOX[b] for b in t]
t[0] ^= RCON[i // 4 - 1]
w.append([w[i - 4][j] ^ t[j] for j in range(4)])
return [sum((w[r * 4 + c] for c in range(4)), []) for r in range(11)]
def _sub(s, box):
return [box[b] for b in s]
def _shift(s):
o = [0] * 16
for r in range(4):
for c in range(4):
o[c * 4 + r] = s[((c + r) % 4) * 4 + r]
return o
def _inv_shift(s):
o = [0] * 16
for r in range(4):
for c in range(4):
o[c * 4 + r] = s[((c - r) % 4) * 4 + r]
return o
def _mix(s, inv):
m = (14, 11, 13, 9) if inv else (2, 3, 1, 1)
o = [0] * 16
for c in range(4):
col = s[c * 4:c * 4 + 4]
o[c * 4 + 0] = _gmul(col[0], m[0]) ^ _gmul(col[1], m[1]) ^ _gmul(col[2], m[2]) ^ _gmul(col[3], m[3])
o[c * 4 + 1] = _gmul(col[0], m[3]) ^ _gmul(col[1], m[0]) ^ _gmul(col[2], m[1]) ^ _gmul(col[3], m[2])
o[c * 4 + 2] = _gmul(col[0], m[2]) ^ _gmul(col[1], m[3]) ^ _gmul(col[2], m[0]) ^ _gmul(col[3], m[1])
o[c * 4 + 3] = _gmul(col[0], m[1]) ^ _gmul(col[1], m[2]) ^ _gmul(col[2], m[3]) ^ _gmul(col[3], m[0])
return o
def _ark(s, rk):
return [s[i] ^ rk[i] for i in range(16)]
def aes_encrypt_block(block, rks):
s = _ark(list(block), rks[0])
for r in range(1, 10):
s = _ark(_mix(_shift(_sub(s, SBOX)), False), rks[r])
return bytes(_ark(_shift(_sub(s, SBOX)), rks[10]))
def aes_decrypt_block(block, rks):
s = _ark(list(block), rks[10])
for r in range(9, 0, -1):
s = _mix(_ark(_sub(_inv_shift(s), INV_SBOX), rks[r]), True)
return bytes(_ark(_sub(_inv_shift(s), INV_SBOX), rks[0]))
# ---- CBC + PKCS#7 ----
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def pkcs7_pad(data, bs=16):
p = bs - (len(data) % bs)
return data + bytes([p]) * p
def pkcs7_unpad(data, bs=16):
if not data or len(data) % bs:
raise ValueError("bad length")
p = data[-1]
if p < 1 or p > bs or data[-p:] != bytes([p]) * p:
raise ValueError("bad padding")
return data[:-p]
def cbc_encrypt(plaintext, key, iv):
rks = key_expansion(key)
data, out, prev = pkcs7_pad(plaintext), b"", iv
for i in range(0, len(data), 16):
prev = aes_encrypt_block(xor(data[i:i + 16], prev), rks)
out += prev
return out
def cbc_decrypt_raw(ciphertext, key, iv):
rks = key_expansion(key)
out, prev = b"", iv
for i in range(0, len(ciphertext), 16):
blk = ciphertext[i:i + 16]
out += xor(aes_decrypt_block(blk, rks), prev)
prev = blk
return out
# ---- the server: leaks only the padding bit ----
class Server:
def __init__(self, key):
self.key = key
self.calls = 0
def oracle(self, iv, ciphertext):
self.calls += 1
try:
pkcs7_unpad(cbc_decrypt_raw(ciphertext, self.key, iv))
return True
except ValueError:
return False
# ---- the attack ----
def recover_block(server, prev_block, target_block):
inter = bytearray(16) # AES-decrypt output, before the XOR
for pad in range(1, 17):
idx = 16 - pad
forged = bytearray(16)
for k in range(idx + 1, 16): # force the known tail to the value `pad`
forged[k] = inter[k] ^ pad
for guess in range(256):
forged[idx] = guess
if server.oracle(bytes(forged), target_block):
if pad == 1: # guard against a false 02 02 (or longer) match
forged[idx - 1] ^= 0xFF
ok = server.oracle(bytes(forged), target_block)
forged[idx - 1] ^= 0xFF
if not ok:
continue
inter[idx] = guess ^ pad
break
else:
raise RuntimeError("byte %d not recovered" % idx)
return bytes(inter[i] ^ prev_block[i] for i in range(16))
def padding_oracle_attack(server, iv, ciphertext):
blocks = [iv] + [ciphertext[i:i + 16] for i in range(0, len(ciphertext), 16)]
return b"".join(recover_block(server, blocks[i - 1], blocks[i]) for i in range(1, len(blocks)))
if __name__ == "__main__":
# FIPS-197 self-test
rks = key_expansion(bytes.fromhex("000102030405060708090a0b0c0d0e0f"))
assert aes_encrypt_block(bytes.fromhex("00112233445566778899aabbccddeeff"), rks).hex() \
== "69c4e0d86a7b0430d8cdb78070b4c55a"
key = bytes.fromhex("140b41b22a29beb4061bda66b6747e14")
iv = bytes.fromhex("4ca00ff4c898d61e1edbf1800618fb28")
secret = b"The access code is 7591. Keep it to yourself!"
ciphertext = cbc_encrypt(secret, key, iv)
print("iv :", iv.hex())
print("ciphertext :", ciphertext.hex())
server = Server(key)
recovered = pkcs7_unpad(padding_oracle_attack(server, iv, ciphertext))
print("oracle calls :", server.calls)
print("recovered :", recovered.decode())
assert recovered == secret
Running it prints the worked example:
iv : 4ca00ff4c898d61e1edbf1800618fb28
ciphertext : 981b31a05553448e66850e0aa0cb7bb9bae28be5a7662a0d30186534696a0b39432f8ec9f128ec113057d51b8f187904
oracle calls : 6269
recovered : The access code is 7591. Keep it to yourself!
For Fun: The Attack Loop in ~20 Lines
Strip away the AES core, which any library provides, and the attack itself is tiny. This version uses pycryptodome for the cipher and keeps only the oracle logic. Same idea, same output, with the heavy lifting handed to a library.
# pip install pycryptodome
from Crypto.Cipher import AES
KEY, IV = bytes.fromhex("140b41b22a29beb4061bda66b6747e14"), bytes.fromhex("4ca00ff4c898d61e1edbf1800618fb28")
pad = lambda d: d + bytes([16 - len(d) % 16]) * (16 - len(d) % 16)
def oracle(iv, ct): # the server: leaks one bit
d = AES.new(KEY, AES.MODE_CBC, iv).decrypt(ct)
p = d[-1]
return 1 <= p <= 16 and d[-p:] == bytes([p]) * p
def crack(prev, block):
inter = bytearray(16)
for pad_val in range(1, 17):
i = 16 - pad_val
f = bytearray(16)
for k in range(i + 1, 16): f[k] = inter[k] ^ pad_val
for g in range(256):
f[i] = g
if oracle(bytes(f), block) and (pad_val > 1 or (f.__setitem__(i - 1, f[i - 1] ^ 1) or oracle(bytes(f), block))):
inter[i] = g ^ pad_val; break
return bytes(a ^ b for a, b in zip(inter, prev))
ct = AES.new(KEY, AES.MODE_CBC, IV).encrypt(pad(b"The access code is 7591. Keep it to yourself!"))
blocks = [IV] + [ct[i:i+16] for i in range(0, len(ct), 16)]
print(b"".join(crack(blocks[i-1], blocks[i]) for i in range(1, len(blocks))).rstrip(b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10").decode())
It prints The access code is 7591. Keep it to yourself!. The compressed pad_val > 1 or ... line folds the first-byte false-positive guard into the loop, which is clever but hides the logic, so the readable version above keeps it as a plain if.
Limitations
The attack is real and the AES is complete, but a live target adds friction this demo skips:
- It needs a padding oracle. The server must reveal, somehow, whether padding was valid. A system that returns one uniform error for every decryption failure, and takes constant time doing it, gives the attacker nothing.
- Query count matters in the wild. At most 4,096 queries per block is cheap for a local function. Against a remote server with rate limiting or logging, thousands of malformed requests per block may be slow or noisy enough to notice.
- Timing oracles are harder. When the signal is a few milliseconds rather than a clear error, the attacker averages many samples per guess to beat the noise. Lucky 13 did exactly this against TLS, which is why constant-time padding checks became mandatory.
- This recovers plaintext, not the key. The AES key stays secret the whole time. The attack decrypts captured ciphertext, which is already a total break of confidentiality, but it never extracts the key itself.
Security Status
- Plain CBC with padding is unsafe without authentication. Vaudenay published the attack in 2002, and it has reappeared for twenty years: POODLE in 2014 against SSL 3.0, Lucky 13 against TLS, and countless application-level bugs. Unauthenticated CBC should be treated as broken for anything an attacker can poke at.
- Authenticate before decrypting. Encrypt-then-MAC, checked with a constant-time comparison before any padding is examined, removes the oracle: a tampered ciphertext fails the MAC and never reaches the padding check. Better still, use an authenticated mode like AES-GCM or ChaCha20-Poly1305, which bakes integrity in.
- The lesson is general. The cipher was never the weak point. A single bit of decryption feedback, leaked through an error message or a timing difference, collapsed the whole scheme. Secure systems deny the attacker that bit by refusing to act on any ciphertext they have not first authenticated.
FAQ
Is AES broken by this attack?
No. AES is untouched, and the key is never recovered. The flaw is in the surrounding protocol, which decrypts attacker-supplied ciphertext and leaks whether the padding was valid. That leak, not any weakness in AES, is what hands over the plaintext.
Why does valid padding reveal anything?
Because the attacker controls the previous ciphertext block, which XORs directly into the plaintext. By forcing the decrypted block to end in valid padding, the attacker learns the exact intermediate byte that AES produced, and from there the real plaintext byte. Each valid-padding hit leaks one byte.
How many queries does it take?
At most 256 per byte, so at most 4,096 per 16-byte block, and usually fewer. The worked example recovered three blocks in 6,269 queries. That is astronomically cheaper than the 256^16 guesses needed to brute-force even one block directly.
What is the first-byte guard for?
When attacking the last byte, a decrypted block might end in a valid longer run like 02 02 by chance, not the intended 01. Changing an earlier byte of the forged block and re-querying distinguishes the two: real 01 padding stays valid, a longer accidental run breaks.
How do I prevent it?
Authenticate the ciphertext before decrypting it. Use an authenticated mode such as AES-GCM, or encrypt-then-MAC with a constant-time check. Return a single, uniform error for any decryption failure, and take constant time to do so, so no observable difference remains for an oracle to read.
References
-
Vaudenay, Serge. “Security Flaws Induced by CBC Padding Applications to SSL, IPSEC, WTLS…,” EUROCRYPT 2002 (the paper that introduced the padding-oracle attack).
-
AlFardan, Nadhem, and Kenny Paterson. “Lucky Thirteen: Breaking the TLS and DTLS Record Protocols,” IEEE S&P 2013 (the timing-based padding oracle against TLS).
-
Möller, Bodo, Thai Duong, and Krzysztof Kotowicz. “This POODLE Bites: Exploiting the SSL 3.0 Fallback,” 2014.
-
The AES guide on this site, whose CBC mode and padding this breaker attacks: /blog/aes_guide/
-
NIST SP 800-38A: Recommendation for Block Cipher Modes of Operation.