Most clusters do not get compromised through a zero-day in the kubelet. They get compromised because a CI ServiceAccount was granted create on deployments in kube-system, or because a "read-only auditor" ClusterRole quietly contains secrets: ["get","list"]. RBAC failures are rarely a single obviously-wrong rule — they are a chain of individually reasonable rules that compose into a path to cluster-admin.

This article is about finding those chains before an attacker does: which verbs are actually dangerous, how to enumerate them mechanically, how to verify effective permissions instead of reading YAML, and how to turn least privilege into something you can assert in CI.

Why RBAC escalation stays invisible

Kubernetes RBAC has three properties that make manual review unreliable:

It is purely additive. There is no deny rule. Effective permissions for a subject are the union of every Role and ClusterRole bound to it, directly or through any group it belongs to — including system:authenticated. Reading a single ClusterRole tells you nothing about what its holder can actually do.

Permissions are transitive through workloads. The API server evaluates whether you can create a Pod. It does not evaluate what that Pod will then be able to do. If you can create a Pod in a namespace, you inherit every ServiceAccount in that namespace, plus whatever host access the SecurityContextConstraint (OpenShift) or Pod Security Standard (upstream) allows.

Aggregation rewrites roles behind your back. ClusterRole objects with an aggregationRule are recomputed by the controller manager from labels on other ClusterRoles. Anyone who can create a ClusterRole with the right label mutates admin, edit or view cluster-wide.

Add to that the fact that nobody audits the ~70 built-in system: ClusterRoles, and you get a large, poorly understood attack surface.

The verbs that actually grant cluster-admin

escalate on roles/clusterroles

The API server normally refuses to let you create a Role granting permissions you do not already hold (escalation prevention). The escalate verb disables that check. A subject with create + escalate on clusterroles is, by definition, cluster-admin with one extra API call.

apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: platform-rbac-manager rules: - apiGroups: ["rbac.authorization.k8s.io"] resources: ["clusterroles", "roles"] # "escalate" looks like a harmless operational convenience. # It is a full bypass of RBAC escalation prevention. verbs: ["get", "list", "create", "update", "escalate"] 

bind on roles/clusterroles

Same logic, applied to bindings. Without bind, you can only reference a Role whose permissions you already have. With bind on clusterroles (unscoped), you can create a ClusterRoleBinding to cluster-admin for yourself.

impersonate

impersonate on users, groups or serviceaccounts lets a subject send requests as somebody else. Unscoped, it is instant cluster-admin via --as=system:admin or any cluster-admin group. Always scope it with resourceNames, and remember that impersonating a group (system:masters) is just as fatal.

create on pods — and the whole workload family

Pod creation in a namespace means: mount any ServiceAccount token in that namespace, mount any Secret, and — if the security policy allows it — hostPID, hostPath: /, or a privileged container. On OpenShift this is gated by SCCs, so the real question is which SCC the ServiceAccount can use.

The same reasoning applies transitively to deployments, daemonsets, statefulsets, jobs, cronjobs, replicasets, and to pods/exec, pods/attach, pods/portforward, pods/ephemeralcontainers. Granting create deployments in kube-system or openshift-* is equivalent to granting the privileges of the most powerful ServiceAccount in that namespace.

Secrets and the TokenRequest API

get/list on secrets cluster-wide is a direct credential dump. Since the removal of auto-generated SA token secrets (1.24+), the sharper edge is the serviceaccounts/token subresource:

# If you can create this subresource, you can mint a token for ANY # ServiceAccount in the namespace - including a cluster-admin one. kubectl create token builder -n openshift-infra --duration=10m kubectl auth can-i create serviceaccounts/token -n kube-system \ --as=system:serviceaccount:ci:deployer 

The quiet ones

  • Aggregation labels: create clusterroles without escalate still lets you inject rules into edit/admin through rbac.authorization.k8s.io/aggregate-to-edit: "true".
  • Admission webhooks: create/update on mutatingwebhookconfigurations means you can inject sidecars, env vars or serviceAccountName into every workload, and read every object sent to the webhook.
  • CSR approval: update on certificatesigningrequests/approval plus approve on signers (kubernetes.io/kube-apiserver-client) lets you issue a client certificate for O=system:masters.
  • nodes/proxy: direct access to the kubelet API — exec into any Pod on the node, no API-server RBAC in the path.
  • create on localsubjectaccessreviews/selfsubjectaccessreviews: not an escalation, but a perfect reconnaissance primitive.

Here is the aggregation trap, which bypasses no check at all and is therefore almost never flagged:

apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: metrics-reader labels: # Any holder of "edit" in ANY namespace now inherits these rules. rbac.authorization.k8s.io/aggregate-to-edit: "true" rules: - apiGroups: [""] resources: ["secrets"] verbs: ["get", "list"] 

Auditing: from inventory to attack paths

Step 1 — enumerate dangerous rules

Start with a mechanical sweep of custom ClusterRoles. Do not exclude system: roles permanently: some clusters have had theirs modified by operators or by hand.

kubectl get clusterroles -o json | jq -r ' .items[] | select(.metadata.name | startswith("system:") | not) | .metadata.name as $name | .rules[]? | select( (.verbs[]? | IN("escalate","bind","impersonate","*")) or ((.verbs[]? | IN("*")) and (.resources[]? | IN("*"))) or (.resources[]? | IN("secrets","serviceaccounts/token", "mutatingwebhookconfigurations","nodes/proxy")) ) | "\($name)\t\(.apiGroups // [])\t\(.resources // [])\t\(.verbs)" ' | column -t | sort -u 

Then look for aggregation injection:

kubectl get clusterroles -o json | jq -r ' .items[] | select(.metadata.labels // {} | keys[]? | startswith("rbac.authorization.k8s.io/aggregate-to")) | "\(.metadata.name): \(.metadata.labels)"' 

Step 2 — resolve bindings to real subjects

A dangerous ClusterRole with zero bindings is technical debt; one bound to system:authenticated is an incident.

kubectl get clusterrolebindings -o json | jq -r ' .items[] | select(.roleRef.name as $r | ["cluster-admin","admin","edit"] | index($r)) | "\(.roleRef.name)\t\(.metadata.name)\t" + ((.subjects // []) | map(.kind + ":" + (.namespace // "-") + "/" + .name) | join(",")) ' | column -t # The classic catastrophic binding kubectl get clusterrolebindings -o json | jq -r ' .items[] | select((.subjects // [])[]?.name | IN("system:authenticated","system:unauthenticated","system:anonymous")) | .metadata.name + " -> " + .roleRef.name' 

Step 3 — verify effective permissions, don't read YAML

kubectl auth can-i resolves the full union of bindings through the authorization stack. This is ground truth.

# Full effective permission list for a ServiceAccount kubectl auth can-i --list \ --as=system:serviceaccount:ci:deployer -n ci # Targeted checks for escalation primitives for check in "create pods/kube-system" "create clusterrolebindings/" \ "impersonate users/" "escalate clusterroles/" \ "get secrets/kube-system" "create serviceaccounts/token/kube-system"; do verb=${check%% *}; rest=${check#* }; res=${rest%%/*}; ns=${rest#*/} printf '%-45s %s\n' "$check" \ "$(kubectl auth can-i "$verb" "$res" ${ns:+-n $ns} \ --as=system:serviceaccount:ci:deployer)" done 

Reverse lookups answer "who can do X":

kubectl krew install who-can rbac-tool kubectl who-can create pods -n kube-system kubectl who-can impersonate users # OpenShift native equivalent oc adm policy who-can create pods -n openshift-monitoring oc adm policy who-can use securitycontextconstraints privileged 

Step 4 — graph the attack paths with rbac-tool

rbac-tool (krew plugin) flattens bindings, normalises wildcards and ships built-in risk analysis rules.

# Flattened policy rules for a subject (wildcards expanded) kubectl rbac-tool policy-rules -e '^system:' \ -o wide # Everything granted to one identity, across namespaces kubectl rbac-tool lookup system:serviceaccount:ci:deployer # Built-in risk analysis: flags escalate/bind/impersonate, secret access, # workload-create-in-privileged-namespace, wildcard rules... kubectl rbac-tool analysis -o json > rbac-findings.json jq -r '.[] | select(.severity=="CRITICAL") | .name' rbac-findings.json # Visual graph for review sessions kubectl rbac-tool viz --outformat html --outfile rbac.html \ --exclude-subjects '^system:(kube-|node|serviceaccount:kube-system)' 

Run rbac-tool analysis in a cron Job and diff the findings between runs — new findings are the signal you care about.

OpenShift specifics

On OpenShift, RBAC is only half the story: SCC assignment is itself expressed as RBAC (use on securitycontextconstraints with a resourceNames).

# Which identities can use the privileged SCC? oc get clusterroles -o json | jq -r ' .items[] | .metadata.name as $n | .rules[]? | select((.resources[]? == "securitycontextconstraints") and (.verbs[]? == "use")) | "\($n): \(.resourceNames // ["ALL"])"' oc adm policy who-can use scc privileged oc get clusterrolebinding -o json | jq -r ' .items[] | select(.roleRef.name | startswith("system:openshift:scc:")) | "\(.roleRef.name) -> " + ((.subjects // []) | map(.name) | join(","))' 

A ServiceAccount with create pods and use privileged is a node-level compromise, not a namespace-level one.

Building a verifiable least-privilege model

Scope the dangerous verbs instead of removing them — you usually need them somewhere.

apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: tenant-rolebinder rules: # Can manage RoleBindings... - apiGroups: ["rbac.authorization.k8s.io"] resources: ["rolebindings"] verbs: ["get", "list", "create", "update", "delete"] # ...but may only reference these two ClusterRoles. No path upward. - apiGroups: ["rbac.authorization.k8s.io"] resources: ["clusterroles"] resourceNames: ["tenant-viewer", "tenant-editor"] verbs: ["bind"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: support-impersonator rules: - apiGroups: [""] resources: ["serviceaccounts"] resourceNames: ["support-readonly"] verbs: ["impersonate"] 

Then block the unscoped variants at admission. A ValidatingAdmissionPolicy (GA since 1.30, available on OpenShift 4.16+) needs no extra controller:

apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: deny-unscoped-rbac-verbs spec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: ["rbac.authorization.k8s.io"] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["clusterroles", "roles"] validations: - expression: >- !has(object.rules) || !object.rules.exists(r, has(r.verbs) && r.verbs.exists(v, v in ['escalate', '*'])) message: "verb 'escalate' or '*' is not allowed in (Cluster)Roles" - expression: >- !has(object.rules) || !object.rules.exists(r, has(r.verbs) && r.verbs.exists(v, v in ['bind', 'impersonate']) && (!has(r.resourceNames) || size(r.resourceNames) == 0)) message: "'bind'/'impersonate' must be restricted with resourceNames" --- apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicyBinding metadata: name: deny-unscoped-rbac-verbs-binding spec: policyName: deny-unscoped-rbac-verbs validationActions: ["Deny"] matchResources: namespaceSelector: {} 

Finally, make least privilege testable. Add assertions to your pipeline that fail the build when an identity gains a capability it should not have:

#!/usr/bin/env bash set -euo pipefail SA="system:serviceaccount:ci:deployer" FAIL=0 assert_no() { # verb resource [namespace] if [[ "$(kubectl auth can-i "$1" "$2" ${3:+-n $3} --as="$SA" 2>/dev/null)" == "yes" ]]; then echo "FAIL: $SA can $1 $2 ${3:+in $3}"; FAIL=1 fi } assert_no create pods kube-system assert_no create clusterrolebindings assert_no get secrets kube-system assert_no impersonate users assert_no escalate clusterroles exit $FAIL 

Security best practices

  • Never grant escalate or unscoped bind/impersonate. If an operator demands them, scope with resourceNames or run it in a dedicated cluster.
  • Ban wildcards in custom roles. apiGroups: ["*"] / resources: ["*"] / verbs: ["*"] is cluster-admin with extra steps — and it silently absorbs every new CRD installed later.
  • Treat create pods (and Deployments/Jobs/DaemonSets) in system namespaces as cluster-admin. Audit kube-system, openshift-*, and any namespace hosting a privileged ServiceAccount.
  • Set automountServiceAccountToken: false on every ServiceAccount and Pod that does not call the API server; use projected tokens with an explicit audience and short expirationSeconds for the rest.
  • Prefer namespaced Roles to ClusterRoles. A ClusterRoleBinding to edit is strictly worse than N RoleBindings.
  • Never bind anything to system:authenticated, system:unauthenticated or system:anonymous. Alert on it from the audit log.
  • Watch aggregation labels as carefully as the rules themselves, and gate ClusterRole creation at admission.
  • Enable API audit logging for rbac.authorization.k8s.io at RequestResponse level, and alert on CREATE/UPDATE of ClusterRoles, ClusterRoleBindings and webhook configurations.
  • Run rbac-tool analysis on a schedule and diff. Drift, not the initial state, is what kills you.
  • Rotate and bound human access: no long-lived cluster-admin kubeconfigs; use OIDC groups with a short token lifetime.

Conclusion

RBAC auditing is not a YAML review exercise. Four things matter:

  1. Know the escalation primitives: escalate, unscoped bind and impersonate, workload creation in privileged namespaces, secrets and serviceaccounts/token, webhook configurations, CSR approval, nodes/proxy, and aggregation labels.
  2. Enumerate mechanically: jq over ClusterRoles and bindings gives you the inventory; rbac-tool analysis and viz give you the attack graph.
  3. Verify effective permissions, never declared ones: kubectl auth can-i --list --as=system:serviceaccount:... is the only authoritative answer, and it is scriptable.
  4. Make least privilege enforceable and testable: scope dangerous verbs with resourceNames, block the unscoped forms with a ValidatingAdmissionPolicy, and assert the negative cases in CI so that a regression breaks the pipeline instead of the cluster.

Pick your five most privileged ServiceAccounts, run kubectl auth can-i --list against each, and check how many of them can reach cluster-admin in two moves. The number is usually higher than expected — and now you have the tooling to drive it to zero and keep it there.