36. Padding Oracle: Secrets Revealed by Ciphertext
A padding oracle is a decryption interface whose external behavior reveals whether the padding of attacker-supplied ciphertext is valid. Even without seeing the key or decrypted result directly, an attacker can repeatedly query this one-bit response and recover CBC plaintext one byte at a time.
Start with an important correction: this is not an attack that "solves the message by looking only at the ciphertext." The attacker must submit modified ciphertext many times and observe the server's valid/invalid signal. AES itself is not what breaks. The vulnerable component is a protocol that uses CBC without integrity protection and exposes internal error states.
The exercise in this document uses only fixed local byte arrays. Do not attempt it against systems outside authorized CTFs, personal practice environments, or an explicitly approved assessment scope.
Conditions required for the attack
The following conditions must exist together.
- A value that the client can resubmit is encrypted with CBC mode and PKCS#7-style padding.
- The attacker can alter the IV or ciphertext blocks.
- There is no ciphertext authentication, or the server performs decryption and unpadding before checking authentication.
- Repeated requests can distinguish padding success from failure.
The oracle does not need to provide a helpful message such as PaddingError. Differences in HTTP
status code, body length, redirect location, whether a cookie is set, or how the connection closes are
enough. If only processing time differs, compare distributions across multiple samples. One unusually
fast or slow response is network noise, not evidence.
HTTPS does not automatically prevent a padding oracle in an application token. TLS protects data in transit, but if the server decrypts an encrypted cookie received inside TLS in a vulnerable way, the application-layer oracle remains.
What PKCS#7 padding validates
If the block size is 16 bytes and the plaintext is 13 bytes long, three bytes must be filled. PKCS#7
writes the number of remaining slots, 03, three times.
73 65 73 73 69 6f 6e 3d 67 75 65 73 74 03 03 03
s e s s i o n = g u e s t
Padding is not omitted even when the plaintext length is already a multiple of 16. A full new block is
added containing 10 sixteen times. Therefore, if the final byte is 04, the implementation checks
that all four final bytes are 04; if it is 01, it checks that the final byte is 01. This permits
unambiguous removal. The rule is defined in RFC 5652 §6.3.
Padding validation does not reveal whether the entire plaintext is correct. It reveals only whether the final byte sequence is syntactically valid. Combined with CBC's XOR structure, however, this small piece of information is sufficient to recover the plaintext.
Where the attack appears in the CBC equation
Let the current ciphertext block be C_i, the preceding block be C_{i-1}, and the decrypted plaintext
be P_i. Introducing the block cipher's intermediate value I_i, decryption is expressed as follows.
The attacker does not know the key and therefore cannot calculate I_i directly. But if the preceding
block is replaced with C'_{i-1}, the plaintext inspected by the server changes as follows.
Let the candidate for the final plaintext byte be g, and the desired padding be 01. Modify the final
byte of the preceding block as follows.
If g equals the actual plaintext byte, the final byte of the modified plaintext becomes the desired
01, and the oracle succeeds. However, longer valid padding already present in the original plaintext
can produce other successful candidates, so an additional check is required. Once the candidate is
confirmed to be the intended 01, the same relation can be solved as follows.
After learning the final byte, fix it so it decrypts to 02 and iterate over candidates for the byte
immediately before it. Then create 03 03 03, followed by 04 04 04 04, and so on. Repeating this 16
times from right to left recovers one block.
Sequence for recovering one block
- Keep the target
C_ifixed and vary only the final byte ofC_{i-1}from 0 through 255. - When a padding-success candidate appears, confirm that it is the intended
01. An existing02 02or03 03 03at the end of the original plaintext may succeed by coincidence. - Fix the recovered byte to the padding value for the next round.
- Increase the target padding through
02,03, …,10, moving left each time. - Move to the preceding block and repeat. The first plaintext block can be recovered in the same way when the attacker can submit a modified IV.
Assuming the correct candidate's position is uniformly distributed, one 16-byte AES block requires an
expected 16 × 128 = 2,048 oracle queries. The actual count depends on the plaintext byte distribution
and search order. The worst-case naive search takes 4,096 queries, with false-positive checks and
network retries adding more. A slow oracle response does not remove the vulnerability.
Safe local exercise
The attack does not calculate D_K(C_i). It uses only the fact that the intermediate value I_i
remains fixed while attacking the same C_i. The code below therefore models the intermediate value
as a secret byte string without an AES library or network. The attack loop sees only the boolean returned by oracle().
BLOCK = 16
def xor_bytes(left: bytes, right: bytes) -> bytes:
return bytes(a ^ b for a, b in zip(left, right, strict=True))
def pkcs7_pad(data: bytes) -> bytes:
length = BLOCK - len(data) % BLOCK
return data + bytes([length]) * length
def valid_pkcs7(block: bytes) -> bool:
if len(block) != BLOCK:
return False
length = block[-1]
return 1 <= length <= BLOCK and block[-length:] == bytes([length]) * length
# Model I_i = D_K(C_i) for a fixed C_i. The attack code does not know this value.
plaintext = pkcs7_pad(b"session=guest")
previous = bytes.fromhex("00112233445566778899aabbccddeeff")
intermediate = xor_bytes(previous, plaintext)
queries = 0
def oracle(candidate_previous: bytes) -> bool:
global queries
queries += 1
candidate_plaintext = xor_bytes(intermediate, candidate_previous)
return valid_pkcs7(candidate_plaintext)
def recover_block(original_previous: bytes) -> bytes:
recovered = bytearray(BLOCK)
for index in reversed(range(BLOCK)):
pad = BLOCK - index
crafted = bytearray(original_previous)
# Fix already recovered bytes on the right to the current target padding.
for known in range(index + 1, BLOCK):
crafted[known] = original_previous[known] ^ recovered[known] ^ pad
for guess in range(256):
crafted[index] = original_previous[index] ^ guess ^ pad
if not oracle(bytes(crafted)):
continue
# Flip a byte outside the target padding to reject false positives from existing padding.
if index > 0:
verification = bytearray(crafted)
verification[index - 1] ^= 1
if not oracle(bytes(verification)):
continue
recovered[index] = guess
break
else:
raise RuntimeError(f"no candidate at byte {index}")
return bytes(recovered)
recovered = recover_block(previous)
assert valid_pkcs7(recovered)
pad_length = recovered[-1]
print(recovered[:-pad_length].decode())
print(f"queries: {queries}")
The first output line is session=guest. The query count varies with the candidate byte values, but the
attack function does not reference plaintext or intermediate. In a real environment, a request that
sends a modified token and a response classifier would replace the local function call.
False positives and unstable oracles
If the original plaintext already ends in 03 03 03, the candidate that leaves the final byte unchanged
also appears "valid." Recording it as the discovered 01 causes subsequent recovery to fail. The
exercise above flips the byte immediately before the target padding region one more time. Genuine 01
padding still succeeds because it checks only the last byte, while the coincidentally preserved
03 03 03 breaks.
Production services introduce more variables.
- A load balancer or multiple application versions may produce different errors.
- Rate limits, retries, and caches may blur response classification.
- Compression, Base64 normalization, and URL encoding may change the bytes before they arrive.
- A timing oracle requires far more samples and statistical validation than a status-code oracle.
Do not conclude that a vulnerability exists from one or two differing responses. Repeat the same mutation at the same position against the same baseline token to confirm reproducibility, and cross-check against server logs and code paths when possible.
Defense: authenticate before hiding errors
The best solution is not to carefully disguise CBC errors, but to replace CBC with authenticated encryption.
1. Use AEAD for new designs
Use an AEAD such as AES-GCM or ChaCha20-Poly1305 from a validated library. AEAD provides confidentiality and integrity through one interface, so ciphertext with an invalid authentication tag is not passed on as plaintext.
- Never reuse a nonce with the same key. Follow the recommended length for the algorithm and the generation strategy of a validated library. A counter-based approach must preserve state so values remain unique after restarts; a randomized approach must define a collision budget and key-rotation point. GCM nonce reuse in particular is a separate critical vulnerability.
- Encode values that are not encrypted but must not be modified—such as the version, algorithm identifier, and protocol context—unambiguously and authenticate them as AAD.
- Do not parse or partially use a decrypted value after authentication fails.
- Map the library's authentication failure to one generic external error.
TLS 1.3's use of AEAD to model every record cipher, unlike earlier versions, follows the same design direction.
2. Use Encrypt-then-MAC for legacy CBC
If immediate replacement is impossible, use a MAC key separate from the encryption key and verify the MAC over the IV and entire ciphertext first, before CBC decryption and unpadding.
receive(iv, ciphertext, tag)
expected = HMAC(mac_key, version || iv || ciphertext)
if !constant_time_equal(tag, expected):
return generic_failure
padded = CBC_decrypt(enc_key, iv, ciphertext)
plaintext = strict_unpad(padded)
return parse(plaintext)
Decrypting before MAC verification exposes the padding path to external input again. The same key must not be reused for encryption and the MAC. For new code, choose an AEAD API instead of designing the composition order and key separation yourself.
3. Uniform errors are a secondary defense
Return padding errors, MAC errors, and parsing errors with the same status code and body, distinguishing them only in internal logs. This alone is not sufficient. Differences in code paths and processing volume can still create a timing oracle, and an arbitrary fixed sleep cannot control scheduling and network noise. The root solution is to prevent unauthenticated ciphertext from reaching the decryption path.
Verification checklist
- Is the client-supplied IV and ciphertext never decrypted before authentication?
- After an authentication-tag failure, are plaintext, padding, and parser state left entirely unused?
- Do error status codes, bodies, headers, and redirects appear as one outcome externally?
- Are timing differences validated across repeated samples rather than a single request?
- Is AEAD nonce reuse under one key prevented across restarts and multiple instances?
- Does the MAC scope for legacy Encrypt-then-MAC include the version, IV, and entire ciphertext?
- Do regression tests cover one valid token and multiple kinds of modified tokens?
Related documents
- A Quick Feel for AES-CBC Bit-Flipping Attacks — how the same XOR malleability leads to value modification in CBC without integrity protection
- Introduction to Cryptographic Attack Techniques — an overview that considers padding oracles, bit-flipping, and ECB together
- ECB Cut-and-Paste — another failure involving block-mode selection and missing authentication
References
- RFC 5652 §6.3 — Content-encryption Process — CMS/PKCS#7 padding-byte rules
- Serge Vaudenay, “Security Flaws Induced by CBC Padding” (EUROCRYPT 2002) — the original paper that systematized the padding oracle
- OWASP WSTG-CRYP-02 — Testing for Padding Oracle — observable signals and test conditions
- RFC 5116 — Authenticated Encryption — AEAD interfaces and nonce requirements
- RFC 8446 §5.2 — TLS 1.3 Record Payload Protection — TLS 1.3 AEAD record protection