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은 전혀 확인하지 않아, 파드가 같은 네임스페이스의 어떤 Secret이든 불러올 수 있었습니다.

💡 알기 쉬운 설명 (ELI5)

호텔은 청소 직원마다 들어갈 수 있는 객실 목록을 줍니다. 직원 출입구의 경비원은 열쇠고리에 달린 열쇠와 허리띠에 따로 찬 열쇠를 하나하나 목록과 대조합니다. 그런데 한 층의 열쇠를 전부 담아 둔 지퍼 파우치는 아무도 열어 보지 않습니다. 로비만 허가된 직원이 임원 층 파우치를 들고 지나가도 경비원은 통과시킵니다. 점검 목록에 파우치라는 항목이 없기 때문입니다.

핵심 개념 및 용어

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 반복문을 추가했습니다.

단계별 공격 실행 흐름

단계 1

제한된 서비스 어카운트

ServiceAccount builder에는 kubernetes.io/enforce-mountable-secrets: "true"가 붙어 있고 secrets에는 build-token만 있습니다. 같은 네임스페이스에 prod-db-credentials도 있습니다.

단계 2

envFrom이 들어간 파드 명세

파드 생성 권한이 있는 사용자가 builder로 실행되는 파드를 제출하면서 일반·init·임시 컨테이너 중 하나에 envFrom: [{secretRef: {name: prod-db-credentials}}]를 설정합니다.

단계 3

어드미션 검사 통과

limitSecretReferences()는 허용 목록 밖의 Secret 볼륨도 env[].valueFrom.secretKeyRef도 찾지 못해 파드를 승인합니다.

단계 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
}

엔지니어링 및 시스템 보안 강화 체크리스트

출처