Newshot
Account

127.0.0.1 是谁说的:GPUStack 回环信任绕过(CVE-2026-102895)

Summary

2026 年 7 月 31 日,GPUStack 的 GitHub Security Advisory 挂出 GHSA-gj9m-f8ph-pmhr: gpustack/api/auth.py 里的 get_current_user 会把"来源被判定为回环"的请求直接提升为内置管理员。CVSS 3.1 9.8 ,

Original text(via FreeBuf)

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)。于是不同部署下"来源"的取值完全不同:

image.png
image.png

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", "")返

The text above was extracted automatically from the source page and may miss images, tables or code blocks. The original is authoritative.

Published by FreeBuf
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗