2026 年 7 月 31 日,GPUStack 的 GitHub Security Advisory 挂出 GHSA-gj9m-f8ph-pmhr:gpustack/api/auth.py里的get_current_user会把"来源被判定为回环"的请求直接提升为内置管理员。CVSS 3.1 9.8,CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H,CWE-287 / CWE-290 / CWE-346,受影响< 2.2.0,修复2.2.0。
三个多月后,这条记录的重量变了。2026 年 11 月 9 日,CISA 把 CVE-2026-102895 加进 KEV 目录,联邦整改截止日 2026 年 11 月 12 日——就是今天。公告里那句"observed exploited in the wild against a v2.1.1 deployment(攻击者自建 backend 起反弹 shell)"不再是一个附注,而是这条 CVE 的主语。
在它进入 KEV 之前,这条漏洞在中文语境里被概括成"默认配置下未认证 RCE"。这个概括需要一处修正:默认的 embedded 拓扑并不因为 TCP peer 而沦陷——那种部署下client_ip_getter根本不读 peer,它读的是网关写进去的一个请求头(这个头会不会被客户端自带的那份顶掉,取决于网关的覆盖语义,见 3.2,未确认)。真正的远程未认证路径是另一种形态:关掉内嵌网关(--gateway-mode disabled,或 external / incluster),再把 API 放到同机反向代理后面。此时 peer 恒为回环,判定直接命中。GHSA 把这种形态称为 "canonical self-hosted shape",报告者的端到端验证也是针对它。
修正之后,这条漏洞的骨架反而更清楚:认证判据是"来源",而"来源"有两条取值通道——socket peer 和网关写的头;两条通道都依赖"中间层不会说谎"这个前提,而这个前提在部署拓扑里随时会破。三行代码本身没有错,错在把一个网络位置当成了身份。
本文的增量放在五处:一是把gateway_mode如何决定取值通道、监听地址、网关上游这条链摊开,并把"默认部署到底打不打穿"拆成三层——默认 embedded 下远程请求不因 peer 命中;客户端自带的同名头能否顶掉网关写的那份(未确认);本机直连并自带该头仍可命中;二是逐行拆get_current_user与client_ip_getter,包括"写死127.0.0.1字面量"与"多 admin 无排序"这两个细节;三是把两条污染路径的可达条件分开写,未确认的部分标注未确认;四是把修复拆成两笔提交,纠正"一笔提交同时删分支又加固头部"的误读,并说明它们为什么必须合并才成立;五是对公告口径给出代码级的收窄。
验证边界:本文没有搭建 GPUStack 环境,未向任何实例发送请求;所有行为结论来自 v2.1.1 源码逐行阅读、git show的 diff 与公告原文。报告者针对 HEAD6bee5883的端到端验证属于报告者的实验,本文只转述。仓库切到v2.1.1(11accdf),下文行号均以该 tag 为准。
一、gateway_mode先把"来源"分成了两半
client_ip_getter只有五行,但它有一个前置分岔:
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
(gpustack/api/auth.py:51-55)
embedded走请求头,其它模式(disabled/external/incluster)走 socket peer。这个分岔同时决定了 API 监听在哪:
serving_host = (
"127.0.0.1"
if self._config.gateway_mode == GatewayModeEnum.embedded
else "0.0.0.0"
)
(gpustack/server/server.py:142-146)
config = uvicorn.Config(
app,
host=serving_host,
port=self._config.get_api_port(),
access_log=False,
log_level="error",
)
(gpustack/server/server.py:148-154)
get_api_port()在服务端就是api_port,默认 30080(gpustack/config/config.py:141)。于是不同部署下"来源"的取值完全不同:

embedded(默认)。API 只绑127.0.0.1:30080,公网侧只有网关端口。网关的上游被写死成回环:
def get_gpustack_higress_registry(cfg: Config) -> McpBridgeRegistry:
# this is only for non-incluster mode.
# In this case, the related resource will be installed into gateway namespace.
# default to incluster mode
registry_type = "dns"
domain = f"{cfg.service_discovery_name}.{cfg.gateway_namespace}.svc"
if cfg.gateway_mode != GatewayModeEnum.incluster:
registry_type = "static"
port = cfg.get_api_port()
if cfg.gateway_mode == GatewayModeEnum.external:
address = cfg.get_advertise_address()
elif cfg.gateway_mode == GatewayModeEnum.embedded:
address = "127.0.0.1"
domain = f"{address}:{port}"
(gpustack/gateway/__init__.py:115-128)
ingress 规则是path="/"全量前缀转发,绑定这份 registry 靠的是higress.io/destination注解(gpustack/gateway/__init__.py:219-232, 239)。注意这里的关键差别:网关从回环转发,但它会把真实客户端 IP 写进X-GPUStack-Real-IP(gpustack/gateway/__init__.py:546-562,token-usage 插件realIPToHeader),而 embedded 模式的client_ip_getter恰好只读这个头(前提:网关覆盖客户端自带的同名头,这一语义见 3.2,本文未确认)。
能确定的是:peer 不参与判定。至于远程请求最终拿到的是它自己的公网 IP 还是客户端自带的127.0.0.1,取决于网关对这个头是覆盖还是追加——该语义在仓库里不可见,本文未确认(见 3.2)。
顺带把一件常被说错的事说清:官方 compose 文件是 12 行,只发布了一个端口。
services:
gpustack-server:
image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-gpustack}/gpustack:${GPUSTACK_TAG:-latest}
container_name: gpustack-server
restart: unless-stopped
volumes:
- gpustack-data:/var/lib/gpustack
ports:
- "80:80"
volumes:
gpustack-data: {}
(docker-compose/docker-compose.server.yaml)
非 embedded + 同机反代。把内嵌网关关掉之后,API 改为绑0.0.0.0,client_ip_getter也切回request.client.host。这时如果前面还有一层同机代理(nginxproxy_pass http://127.0.0.1:30080、Caddy、cloudflared 的本地回源),每个远程请求到达 API 时的 TCP peer 都是127.0.0.1,判定命中。
这条路不是边角配置。--gateway-mode disabled是公开选项(docs/cli-reference/start.md:50),仓库文档里就用--gateway-mode disabled --api-port 80起本地服务(docs/development.md:24)。自建场景里把 TLS 交给既有 nginx、GPUStack 在后端起服务也是常见做法——但要注意disabled下serving_host是0.0.0.0(server.py:145),"只对本机可达"靠的是防火墙与端口映射,不是gateway_mode本身。GHSA 称这种形态为 "canonical self-hosted shape",报告者针对 HEAD6bee5883做过端到端验证——验证的就是这一种。
非 embedded + 直连。API 绑0.0.0.0,peer 就是客户端真实地址,判定不命中。这是安全的基线形态,也解释了为什么disabled本身不是问题,问题在"disabled + 同机代理"这个组合。
二、认证入口:get_current_user的第 89 行
get_current_user是 FastAPI 的依赖注入入口,几乎所有业务路由都挂在它下面。前半段是规规矩矩的多凭证解析:Basic、Cookie 里的 JWT、Bearer、X-API-Key,依次尝试。问题出在解析失败之后:
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)
if user:
if not user.is_active:
raise UnauthorizedException(message="User account is deactivated")
request.state.user = user
if api_key is not None:
request.state.api_key = api_key
return user
(gpustack/api/auth.py:89-98)
- 判据是字符串等值。不是"请求来自本机进程",不是"已通过本机 socket 认证",是一个可以被代理拓扑或请求头影响的字面量比较。
- 默认允许。开关叫force_auth_localhost,默认False(gpustack/config/config.py:151),字段说明写得很清楚:"Force authentication for requests originating from localhost (127.0.0.1). When set to True, all requests from localhost will require authentication."(config.py:87-89)。默认语义就是"本机不需要认证"。
- 提升到的是数据库里第一个 admin。User.first_by_field(session, "is_admin", True)走gpustack/mixins/active_record.py:120-123→first_by_fields(:138-149):SQL 只有WHERE is_admin = true,没有 ORDER BY,取result.first()。
第三点值得多说一句。数据库里只有一个 admin 时,命中的当然是启动时创建的admin(gpustack/server/server.py:410-437:username="admin"、is_admin=True,口令来自bootstrap_password或随机生成后写入<data_dir>/initial_admin_password)。但只要存在第二个is_admin=true的行,"第一个"就取决于数据库返回顺序——这直接影响应急:改掉内置 admin 的口令不保证恢复控制,可能仍取到内置那条,也可能取到攻击者留下的那条。
另有一处与流传说法不同的细节:这个分支不绕过账号的停用状态。命中之后仍然落到auth.py:92-94的if not user.is_active: raise UnauthorizedException。把 admin 停用之后,回环分支会失败关闭。is_active默认True(gpustack/schemas/users.py:49),正常部署下这道检查不会触发,但它确实存在。
还有一个更重要的判断:这三行不是遗留的调试代码。它有一个官方消费者。v2.1.1 的gpustack/cmd/reset_admin_password.py:
server_urls = ["http://localhost", "http://localhost:30080"]
(gpustack/cmd/reset_admin_password.py:40)
client = ClientSet(
base_url=server_url,
api_key=api_key,
)
user_me = client.users.get("me")
(gpustack/cmd/reset_admin_password.py:57-62)
api_key默认空。它先试http://localhost(默认端口 80,经内嵌网关),不通再试:30080。走网关那一跳时,网关看到的源是回环,于是把127.0.0.1写进X-GPUStack-Real-IP,embedded 模式的client_ip_getter原样返回——admin 身份到手。一个"管理员忘记密码后在服务器上重置"的运维动作,把"本机免认证"固化成了产品能力;在 embedded 模式里,这份信任没有消失,只是从 socket peer 搬到了请求头。
第二跳只在非 embedded部署里成立。embedded 下 CLI 直连:30080时不会带这个头,headers.get("X-GPUStack-Real-IP", "")返