h3llh0und

CVE-2026-122304, the Kyverno 9.9 Critical: the two fixes were still there, asking for a namespace that one caller never handed them

Kyverno is the thing that keeps a tenant inside their namespace. Two of its own patches — one from January, one from September — stopped a namespaced policy from reaching across the cluster. Both read a single field to decide, and I found the one caller that built the loader with that field blank — so both patches ran and did nothing, and an ordinary namespace admin borrowed the cleanup controller's cluster-wide identity. CVSS 9.9 Critical (v3.1) recorded in the GHSA advisory; the CVE record restates it as 9.4 (v4.0). Fixed in Kyverno 1.19.2.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Everything below ran on my own machine: Go tests driving the released code’s own constructors, a compile check in a throwaway golang:1.26 container, and reading the source across tags. No live cluster was attacked, and nothing belonging to anyone else was touched. The issue was reported privately through GitHub’s advisory flow and is written up here after the fixed release and the advisory were public.

Kyverno is a policy engine for Kubernetes. A platform team installs it to say things like no Pod runs as root or every image comes from our registry, and Kyverno enforces that at admission time. A large part of what it offers is a safe way to let tenants write their own policies: a namespace admin gets a Policy object scoped to their namespace, and Kyverno makes sure that policy can only see and act inside it. The whole value proposition is that the boundary holds even when the person on the other side of it is hostile.

A policy can pull in outside data while it runs. A context entry can call the Kubernetes API (apiCall), read a cached blob (globalReference), or load a ConfigMap. For a namespaced policy, that reach is exactly where the boundary has to be enforced, because the identity doing the reading is Kyverno’s, not the tenant’s. Kyverno patched that reach twice:

Both fixes live inside the context loader, and both decide the same way: they read one field, policyNamespace. If it is set, the policy is namespaced and the guard fires. If it is empty, the policy is cluster-scoped and the guard stands down. This is the story of a caller that left that field empty for a namespaced policy, so both guards stood down at once — and of the fact that the caller had no way to fill it in.

The field both guards read

Here is where policyNamespace is set, in the factory that builds every loader (pkg/engine/factories/contextloaderfactory.go, at the v1.19.1 tag):

func DefaultContextLoaderFactory(cmResolver engineapi.ConfigmapResolver, opts ...ContextLoaderFactoryOptions) engineapi.ContextLoaderFactory {
	return func(policy kyvernov1.PolicyInterface, _ kyvernov1.Rule) engineapi.ContextLoader {
		policyNamespace := ""
		if policy != nil && policy.IsNamespaced() {
			policyNamespace = policy.GetNamespace()
		}
		cl := &contextLoader{
			logger:          logging.WithName("DefaultContextLoaderFactory"),
			cmResolver:      cmResolver,
			policyNamespace: policyNamespace,
		}

policyNamespace starts empty and is filled only from a policy that is both non-nil and namespaced. Empty is the correct value for a ClusterPolicy, which has no namespace, and it means apply no namespace clamp.

The January fix, in the apiCall path, keys off it:

	if a.policyNamespace != "" {
		// Namespaced Policy: verify path accesses only the policy's namespace
		if matches := namespacePathRegex.FindStringSubmatch(cleanPath); len(matches) > 2 {
			ns := matches[2]
			if ns != a.policyNamespace {
				return nil, fmt.Errorf("path %s refers to namespace %s, which is different from the policy namespace %s", cleanPath, ns, a.policyNamespace)
			}
		}
		// ...
	}

And the September fix, in the globalReference path, keys off the same field. Neither is bypassed in the sense of being tricked or outrun: both run exactly as written. They just run with policyNamespace == "", which is the one input value that tells both of them there is nothing to confine.

One caller built the loader with no policy

Kyverno has a cleanup controller: it runs CleanupPolicy objects, which delete resources on a schedule. A CleanupPolicy is namespaced, the same as a Policy, and it carries the same kind of context entries. It is a separate object type (kyverno.io/v2, CleanupPolicyInterface) handled by its own controller.

That controller built the loader like this (pkg/controllers/cleanup/controller.go):

func (c *controller) cleanup(ctx context.Context, logger logr.Logger, policy kyvernov2.CleanupPolicyInterface) error {
	// ...
	spec := policy.GetSpec()
	// ...
	ctxFactory := factories.DefaultContextLoaderFactory(c.cmResolver, factories.WithGlobalContextStore(c.gctxStore))
	loader := ctxFactory(nil, kyvernov1.Rule{})
	if err := loader.Load(ctx, c.jp, c.client, nil, spec.Context, enginectx); err != nil {
		return err
	}
	for kind := range kinds {
		// ...
		list, err := c.client.ListResource(ctx, "", kind, policy.GetNamespace(), nil)

The policy is the function’s first argument. It is used on the last line shown, policy.GetNamespace(), fourteen lines below the loader — to scope the resource listing to the policy’s own namespace, which is the very thing the loader was missing. The code has the namespace in hand. It passes nil to the factory anyway.

With nil, policyNamespace is "". Both guards stand down. A CleanupPolicy created in one namespace could issue an apiCall to any path in the cluster, and the request went out under the cleanup controller’s ServiceAccount, not the tenant’s.

Why nil, and not a mistake you’d catch in review

This is the part that makes the bug more than a typo, and it is the special shape of this one. nil was not a careless placeholder someone forgot to replace. It was the only thing that compiled.

Look at the factory’s parameter: func(policy kyvernov1.PolicyInterface, ...). It wants a kyvernov1.PolicyInterface — the interface that Policy and ClusterPolicy implement. The cleanup controller holds a kyvernov2.CleanupPolicyInterface, a different interface on a different type. It does not implement PolicyInterface. You cannot pass it. nil is what the type system leaves you with, and nil silently resolves to the empty namespace, which is the exact value that means skip the clamp.

I checked that this was really the constraint, and not something I was imagining, by changing that one word in the released v1.19.1 source and compiling it — in a throwaway container, so the claim is reproducible rather than a story about my laptop:

# docker run --rm golang:1.26, source: kyverno v1.19.1 (read-only mount)

## v1.19.1 as released: ctxFactory(nil, kyvernov1.Rule{})
174:	loader := ctxFactory(nil, kyvernov1.Rule{})
build: ok

## v1.19.1 with the one-word change: ctxFactory(policy, kyvernov1.Rule{})
174:	loader := ctxFactory(policy, kyvernov1.Rule{})
# github.com/kyverno/kyverno/pkg/controllers/cleanup
pkg/controllers/cleanup/controller.go:174:23: cannot use policy (variable of
interface type "...v2".CleanupPolicyInterface) as "...v1".PolicyInterface value
in argument to ctxFactory: "...v2".CleanupPolicyInterface does not implement
"...v1".PolicyInterface (missing method AdmissionProcessingEnabled)
build exit: 1

The obvious fix does not build. So the guard that was meant to confine every namespaced policy quietly did not apply to one whole class of namespaced policy, because that class could not be expressed in the type the guard demanded. The two security patches were real, were merged, were released — and one caller could not reach them.

How the same guard reaches two answers A namespaced Policy passes itself to the context loader factory, so policyNamespace is set and the namespace guard fires and refuses a cross-namespace apiCall. The cleanup controller holds a CleanupPolicy of a different type that the factory's parameter cannot accept, so it passes nil, policyNamespace is empty, the same guard stands down, and the request is issued cluster-wide under the cleanup controller's ServiceAccount. A NAMESPACED POLICY — THE GUARD FIRES Tenant Policy namespaced, in attacker-ns policy ctxFactory(policy) policyNamespace = "attacker-ns" apiCall to victim-ns → REFUSED "different from the policy namespace" A CLEANUPPOLICY — THE SAME GUARD STANDS DOWN Tenant CleanupPolicy namespaced, in attacker-ns type v2 — not PolicyInterface nil ctxFactory(nil) policyNamespace = "" apiCall to victim-ns → ISSUED clamp inert (policyNamespace == "") sent as cleanup-controller SA WHAT BOTH GUARDS READ if policyNamespace != "" { // CVE-2026-22039 fix: apiCall path clamp // reject a path whose namespace != policyNamespace } // GHSA-59v6 fix: refuse globalReference the same way Empty namespace is the cluster-scoped value. Both guards read it as "nothing to confine."

What the borrowed identity could do

The request went out as the cleanup controller’s ServiceAccount. That account cannot read Secrets across the cluster — I confirmed that on a throwaway cluster, as a check on the precondition rather than as an exploit: with Kyverno’s default chart, a user holding Kubernetes’ built-in admin role in a single namespace can create a CleanupPolicy, and cannot create a ValidatingWebhookConfiguration; the cleanup controller’s ServiceAccount can. That asymmetry is the whole point. The account’s default cluster-wide grants include create, update and delete on validatingwebhookconfigurations, and create/update on globalcontextentries and policyexceptions.

From a namespace admin’s seat, reaching that identity means, in rough order of severity:

The sibling advisory that shares this CVE, GHSA-rcxg-3f92-3mgr, chains the first primitive one step further — the cleanup account creates a GlobalContextEntry, which the admission controller later executes under its own, wider account — and reaches cluster-admin. Same root cause, one more hop.

Showing the gap without a cluster

I did not need a running cluster to show the guard was off. The thing to prove is that the identical context entry is refused on a namespaced Policy and issued on a CleanupPolicy, and that lives entirely in the loader. So I built the loader both ways from the released factory — factory(tenantPolicy, …) and factory(nil, …), the literal cleanup-controller construction — and loaded one entry through each, inside Kyverno’s own test suite at v1.19.1:

control  namespaced Policy, apiCall to victim-ns   -> refused, 0 requests issued
control  namespaced Policy, globalReference        -> refused, store untouched
control  own-namespace apiCall                      -> allowed  (recorder works)

result   ctxFactory(nil), apiCall to victim-ns     -> GET /api/v1/namespaces/victim-ns/secrets issued
result   ctxFactory(nil), globalReference          -> cluster-wide entry read

The two refusals with zero requests prove the guard is present and active on the Policy path in the same binary. The allowed own-namespace call proves the recorder is not simply silent. The last two lines are the same entries, on the cleanup path, going out unconfined. What the tests do not claim is the end-to-end webhook install on a live cluster: that rests on the cleanup controller running on its schedule and on the ServiceAccount’s documented grants, both of which I read out of the shipped chart rather than staged.

The fix is a type, not a branch

The patch in 1.19.2 does not add an if. It changes what the factory accepts. The parameter was narrowed from PolicyInterface to a small new interface, PolicyScope, that asks only for the two things the loader ever reads — the object’s identity and whether it is namespaced:

type PolicyScope interface {
	metav1.Object
	IsNamespaced() bool
}

Both policy families satisfy that, so the cleanup controller can pass its real policy, and the one line becomes ctxFactory(policy, kyvernov1.Rule{}). The maintainers’ own commit message says it plainly: narrowing the parameter “removes the reason nil was ever passed, rather than compensating for it.” They added a second layer too — CleanupPolicy admission now checks the submitting user’s own permissions against the context it asks for, independently of the controller’s — so the loader fix is defence in depth rather than the only barrier. If you run Kyverno 1.17 through 1.19.1 with the cleanup controller enabled, which is the default, upgrade to 1.19.2.

I kept thinking, writing this up, about how ordinary the line looked. ctxFactory(nil, …) reads like boilerplate. The cleanup controller predates both fixes — the nil was written in 2023, when the factory ignored its policy argument entirely and nil was genuinely harmless. Each fix that later started reading that argument made the old nil a little more dangerous, and neither fix could see the one caller that was structurally unable to comply, because the compiler had already accepted it years earlier. The guard and the hole were added by the same commits. That is the thing worth carrying from a 9.9: the patch and its own blind spot can arrive together, and the type system will wave both through.


Advisory GHSA-p58h-r4j3-h5vx · CVE CVE-2026-122304 · CVSS 9.9 Critical (v3.1), AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H, recorded in the GHSA advisory — the vector I filed; the CVE record restates it as 9.4 Critical (v4.0), AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H · CWE CWE-863, CWE-1188 · Fix #17984, #18021 · Affected >= 1.17.0, <= 1.19.1 · Fixed Kyverno 1.19.2

Reported 12 September, reporter credit accepted the same day · accepted 20 October by a maintainer after the fix PRs were pointed out · fix merged on main 12 October and on release-1.19 13–14 October · Kyverno 1.19.2 released 20 October · advisory GHSA-p58h-r4j3-h5vx published 21 October, crediting me as reporter · CVE-2026-122304 published 5 November by VulnCheck as a third-party CNA, covering this advisory together with the sibling GHSA-rcxg-3f92-3mgr and crediting euriconicacio and thevilledev as finders. The request Kyverno made to GitHub on 21 October never produced an identifier; this one came from VulnCheck’s own cataloguing.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und