CVE-2026-112000 — Vanus: Missing Authorization in Gateway Streaming RPCs
Missing authorization in Vanus gateway streaming RPCs and default-allow policy.
- #Vanus
- #Missing Authz
- #CWE-862
- #gRPC
The third Vanus CVE from the same audit (`vanus-labs/vanus` @ `main@1334a7b`, clean `go build ./...`, Go 1.24.2). This one is the most *layered* of the package, and also the one that made me briefly question my career choices when I found out how trivially the whole tenant model fell apart. Because this isn't a single hole in a wall — it's the auth model's *default policy* broken in two directions at once: a literal `// todo` sitting in the streaming path, and a default-**allow** evaluator for everything the auth provider never registered.
Yes. A `// todo`. In shipped code. In an auth layer. And yes, the finding file that tracks it is named `reigister.go` in the upstream repo — the typo was already in the tree, it wasn't mine. Small mercies.
§Hole #1: streams never got interceptors at all
Vanus's gRPC auth shells out to an interceptor that runs `Authenticate`/`Authorize` for unary calls. Streaming calls are a different function shape — and `server/gateway/auth/interceptor.go:35–39` handles that difference in the developer's own words:
// todo stream authorize…then invokes the handler directly. No `Authenticate`, no `Authorize`. That's not a misconfigured policy; that's a to-do item as shipped. I don't think I've ever seen an auth model that admits its own gap so plainly, and I review a lot of auth layers.
Which streams? The big ones, naturally. `StoreProxy.Subscribe` (`proxy.go:257`) takes an arbitrary `SubscriptionId` (parsed with `NewIDFromString`, then `GetSubscription` → update/disable/resume and `cache.Store`) and streams events out; `Ack` (`proxy.go:356`) loads the cached subscription and acknowledges sequences. Neither checks who the caller is.
What the todo means in practice — and this list is genuinely why the finding got a CVE, not just an inline sigh:
- open `Subscribe` with another tenant's subscription ID and **hijack the delivery stream** — receive its events; the legitimate consumer simultaneously starves;
- also rewrite the sink target to the pod's own sink port on resume, and poison the subscription's cache entry;
- send forged `Ack` messages (`{"subscriptionId":…,"sequenceId":1,"success":false}`) — a false ack drives the consumer's retry logic into a loop, corrupting offsets and burning the trigger;
- hold the stream open against `sub.eventc` (capacity 10, plus a blocking `<-donec`) — slowloris-style DoS, elegant in the "how little do I actually have to do to break someone" sense
§Hole #2: default-allow for unregistered methods
Second dimension, and honestly my favorite line of Go I've had the pleasure of reading in an audit: in `server/gateway/auth/auth.go:97–110`, when the authorization map doesn't know a method — `ResourceUnknown` — the evaluator returns `nil`. Which, given the code around it, means **allowed**.
Allow-by-default. In an authorization layer. Voluntarily implemented. It's always slightly eerie when the concrete reality is worse than the cynical expectation.
Then ask: how many methods are actually unregistered? The registration helper (`proxy/reigister.go:19` — yes, that's the filename, misspelled in production) omits a dozen methods entirely, including: `ClusterInfo`, `ListUser`, `GetUser`, `CreateToken`, `GetUserToken`, `GetUserRole`, `ListNamespace`, `ListEventbus`, `ListSubscription`, `ValidateSubscription`, `CreateSystemEventbus`.
So any user holding *any* valid token — including a low-priv tenant token — can call `ListUser`/`GetUserToken`/`ClusterInfo` and enumerate the cluster's users and admin tokens. Which, combined with the hardcoded default token (**CVE-2026-111998**), is a very short ladder to full takeover — like two rungs, both labeled "admin."
And note `ValidateSubscription` specifically: it takes attacker-supplied `req.Event` + `req.Subscription` and *genuinely evaluates the stored filter/transformer* — a CEL/CESQL/regexp evaluation oracle with **no timeout or resource cap**. A ReDoS/CPU-exhaustion primitive against the controller, offering a convenient "please evaluate hostile regex for unspecified seconds" API. Free of charge, pre-authorized.
The assigned record scopes the CVE title to the streaming RPCs, with technical refs at `auth/interceptor.go#L35–L40`, `auth.go#L97–L110`, and `proxy/proxy.go#L256–300`, which matches exactly what the audit's finding #4 documented as layers (a) and (b).
§Supporting evidence from the same sweep
The authz finding file also documents two adjacent weaknesses I included as context in the package: the sink ACK callback :8082 (`/gatewaysink/{subscriptionID}`) is unauthenticated and enumeration-friendly — subscription IDs are snowflake uint64s, and a cache-miss 500 vs. a malformed-path 400 gives a distinguishable oracle for probing valid subscriptions — and the `direct.go:86,218,336` list endpoints trust caller-supplied namespace/eventbus IDs for filtering rather than enforcing server-side row-level access. I mention these not because they're CVE-grade on their own (they weren't among the assigned IDs) but because they're the same "the server believes the client" disease and fixing the two CVEs without fixing the pattern is just relocating the bug.
§The exploit, minimal form
Assuming only network reachability, and (for the second hole) any valid low-priv token:
# 1. Stream theft — no token at all (the todo-stream path):
grpcurl -plaintext <gw>:8080 vanus.core.proxy.StoreProxy/Subscribe \
-d '{"subscriptionId":"<victim-sub-id>"}'
# 2. Held stream => DoS; forged aks drive the retry loop:
# {"subscriptionId":id,"sequenceId":1,"success":false}
# 3. Enumeration with any low-priv token (the default-allow path):
grpcurl -plaintext -H "authorization: Bearer <any-valid-token>" \
<gw>:8080 vanus.core.proxy.ControllerProxy/ListUserImpact reads straight off those calls: cross-tenant event read (`C:H`), delivery-logic corruption (`I:L/A:L`), user/token enumeration, and a free oracle for CPU-DoS on the controller.
§Fix model
From the report's maintainer checklist, in priority order — and I stand by these:
- 01**Default-deny, not default-allow.** Unknown/unregistered RPCs must reject, never return `nil`-allowed. Then register all methods explicitly. This is Rule Zero of authorization layers and, apparently, also news.
- 02**Stream interceptors.** `grpc.StreamServerInterceptor` with per-method `Authenticate` + `Authorize`; verify ownership of the subscription ID *before* touching cache or state.
- 03**Move ownership checks server-side.** Rely on the controller (which holds tenant bindings), not the gateway trusting parsed IDs; authenticate the sink callback with an HMAC secret; cap `eventc` with timeouts.
For operators: don't expose :8080/:8082 outside a mesh; monitor abnormal `Subscribe`/`Disable`/`Resume` bursts; rotate subscription IDs after an incident.
§Submission and assignment
It went out as VulnCheck file #4 (finding #4) — separate CWE (`CWE-862`, records also touch the default-allow family), separate root cause from the immutable hardcoded token and from the missing-auth HTTP ingress. That separation is why it got its own ID rather than being folded into either sibling; the CNA treats "different root cause" as the boundary, not "same bug-hunting session."
Result:
- **CVE-2026-112000** assigned, CNA VulnCheck, published 2026-10-18
- Affected: Vanus 0.8.0 – 0.9.0 (`pkg:golang/github.com/vanus-labs/vanus`)
- Tracked under issue #647 in vanus-labs/vanus
- CISA SSVC: Exploitation=none · Automatable=no · Technical Impact=partial
- Finder credit recorded as **MJ DHURGESH**
§The lesson (and two greps I now run religiously)
Two auth failure shapes that auditors should always chase in Go gRPC services, in the order I encountered them in the wild:
- 01**`grep -r "todo"` in auth directories** — some bugs politely announce themselves. This one literally did, then shipped for years anyway, presumably because "unary works" satisfied the demo.
- 02**Ask what happens to a method the authorization layer has never heard of.** If anything-unknown-equals-allowed, your policy is inverted and every future RPC is born pre-authorized — no exploit development required, just a to-do list nobody finished.
Both defaults feel like engineering convenience; neither survives contact with a hostile network, and together they unwound the entire tenant model from any valid token — or, on the streams, from no token at all.
Written by
MJ — offensive security researcher
I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.