Kubernetes RBAC: Detecting Hidden Privilege Escalation Paths in Your ClusterRoles
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 quie
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 clusterroleswithoutescalatestill lets you inject rules intoedit/adminthroughrbac.authorization.k8s.io/aggregate-to-edit: "true". -
Admission webhooks:
create/updateonmutatingwebhookconfigurationsmeans you can inject sidecars, env vars orserviceAccountNameinto every workload, and read every object sent to the webhook. -
CSR approval:
updateoncertificatesigningrequests/approvalplusapproveonsigners(kubernetes.io/kube-apiserver-client) lets you issue a client certificate forO=system:masters. -
nodes/proxy: direct access to the kubelet API — exec into any Pod on the node, no API-server RBAC in the path. -
createonlocalsubjectaccessreviews/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
escalateor unscopedbind/impersonate. If an operator demands them, scope withresourceNamesor run it in a dedicated cluster. -
Ban wildcards in custom roles.
apiGroups: ["*"] / resources: ["*"] / verbs: ["*"]iscluster-adminwith extra steps — and it silently absorbs every new CRD installed later. -
Treat
create pods(and Deployments/Jobs/DaemonSets) in system namespaces ascluster-admin. Auditkube-system,openshift-*, and any namespace hosting a privileged ServiceAccount. -
Set
automountServiceAccountToken: falseon every ServiceAccount and Pod that does not call the API server; use projected tokens with an explicitaudienceand shortexpirationSecondsfor the rest. -
Prefer namespaced Roles to ClusterRoles. A ClusterRoleBinding to
editis strictly worse than N RoleBindings. -
Never bind anything to
system:authenticated,system:unauthenticatedorsystem: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.ioatRequestResponselevel, and alert on CREATE/UPDATE of ClusterRoles, ClusterRoleBindings and webhook configurations. -
Run
rbac-tool analysison a schedule and diff. Drift, not the initial state, is what kills you. -
Rotate and bound human access: no long-lived
cluster-adminkubeconfigs; use OIDC groups with a short token lifetime.
Conclusion
RBAC auditing is not a YAML review exercise. Four things matter:
-
Know the escalation primitives:
escalate, unscopedbindandimpersonate, workload creation in privileged namespaces,secretsandserviceaccounts/token, webhook configurations, CSR approval,nodes/proxy, and aggregation labels. -
Enumerate mechanically:
jqover ClusterRoles and bindings gives you the inventory;rbac-tool analysisandvizgive you the attack graph. -
Verify effective permissions, never declared ones:
kubectl auth can-i --list --as=system:serviceaccount:...is the only authoritative answer, and it is scriptable. -
Make least privilege enforceable and testable: scope dangerous verbs with
resourceNames, block the unscoped forms with aValidatingAdmissionPolicy, 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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.