flawopen.com/Teardowns/cve-2024-3177-kubernetes-serviceaccount-envfrom-secrets-bypass

● CVE-2024-3177 · CVSS 2.7 · 低
セキュリティ研究 · FlawOpen

CVE-2024-3177:Kubernetes ServiceAccount アドミッションの envFrom による Secret 制限回避

CVE-2024-3177(CVSS 2.7、低):kube-apiserver の ServiceAccount アドミッションプラグインは Secret ボリュームと env の valueFrom 参照をサービスアカウントの許可リストと照合していましたが、envFrom は一切確認しなかったため、Pod は同じ名前空間の任意の Secret を読み込めました。

💡 わかりやすい解説 (ELI5)

ホテルは清掃スタッフ一人ひとりに、入ってよい部屋のリストを渡しています。通用口の警備員は、キーリングの鍵とベルトに付けた単独の鍵を一本ずつリストと照合します。ところが、1フロア分の鍵をまとめて入れたファスナー付きポーチの中は誰も確認しません。ロビーしか許可されていないスタッフが役員フロアのポーチを持って通っても、確認項目に「ポーチ」がないので警備員はそのまま通してしまいます。

主要な概念と専門用語

ServiceAccount admission plugin
kube-apiserver に組み込まれたアドミッションコントローラー。Pod のサービスアカウントを設定し、必要に応じて Pod が参照できる Secret を制限します(plugin/pkg/admission/serviceaccount)。
kubernetes.io/enforce-mountable-secrets
ServiceAccount に付けるアノテーション。"true" にすると、そのアカウントで動く Pod はアカウントの secrets フィールドにある Secret しか参照できません。
envFrom / secretRef
Secret のすべてのキーを一度に環境変数として取り込むコンテナのフィールド。1つのキーだけを取り込む env[].valueFrom.secretKeyRef とは異なります。
エフェメラルコンテナ
pods/ephemeralcontainers サブリソース経由で実行中の Pod に追加されるデバッグ用コンテナ。limitEphemeralContainerSecretReferences() で別途アドミッションされます。

根本原因の分析 (Root Cause)

plugin/pkg/admission/serviceaccount/admission.go の limitSecretReferences() は、Pod の Secret ボリュームと各コンテナの env[].valueFrom.secretKeyRef をたどってマウント可能 Secret の許可リストを適用していました。Secret 全体を取り込む envFrom[].secretRef はたどっていませんでした。init コンテナと、limitEphemeralContainerSecretReferences() のエフェメラルコンテナにも同じ抜けがありました。PR #124322 で 3 か所すべてに欠けていた envFrom のループが追加されました。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

制限付きのサービスアカウント

ServiceAccount builder には kubernetes.io/enforce-mountable-secrets: "true" が付いており、secrets には build-token だけが並んでいます。同じ名前空間には prod-db-credentials もあります。

ステップ 2

envFrom を含む Pod 定義

Pod 作成権限を持つユーザーが、builder で動く Pod を送信し、通常・init・エフェメラルのいずれかのコンテナに envFrom: [{secretRef: {name: prod-db-credentials}}] を設定します。

ステップ 3

アドミッションチェックを通過

limitSecretReferences() は許可リスト外の Secret ボリュームも env[].valueFrom.secretKeyRef も見つけられず、Pod を受け入れます。

ステップ 4

kubelet が Secret を注入

kubelet が envFrom を解決し、prod-db-credentials のすべてのキーを環境変数として設定するため、コンテナから読めるようになります。

ソースコード比較:脆弱 vs 堅牢化

脆弱な実装
// plugin/pkg/admission/serviceaccount/admission.go (kube-apiserver v1.29.3)
func (s *Plugin) limitSecretReferences(serviceAccount *corev1.ServiceAccount, pod *api.Pod) error {
	// Only allow Secrets that the service account lists in its "secrets" field.
	mountableSecrets := sets.NewString()
	for _, ref := range serviceAccount.Secrets {
		mountableSecrets.Insert(ref.Name)
	}

	for _, volume := range pod.Spec.Volumes {
		source := volume.VolumeSource
		if source.Secret != nil && !mountableSecrets.Has(source.Secret.SecretName) {
			return fmt.Errorf("volume with secret.secretName=%q is not allowed because service account %s does not reference that secret", source.Secret.SecretName, serviceAccount.Name)
		}
	}

	for _, container := range pod.Spec.Containers {
		for _, env := range container.Env {
			if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil {
				if !mountableSecrets.Has(env.ValueFrom.SecretKeyRef.Name) {
					return fmt.Errorf("container %s with envVar %s referencing secret.secretName=%q is not allowed because service account %s does not reference that secret", container.Name, env.Name, env.ValueFrom.SecretKeyRef.Name, serviceAccount.Name)
				}
			}
		}
		// BUG: container.EnvFrom is never inspected. envFrom[].secretRef imports
		// every key of any Secret in the namespace and still passes admission.
	}
	return nil
}
堅牢化されたセキュアパッチ
// plugin/pkg/admission/serviceaccount/admission.go (fixed in v1.29.4, PR #124322)
func (s *Plugin) limitSecretReferences(serviceAccount *corev1.ServiceAccount, pod *api.Pod) error {
	// Only allow Secrets that the service account lists in its "secrets" field.
	mountableSecrets := sets.NewString()
	for _, ref := range serviceAccount.Secrets {
		mountableSecrets.Insert(ref.Name)
	}

	for _, volume := range pod.Spec.Volumes {
		source := volume.VolumeSource
		if source.Secret != nil && !mountableSecrets.Has(source.Secret.SecretName) {
			return fmt.Errorf("volume with secret.secretName=%q is not allowed because service account %s does not reference that secret", source.Secret.SecretName, serviceAccount.Name)
		}
	}

	for _, container := range pod.Spec.Containers {
		for _, env := range container.Env {
			if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil {
				if !mountableSecrets.Has(env.ValueFrom.SecretKeyRef.Name) {
					return fmt.Errorf("container %s with envVar %s referencing secret.secretName=%q is not allowed because service account %s does not reference that secret", container.Name, env.Name, env.ValueFrom.SecretKeyRef.Name, serviceAccount.Name)
				}
			}
		}
		// FIX: envFrom can import a whole Secret, so it gets the same allow-list check.
		// The patch adds this loop for init and ephemeral containers too.
		for _, envFrom := range container.EnvFrom {
			if envFrom.SecretRef != nil && !mountableSecrets.Has(envFrom.SecretRef.Name) {
				return fmt.Errorf("container %s with envFrom referencing secret.secretName=%q is not allowed because service account %s does not reference that secret", container.Name, envFrom.SecretRef.Name, serviceAccount.Name)
			}
		}
	}
	return nil
}

エンジニアリング&システム堅牢化チェックリスト

参考資料