Authenticated Encryption / AEAD (the CBC padding oracle)
Security & Cryptography · Nutthakorn Chalaemwongwan
Today
- What AEAD guarantees, in one primitive (RFC 5116)
- Encrypt-then-MAC vs the two fragile alternatives
- Why unauthenticated CBC + a padding check = a padding oracle
- Why AES-GCM has no equivalent hole
- 🔓 Game: Read the Secret Without the Key
Recap — Week 5
- Diffie–Hellman gives Alice & Bob a shared secret no eavesdropper can compute
- But raw DH authenticates nobody — an active attacker (
relay) ran two independent handshakes and sat invisibly in the middle - Fix: authenticate the exchange (HMAC over the public keys) — one added check closed it, no crypto swapped
What is AEAD?
Authenticated Encryption with Associated Data — three guarantees, one primitive:
| Guarantee | Stops |
|---|---|
| Confidentiality | eavesdropping — ciphertext reveals nothing about plaintext |
| Integrity | undetected tampering — any bit-flip is caught |
| Authenticity | forgery — only a key-holder could have produced this ciphertext |
Safe composition: how NOT to build it yourself
- Encrypt-then-MAC (EtM) — MAC over the ciphertext, verified before decryption is even attempted. The provably safe generic composition.
- MAC-then-Encrypt (MtE) — MAC over plaintext, then encrypt the (plaintext‖MAC). Verifier must decrypt first — old TLS CBC cipher suites did this.
- Encrypt-and-MAC (E&M) — MAC over plaintext, sent alongside ciphertext (legacy SSH). MAC itself can leak plaintext info.
If the verifier has to touch the plaintext before the MAC check passes, you have a problem.
Bridge: CBC is malleable (Week 4, recap)
CBC decryption: P_i = D(C_i) ⊕ C_{i-1}
- Flip a bit in ciphertext block C_{i-1} → the same bit flips in plaintext block P_i
- No MAC, no signature — nothing stops the attacker from tampering
- Week 4: bit-flipping. Today: malleability weaponized into full decryption.
The vulnerability: a padding oracle
vulnerable_app.py — unauthenticated AES-256-CBC, POST /decrypt:
if pkcs7_valid(plaintext):
return jsonify({"status": "ok"}), 200
return jsonify({"error": "bad padding"}), 403
- The endpoint never returns the plaintext
- But
200vs403is a distinguishable signal — that's the oracle - "We don't leak the plaintext" is not the same as "we leak nothing"
The attack: byte-by-byte, without the key
Attacker submits a forged previous block + the real last block as a fake IV‖ct:
- Server computes
P = D(C_t) ⊕ forged_prev, checks only: isP's padding valid? - Try every
forged_prev[15]until the server says200→ last byte ofPis (almost always)0x01 D(C_t)[15] = winning_guess ⊕ 0x01— one byte of the intermediate value, per query- Repeat forcing
…0x02 0x02,…0x03 0x03 0x03, … → recover the whole block - False-positive case (
Pends…0x02 0x02by accident) → re-query a neighbour byte to disambiguate
The fix: AES-GCM has no padding to probe
fixed_app.py — AES-256-GCM, POST /decrypt:
try:
_aesgcm.decrypt(nonce, ct_and_tag, None) # raises on ANY tamper
except Exception:
return jsonify({"error": "decryption failed"}), 403 # uniform, always
return jsonify({"status": "ok"}), 200
- No padding — GCM is a stream mode (CTR-based); there's no "valid vs invalid padding" question to ask
- The 128-bit tag is checked first — no plaintext is released until it passes
- Every failure — bad base64, wrong length, bad nonce, forged tag — returns the identical
403
Associated data — the "AD" in AEAD
- Data you want authenticated but NOT encrypted — stays readable, but tamper-evident
- Example: a protocol header, sequence number, or recipient/context field a router needs to read
- If that data is left unauthenticated, an attacker can splice a valid ciphertext onto a different header — a context-confusion forgery, even though the ciphertext itself is untouched
This is not a toy bug
- CWE-347 — Improper Verification of Cryptographic Signature (the tag/MAC isn't checked first)
- CWE-757 — Selection of Less-Secure Algorithm (unauthenticated CBC where AEAD belongs)
- CWE-208 — Observable Timing/Response Discrepancy (the 200-vs-403 side channel itself)
- Real CVEs, not hypothetical: POODLE (2014, SSLv3 CBC padding oracle), Lucky-13 (TLS CBC timing variant) — deployed in production TLS for over a decade
🔓 Game — "Read the Secret Without the Key"
- Two live targets, same secret (contains your flag):
:8098= AES-CBC (vulnerable),:8099= AES-GCM (fixed) - Attacker only ever sees
200/403from/decrypt— never the plaintext, never the key - Run the padding-oracle attack on
:8098→ recovermsg:FLAG{...}byte by byte - Run the identical attack on
:8099→ zero usable signal, nothing recovered — AEAD holds
Lab today
📋 Worksheet 6 —
labs/week06-aead/worksheet.md· kickoff:docker compose up -d(:8098CBC,:8099GCM) →python exploit.py
- Tasks 0–5: read the oracle branch, capture ciphertext, derive
D(C_t)[15] = guess ⊕ 0x01, recover the flag, confirm GCM gets no signal - Deliverable: recovered flag + which byte you forced to
0x01/0x02 0x02and why - + Audit the AI (a "safe" decrypt endpoint that reintroduces the oracle via
400/422/200), EiPE, Prompt Problem - Part 2: 8 conventional written questions (Q1–Q8), no AI-resilience layer — answer yourself
Key takeaways
- Textbook-secure primitive, real-system failure: AES-CBC is a fine cipher — "AES-CBC + tell me if the padding failed" is not
- The fix isn't hiding the error message — it's composing correctly (encrypt-then-MAC) or reaching for a real AEAD cipher (AES-GCM)
- Any distinguishable response to an attacker-controlled input is a potential oracle — ask what your error codes reveal
Questions?
Next week: Review — consolidating Weeks 1–6 before the midterm