Automated Threat Modeling

Brief Summary: Kyverno CVE-2026-122313, Cross-Namespace Privilege Escalation via NamespacedMutatingPolicy

A brief summary of CVE-2026-122313, a cross-namespace privilege escalation in Kyverno's NamespacedMutatingPolicy, covering the maintainer-published PoC and the v1.19.2 patch.

CVE Analysis

11 min read

Gaurav Sarraf
Gaurav Sarraf

2026-11-04

Brief Summary: Kyverno CVE-2026-122313, Cross-Namespace Privilege Escalation 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

In multi-tenant Kubernetes clusters, the entire point of namespace-scoped policy is that a tenant can only affect its own namespace. CVE-2026-122313 breaks that assumption. A tenant allowed to create a NamespacedMutatingPolicy in its own namespace can direct Kyverno's privileged background controller to rewrite RBAC, networking and configuration objects belonging to other tenants.

Kyverno is a Kubernetes native policy engine, originally created by Nirmata and contributed to CNCF in 2020, that enforces, validates, mutates and generates cluster resources as policy as code. It graduated within CNCF in 2026 and is used in production by organizations including Bloomberg, Coinbase, Spotify, Mandiant and the US Department of Defense Platform One, where it is the default Kubernetes policy engine. Kyverno sits directly on the admission and resource mutation path, so a failure of namespace confinement there has real consequences.

Technical information

The root cause is a missing namespace clamp on one specific resolution path for mutate-existing targets. Kyverno documents NamespacedMutatingPolicy as applying only to resources in the policy's own namespace. Mutation of existing resources, however, runs asynchronously through the background controller, and the target selected for mutation can differ from the resource that triggered evaluation. Matching a permitted trigger therefore does not establish that a later-selected target is permitted.

The advisory pins the vulnerable branch to pkg/background/mpol/processor.go. When mutateExisting is enabled, the processor takes the expression branch (processor.go:146-162) and calls getTargetsFromExpression (processor.go:456). The returned target list is built verbatim from the policy author's CEL output, with no check that each target's metadata.namespace equals the policy namespace. The processor then re-fetches each target via GetResource (processor.go:245) and writes it back via UpdateResource (processor.go:261) using the background controller's ServiceAccount client. There is no per-user impersonation, so the write carries the controller's authority, not the tenant's.

That authority is broad. The advisory notes the background ServiceAccount (kyverno:background-controller:core) holds cluster-wide create, update, patch and delete over roles, rolebindings, networkpolicies, ingresses, configmaps, resourcequotas and limitranges.

Admission validation does not help here. The advisory describes policy compilation, a requirement for nonempty matchConstraints, and restrictions on http.*, but no target namespace validation. The NamespacedPolicy predicate selects policies according to the trigger namespace and does not confine the target namespace. The legacy v1 engine, the deleting controller and the non-expression mpol path all limited their results to the policy's own namespace. Only the CEL targetMatchConstraints.expression path did not.

Attack flow

  1. The attacker, a tenant with rights to create a NamespacedMutatingPolicy, authors a policy in tenant-a with mutateExistingEvaluation enabled.
  2. The policy's matchConstraints match a benign trigger the tenant can legitimately create (a ConfigMap in tenant-a).
  3. The policy's targetMatchConstraints.expression emits a literal target object naming an existing resource in another namespace, for example a RoleBinding in victim-ns.
  4. The attacker creates the trigger ConfigMap in tenant-a.
  5. The background controller resolves the attacker-fabricated literal target, re-fetches the real RoleBinding in victim-ns, applies the mutation, and writes the result back using its own cluster-wide ServiceAccount.
  6. The mutation adds the attacker as a subject to the victim RoleBinding, or rewrites a Role's rules, completing cross-namespace privilege escalation.

The attacker controls the target identity, and the controller supplies the authority to write it. A valid policy and a valid trigger do not establish a valid destination.

Severity and validation limits

The CVSS 3.1 vector is CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H: network attack vector, low complexity, low privileges, no user interaction and changed scope. The vector does not indicate unauthenticated exploitation, because the concrete attack requires tenant policy authoring rights. The advisory's reproduction narrative is maintainer analysis of released code, not a documented victim incident. The advisory also lists capability limits. The examined background controller permissions cover RBAC, networking and configuration resources but not direct writes to Secrets, Pods or Deployments, so those should not be treated as established impact for this CVE.

Proof of concept

The official Kyverno security advisory GHSA-f96w-622v-rqpq includes a public, maintainer-authored PoC (the advisory was published 2026-10-21 by Kyverno maintainer JimBugwadia and reported by @manus-use). VulnCheck independently maps the advisory to CVE-2026-122313 (affected kyverno >= 1.17.0, < 1.19.2; fixed in 1.19.2). The PoC is maintainer-published reproduction guidance (policy YAML, trigger command, expected behavior and annotated attack chain). It was reviewed from the source advisory, not independently executed in a lab.

The PoC manifest, reproduced verbatim from the advisory:

apiVersion: policies.kyverno.io/v1beta1 kind: NamespacedMutatingPolicy metadata: {name: escalate, namespace: tenant-a} spec: evaluationConfiguration: {mutateExistingEvaluation: {enabled: true}} matchConstraints: resourceRules: [{apiGroups:[""],apiVersions:["v1"],operations:["CREATE"],resources:["configmaps"]}] targetMatchConstraints: resourceRules: [{apiGroups:["rbac.authorization.k8s.io"],apiVersions:["v1"],operations:["UPDATE"],resources:["rolebindings"]}] expression: > {"items": [{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"RoleBinding", "metadata":{"namespace":"victim-ns","name":""}}]} mutations: - patchType: ApplyConfiguration applyConfiguration: expression: 'Object{ subjects: [Object.subjects{kind:"User", name:"attacker", apiGroup:"rbac.authorization.k8s.io"}] }'

Trigger command, run as the tenant:

kubectl -n tenant-a create configmap trigger --from-literal=a=b

Expected result: creating the ConfigMap in tenant-a fires the policy. The background controller then resolves the attacker-fabricated literal target in victim-ns, re-fetches the real victim RoleBinding, and writes the attacker (User: attacker) into its subjects cluster-side, yielding cross-namespace privilege escalation. According to the advisory, the background SA's cluster-wide create/update/patch/delete over roles, rolebindings, networkpolicies, ingresses, configmaps, resourcequotas and limitranges enables further impact, such as RBAC takeover, breaking network isolation, config injection and availability attacks.

Patch information

Kyverno fixed this in v1.19.2. The structured GitHub advisory data and the v1.19.2 release notes both list 1.19.2 as fixed, under NamespacedMutatingPolicy target confinement, alongside related advisories GHSA-jcw5-3wcg-fqx5 and GHSA-vm98-8c25-h274. The advisory body text still reads "no fix exists", but that wording is carried over from the original report. The structured advisory data and release notes are authoritative. The release credits PRs #17984 and #18021.

The code change is in PR #17984 ([1.19] fix(security): API path validation, apiCall canonicalization, mpol/cleanup namespace confinement), squash-merged into release-1.19 on 2026-10-13 as merge commit f67231de. The relevant part touches pkg/background/mpol/processor.go and derives from two upstream commits: "confine NamespacedMutatingPolicy mutate-existing targets to their own namespace" (635c28b1) and "decide mpol target scope by REST scope, not kind name" (da79245e). PR #18021 moves related namespace checks for apiCall and CleanupPolicy to the release branch.

The fix adds checks in layers so a namespaced policy cannot reach outside its namespace through any resolution path or through the write itself.

1. Checking expression output, the direct fix

resolveTargetRoute now also returns the REST scope of the target kind. In the expression branch, a namespaced policy rejects any target that is cluster-scoped or sits in another namespace, before the re-fetch and write:

- parentResource, subresource, err := p.resolveTargetRoute(object, mpol.GetTargetMatchConstraints().MatchResources) + parentResource, subresource, scope, err := p.resolveTargetRoute(object, mpol.GetTargetMatchConstraints().MatchResources) if err != nil { failures = append(failures, fmt.Errorf("failed to resolve target route for mpol %s: %v", ur.Spec.GetPolicyKey(), err)) continue } + 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 + } + }

Both checks are needed. A CEL expression builds the object, so it can claim any metadata.namespace, including a cluster-scoped kind pretending to belong to the policy's namespace. A namespace comparison alone would accept that. The REST scope comes from the API server's discovery data, which the policy author cannot change.

2. One final check for every path

After all resolution paths run, every target is checked once more against the policy namespace:

if policyNamespace := mpol.GetNamespace(); policyNamespace != "" { confined := make([]resolvedTarget, 0, len(targets)) for _, target := range targets { if target.object.GetNamespace() != policyNamespace { failures = append(failures, fmt.Errorf("target %s %s/%s is outside policy namespace %s for mpol %s", target.object.GetKind(), target.object.GetNamespace(), target.object.GetName(), policyNamespace, ur.Spec.GetPolicyKey())) continue } confined = append(confined, target) } targets = confined }

The code comments call this a backstop. It runs too late to undo a read that already happened, so the per-path checks remain required.

3. Stopping the mutation from changing its target

The old code wrote back using the patched object's own identity fields, so a mutation could rewrite metadata.namespace, name, kind or apiVersion to redirect the write. The fix rejects any patch that changes the object's identity:

if new.GetAPIVersion() != object.GetAPIVersion() || new.GetKind() != object.GetKind() || new.GetNamespace() != object.GetNamespace() || new.GetName() != object.GetName() {

4. Deciding scope from the API server, not the kind name

The resource-rules path decided cluster-wide listing from the kind's name and assigned to the loop's own range variable, so one reset carried over to later kinds in the same rule set:

- if r.gvk.Kind == "Namespace" || ns == "*" { - ns = "" + listNS := ns + if !r.namespaced || ns == "*" { + listNS = "" + }

Now collectGVK looks up the real REST scope once and refuses the kind if the scope cannot be found, rather than treating it as safe:

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 }

The same hunks pass policy-supplied subresource strings through dclient.ValidatePathSegment, so a value like .. can no longer change the request path that UpdateResource builds.

Rejected targets are not skipped silently. Each one is added to the failures list and surfaces in the UpdateRequest status, so operators can see a cross-namespace attempt. Cluster-scoped MutatingPolicy objects are unaffected because every new check is gated on mpol.GetNamespace() != "". The PR reports regression tests covering the expression, write and resource-rules paths, plus a test pinning the cluster-scoped behaviour.

Affected systems and versions

  • Affected: Kyverno 1.17.0 up to, but not including, 1.19.2 (CVE record and VulnCheck range: 1.17.0 before 1.19.2).
  • Fixed: Kyverno 1.19.2. Nirmata's security release guidance identifies Helm chart 3.9.2 as shipping this version.
  • Exploitation prerequisite: a tenant with rights to create or update a NamespacedMutatingPolicy, plus the ability to create a resource that triggers its evaluation. Installations that delegate policy authoring to tenants should be prioritized.

Note on advisory metadata: the GitHub advisory's historical narrative describes the code as unfixed, and its affected version field references versions through 1.19.0. The same advisory's structured data and the official release both identify 1.19.2 as patched. The CVE record and VulnCheck set the remediation boundary at 1.19.2.

Vendor security history

Kyverno completed a third-party security audit in 2023 involving Ada Logics, project maintainers and OSTIF, with CNCF funding, covering threat modeling, manual code review, fuzzing and supply chain security. Auditors identified 10 findings associated with six CVEs, all reported fixed, with maintainer fixes incorporated into 1.10.6 and 1.11.1. Four of those CVEs concerned an unreleased Notary verifier that did not affect users of official releases. The 1.19.2 security release addressed a large group of serious findings (reported as 24 advisories in the release notes and 26 in Nirmata's later account, with the difference attributed to two additional Critical advisories published on October 23 that were already fixed in 1.19.2). This record shows an established audit and remediation process, but an earlier audit does not establish immunity from this later namespace confinement flaw.

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