Padding Oracle Attacks Explained

Padding Oracle Attacks Explained - article cover image Security
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

A padding oracle attack is an adaptive chosen-ciphertext attack against block ciphers used in CBC mode with a standard padding scheme, most commonly PKCS#7. It does not break the cipher itself — AES stays AES — it abuses an application that leaks, through an error message, a status code, or a timing difference, whether a decrypted plaintext’s padding was valid. Given nothing more than that single bit of information and the ability to submit crafted ciphertexts repeatedly, an attacker can decrypt an entire ciphertext byte by byte without ever knowing the key, and in many implementations can also forge arbitrary ciphertexts that decrypt to attacker-chosen plaintext.

The attack was formalized by Serge Vaudenay in 2002 and went on to compromise a long list of real systems: ASP.NET’s default ViewState/forms-auth encryption (the Rizzo/Duong “Padding Oracle” disclosure of 2010), JSF and Ruby on Rails encrypted cookies, IPsec IKE implementations, and TLS itself through the closely related POODLE and Lucky 13 issues. It matters because it turns a seemingly cosmetic implementation detail — which error an API returns — into a full decryption and, often, encryption oracle for any data protected under that key.

Attack Prerequisites

Padding oracle attacks need a narrow but common set of conditions:

  • CBC-mode decryption with block padding (PKCS#7/PKCS#5) somewhere the attacker can submit ciphertext — a session cookie, an encrypted URL parameter, an API payload, a SOAP/XML encrypted field.
  • A distinguishable failure signal on bad padding vs. good padding: a different HTTP status code, a different error string, a different response size, or a measurable timing difference.
  • Ability to resubmit ciphertext repeatedly — the endpoint must not rate-limit or lock out after a modest number of decryption attempts (attacks typically need on the order of 128 requests per plaintext byte in the worst case).
  • Knowledge of the IV/first block or a two-block ciphertext — the attacker needs at least two ciphertext blocks (an IV plus one block, or two data blocks) to recover one block of plaintext.

How It Works

CBC decryption computes plaintext block P[i] = D(K, C[i]) XOR C[i-1], where D is the raw block-cipher decryption and C[i-1] is the previous ciphertext block (or the IV for the first block). PKCS#7 padding fills the last block so its final N bytes all equal the byte value N (e.g. ...05 05 05 05 05). After decryption, the application strips this padding — and it is exactly that strip step that leaks information if success/failure is observable.

The attacker takes a target ciphertext block C[i] and pairs it with an attacker-controlled ‘previous’ block C’. By flipping bits in C’, the attacker changes the decrypted intermediate value D(K, C[i]) XOR C’ one byte at a time and resubmits, watching whether the oracle reports valid or invalid padding. Because valid padding of 01 is trivially achievable by flipping the last byte, the attacker first finds the byte of C’ that produces a valid 01 pad — this reveals D(K, C[i])’s last byte via XOR. The attacker then forces 02 02 in the last two bytes and solves for the second-to-last byte, and so on, recovering the full intermediate value D(K, C[i]) for that block. XORing that intermediate value with the real, original C[i-1] yields the true plaintext block — no key required.

Because D(K, C[i]) is now known, the attack additionally yields an encryption oracle: pairing that same C[i] with any attacker-chosen ‘previous’ block C” produces a ciphertext pair that decrypts to D(K, C[i]) XOR C”, i.e. the attacker can forge ciphertext that decrypts to any plaintext of their choosing. This is exactly how the ASP.NET ViewState padding oracle was escalated into arbitrary file read/write on the server: the forms-auth encryption was reachable for both directions.

Vulnerable Code / Configuration

The bug is almost never in the cipher call — it is in how the application reports decryption failures. Here a Flask endpoint decrypts an authentication token and distinguishes padding failure from every other outcome:

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

@app.route('/session/decode', methods=['POST'])
def decode_session():
    raw = base64.b64decode(request.json['token'])
    iv, ct = raw[:16], raw[16:]
    cipher = AES.new(SESSION_KEY, AES.MODE_CBC, iv)
    padded = cipher.decrypt(ct)
    try:
        plaintext = unpad(padded, 16)          # PKCS#7 strip
    except ValueError:
        # VULNERABLE: distinct signal for a padding failure
        return {'error': 'invalid padding'}, 400
    if not verify_session(plaintext):
        return {'error': 'invalid session'}, 401
    return {'ok': True}
Python

Two additional variants of the same root cause show up constantly in real code review: encrypt-then-decrypt pipelines with no integrity check at all (CBC ciphertext with no HMAC/AEAD, so any padding failure is directly observable), and pipelines that check the MAC only *after* unpadding, which still leaks the padding-validity bit even though a MAC is present:

# VULNERABLE ORDER: unpad before verifying integrity
def decrypt_and_verify(token, key, mac_key):
    iv, ct, tag = token[:16], token[16:-32], token[-32:]
    cipher = AES.new(key, AES.MODE_CBC, iv)
    padded = cipher.decrypt(ct)
    plaintext = unpad(padded, 16)      # <- fails/raises BEFORE MAC check
    expected = hmac.new(mac_key, ct, hashlib.sha256).digest()
    if not hmac.compare_digest(expected, tag):
        raise ValueError('bad mac')
    return plaintext
Python

The correct pattern is encrypt-then-MAC, verify the MAC first in constant time, and only attempt to unpad — with a single generic error for any failure — after the MAC has already succeeded. Better still, use an AEAD cipher (AES-GCM, ChaCha20-Poly1305) so there is no separate padding step to leak in the first place.

Walkthrough / Exploitation

Given the vulnerable endpoint above, the classic tool is PadBuster (GDS Security), which automates the full Vaudenay algorithm against any HTTP oracle. First, identify the block size and confirm the oracle behavior by tampering with a single byte of a captured token:

# captured token (base64, url-encoded) from a Set-Cookie or hidden field
perl padBuster.pl https://target.example/session/decode \
  'dGhpc2lzYXNhbXBsZXRva2Vu...' 16 \
  -cookies 'session=dGhpc2lzYXNhbXBsZXRva2Vu...' \
  -error 'invalid padding'
Bash

PadBuster brute-forces the last byte of the final block first (256 candidate values for the injected byte, looking for a response that is NOT the ‘invalid padding’ error), then extends to two, three, and more padding bytes, recovering the full intermediate state and thus the plaintext block by block, right to left. For a manual proof-of-concept the core loop looks like:

# simplified single-byte-recovery core of the attack
def recover_last_byte(oracle, iv_or_prev_block, target_block):
    for guess in range(256):
        forged_prev = bytearray(iv_or_prev_block)
        forged_prev[-1] ^= guess
        ct = bytes(forged_prev) + target_block
        if oracle(ct):                 # True == padding accepted
            intermediate_last_byte = guess ^ 0x01 ^ iv_or_prev_block[-1]
            return intermediate_last_byte
    raise RuntimeError('oracle inconsistent')
Python

Once every byte of the block’s intermediate value D(K, C[i]) is recovered, the attacker XORs it with the *real* previous ciphertext block to obtain the true plaintext, and — separately — can XOR it with any chosen plaintext to forge a new valid ciphertext for that block, which is the step that turns cookie decryption into arbitrary token forgery.

Note: The oracle need not be an explicit error string. Status code (500 vs 400 vs 200), response length, and timing (as exploited by Lucky 13 against TLS’s CBC suites) are all valid oracle channels. Unifying only the error *text* while keeping a different status code or timing is not a fix.

Opsec: Padding oracle attacks are noisy — full recovery needs roughly 128 requests per byte worst case, so throttle to avoid WAF/IDS triggers and prefer PadBuster’s built-in delay/thread options over a tight loop.

Detection and Defense

The fix is architectural, not cosmetic — do not try to patch the error message alone:

  • Use an AEAD cipher (AES-GCM, AES-CCM, ChaCha20-Poly1305) instead of raw CBC + padding wherever possible; AEAD authenticates before decrypting and has no padding-validity signal to leak.
  • If CBC must be used, do encrypt-then-MAC and verify the MAC in constant time (hmac.compare_digest or equivalent) *before* attempting to remove padding, returning one generic error for any failure of either step.
  • Normalize error responses: identical status code, identical body, and constant-time behavior regardless of whether the failure was a MAC mismatch, a padding error, or a downstream validation error.
  • Rate-limit and monitor decryption endpoints — thousands of near-identical requests differing by a few bytes against a token-decode endpoint is a strong IOC for an in-progress padding oracle attack.
  • Prefer vetted libraries (libsodium’s secretbox, JWE with an AEAD algorithm, framework-provided signed/encrypted cookie helpers) over hand-rolled CBC pipelines.

Real-World Impact

The 2010 disclosure of a padding oracle in ASP.NET’s default ViewState/forms-authentication encryption by Rizzo and Duong affected a huge swath of production .NET applications and, because the same encryption protected internal file paths in some configurations, allowed attackers to escalate from cookie decryption to full arbitrary file read on the web root. The closely related POODLE (SSLv3 CBC padding) and Lucky 13 (TLS CBC timing) attacks against TLS itself drove the industry-wide move away from CBC cipher suites toward AEAD suites like AES-GCM and ChaCha20-Poly1305 in TLS 1.2/1.3, and padding oracle findings remain a routine, high-severity finding in web and API penetration tests wherever hand-rolled CBC encryption is exposed.

Conclusion

A padding oracle attack turns one bit of leaked information — did the padding parse — into a complete decryption, and often encryption, primitive against CBC ciphertext. The mathematics is old and well understood; what keeps the bug alive is applications that still hand-roll CBC decryption and let a try/except distinguish padding failure from everything else. Move to AEAD, or at minimum encrypt-then-MAC with constant-time verification and a single generic error, and the oracle disappears.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL