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)

酒店给每位保洁员一张允许进入的房间清单。员工通道的保安会拿清单核对钥匙圈上的每一把钥匙,以及挂在腰带上的每一把单独的钥匙。但没人打开过那只装着整层楼全部钥匙的拉链小包。一个只被允许进大堂的保洁员拎着行政楼层的钥匙包走进去,保安照样放行,因为他的检查清单里根本没有“钥匙包”这一项。

核心概念与专有名词

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 的所有键导入为环境变量;而 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 在这三处补上了缺失的 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 的每个键都设为环境变量,容器即可读取。

源代码对比:漏洞与安全实现

存在漏洞的实现
// 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
}

工程与系统安全加固清单

参考来源