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은 전혀 확인하지 않아, 파드가 같은 네임스페이스의 어떤 Secret이든 불러올 수 있었습니다.
호텔은 청소 직원마다 들어갈 수 있는 객실 목록을 줍니다. 직원 출입구의 경비원은 열쇠고리에 달린 열쇠와 허리띠에 따로 찬 열쇠를 하나하나 목록과 대조합니다. 그런데 한 층의 열쇠를 전부 담아 둔 지퍼 파우치는 아무도 열어 보지 않습니다. 로비만 허가된 직원이 임원 층 파우치를 들고 지나가도 경비원은 통과시킵니다. 점검 목록에 파우치라는 항목이 없기 때문입니다.
핵심 개념 및 용어
ServiceAccount admission plugin- kube-apiserver에 내장된 어드미션 컨트롤러로, 파드의 서비스 어카운트를 채우고 요청 시 파드가 참조할 수 있는 Secret을 제한합니다(
plugin/pkg/admission/serviceaccount). kubernetes.io/enforce-mountable-secrets- ServiceAccount에 붙이는 어노테이션입니다. "true"이면 그 어카운트로 실행되는 파드는 어카운트의
secrets필드에 나열된 Secret만 참조할 수 있습니다. envFrom / secretRef- Secret의 모든 키를 한 번에 환경 변수로 가져오는 컨테이너 필드입니다. 키 하나만 가져오는
env[].valueFrom.secretKeyRef와 다릅니다. 임시 컨테이너pods/ephemeralcontainers하위 리소스로 실행 중인 파드에 추가하는 디버깅용 컨테이너입니다.limitEphemeralContainerSecretReferences()에서 따로 어드미션을 거칩니다.
근본 원인 분석 (Root Cause)
plugin/pkg/admission/serviceaccount/admission.go의 limitSecretReferences()는 파드의 Secret 볼륨과 각 컨테이너의 env[].valueFrom.secretKeyRef를 순회하며 마운트 가능 Secret 허용 목록을 적용했습니다. Secret 전체를 가져오는 envFrom[].secretRef는 순회하지 않았습니다. init 컨테이너와 limitEphemeralContainerSecretReferences()의 임시 컨테이너에도 같은 빈틈이 있었습니다. PR #124322가 세 곳 모두에 빠져 있던 envFrom 반복문을 추가했습니다.
단계별 공격 실행 흐름
제한된 서비스 어카운트
ServiceAccount builder에는 kubernetes.io/enforce-mountable-secrets: "true"가 붙어 있고 secrets에는 build-token만 있습니다. 같은 네임스페이스에 prod-db-credentials도 있습니다.
envFrom이 들어간 파드 명세
파드 생성 권한이 있는 사용자가 builder로 실행되는 파드를 제출하면서 일반·init·임시 컨테이너 중 하나에 envFrom: [{secretRef: {name: prod-db-credentials}}]를 설정합니다.
어드미션 검사 통과
limitSecretReferences()는 허용 목록 밖의 Secret 볼륨도 env[].valueFrom.secretKeyRef도 찾지 못해 파드를 승인합니다.
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이 파드 서비스 어카운트의secrets목록에 없는 파드 생성·임시 컨테이너 업데이트 요청을 찾으세요. - ✓네임스페이스에서 파드를 만들 수 있는 권한은 그 네임스페이스의 Secret을 읽을 수 있는 권한으로 취급하고, 민감한 Secret은 파드 생성자가 이미 신뢰받는 네임스페이스에만 두세요.
- ✓어드미션 검사가 어떤 리소스 유형을 제한한다면, 그것을 참조할 수 있는 파드 명세 필드(볼륨, 프로젝티드 볼륨,
env,envFrom, init·임시 컨테이너)를 모두 나열하고 필드마다 테스트를 하나씩 작성하세요.