Automated Threat Modeling

Brief Summary: Kyverno CVE-2026-122303, Cross-Namespace Resource Overwrite via NamespacedMutatingPolicy

A brief summary of CVE-2026-122303, an authorization bypass in Kyverno where a NamespacedMutatingPolicy mutate-existing CEL target expression lets a tenant overwrite resources in other namespaces. Includes the advisory PoC, patch details, and detection guidance.

CVE Analysis

12 min read

Gaurav Sarraf
Gaurav Sarraf

2026-11-04

Brief Summary: Kyverno CVE-2026-122303, Cross-Namespace Resource Overwrite via NamespacedMutatingPolicy
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 recently patched authorization bypass in Kyverno let a tenant with permission to author a policy in a single namespace overwrite Secrets, Deployments and policies across an entire Kubernetes cluster. The tenant never makes the damaging write. Kyverno's own privileged background controller makes it, after accepting an attacker-fabricated target without checking whose namespace it belongs to.

Kyverno is a Kubernetes-native policy engine for defining, managing and enforcing policy as code. It was originally created by Nirmata and contributed to CNCF in 2020, later reaching graduated status after technical due diligence, a security assessment and governance review. CNCF's graduation announcement names Bloomberg, Coinbase, Deutsche Telekom, Groww, LinkedIn, Spotify, Vodafone and Wayfair among its public users, so a policy authorization defect here reaches well beyond a single application.

Technical Information

The affected feature is NamespacedMutatingPolicy with mutation of existing resources enabled through evaluation.mutateExisting.enabled=true. A tenant supplies targetMatchConstraints.expression, a Common Expression Language (CEL) expression that returns the object the policy will act upon. The core problem is that the expression can return a CEL map literal fabricating a target object whose metadata.namespace is attacker-chosen, and the background controller trusts that value.

Root cause

When the background controller processes the policy, it builds the target object directly from the CEL target expression with no namespace or scope confinement. According to the advisory, the vulnerable construction lives in pkg/background/mpol/processor.go:

  • (*processor).getTargetsFromExpression (lines 456 to 590) builds target objects directly from the CEL target expression.
  • (*processor).Process (lines 178 to 274) writes each resolved target via p.client.UpdateResource (line 261) using the fabricated namespace, with a secondary Namespace-kind cluster-scope escape at line 121.
  • pkg/cel/policies/mpol/validate.go Validate (lines 40 to 48) forbids only globalContext.* and http.* and imposes no per-target namespace or scope constraint.

The processor applies the mutation, copies the existing object's resourceVersion, and writes through UpdateResource using the background controller's ServiceAccount. Kubernetes therefore evaluates the write using the controller's privileges rather than the tenant's privileges in the victim namespace. There is no SubjectAccessReview, no SelfSubjectAccessReview and no CanI check against the tenant for the target. NVD classifies this as CWE-862 Missing Authorization, and GitHub additionally lists CWE-863 Incorrect Authorization.

Documented impacts and their limits

The reach of this flaw is bounded by what the background controller is permitted to update. Documented consequences include:

  • Overwrite of an existing Secret in another namespace, where the controller has update permission.
  • Overwrite of an existing Deployment or DaemonSet pod template, potentially enabling code execution as that workload's ServiceAccount. This is a documented credible consequence, not a reported compromise.
  • Updates to Kyverno policy resources and GlobalContextEntry across namespaces, which the advisory identifies in the default background controller permissions.
  • Mutation of Namespace objects through a secondary scope escape in the resource rules branch, which drops namespace confinement for kind Namespace or namespace glob *.

The common failure across all of these is missing target scope enforcement. That does not let every installation overwrite every resource, because the controller's aggregated ClusterRole bounds the available targets.

Attack flow

The documented attack sequence is straightforward and does not require unauthenticated access:

  1. A tenant creates a namespaced mutation policy in a namespace it controls, for example tenant-a.
  2. The policy enables mutation of existing resources and matches a trigger resource the tenant can create in tenant-a.
  3. The CEL target expression fabricates an object identifying an existing resource in another namespace, for example the Secret victim-ns/app-credentials.
  4. Creating the trigger resource enqueues a background UpdateRequest. The background controller processes the policy and overwrites the victim object using its own identity.

The attacker needs policy creation rights and trigger resource creation rights in a single namespace. The background controller must be deployed, and the target must already exist, because missing targets are skipped.

Proof of Concept

A public, vulnerability-specific proof of concept is contained within the maintainer-published GitHub Security Advisory GHSA-jcw5-3wcg-fqx5. The advisory author states explicitly that the PoC was not executed against a live cluster. It was confirmed by an end-to-end source trace and version-pinned reads of the shipped release, which makes it a documented, code-level PoC rather than an independently verified live exploit. No separate standalone exploit repository was found.

Prerequisites

  • A cluster running an affected Kyverno release with the background controller deployed.
  • A tenant namespace (tenant-a) where the attacker can create a NamespacedMutatingPolicy and a trigger resource.
  • A pre-existing victim object (for example victim-ns/app-credentials).

The target must pre-exist, since a missing target is skipped.

Reproduction outline (from the advisory)

  1. As the tenant, create a NamespacedMutatingPolicy in tenant-a with evaluation.mutateExisting.enabled=true, a matchConstraints on a trigger kind the tenant can create in tenant-a, targetMatchConstraints.resourceRules covering the victim kind, and a mutation.
  2. Set a raw targetMatchConstraints.expression that returns a fabricated target object pinned to the victim namespace.
  3. Create the trigger resource in tenant-a to enqueue the background UpdateRequest.

PoC payload (exact snippet from the advisory)

apiVersion: policies.kyverno.io/v1beta1 kind: NamespacedMutatingPolicy metadata: name: pwn namespace: tenant-a spec: evaluation: mutateExisting: enabled: true matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["configmaps"] targetMatchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["UPDATE"] resources: ["secrets"] expression: > {"apiVersion": "v1", "kind": "Secret", "metadata": {"name": "app-credentials", "namespace": "victim-ns"}, "type": "Opaque", "data": {"token": "YXR0YWNrZXItY29udHJvbGxlZA=="}} mutations: - patchType: ApplyConfiguration applyConfiguration: expression: > Object{metadata: Object.metadata{labels: {"owned": "true"}}}

Expected result per the advisory: the real victim-ns/app-credentials Secret is overwritten with the attacker-fabricated body (only its resourceVersion is preserved) by the background controller ServiceAccount, even though the policy author has no RBAC in victim-ns.

Patch Information

Kyverno fixed this in v1.19.2, published 20 October 2026. NVD lists 1.19.2 as the first unaffected version, and the advisory lists 1.19.2 under patched versions. The advisory body still contains the reporter's original "not fixed" wording, which was written before the release and no longer applies. The v1.19.2 release notes group this advisory under "NamespacedMutatingPolicy target confinement."

The code change is in PR #17984, titled [1.19] fix(security): API path validation, apiCall canonicalization, mpol/cleanup namespace confinement, squash-merged into release-1.19 on 13 October 2026 as commit f67231def576eb0ad153dd8cbfc836908ab14c14. The relevant part is a rewrite of pkg/background/mpol/processor.go. No backport to the 1.17 or 1.18 lines was found.

MutatingPolicy and NamespacedMutatingPolicy share one spec type, and the API gives no structural guarantee keeping a namespaced policy inside its namespace, so confinement has to be enforced in the processor itself. The patch adds checks at three points: where a target comes from, the final list of targets, and the write.

1. Checking targets built by the CEL expression (the main bug)

Before the fix, a map returned by targetMatchConstraints.expression was used as the target exactly as written, including its metadata.namespace. Now resolveTargetRoute also returns the kind's REST scope, taken from the RESTMapper. A namespaced policy rejects any target that is cluster-scoped or whose namespace differs from the policy's own, before the re-fetch and the write:

if mpolNamespace := mpol.GetNamespace(); mpolNamespace != "" { if scope != meta.RESTScopeNameNamespace { failures = append(failures, fmt.Errorf("target %s %s is cluster-scoped, which is not permitted for namespaced mpol %s", object.GetKind(), object.GetName(), ur.Spec.GetPolicyKey())) continue } if object.GetNamespace() != mpolNamespace { failures = append(failures, fmt.Errorf("target %s/%s/%s is outside policy namespace %s for mpol %s", object.GetNamespace(), object.GetKind(), object.GetName(), mpolNamespace, ur.Spec.GetPolicyKey())) continue } }

The scope comes from the API server's REST mapping, not from the fabricated object, so an attacker can no longer set a namespace and have it trusted. The REST-scope check is needed alongside the namespace check, because a fabricated cluster-scoped object could put the policy's namespace in its metadata and pass a namespace-only check.

2. Closing the Namespace-kind escape in the resource-rules path

The old code decided whether to list across the whole cluster by looking at the kind's name, and it reassigned the loop's range variable, so one match switched every later kind in the set to cluster-wide listing:

- if r.gvk.Kind == "Namespace" || ns == "*" { - ns = "" + // listNS, not ns: ns is the range variable, and assigning to it would leak a + // cluster-wide listing to every later kind in the same set. + listNS := ns + if !r.namespaced || ns == "*" { + listNS = "" + }

The REST scope is now resolved once in collectGVK and stored in a new namespaced bool field on gvkItem. For a namespaced policy, collectGVK drops cluster-scoped kinds entirely, and fails closed when a kind has no REST mapping rather than treating it as safe to list cluster-wide:

mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) if err != nil { errs = append(errs, fmt.Errorf("cannot resolve the REST scope of target kind %s", gvk.String())) continue } namespaced := mapping.Scope.Name() == meta.RESTScopeNameNamespace if ns != "" && !namespaced { errs = append(errs, fmt.Errorf("target kind %s is cluster-scoped, which is not permitted for a namespaced policy", gvk.String())) continue }

Checking the kind name was not enough because a namespace-scoped CRD with Kind Namespace in a non-core API group would pass the old name check and reopen the hole.

3. A final namespace check on all targets

After both resolution paths run, a second pass drops any target outside the policy's namespace, whichever path produced it. A code comment notes that this check only covers the write and cannot undo a read that already happened, so the per-path checks are still required:

if policyNamespace := mpol.GetNamespace(); policyNamespace != "" { confined := make([]resolvedTarget, 0, len(targets)) for _, target := range targets { if target.object.GetNamespace() != policyNamespace {

4. Blocking identity changes on the write

The privileged UpdateResource call used to take the namespace, kind, apiVersion and name from the mutated object, so a mutation that rewrote those fields could send the write to a different object after the earlier checks passed. The patch now requires the patched object to keep the same identity as the object that was re-fetched:

if new.GetAPIVersion() != object.GetAPIVersion() || new.GetKind() != object.GetKind() || new.GetNamespace() != object.GetNamespace() || new.GetName() != object.GetName() { failures = append(failures, fmt.Errorf("mutation for mpol %s changed the target's identity from %s/%s %s/%s to %s/%s %s/%s, which is not permitted",

Related hardening in the same file validates the subresource part of a targetMatchConstraints.resources[] entry with a new shared dclient.ValidatePathSegment helper in both resolveTargetRoute and collectGVK, so a value like ../../namespaces/kube-system/... cannot redirect the request. collectGVK now returns an error instead of failing silently, and rejected targets are recorded in the UpdateRequest status.

The fix does not add an authorization check against the tenant. There is still no SubjectAccessReview or impersonation. Instead it enforces a structural rule: a namespaced policy may act only on namespace-scoped objects in its own namespace, and only on the exact object it resolved. PR #17984 adds regression tests for the expression path, the resource-rules path and the write path. The commit notes that the resource-rules path for namespaced policies had no test coverage before.

Detection Methods

No public YARA, Sigma, Snort/Suricata signatures, Nuclei templates or attack IoCs exist for CVE-2026-122303. That fits the bug, because the attack does not traverse a network protocol or drop a binary. A tenant writes a valid Kubernetes custom resource, and Kyverno's trusted background controller makes the damaging API write. Detection therefore means three things: checking exposure, auditing policies, and looking for signs the controller wrote outside a tenant's namespace. Everything below comes from the GitHub advisory and Nirmata's 1.19.2 release guidance.

1. Find the Kyverno version actually running

The running image tag is what counts, not the Helm chart you think you installed:

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

The sources disagree on where the affected range starts. NVD and VulnCheck say 1.17.0, while the GitHub advisory confirms reachability from 1.18.0. Treat anything from 1.17.0 up to (not including) 1.19.2 as in scope.

2. List namespaced mutating policies and note who created them

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

3. Look inside each policy for the exploit pattern

Look for a NamespacedMutatingPolicy with all of these:

  • spec.evaluation.mutateExisting.enabled: true
  • a non-empty spec.targetMatchConstraints.expression
  • an expression that returns a CEL map literal (an inline object with apiVersion, kind, and metadata) instead of looking targets up dynamically
  • a metadata.namespace inside that literal that differs from the policy's own namespace, or a target that is cluster-scoped

Treat any namespaced policy as malicious until proven otherwise if its target expression names another namespace (for example kube-system), a Kyverno policy resource, a GlobalContextEntry, a Deployment or DaemonSet, or a Secret. A second variant also applies. A namespaced policy whose targetMatchConstraints.resourceRules targets the Namespace kind, or uses a * namespace glob, loses namespace confinement and can mutate every Namespace object in the cluster.

4. Check who can create these policies

The only privilege the attack needs is create on namespacedmutatingpolicies.policies.kyverno.io in a single namespace:

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

Repeat this for every tenant identity and every namespaced kind. For this CVE the kind that matters is namespacedmutatingpolicies, and any tenant who gets a yes there could exploit the bug.

5. Look for signs of compromise in audit logs and resource state

  • Who makes the write: the overwrite is an UpdateResource call made under Kyverno's background controller ServiceAccount, with no impersonation. In Kubernetes audit logs it looks like the controller, not the tenant. Look for update events by that ServiceAccount against objects in namespaces other than the policy's own namespace, especially in kube-system, on Secrets, Deployments and DaemonSets, and on Kyverno's own policy resources.
  • Timing: the attack starts when the tenant creates a trigger resource in their own namespace (a ConfigMap in the PoC), after which the background controller processes an UpdateRequest. A tenant creating a policy and a trigger, followed closely by a controller write into a different namespace, is a strong signal.
  • What the victim object looks like: the controller re-fetches the real target only to copy its resourceVersion, then replaces the whole object with the attacker's fabricated body. Watch for existing objects whose content was suddenly replaced, such as Secret data that changed or labels dropped or added (the PoC adds an owned: "true" label), and pod templates in other namespaces that changed with no matching pipeline activity.
  • Related changes: watch kube-system and other sensitive namespaces for new RoleBindings, webhook configurations and NetworkPolicies you did not create.

These checks find exposure and suspicious activity, but they cannot prove no compromise happened. Fabricated targets can be any kind the background controller is allowed to update, so the scope of your hunt depends on how broadly that controller's aggregated ClusterRole grants permissions.

Affected Systems and Versions

Kyverno is affected from version 1.17.0 up to, but not including, 1.19.2, per NVD and VulnCheck. GitHub's confirmed lower bound is 1.18.0. Its narrative identifies vulnerable code in 1.17.0 but leaves reachability in that branch unproven. For exposure assessment, treat the full 1.17.0 through 1.19.1 range as in scope. Exploitation also requires that the background controller is deployed, that mutation of existing resources is enabled on a NamespacedMutatingPolicy, that a tenant can create that policy and a trigger resource, and that the victim object already exists. Kyverno v1.19.2 (published 20 October 2026) is the first unaffected release. No backport to the 1.17 or 1.18 lines was found.

Vendor Security History

Kyverno completed a 2023 security audit by Ada Logics with the maintainers and the Open Source Technology Improvement Fund, funded by CNCF, reporting 10 security issues and six CVEs with fixes in 1.10.6 and 1.11.1. Four findings involved an unreleased Notary verifier and did not affect users consuming official releases. That audit's threat model treats Kyverno administrators as fully trusted and excludes attacks from or against them, so it does not assess this later namespaced mutation vulnerability.

A directly relevant precedent is CVE-2026-54523 (GHSA-79gf-7frw-68m9), in which a tenant could use generator.apply() through a NamespacedMutatingPolicy to generate resources outside its namespace using admission controller permissions. Affected versions were 1.18.0 and 1.18.1, with 1.18.2 listed as patched. The current advisory states explicitly that the earlier generate fix left the background mutation processor untouched. The two share a tenant-controlled policy surface but differ in operation and privileged identity. The earlier bypass used the admission controller to create resources, while CVE-2026-122303 uses the background controller's ServiceAccount to overwrite existing ones. Fixing namespace authorization in one execution path does not give the same protection to another path that uses a different controller. Nirmata's October 23 security release bundled 26 advisories: 11 critical, 14 high and one moderate.

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