flawopen.com/Teardowns/cve-2024-3177-kubernetes-serviceaccount-envfrom-secrets-bypass
CVE-2024-3177:Kubernetes ServiceAccount アドミッションの envFrom による Secret 制限回避
CVE-2024-3177(CVSS 2.7、低):kube-apiserver の ServiceAccount アドミッションプラグインは Secret ボリュームと env の valueFrom 参照をサービスアカウントの許可リストと照合していましたが、envFrom は一切確認しなかったため、Pod は同じ名前空間の任意の Secret を読み込めました。
ホテルは清掃スタッフ一人ひとりに、入ってよい部屋のリストを渡しています。通用口の警備員は、キーリングの鍵とベルトに付けた単独の鍵を一本ずつリストと照合します。ところが、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 のループが追加されました。
ステップ・バイ・ステップの攻撃フロー
制限付きのサービスアカウント
ServiceAccount builder には kubernetes.io/enforce-mountable-secrets: "true" が付いており、secrets には build-token だけが並んでいます。同じ名前空間には prod-db-credentials もあります。
envFrom を含む Pod 定義
Pod 作成権限を持つユーザーが、builder で動く Pod を送信し、通常・init・エフェメラルのいずれかのコンテナに envFrom: [{secretRef: {name: prod-db-credentials}}] を設定します。
アドミッションチェックを通過
limitSecretReferences() は許可リスト外の Secret ボリュームも env[].valueFrom.secretKeyRef も見つけられず、Pod を受け入れます。
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
}
エンジニアリング&システム堅牢化チェックリスト
- ✓kube-apiserver を v1.29.4、v1.28.9、v1.27.13 以降に更新する。設定による回避策はない。
- ✓このポリシーに依存するサービスアカウントを洗い出す:
kubectl get serviceaccounts -Aをアノテーションkubernetes.io/enforce-mountable-で絞り込む。secrets=true - ✓API 監査ログから、
envFrom[].secretRef.nameが Pod のサービスアカウントのsecretsリストにない Pod 作成・エフェメラルコンテナ更新を探す。 - ✓名前空間で Pod を作成できる権限は、その名前空間の Secret を読める権限として扱う。機密性の高い Secret は、Pod 作成者がもともと信頼されている名前空間にだけ置く。
- ✓アドミッションチェックでリソース種別を制限するときは、それを参照できる Pod 定義のフィールド(ボリューム、投影ボリューム、
env、envFrom、init・エフェメラルコンテナ)をすべて列挙し、フィールドごとにテストを書く。