GPUStack 请求头信任引发管理员绕过
文章分析 GPUStack 2.1.1 的认证绕过:嵌入式网关模式下,客户端 IP 取自请求头,而本地回环地址可能触发免认证管理员身份。作者称已本地复现绕过、持久化与修复过程。
AI 深度解读
GPUStack 2.1.1 的这次漏洞,把「请求来自本机」当成了管理员凭证;默认部署模式下,判断来源的依据又是一个 HTTP 请求头。两个信任假设叠在一起,未经登录的请求就可能拿到完整管理权限。
作者在本地复现了认证绕过、管理员凭证持久化和 2.2.0 的修复效果。对维护 AI 服务或 Node 网关的程序员来说,最值得看的是:客户端身份如何经过代理进入应用,又在哪一步被错误地换成了权限。
一个请求头为什么能变成管理员凭证
GPUStack 是用于部署 AI 模型的开源 GPU 集群管理器,认证逻辑位于 gpustack/api/auth.py。它先通过下面的函数判断客户端 IP:
def client_ip_getter(request: Request) -> str:
if request.app.state.server_config.gateway_mode == GatewayModeEnum.embedded:
return request.headers.get("X-GPUStack-Real-IP", "")
else:
return request.client.host
要看的是 request.headers.get:在 embedded 网关模式下,函数直接读取 X-GPUStack-Real-IP,这里没有验证这个值是否来自可信网关。
随后,get_current_user 中还有一条本地访问捷径:
if user is None and client_ip_getter(request=request) == "127.0.0.1":
if not server_config.force_auth_localhost:
user = await User.first_by_field(session, "is_admin", True)
这段代码服务于本地 CLI 和同机网关的免认证访问。要看最后一行:请求尚未获得用户身份,只要 IP 判断通过,就会查出管理员用户并赋给它。
force_auth_localhost 默认是 False,embedded 又是默认部署形态。于是,客户端提供的地址声明,经过本地访问分支,变成了管理员身份。这里匹配的还是字面字符串 127.0.0.1,并非所有回环地址。
漏洞成立的关键,是未经验证的来源信息直接参与了授权。
复现证明了认证问题,但没有跑完整个网关链路
作者从 PyPI 安装 GPUStack 2.1.1,数据库使用 PostgreSQL 16。测试机没有 GPU,API 服务仍然可以运行。
第一组使用真实二进制,指定 --gateway-mode disabled,监听 0.0.0.0:30080。这个模式读取 TCP 对端地址:来源为 127.0.0.1 时,访问 GET /v2/users 返回 200 和管理员用户列表;来源改成 127.0.0.2,返回 401 Invalid authentication credentials。即使后者携带伪造请求头,也仍然是 401。
第二组在 30081 端口运行真实 create_app(),配置为 gateway_mode="embedded"。此时,127.0.0.2 来源不带请求头返回 401,携带 X-GPUStack-Real-IP: 127.0.0.1 则返回 200;把头里的值改为 127.0.0.2,又回到 401。
这些对照把问题定位到了 embedded 模式下的来源判断。
但实验边界也要保留。沙箱无法连接虚拟机外部 IP,作者用第二个回环地址模拟远端来源;完整 embedded 启动依赖 Kubernetes API 和 Higress 网关,测试没有启动网关进程。因此,它验证了真实应用的认证代码如何处理这个头,没有完整验证公网请求经过实际网关后的行为。
运行旧版本还需要修补两条数据库迁移 SQL,以处理 psycopg2 与 psycopg3 参数类型差异,并固定 fastapi==0.128.0。作者称较新的 FastAPI 移除了旧调用需要的 response_model_exclude_unset,这些兼容处理没有修改 auth.py。
只绕过一次,为什么补丁之后还要查凭证
管理员可以创建正常凭证,把一次绕过变成持续访问。
作者从本地来源、在没有认证信息的情况下调用 POST /v2/api-keys,提交 {"name": "persistence-demo"},得到 200、id 1,以及截断展示为 gpustack_d6da8176… 的密钥。响应中的 expires_at: null 表示没有设置过期时间。
随后,作者从模拟远端地址分别通过 Bearer token 和 X-API-Key 使用这把密钥,两种方式都拿到了完整管理员用户列表。后续请求已经不依赖伪造来源头,服务端也无法仅凭这把合法签发的密钥判断它是否由攻击者创建。
原文引用的公告编号为 GHSA-gj9m-f8ph-pmhr,对应 CVE-2026-102895,CVSS 3.1 评分为 9.8。公告称,攻击者曾利用真实 2.1.1 部署创建自定义推理后端,在 worker 主机上打开反向 shell;影响还包括用户和模型修改、worker 与云服务商秘密泄露,以及通过 POST /v2/inference-backends/from-yaml 执行代码。
作者没有执行这个远程代码执行步骤。认证绕过和密钥持久化是其本地实测,worker 上的执行链来自公告,两者不能混写成完整复现。
据原文,CVE 于 2026 年 11 月 4 日发布,CISA 于 11 月 9 日将其加入已知被利用漏洞目录,联邦机构修补期限为 11 月 12 日。这些外部事件在本文中按原文转述,未作独立核验。
2.2.0 为什么要同时改授权和代理信任
GPUStack 2.2.0 的修复有两处。
首先,get_current_user 彻底删除了本地来源自动成为管理员的分支。即使请求确实来自本机,也必须提供凭证。
其次,client_ip_getter 只有在 gateway_token_auth 成功后才读取来源头。请求必须携带与服务器派生网关令牌匹配的 X-GPUStack-Auth-Token;校验不通过,就忽略该头,回退到 TCP 对端地址。
前者取消免认证授权,后者为代理提供的身份信息补上可信条件。
作者在相同 embedded 测试框架中验证:2.2.0 的本地无认证请求返回 401,模拟远端携带伪造来源头也返回 401,合法管理员 Basic Auth 返回 200。真实 2.2.0 二进制在 disabled 模式下也得到本地无认证 401、管理员认证 200 的结果。原文将修复对应到提交 c6e6b91。
我的判断
这篇文章有价值的地方,是把认证捷径、代理信任和凭证生命周期连成了一条可检查的链。disabled 模式的对照也解释了为何同一个请求头只在特定配置下有效。
但标题里的「一个头拿下管理员」有部署前提。作者没有运行 Higress,无法仅凭这个实验断言所有实际网关都会把外部伪造头原样送入应用。原文引用的真实利用记录支持风险已经发生,具体入口、网关配置和请求经过哪些处理,文中没有交代清楚。
反向代理因此也不能一概视为无效。配置正确的代理可以覆盖外部同名头,并限制后端直连;问题在于,不能只凭「放在代理后面」就认定安全。与依靠部署约定相比,2.2.0 在应用中强制认证、验证网关身份,更容易形成明确的安全边界。
升级关闭的是漏洞入口,已经签发的管理员凭证仍需单独处理。
能拿来做什么
- 如果维护 GPUStack,按原文建议升级到 2.2.0 或更高版本;暂时无法升级时,使用
--force-auth-localhost关闭本地免认证分支,并安排后续升级。 - 对可能暴露过的实例,通过
GET /v2/api-keys检查密钥,同时审计用户和不认识的 YAML 推理后端,轮换 worker 凭证及云服务商秘密,不能把补丁安装成功当成排查结束。 - 检查 Node 服务和内部管理 API 中按 localhost、来源 IP 或代理头跳过认证的逻辑。来源判断与用户认证应分别验证,尤其要确认后端是否允许绕过代理直连。
- 用 AI 辅助审查认证代码时,要求它追踪「请求头从哪里进入、谁有权设置、最终影响什么权限」,并列出本地无凭证、伪造代理头、合法凭证三类测试。这个案例说明,只检查登录接口还不够。