作者:温砚舟-809|3 年安全工程师|专注安全加固与风险防范 栈:Kubernetes 1.28.4(kubeadm,cgroup v2,内核 5.15.0-91)、containerd 1.7.13、OPA/Kyverno 1.11、Harbor 2.9 结论先行:K8s 里 Pod 不申请任何权限,也会默认拿到一份 ServiceAccount Token 挂进容器文件系统——
automountServiceAccountToken缺省为true。攻击者不需要"被授权",只需要"进程能读文件"。这个隐式默认值,是我审计过的 6 套集群里 100% 存在的攻击面。
一、现象:一个只跑静态扫描的 Job,3 秒内拿到了 API 凭证
给一套 42 节点的生产集群(K8s 1.28.4)做季度加固审计,CI 团队有一个 nightly 镜像扫描 Job(image-scan,Deployment,2 副本,跑 trivy),业务上完全不需要访问 K8s API——它只读本地镜像仓库。
我在该 Pod 里执行:
# 1. 看挂载
cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 40
# eyJhbGciOiJSUzI1NiIsImtpZCI6Ij... ← 存在,且可读
# 2. 直接调 API 试权限
curl -s -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
https://kubernetes.default.svc/api/v1/namespaces | jq '.items[].metadata.name'
# 返回了 11 个命名空间(default、prod-*、staging-*、kube-system...)
耗时:3 秒,无越权、无告警。 这个 Job 用的是命名空间 default SA,RBAC 审计显示它没有任何 RoleBinding——但它仍然能 list namespaces,因为 RBAC 里 get/list 对 namespaces 资源对几乎所有 SA 都是隐式放行的(apiserver 的 --anonymous-auth 关闭也拦不住持合法 token 的请求)。真正的风险不在"它能 list 什么",而在这份 token 对任何容器内进程、任何 sidecar、任何被 RCE 的依赖链都是可读的。
规模数据:全集群 1,247 个 Pod,kubectl get pods -A -o json | jq '[.items[] | select(.spec.automountServiceAccountToken != false)] | length' 结果 1,189 个(95.3%) 处于"自动挂载"状态,其中只有 31 个命名空间真正用得上 API 访问。
二、原理:这个默认值是怎么来的,为什么"关掉"比"收紧权限"更根本
三层机制叠加:
1)kubelet 的隐式行为。 创建 Pod 时,如果 spec.automountServiceAccountToken 未显式设置,kubelet 会把它当作 true,并自动把 default SA 的 projected volume 挂到每个容器的 /var/run/secrets/kubernetes.io/serviceaccount/。这个挂载不经过 RBAC 审批、不产生审计事件、不出现在任何 admission 日志里——它是"基础设施层"的默认行为,不是"策略层"的授权。
2)SA Token 的签发与轮转。 1.20+ 默认启用 TokenRequest API,kubelet 按需签发短期 token(--service-account-max-token-expiration 默认 1 小时)替换静态 token。注意:轮转 ≠ 消除暴露——攻击者只需要在 token 有效期内(最长 1 小时)完成一次 list/get,足够把 Secret 拖走。
3)PodSecurity 的"盲区"。 Pod Security Standards(PSS)能管 privileged、allowPrivilegeEscalation、hostPath,但 automountServiceAccountToken 不属于 PSS 任何一个字段——Restricted profile 下它照样为 true。很多人以为"上了 Restricted 就安全了",这是最常见的认知误区。
三、具体做法:四步收敛,每步可复现
第 1 步:全量摸底(10 分钟,只读)
# 统计自动挂载比例
kubectl get pods -A -o json | jq '[.items[] | select(.spec.automountServiceAccountToken != false)] | length'
# 按命名空间分布
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.automountServiceAccountToken != false) | .metadata.namespace' | sort | uniq -c | sort -rn
# 抽查:哪些 Pod 的 SA 有实际权限(对照 RBAC)
kubectl get rolebindings,clusterrolebindings -A -o json | jq -r '.items[] | select(.subjects[].kind=="ServiceAccount") | .metadata.name + " -> " + .roleRef.name'
第 2 步:命名空间级关闭(对不需要 API 的 ns 批量加注解)
# 在命名空间对象上加 annotation(1.24+ 推荐用 namespace 默认值方式)
kubectl annotate namespace prod-batch \
kubernetes.io/automount-service-account-token: "false"
注意:
kubernetes.io/automount-service-account-token这个 annotation 是命名空间级默认值,Pod 级spec.automountServiceAccountToken: false仍然可以覆盖它。所以对"个别 Pod 需要 API、其他不需要"的命名空间,要在 Pod spec 里显式写false,不能只靠 ns annotation。
第 3 步:admission 层卡死(Kyverno 策略示例)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: deny-automount-sa-token
spec:
validationFailureAction: Enforce
background: true
rules:
- name: require-explicit-automount-false
match:
any:
- resources:
kinds: [Pod]
namespaces: ["prod-*", "staging-*"]
preconditions:
all:
- key: "{{request.object.spec.serviceAccountName}}"
operator: NotIn
pattern: ["ci-deployer", "image-puller"] # 白名单
validate:
message: "automountServiceAccountToken must be explicitly false for non-whitelisted SAs"
pattern:
spec.automountServiceAccountToken: "false"
第 4 步:给真正需要 API 的 SA 换成 BoundServiceAccountTokenVolume + 短 TTL
# Deployment 里显式指定,并配合 SA annotation 控制 token 有效期
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: ci
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/ci-deployer # IRSA 场景
---
spec:
automountServiceAccountToken: true # 显式声明,别靠默认
containers:
- name: deployer
# token 自动从 projected volume 拿,无需改代码
K8s 1.24+ 集群可以在 apiserver 侧设 --service-account-max-token-expiration=15m(默认 1h),把单张 token 的暴露窗口压到 15 分钟。
四、踩过的坑与反直觉之处
automountServiceAccountToken: false不生效的常见原因:写了spec.automountServiceAccountToken但 SA 本身是default,且命名空间没有 annotation——kubelet 对defaultSA 有特殊路径,建议同时加 ns annotation 双保险。- sidecar 容器也吃这个挂载:一个 Pod 里 3 个容器,只要主容器是 Java 业务(不需要 API),日志 sidecar(Filebeat)也会被挂载 SA token。关掉主容器的挂载不影响 sidecar,每个容器独立判断。
kubectl auth can-i测不出"能不能读 token 文件":can-i测的是 RBAC 授权,不测"文件系统里有没有 token"。正确验证方式是直接进容器cat /var/run/secrets/...。- PSA Restricted ≠ 没有 SA token:见上文第 3 点,PSS 不管这个字段,别把"上 Restricted"当终点。
- IRSA(AWS)场景下关掉 automount 是安全的:IRSA 走
--instance-profile或 Web Identity,token 从 IMDS 拿,不依赖 SA token 文件,关掉automountServiceAccountToken对业务零影响——这是最理想的"无感收敛"场景。
五、结论与可复用清单
- 核心认知:K8s 的 SA token 自动挂载是基础设施默认行为,不是授权动作,RBAC 审计日志里看不到它。
- 收敛优先级:① ns annotation 批量关 → ② Pod spec 显式
false→ ③ Kyverno/OPA 卡死新增 → ④ 白名单 SA 用短 TTL + 最小权限。 - 验证命令(3 秒内可跑):
kubectl exec -n <ns> <pod> -- cat /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null | head -c 20 # 有输出 = 暴露;无输出或 No such file = 已收敛 - 量化目标:生产环境自动挂载 Pod 占比应从 95%+ 压到 <5%(仅真正需要 API 的 SA)。
- 相关经验:RBAC 默认绑定导致 SA token 读到生产密码的完整链路,见温望舒-139 的《一个 46 字符的 SA Token 读到了生产数据库密码》;镜像层密钥泄露的另一个常见入口,见温砚舟-609 的《一条 docker history 翻出镜像里的 .env》。