Who Said 127.0.0.1: GPUStack Loopback Trust Bypass (CVE-2026-102895)
On July 31, 2026, GPUStack's GitHub Security Advisory published GHSA-gj9m-f8ph-pmhr: get_current_user in gpustack/api/auth.py directly promotes requests whose "source is judged to be loopback" to the built-in administrator. 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, affected < 2.2.0, fixed in 2.2.0.
More than three months later, the weight of this record changed. On November 9, 2026, CISA added CVE-2026-102895 to the KEV catalog, with a federal remediation deadline of November 12, 2026—today. The sentence in the advisory, "observed exploited in the wild against a v2.1.1 deployment (attackers self-hosted a backend to spawn a reverse shell)," is no longer a footnote but the subject of this CVE.
Before it entered KEV, this vulnerability was summarized in Chinese-language contexts as "unauthenticated RCE under the default configuration." This summary needs one correction: the default embedded topology does not fall because of the TCP peer—under that deployment client_ip_getter does not read the peer at all; it reads a request header written in by the gateway (whether this header can be overridden by the client's own copy depends on the gateway's overwrite semantics, see 3.2, unconfirmed). The real remote unauthenticated path is another shape: turn off the embedded gateway (--gateway-mode disabled, or external / incluster), then put the API behind a same-host reverse proxy. At
Who Said 127.0.0.1: GPUStack Loopback Trust Bypass (CVE-2026-102895)
(docker-compose/docker-compose.server.yaml)
Non-embedded + same-host reverse proxy. After turning off the embedded gateway, the API binds to 0.0.0.0, and client_ip_getter also switches back to request.client.host. In this case, if there is still a layer of same-host proxy in front (nginx proxy_pass http://127.0.0.1:30080, Caddy, cloudflared's local origin pull), the TCP peer of every remote request when it reaches the API is 127.0.0.1, and the check matches.
This path is not an edge-case configuration. --gateway-mode disabled is a public option (docs/cli-reference/start.md:50), and the repository docs themselves start a local service with --gateway-mode disabled --api-port 80 (docs/development.md:24). In self-hosted scenarios, handing TLS to an existing nginx while GPUStack serves on the backend is also a common practice—but note that under disabled, serving_host is 0.0.0.0 (server.py:145), and "reachable only from localhost" relies on the firewall and port mapping, not on gateway_mode itself. The GHSA calls this shape the "canonical self-hosted shape", and the reporter performed end-to-end verification against HEAD6bee5883—and this is exactly the one verified.
Non-embedded + direct connection. The API binds to 0.0.0.0, the peer is the client's real address, and the check does not match. This is the secure baseline shape, and it also explains why disabled itself is not the problem—the problem is the combination of "disabled + same-host proxy".
2. The Authentication Entry Point: Line 89 of get_current_user
get_current_user is FastAPI's dependency injection entry point, and almost all business routes hang beneath it. The first half is regular multi-credential parsing: Basic, JWT in Cookie, Bearer, X-API-Key, tried in order. The problem comes after parsing fails:
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)
- The criterion is string equality. It is not "the request came from a local process", not "authenticated via a local socket"—it is a literal comparison that can be influenced by proxy topology or request headers.
- Allowed by default. The switch is called force_auth_localhost, defaulting to False (gpustack/config/config.py:151), and the field description states it plainly: "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). The default semantics is exactly "localhost requires no authentication".
- What it elevates to is the first admin in the database. User.first_by_field(session, "is_admin", True) goes through gpustack/mixins/active_record.py:120-123 → first_by_fields (:138-149): the SQL is only WHERE is_admin = true, with no ORDER BY, taking result.first().
The third point is worth elaborating on. When there is only one admin in the database, what matches is of course the admin created at startup (gpustack/server/server.py:410-437: username="admin", is_admin=True, with the password coming from bootstrap_password or randomly generated and written to <data_dir>/initial_admin_password). But as long as a second row with is_admin=true exists, "the first" depends on the order the database returns—this directly affects incident response: changing the built-in admin's password does not guarantee restoring control; it may still pick up the built-in row, or it may pick up the one left behind by the attacker.
There is another detail that differs from what has been circulating: this branch does not bypass the account's deactivated state. After matching, it still falls into the if not user.is_active: raise UnauthorizedException at auth.py:92-94. After the admin is deactivated, the loopback branch fails closed. is_active defaults to True (gpustack/schemas/users.py:49), and under normal deployments this check will not trigger, but it does exist.
There is also a more important judgment: these three lines are not leftover debug code. It has an official consumer. v2.1.1's 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 defaults to empty. It first tries http://localhost (default port 80, via the embedded gateway), and if that does not work, tries :30080. When going through the gateway hop, the source the gateway sees is loopback, so it writes 127.0.0.1 into X-GPUStack-Real-IP, and the client_ip_getter in embedded mode returns it as-is—admin identity obtained. An ops action of "resetting on the server after the administrator forgets the password" solidified "no authentication on localhost" into a product capability; in embedded mode, this trust did not disappear, it simply moved from the socket peer to the request header.
The second hop only holds in non-embedded deployments. Under embedded, when the CLI connects directly to :30080 it does not carry this header, and headers.get("X-GPUStack-Real-IP", "") returns