◐ Off-By-One · answer catalog

python-jwt-alg-confusion-rs256-hs256

2 answer(s)pythonpython3pythonpython3

raise AlgorithmMismatchError("header is missing the alg claim")

📦 Source in repository (JSON)

Answer 1

Files: ~/hardened_jwt.py (hardened verifier) and ~/test_hardened_jwt.py (23 tests).

The bug (CVE-2015-9235 class)

The vulnerable verifier takes alg from the token header. An attacker grabs the server's RSA public key (public knowledge), signs {"sub":"admin","role":"admin"} with HS256 using the public-key PEM bytes as the HMAC secret, and sets alg: HS256. The buggy server sees HS256 and uses its public key bytes as an HMAC secret — the HMAC matches, and the forged admin token is accepted. A vulnerable reference verifier is included in the test file and demonstrates exactly that (vulnerable_verify(forged, pubkey) returns the admin payload).

The fix: HardenedJWT.verify(token, key, expected_alg)

The expected_alg is pinned by the caller and is the only algorithm ever accepted — the header's alg must match it byte-for-byte. Combined with strict key-type/alg binding:

# hardened_jwt.py — algorithm pinning (excerpt)
if "alg" not in header:
    raise AlgorithmMismatchError("header is missing the alg claim")
alg = header["alg"]
if not isinstance(alg, str):
    raise AlgorithmMismatchError(...)
if alg == "none":
    raise NoneAlgorithmError("alg=none is never accepted")   # defense vs alg=none
if alg not in KNOWN_ALGS:
    raise AlgorithmMismatchError(...)
if alg != expected_alg:
    raise AlgorithmMismatchError(
        f"header alg {alg!r} does not match the pinned algorithm {expected_alg!r}")

# Key-type <-> algorithm binding (excerpt)
if alg in SYMMETRIC_ALGS:                       # HS256/384/512
    # str/bytes secret ONLY — an RSA/EC key object is refused
    if not isinstance(key, (str, bytes)):
        raise KeyTypeError("...refusing to use an asymmetric key object as an HMAC secret")
    if not allow_pem_hmac_secret and b"-----BEGIN" in key_bytes:
        raise KeyTypeError("refusing to use PEM-encoded key material as an HMAC secret "
                           "(RS256->HS256 confusion defense)")
else:                                           # RS*/PS*/ES*/EdDSA
    # raw bytes/str are NEVER accepted as a public key
    if isinstance(key, (str, bytes)):
        raise KeyTypeError("...raw bytes/str are never accepted as the verification key")
    if hasattr(key, "private_bytes"):           # private key refused
        raise KeyTypeError("...supply the public key")
    _check_public_key_family(key, alg)          # RSA vs EC vs Ed25519 + curve check

This blocks the attack three independent ways: the forged token says HS256 while the server pins RS256 → AlgorithmMismatchError; if a server were misconfigured to pin HS256, an RSA key object → KeyTypeError, and the PEM bytes as a "secret" → KeyTypeError (PEM-as-HMAC defense). Signatures are genuinely verified (cryptography: RSA PKCS1v15/PSS, ECDSA raw r‖s, Ed25519, constant-time HMAC).

Embedded-JWK confusion: any jwk / jku / x5u / x5c header → EmbeddedKeyError. The verification key is always the caller's, never the token's.

Duplicate alg headers: JSON is parsed with a strict object_pairs_hook that rejects any repeated key, so {"alg":"none","alg":"RS256"} (which Python's default parser collapses to last-wins RS256, other parsers to first-wins) → DuplicateClaimError. Duplicate payload keys are rejected too, and the token must have exactly 3 non-empty base64url segments.

def _reject_duplicate_keys(pairs):
    obj = {}
    for key, value in pairs:
        if key in obj:
            raise DuplicateClaimError(f"duplicate JSON key {key!r} in token segment")
        obj[key] = value
    return obj
# ...used as: json.loads(text, object_pairs_hook=_reject_duplicate_keys)

Caller usage: HardenedJWT.verify(token, public_key, "RS256") or HardenedJWT.verify_pem(token, public_pem, "RS256") for asymmetric, HardenedJWT.verify(token, secret, "HS256") for symmetric. Never derive the algorithm from the token.

Evidence & signatures

`python3 test_hardened_jwt.py` → **23/23 PASS** (also `pytest`: 23 passed). Core exploit test (`test_forged_hs256_signed_with_public_key_bytes_fails`) proves a token signed with the public-key bytes fails under every presentation (RS256-pinned → `AlgorithmMismatchError`; HS256-pinned with key object → `KeyTypeError`; HS256-pinned with PEM bytes → `KeyTypeError`).

Live demonstration:

```
Attacker builds HS256 token with signature = HMAC(RSA public key bytes)
  vulnerable_verify(forged, pubkey)      -> {'sub': 'admin', 'role': 'admin', 'iss': 'attacker'}   # BUG

Hardened verifier (pinned RS256, public key object):
  REJECTED: verify(forged, RSA pubkey obj, 'RS256')
    -> AlgorithmMismatchError: header alg 'HS256' does not match the pinned algorithm 'RS256'
  REJECTED: verify(forged, RSA pubkey obj, 'HS256')
    -> KeyTypeError: ... refusing to use an asymmetric key object as an HMAC secret
  REJECTED: verify(forged, PEM bytes,     'HS256')
    -> KeyTypeError: refusing to use PEM-encoded key material as an HMAC secret
  REJECTED: alg=none            -> NoneAlgorithmError: alg=none is never accepted
  REJECTED: embedded jwk        -> EmbeddedKeyError: ... 'jwk'
  REJECTED: duplicate alg       -> DuplicateClaimError: duplicate JSON key 'alg' in token segment

Legitimate RS256 token still verifies: {'sub': 'alice', 'role': 'user'}
```

Edge cases covered: valid RS256/HS256/ES256/PS256 round-trips (incl. PEM loading); alg=none; missing/unknown/fuzzy algs (`RS257`, `rs256`, `"RS256 "`, `123`); pinned-alg mismatch both directions; key-type mismatch both directions (HS+key-object, RS+string secret, RS+raw bytes); private key refused; key-family/curve mismatch (ES256 vs RSA key); embedded `jwk`/`jku`/`x5u`/`x5c`; duplicate `alg` in all three orderings; duplicate payload key; tampered signature and tampered payload; wrong segment counts (`1/2/4` segments, `".."`, `""`); invalid base64url chars; non-object header/payload. Bonus: Python's default parser collapses `{"alg":"none","alg":"RS256"}` to last-wins `RS256` — confirming the ambiguity the duplicate-key defense kills — and PyJWT 2.10.1 with `algorithms=["RS256"]` also rejects the forgery (`InvalidAlgorithmError`), while its own PEM-as-HMAC check raises `InvalidKeyError` for the HS256 misconfiguration (same defense-in-depth as this library).
{"model": "deepseek-v4-flash", "problem_class": "python-jwt-alg-confusion-rs256-hs256", "result": "passed", "tests": 23}

Answer 2

Files: ~/hardened_jwt.py (hardened verifier) and ~/test_hardened_jwt.py (23 tests).

The bug (CVE-2015-9235 class)

The vulnerable verifier takes alg from the token header. An attacker grabs the server's RSA public key (public knowledge), signs {"sub":"admin","role":"admin"} with HS256 using the public-key PEM bytes as the HMAC secret, and sets alg: HS256. The buggy server sees HS256 and uses its public key bytes as an HMAC secret — the HMAC matches, and the forged admin token is accepted. A vulnerable reference verifier is included in the test file and demonstrates exactly that (vulnerable_verify(forged, pubkey) returns the admin payload).

The fix: HardenedJWT.verify(token, key, expected_alg)

The expected_alg is pinned by the caller and is the only algorithm ever accepted — the header's alg must match it byte-for-byte. Combined with strict key-type/alg binding:

# hardened_jwt.py — algorithm pinning (excerpt)
if "alg" not in header:
    raise AlgorithmMismatchError("header is missing the alg claim")
alg = header["alg"]
if not isinstance(alg, str):
    raise AlgorithmMismatchError(...)
if alg == "none":
    raise NoneAlgorithmError("alg=none is never accepted")   # defense vs alg=none
if alg not in KNOWN_ALGS:
    raise AlgorithmMismatchError(...)
if alg != expected_alg:
    raise AlgorithmMismatchError(
        f"header alg {alg!r} does not match the pinned algorithm {expected_alg!r}")

# Key-type <-> algorithm binding (excerpt)
if alg in SYMMETRIC_ALGS:                       # HS256/384/512
    # str/bytes secret ONLY — an RSA/EC key object is refused
    if not isinstance(key, (str, bytes)):
        raise KeyTypeError("...refusing to use an asymmetric key object as an HMAC secret")
    if not allow_pem_hmac_secret and b"-----BEGIN" in key_bytes:
        raise KeyTypeError("refusing to use PEM-encoded key material as an HMAC secret "
                           "(RS256->HS256 confusion defense)")
else:                                           # RS*/PS*/ES*/EdDSA
    # raw bytes/str are NEVER accepted as a public key
    if isinstance(key, (str, bytes)):
        raise KeyTypeError("...raw bytes/str are never accepted as the verification key")
    if hasattr(key, "private_bytes"):           # private key refused
        raise KeyTypeError("...supply the public key")
    _check_public_key_family(key, alg)          # RSA vs EC vs Ed25519 + curve check

This blocks the attack three independent ways: the forged token says HS256 while the server pins RS256 → AlgorithmMismatchError; if a server were misconfigured to pin HS256, an RSA key object → KeyTypeError, and the PEM bytes as a "secret" → KeyTypeError (PEM-as-HMAC defense). Signatures are genuinely verified (cryptography: RSA PKCS1v15/PSS, ECDSA raw r‖s, Ed25519, constant-time HMAC).

Embedded-JWK confusion: any jwk / jku / x5u / x5c header → EmbeddedKeyError. The verification key is always the caller's, never the token's.

Duplicate alg headers: JSON is parsed with a strict object_pairs_hook that rejects any repeated key, so {"alg":"none","alg":"RS256"} (which Python's default parser collapses to last-wins RS256, other parsers to first-wins) → DuplicateClaimError. Duplicate payload keys are rejected too, and the token must have exactly 3 non-empty base64url segments.

def _reject_duplicate_keys(pairs):
    obj = {}
    for key, value in pairs:
        if key in obj:
            raise DuplicateClaimError(f"duplicate JSON key {key!r} in token segment")
        obj[key] = value
    return obj
# ...used as: json.loads(text, object_pairs_hook=_reject_duplicate_keys)

Caller usage: HardenedJWT.verify(token, public_key, "RS256") or HardenedJWT.verify_pem(token, public_pem, "RS256") for asymmetric, HardenedJWT.verify(token, secret, "HS256") for symmetric. Never derive the algorithm from the token.

Evidence & signatures

`python3 test_hardened_jwt.py` → **23/23 PASS** (also `pytest`: 23 passed). Core exploit test (`test_forged_hs256_signed_with_public_key_bytes_fails`) proves a token signed with the public-key bytes fails under every presentation (RS256-pinned → `AlgorithmMismatchError`; HS256-pinned with key object → `KeyTypeError`; HS256-pinned with PEM bytes → `KeyTypeError`).

Live demonstration:

```
Attacker builds HS256 token with signature = HMAC(RSA public key bytes)
  vulnerable_verify(forged, pubkey)      -> {'sub': 'admin', 'role': 'admin', 'iss': 'attacker'}   # BUG

Hardened verifier (pinned RS256, public key object):
  REJECTED: verify(forged, RSA pubkey obj, 'RS256')
    -> AlgorithmMismatchError: header alg 'HS256' does not match the pinned algorithm 'RS256'
  REJECTED: verify(forged, RSA pubkey obj, 'HS256')
    -> KeyTypeError: ... refusing to use an asymmetric key object as an HMAC secret
  REJECTED: verify(forged, PEM bytes,     'HS256')
    -> KeyTypeError: refusing to use PEM-encoded key material as an HMAC secret
  REJECTED: alg=none            -> NoneAlgorithmError: alg=none is never accepted
  REJECTED: embedded jwk        -> EmbeddedKeyError: ... 'jwk'
  REJECTED: duplicate alg       -> DuplicateClaimError: duplicate JSON key 'alg' in token segment

Legitimate RS256 token still verifies: {'sub': 'alice', 'role': 'user'}
```

Edge cases covered: valid RS256/HS256/ES256/PS256 round-trips (incl. PEM loading); alg=none; missing/unknown/fuzzy algs (`RS257`, `rs256`, `"RS256 "`, `123`); pinned-alg mismatch both directions; key-type mismatch both directions (HS+key-object, RS+string secret, RS+raw bytes); private key refused; key-family/curve mismatch (ES256 vs RSA key); embedded `jwk`/`jku`/`x5u`/`x5c`; duplicate `alg` in all three orderings; duplicate payload key; tampered signature and tampered payload; wrong segment counts (`1/2/4` segments, `".."`, `""`); invalid base64url chars; non-object header/payload. Bonus: Python's default parser collapses `{"alg":"none","alg":"RS256"}` to last-wins `RS256` — confirming the ambiguity the duplicate-key defense kills — and PyJWT 2.10.1 with `algorithms=["RS256"]` also rejects the forgery (`InvalidAlgorithmError`), while its own PEM-as-HMAC check raises `InvalidKeyError` for the HS256 misconfiguration (same defense-in-depth as this library).
{"model": "deepseek-v4-flash", "problem_class": "python-jwt-alg-confusion-rs256-hs256", "result": "passed", "tests": 23}
Generated from the verified corpus · MIT licensedBack to the catalog