Least privilege that isn't: the Kubernetes RBAC permissions that can add up to cluster-admin

DedSec Application Security ·

The pull request adds a ServiceAccount, a Role and a RoleBinding for a batch worker. The Role lets it create Jobs and read Secrets in its own namespace. The reviewer sees no ClusterRoleBinding and no cluster-admin, and approves it. Nobody granted cluster-admin, but depending on what else is true of the namespace and the cluster, that worker may now be able to act as every service account in its namespace, read every Secret there, and run a privileged container on a node.

Kubernetes RBAC is checked one rule at a time, but a subject's real power is not the list of its rules. It is everything those rules let it become. This post covers the permissions where those two differ, the reasons they differ, and how to review for them in the repository before anything is applied. Everything below comes from the Kubernetes documentation: the RBAC reference, the Role Based Access Control Good Practices page, the Pod Security Standards and Pod Security Admission pages, and the service account and kubelet authorization references.

Why reading a Role line by line misleads

Three properties of RBAC make a Role-by-Role review unreliable.

Permissions are additive and there are no deny rules. A subject's access is the union of every RoleBinding and ClusterRoleBinding that names it, either directly or through a group. A service account is in the groups system:serviceaccounts and system:serviceaccounts:<namespace>, so a binding to either group grants it something even if no binding names it. Reviewing the one Role in this pull request shows only part of the union.

Some permissions are identity acquisition, not actions. Creating a pod sounds like an action on pods. It is really the ability to run code as any service account in the namespace. Reading Secrets sounds like reading configuration, but some of those Secrets hold credentials for more powerful identities. A permission like that is worth whatever the identities it reaches are worth.

Where the grant is bound matters as much as what it says. The same ClusterRole means one namespace through a RoleBinding and every namespace through a ClusterRoleBinding.

So the review question is not "what does this rule allow?" but "what can this subject become, and from there, what can it do?"

The API server's escalation check, and the two verbs that switch it off

RBAC has a built-in guard against granting more than you hold. You can create or update a Role or ClusterRole only if you already have every permission it contains, at the same scope, or if you have the escalate verb on roles or clusterroles in the rbac.authorization.k8s.io group. You can create or update a RoleBinding or ClusterRoleBinding only if you already hold every permission in the referenced role, at the binding's scope, or if you have the bind verb on that role. The documentation's example: a user who cannot list Secrets cluster-wide cannot create a ClusterRole containing that permission, and cannot create a ClusterRoleBinding to a role that grants it.

That check is why "can create roles" is not, by itself, an escalation. The two verbs that bypass it are.

A bind or escalate grant without resourceNames, next to the ability to write roles or bindings, should be read as cluster-admin over the scope it is granted in: the whole cluster through a ClusterRoleBinding, one namespace through a RoleBinding.

impersonate: borrowing someone else's identity

The impersonate verb on users, groups or serviceaccounts lets a subject send requests as another identity. The impersonation documentation is direct: with the impersonate verb, what you do while impersonating cannot be limited or scoped, and once you may impersonate a user you can do anything that user can do, across all resources and namespaces. Impersonation of service accounts is granted per namespace, but the impersonated service account may itself have permissions outside that namespace.

Group impersonation is where this becomes absolute. Membership of the system:masters group bypasses RBAC entirely, so impersonate on groups with no resourceNames restriction includes impersonating that group. resourceNames can narrow impersonation to named users and groups, and the documentation shows a limited-impersonator role doing exactly that. Recent Kubernetes releases also add constrained impersonation, which pairs the identity with the specific actions allowed while impersonating it; check its feature status for your cluster version. A plain impersonate grant does neither.

Creating a workload is choosing an identity

This is an escalation path that is easy to approve by accident, because nearly every application chart needs to create something.

A pod can name any service account in its namespace and mount any Secret, ConfigMap or PersistentVolumeClaim there. The good-practices page says it plainly: permission to create workloads in a namespace implicitly grants access to the Secrets, ConfigMaps and PersistentVolumes that can be mounted in pods, and the API access level of any service account in that namespace. The documentation of the built-in edit role makes the same point about itself: it does not allow viewing or modifying roles, but it allows accessing Secrets and running pods as any service account in the namespace, so it can be used to gain the API access of any service account there.

That applies to every resource that results in pods, not just pods: Deployments, ReplicaSets, StatefulSets, DaemonSets, Jobs, CronJobs and ReplicationControllers. The authorization documentation adds custom resources whose controllers create pods or touch Secrets, ServiceAccounts or volumes for you: create on such a resource is workload creation once removed. If an operator in the cluster turns a custom resource into a pod with a service account you name, create on that custom resource is create on pods.

Whether workload creation also reaches the node depends on pod security. The Baseline Pod Security Standard forbids privileged containers, the host namespaces (hostNetwork, hostPID, hostIPC), hostPath volumes, host ports and added capabilities beyond a short list. Without enforcement, a subject that can create pods can create one that is privileged and mounts the host filesystem. The good-practices page notes that a container with unrestricted access to the host filesystem can read data from other containers and abuse the credentials of system services such as the kubelet.

Three details of Pod Security Admission matter for review:

The label is itself a permission target. The good-practices page notes that a subject that can patch its Namespace can change those labels and choose a more permissive policy. Patch on namespaces belongs on the escalation list.

One more detail rules out an apparent mitigation: create cannot be restricted by resourceNames for top-level resources, because the name of the new object may not be known at authorization time. A rule allowing create on pods for resourceNames worker-1 does not do what it appears to.

Secrets: get, list and watch all read the contents

It is tempting to assume that list returns names and get returns values. For Secrets that is wrong. The good-practices page states that list and watch also effectively reveal Secret contents: a List response, for example from kubectl get secrets -A -o yaml, includes the data of every Secret it returns. A role that allows list but not get on Secrets is a role that reads Secrets.

Two less obvious routes produce service account credentials without reading an existing Secret:

Getting into running things: exec, attach and ephemeral containers

A pod that is already running carries an identity: its mounted service account token and whatever Secrets it mounts. Subresources that reach into running pods are a way to take that identity.

Cluster-scoped grants that look like plumbing

Some of the most powerful permissions sit in ClusterRoles written for infrastructure components, where they read as operational detail.

Wildcards grant resources that do not exist yet

A wildcard in apiGroups, resources or verbs matches everything, including what is added later. The RBAC documentation's caution is specific: if a new resource type, a new subresource or a new custom verb is added, the wildcard grants it automatically. Verbs set to "*" include escalate, bind and impersonate. Resources set to "*" in the core group include secrets and pods/exec. Resources set to "*" in any group include custom resources that a future operator will introduce, some of which will create pods. An empty resourceNames list means every object.

The practical rule follows: a wildcard in a role means you cannot list what the role grants, so review it as if it granted everything.

Scope: the binding decides, and some membership is not in RBAC at all

ClusterRole is not cluster-wide by itself. A RoleBinding that references a ClusterRole grants that ClusterRole's permissions only in the RoleBinding's namespace. The same ClusterRole bound with a ClusterRoleBinding applies across all namespaces and to cluster-scoped resources. cluster-admin bound with a RoleBinding gives full control over every resource in that namespace, including the namespace itself. Review the binding kind and namespace, not just the role kind. Rules on cluster-scoped resources such as nodes take effect only through a ClusterRoleBinding.

Aggregation adds rules from elsewhere. A ClusterRole with an aggregationRule has its rules filled in by a control-plane controller from every ClusterRole whose labels match its selector, and the controller overwrites any rules written directly into it. The built-in admin, edit and view roles aggregate this way. A chart that ships a ClusterRole labelled rbac.authorization.k8s.io/aggregate-to-edit is adding permissions to everyone who already holds edit, in every namespace where it is bound, without creating a single binding. The manifest that grants access carries no binding at all, and the bindings it relies on are already in the cluster.

Some identities are outside RBAC. Any user in the system:masters group bypasses all RBAC checks, and an authorization webhook if one is configured. That access cannot be revoked by removing bindings, because none are involved. Group membership comes from the credential, for example a client certificate, so no manifest in any repository shows it.

Broad groups are bindings to everyone. A binding whose subject is system:serviceaccounts covers every service account in every namespace. A binding to system:authenticated covers every authenticated identity, and a binding to system:unauthenticated covers anyone who can reach the API server, wherever anonymous requests are enabled. The RBAC documentation calls cluster-admin for all service accounts strongly discouraged, and notes it also grants full cluster access to anyone who can read Secrets or create any pod.

The default service account and token automounting

Every namespace has a service account called default, and a pod that does not set serviceAccountName runs as it. By default, Kubernetes gives each pod its service account's credentials; since v1.22 that is a time-bound, automatically rotated token obtained through the TokenRequest API and mounted as a projected volume. Setting automountServiceAccountToken: false on the ServiceAccount or the pod stops it, and if both set it, the pod spec wins.

The default RBAC policy grants service accounts outside kube-system nothing beyond the API discovery roles, so the default account is harmless until something binds to it. That is the review point. The RBAC documentation notes that permissions given to the default service account are available to any pod in the namespace that does not name a service account. A chart that binds a role to default has granted it to every unnamed workload in the namespace, now and in future, and a remote code execution in any of them carries that token. Give each workload its own service account, and turn automounting off on workloads that never call the API.

What a grant actually grants: a review list

How to review it in the repository

Review what will be applied, not what is written. RBAC in Helm charts is templated: roles are switched on and off by values, cluster-wide or namespaced modes swap a RoleBinding for a ClusterRoleBinding, and rules are often assembled from lists in values. Reviewing values.yaml shows inputs, not results. Render with helm template using the same values files each environment uses, and review the output. Know its limits: the Helm documentation states that values normally looked up in-cluster are faked locally, and no server-side validation is done. A template that branches on a lookup or on Capabilities.APIVersions can render differently in CI than at install. Pass --api-versions for the target cluster's APIs when templates depend on them. The same applies to Kustomize overlays: review the built output.

Build the union per subject. For each ServiceAccount in the rendered output, collect every RoleBinding and ClusterRoleBinding naming it, its namespace's service account group, system:serviceaccounts and system:authenticated, and resolve each roleRef, including ClusterRoles carrying aggregate-to labels that will join the built-in roles.

Check the list above regardless of who holds it. Workload creation, Secret reads, the three escalation verbs, the cluster-scoped grants and wildcards should be findings in review, each with an owner who can say why it is needed, even when the holder is a trusted component.

Check the workload side as well. Which service account each pod template uses, whether it automounts a token, and whether the Namespace manifests in the repository carry an enforce label.

Do it in the pull request. A Role is easiest to narrow before anything depends on it. Once applied, removing a permission means first finding out which code path uses it. A check on the pull request reaches the author while they still know why the permission was requested. The post on catching vulnerabilities in the pull request covers the gate pattern, and the post on stale baselines covers how a gate can blame the wrong change.

Confirm against a live cluster where you can. kubectl auth can-i --list --as=system:serviceaccount:<namespace>:<name> -n <namespace> lists what a service account can do in a namespace. The --as flag is itself impersonation, so the person running it needs impersonate rights. --list is backed by SelfSubjectRulesReview, which the API reference describes as possibly incomplete depending on the authorization mode, and not to be relied on for authorization decisions. For the dangerous items, ask directly, with the same --as: kubectl auth can-i create pods --subresource=exec, or kubectl auth can-i get nodes --subresource=proxy. A single can-i query uses SelfSubjectAccessReview, which works whatever authorization mode is in use.

What static review cannot see

Reviewing rendered manifests catches what the repository grants. It cannot see:

Static review is the cheap first pass, and the right one for a pull request. Periodic review of live bindings is still needed, which the good-practices page also calls for. It warns that a newly created account with the same name as a deleted user inherits that user's remaining bindings.

Where AppSecD fits

AppSecD runs Terraform, CloudFormation, Helm, Ansible and Pulumi rules, and analyses Kubernetes RBAC, network policy and pod security context. Those results land in the same deduplicated finding model as the platform's other scanning, and pull requests and commits can be gated by policy. The review technique in this post, rendering before review, building the union per subject and reading grants as identities, is general practice, not a description of how AppSecD's checks are implemented.

A checklist for the next RBAC change

  1. Render it. Review helm template or kustomize build output for each environment's values, not the chart source.
  2. Name the subject and resolve the union. Every binding to the service account, its groups, and any aggregated roles.
  3. Treat these as findings: workload creation, Secret get, list or watch, Secret create, serviceaccounts/token, exec, attach and ephemeral containers, namespace patch, escalate, bind, impersonate, nodes/proxy, CSR approval, webhook configurations, PersistentVolume creation and any wildcard.
  4. Check the binding scope. ClusterRoleBinding where a RoleBinding would do is a finding of its own.
  5. Check resourceNames. It narrows bind, impersonate and exec. It does nothing for top-level create.
  6. Check the namespace's pod security label. Any subject that can create workloads where no pod security level or other admission policy is enforced can reach the node.
  7. Stop binding to default. Give each workload its own service account and set automountServiceAccountToken: false where the API is not used.
  8. Verify live and keep reviewing. Spot-check with kubectl auth can-i --as for the dangerous verbs, and review live bindings periodically for what the repository never showed.

A Role that looks narrow is a claim about the rules it contains. Whether it is least privilege depends on what those rules let its holder become.

More about AppSecD · All posts