A Tenant Annotation Decides Where Kubeflow’s Controller Sends Its Cluster Token
CVE-2026-110220 reached NVD on 13 October 2026 at 19:18:46Z, assigned by VulnCheck, scored 7.1 under both CVSS 4.0 (AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N) and CVSS 3.1 (AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N), CWE-918. The one-line version: in Kubeflow Model Registry through 0.3.17, a namespace tenant who can create a KServe InferenceService chooses the host that the Model Registry controller talks to, and the controller brings its own Kubernetes ServiceAccount bearer token along for the trip.
That is not an SSRF in the usual sense — the interesting part is not which internal address the server can be made to reach. The interesting part is what rides in the header. We read the whole path first-hand at tag v0.3.17 in kubeflow/hub (the repository formerly named kubeflow/model-registry), and then again at main, and it is four lines of ordinary Go spread across three files that never meet in review.
The chain, read at the tag
In pkg/inferenceservice-controller/controller.go, Reconcile opens by loading a credential it does not yet have a destination for:
mrApiCtx := context.Background() — then, at lines 88–90, if r.bearerToken != "" { mrApiCtx = context.WithValue(context.Background(), openapi.ContextAccessToken, r.bearerToken) }. Twenty-one lines later, at line 111, it reads the destination out of the object it is reconciling: mrUrl, okMrUrl := isvc.Annotations[r.modelRegistryURLAnnotation]. The annotation key comes from the shipped controller overlay, manifests/kustomize/options/controller/overlays/base/params.env line 3: URL_ANNOTATION=modelregistry.kubeflow.org/url. It is an ordinary annotation on an ordinary namespaced custom resource.
Where does r.bearerToken come from? cmd/controller/main.go constructs the controller with cfg.BearerToken (line 228) from the rest config. In the default in-cluster deployment that is the controller ServiceAccount token — client-go’s rest.InClusterConfig() reads the projected token file into exactly that field. There is no separate flag for it.
Then initModelRegistryService (lines 268–295) takes the annotation value as its url parameter. If the string is empty it falls back to discovering the registry Service in-cluster. If it is non-empty, it is used, verbatim:
cfg := &openapi.Configuration{ HTTPClient: r.httpClient, Servers: openapi.ServerConfigurations{{ URL: url }} }. No url.Parse. No scheme check. No host allowlist. No private-address or loopback rejection. Nothing between the annotation and the base URL of the API client.
The last link is in generated code. pkg/openapi/client.go lines 420–423: if auth, ok := ctx.Value(ContextAccessToken).(string); ok { localVarRequest.Header.Add("Authorization", "Bearer "+auth) }. The header is attached because the token is in the context, not because the destination deserves it. The client has no opinion about where it is pointed; that decision was made in link three, by a tenant.
What the shipped RBAC actually grants
The researcher who found this, hieuPenguinnn, was careful in the published write-up to decline a claim about blast radius: “The exact RBAC scope of the captured token is deployment-specific, so no claim of full cluster-admin takeover is made.” VulnCheck’s CVE description is less cautious, asserting the attacker can “modify other tenants’ InferenceServices cluster-wide”. Both are defensible positions from where each party stood, and the repository settles the question.
We read manifests/kustomize/options/controller/rbac/role.yaml at v0.3.17. The shipped model-registry-manager-role is a ClusterRole, not a Role, granting get, list, patch, update, watch on serving.kserve.io/inferenceservices, full create/delete/get/list/patch/update/watch on inferenceservices/finalizers, and get, list, watch on core services. role_binding.yaml is a ClusterRoleBinding attaching it to the controller-manager ServiceAccount. So in a deployment using the project’s own manifests, VulnCheck’s sentence is accurate and the researcher’s caution was simply the right posture for someone who had not been told which manifests the operator used. The captured token is not cluster-admin; it is cluster-wide write on every tenant’s inference workloads, plus the ability to add and remove finalizers — which is a denial-of-deletion primitive in its own right.
Two things that do not mitigate it. The default SKIP_TLS_VERIFY=false (params.env line 10) is irrelevant: the attacker owns the destination host, so they present a valid certificate for their own domain or simply use http://. And the controller overlay’s only NetworkPolicy, network-policy/allow-metrics-traffic.yaml, governs metrics ingress — there is no egress restriction in the shipped manifests to stop the controller pod from reaching the internet.
The one genuine limiting case is also in the write-up: if the controller runs out-of-cluster with a kubeconfig that authenticates by client certificate, cfg.BearerToken is empty, the guard at line 88 is false, and no Authorization header is attached. The annotation still steers the request — it remains an SSRF — but no credential leaves.
There is no fixed version
This is the part that should drive your response. v0.3.17, released 21 September 2026, is the newest release of kubeflow/hub. We checked the release and tag lists through the GitHub API; nothing has shipped since. We then re-read controller.go at main (the repository was last pushed 13 October 2026 at 19:06Z, twelve minutes before the CVE record published) and lines 88–90 and 268–295 are byte-for-byte what they are at the tag. The advisory names an affected range with no upper bound to escape to.
So the mitigation is operational, not a version bump. In priority order: (1) block the annotation at admission — a validating webhook, Kyverno or Gatekeeper policy that rejects or strips modelregistry.kubeflow.org/url on InferenceService objects created by tenants costs nothing and closes the whole chain, since the empty-string path falls back to in-cluster Service discovery, which is what most deployments want anyway. (2) Apply an egress NetworkPolicy to the controller pod restricting it to in-cluster registry endpoints. (3) Audit the ClusterRoleBinding: if your registries all live in one namespace, a namespaced Role is sufficient and shrinks the value of a stolen token. (4) If you can tolerate it, scale the InferenceService controller to zero — it is an optional component (INFERENCE_SERVICE_CONTROLLER=managed in params.env) and the registry’s core function does not depend on it.
The researcher’s suggested code fix is the obvious one and worth quoting for anyone writing a patch: reject the annotation URL unless its host matches an operator-configured allowlist, before building the client, with the medium-term recommendation to “stop deriving the destination of a credentialed request from a tenant annotation” entirely and resolve registry endpoints only from in-cluster Service objects, which require more privilege to create than an InferenceService does.
The pattern worth taking away
Strip the Kubernetes vocabulary and this is the same defect we wrote up in Conductor’s five SSRF records yesterday and in SAP’s CVSS-10 Cloud SDK for Python record: a component with ambient authority lets a lower-privileged caller choose the destination of an authenticated outbound request. The ML-platform context sharpens it, because controllers are exactly the kind of component that accumulates cluster-wide RBAC while reading fields that tenants write. Every operator in a model-serving stack watches tenant-authored CRs; many of them hold a token; several of them call out to a URL that came from somewhere.
The audit that falls out of this is mechanical, and it is worth running across your whole MLOps control plane rather than just this one controller: for every reconciler you run, list the outbound HTTP calls, and for each one ask whether the destination and the credential are decided by the same trust level. Here, the credential is cluster-scoped and the destination is namespace-tenant-scoped, and the two were joined by a context value set twenty-one lines before anyone knew where the request was going. That ordering — authenticate first, resolve destination later — is the smell. It also explains why the generated OpenAPI client is blameless: by the time it ran, both decisions had already been made.
What we verified, and what we did not
The CVE identifier, NVD publication timestamp, both CVSS vectors and scores, the CWE-918 assignment, the description text, the full reference list and the Awaiting Analysis status were read first-hand from the NVD 2.0 API. GHSA-q9m2-3393-574q (published 13 October 2026 at 21:33:38Z, severity high, 7.1) was read first-hand from the GitHub advisories API; it carries no package range and no credits. Every source line quoted above — controller.go lines 88–90, 111 and 268–295, cmd/controller/main.go line 228, pkg/openapi/client.go lines 420–423, params.env, rbac/role.yaml, rbac/role_binding.yaml and the network-policy directory listing — was fetched directly from the repository at tag v0.3.17, and the two controller.go excerpts were re-fetched at main to confirm they are unchanged. Release list, tag list, default branch, last-push timestamp, licence (Apache-2.0) and the repository’s non-archived status come from the GitHub API. The mechanism narrative, the attack steps, the client-certificate limiting case and the suggested fix are from hieuPenguinnn’s published write-up, read in full; quoted passages are verbatim from it.
We did not deploy Kubeflow, did not install the Model Registry controller, did not create an InferenceService, did not stand up a listener, and captured no token. The researcher states plainly that their own work was “static review only” against read-only source pages, with no PoC run and no live deployment contacted; we report the attack flow as their analysis, not as an executed exploit. We have not bisected the earlier affected range — neither has anyone else — so “through 0.3.17” should be read as the advisory writes it, with the lower bound unestablished. The claim that cfg.BearerToken is populated in any specific deployment follows from documented client-go behaviour, not from an observation of your cluster.
Sources:
- NVD — CVE-2026-110220, Kubeflow Model Registry SSRF via InferenceService controller URL annotation (7.1, CWE-918, published 13 October 2026)
- GitHub Advisory Database — GHSA-q9m2-3393-574q (high, 7.1, published 13 October 2026)
- hieuPenguinnn — primary write-up: tenant-set URL annotation makes the controller send its ServiceAccount bearer token to an attacker-chosen host
- kubeflow/hub v0.3.17 — pkg/inferenceservice-controller/controller.go (token into context at 88–90, annotation read at 111, unvalidated server URL at 268–295)
- kubeflow/hub v0.3.17 — pkg/openapi/client.go (Authorization: Bearer added at 420–423)
- kubeflow/hub v0.3.17 — shipped ClusterRole granting cluster-wide patch/update on inferenceservices
- kubeflow/hub — v0.3.17 release (21 September 2026), the newest release and the top of the affected range
- VulnCheck — advisory page for the assigning CNA