关键词:K8s 审计日志、audit.log、audit-policy、ConfigMap、Secret、base64 非加密、etcd 静态加密、SA token
难度:进阶
前置知识:Kubernetes RBAC 与对象模型、JSON Lines 日志解析、JWT 结构、etcd 存储机制
一、概述
Secret 泄漏是国内企业容器化架构里最常见、也最难彻底根治的事故原因。 根因不是加密做得不好,而是 K8s 的设计目标从来不是"防泄漏",而是"防误显":
- 默认情况下 Secret 的值是 base64 编码,不是加密——
echo 'TXlQYXNzd29yZDEyMw==' | base64 -d 就能还原。
- 绝大多数企业没有配置 etcd 静态加密,因此 etcd 快照里躺着的是同一批明文。
- 每个 Pod 默认自动挂载自己的 ServiceAccount token,一次误配置或一次 SSRF 就可能把它带出集群。
- 应用普遍用 ConfigMap / 环境变量接收数据库口令、第三方 API Key,一旦打进日志或前端就等于公开。
而"谁在什么时候读了哪个 Secret"这个问题的答案,只存在于 API Server 审计日志里。
这是 K8s 取证中唯一能回答行为问题的日志,也是本篇的一半。另一半是 ConfigMap / Secret 本身:类型有哪几种、值存在哪、怎么读,以及三种常见的"以为加密了其实没加密"的误判。
在取证流程中的位置:本篇属于分析环的核心证据源,跨采集与分析两环——审计日志要先抢(会被轮转覆盖),分析时又要作为主证据反复查询。
它与 K8s 取证工件地图 的 etcd 快照构成凭据取证的完整链条:快照告诉你有什么凭据,审计日志告诉你谁动过它们。 只有快照时无法归因,只有日志时看不到凭据内容。
必须先建立的判断框架:
| 问题 |
答案在哪 |
若答案是"没有" |
| 攻击者做了什么 API 调用? |
审计日志 |
只能靠其他日志侧面推断,必须写明局限 |
| Secret 的值是什么? |
etcd 快照 / API 响应 / Pod 内投影文件 |
数据不可得 |
| 集群是否启用了静态加密? |
--encryption-provider-config 指向的文件 |
值为 base64 明文 |
| 某个 ServiceAccount 有多大权限? |
RoleBinding + kubectl auth can-i |
不可判定 |
涉及的证据形态:
| 形态 |
具体位置 |
结构特征 |
| 审计日志 |
/var/log/kubernetes/audit.log |
每行一条 JSON,回答"谁/何时/调了什么/结果如何"四问 |
| 审计策略 |
/etc/kubernetes/audit-policy.yaml |
级别决定你能看到多少,见 2.2 审计级别 |
| 加密配置 |
/etc/kubernetes/encryption-config.yaml |
secret: 字段就是密钥本体;identity: {} 兜底项会让部分 Secret 仍是明文 |
| 密钥挂载 |
/var/run/secrets/kubernetes.io/serviceaccount/token |
每个 Pod 都有,有效期内可直接使用 |
| 宿主机对应位置 |
/var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~projected/default/ |
projected 卷的实际落点 |
| ConfigMap / Secret 对象 |
etcd 中,data 或 stringData 字段 |
Secret 的 data 是 base64,不是加密 |
审计级别是本篇最实际的门槛。
None 完全不记录;Metadata 只记元数据(大多数生产集群就是这个级别);Request 加请求体,能看到创建了什么;RequestResponse 加响应体,能看到读到了什么,但体积可达 GB 级/天。
动手前先确认实际级别,否则会对着一片空白解释"没有异常"。
Secret 的三种加密状态(本篇最需要讲清、也最容易被误判的一点):未加密(默认)、etcd 静态加密已启用、存储后端加密。三者的判据与还原方式完全不同,而且——"启用了加密"不等于"所有 Secret 都加密了",因为配置里的 identity: {} 兜底项会让旧格式写入的 Secret 仍是明文,必须逐个检查值的开头,不能假设一致。
能回答什么:某个 ServiceAccount 在什么时候读了哪些 Secret、调了哪些 API、结果如何;集群有没有启用静态加密;某个 SA 的权限边界;某次异常操作是哪个 Pod 发起的。
不能回答什么:审计级别为 Metadata 时看不到请求体与响应体;sourceIPs[0] 通常不是攻击者的真实 IP(用 token 调 API 时它是 Pod IP 或节点 IP);已经轮转掉的时段;审计日志只记录 API 调用,不记录 API Server 内部处理。
内容边界(本篇的红线):从检材读到的 Secret、token、加密密钥只做离线分析——识别类型、固定哈希、评估暴露面,不做在线使用,不尝试用检材中的凭据访问任何系统。读到明文口令时报告必须脱敏。encryption-config.yaml 里的 secret: 字段就是密钥本体,拿到它等于能解开集群里所有加密的 Secret——取证时必须单独固定、单独编号,并提示委托方处置完毕后立即轮换。
读者前提:需要 Kubernetes 的 RBAC、ConfigMap/Secret、ServiceAccount 概念,以及 JSON 与日志分析能力。建议先做 3.1 第 1 步——分析脚本的很多输出会因级别不足而空着,事先知道这一点能省掉大量误判。
二、核心原理
2.1 审计日志:唯一的行为证据源
API Server 是所有 API 调用的唯一入口。配置了审计后,每一次调用都会产生一条 JSON 记录,记录认证、鉴权、准入、执行的全过程。
审计日志回答四个问题,缺一不可:
| 问题 |
字段 |
| 谁 |
user.username、user.groups、user.extra(含 Pod 绑定的 SA 与 Pod 名) |
| 何时 |
requestReceivedTimestamp、stageTimestamp、annotations["authorization.k8s.io/decision"] |
| 调了什么 |
verb、objectRef.resource / subresource、objectRef.namespace、objectRef.name |
| 结果如何 |
responseStatus.code、responseStatus.message、annotations 里的授权依据 |
2.2 审计级别:决定你能看到多少
/etc/kubernetes/audit-policy.yaml 里每条规则有四级,差别巨大:
| 级别 |
记录内容 |
体积 |
取证价值 |
None |
完全不记录 |
0 |
无 |
Metadata |
只记元数据(谁、何时、调了哪个对象、结果码) |
小 |
★★★★ 大多数生产集群就是这个级别 |
Request |
元数据 + 请求体(requestObject) |
中 |
★★★★★ 能看到创建了什么 |
RequestResponse |
元数据 + 请求体 + 响应体(responseObject) |
大(可达 GB 级/天) |
★★★★★ 能看到读到了什么 |
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- RequestReceived # ★ 省略此阶段可减少约 40% 记录量
rules:
- level: None
users: ["system:kube-proxy", "system:unsecured"]
verbs: ["watch"]
resources: ["endpoints", "services"]
- level: RequestResponse # ★ 敏感操作记全量
verbs: ["create", "update", "patch", "delete"]
resources: ["secrets", "configmaps", "clusterrolebindings", "rolebindings"]
- level: Metadata # ★ 其余一律只记元数据
- level: Request
resources: ["pods", "pods/exec", "pods/log"]
⚠️ 这是"证据不足"最常见的根因:生产集群为性能普遍用 Metadata 级别,结果是审计日志只能告诉你"某人在某时列了一次 secrets",看不到他读到了什么。此时必须靠 etcd 快照与 Pod 配置间接推断,报告里要写清"Secret 内容未入审计日志"这一局限。
⚠️ omitStages: [RequestReceived] 的副作用:省掉该阶段后日志里不再有 requestReceivedTimestamp,只剩 stageTimestamp,无法计算请求耗时。取证脚本必须兼容两种情况(3.3 已处理)。
2.3 关键字段的取证含义
| 字段 |
取证含义 |
注意 |
user.username |
主体标识。system:serviceaccount:<ns>:<name> = Pod 用的 SA |
SA 泄露的定案字段 |
user.extra[...pod-name] |
★ SA 调 API 时能定位到具体哪个 Pod |
仅 SA token 认证时存在;把"API 调用"与"具体容器"关联起来的关键 |
sourceIPs[0] |
来源 IP。SA token 从 Pod 内发起时是 Pod IP |
★ 别把 Pod IP 当攻击者 IP |
userAgent |
客户端标识 |
kubectl/v1.x = 人工操作;伪造或无版本号 = 脚本 |
verb + objectRef.resource |
行为的原子描述 |
如 create pods、create pods/exec、update clusterrolebindings |
objectRef.name |
对象名 |
能精确定位"改了哪个 RoleBinding" |
responseStatus.code |
200 成功 / 403 拒绝 / 101 exec 成功 |
大量 403 说明在枚举试探 |
annotations["authorization.k8s.io/reason"] |
授权依据,含"由哪个 RoleBinding 允许" |
判断权限来源与提权链的关键 |
requestObject / responseObject |
请求/响应体 |
仅 Request / RequestResponse 级别才有 |
stage |
RequestReceived / ResponseStarted / ResponseComplete |
同一请求会有多条 stage 记录,必须去重,见 4 |
auditID |
单次请求的全局 ID |
★ 可与 etcd 的 Revision 交叉验证时序 |
2.4 ConfigMap 与 Secret:类型与落地
| 类型 |
用途 |
取证关注 |
Opaque(默认) |
任意键值对。最常见,也最危险 |
常含数据库口令、第三方 Key |
kubernetes.io/dockerconfigjson |
镜像仓库凭证 |
泄露即可向私库推送投毒镜像 |
kubernetes.io/tls |
TLS 证书与私钥 |
泄露即冒充服务端 |
kubernetes.io/service-account-token |
SA token(系统自动创建) |
与 Pod 挂载的是同一套 |
kubernetes.io/basic-auth |
用户名口令 |
一般不推荐使用 |
kubernetes.io/ssh-auth |
SSH 私钥 |
泄露即横向移动凭据 |
Secret 在 Pod 内的三种消费方式(决定它出现在哪):
| 方式 |
容器内形态 |
落盘位置 |
环境变量 env |
进程环境 |
只在 /proc/<pid>/environ,不落文件 |
卷挂载 volumeMounts |
文件 |
/var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~secret/<name>/<key> |
投射卷 projected |
文件(含 SA 三件套) |
.../kubernetes.io~projected/default/ |
⚠️ 环境变量方式的 Secret 不落文件,只在容器进程环境里,容器停止后若未落盘就彻底消失(见第 01 篇的取证时机)。
2.5 ⚠️ 核心认知:Secret 的"三种加密状态"
这是本篇最需要讲清、也最容易被误判的一点。
| 状态 |
判断依据 |
值的外观 |
还原方法 |
| ① 未加密(默认) |
--encryption-provider-config 未配置或文件不存在 |
"password": "TXlQYXNzd29yZDEyMw==" |
base64 -d |
| ② etcd 静态加密已启用 |
配置指向的 EncryptionConfiguration 且文件存在 |
"password": "k8s:enc:...:keyname:data:..." |
需 decrypt 流程(见下) |
| ③ 存储后端加密 |
etcd 侧开启静态加密或磁盘加密 |
落盘已是密文,与 ② 外观不同 |
需 etcd / 存储侧密钥 |
/etc/kubernetes/encryption-config.yaml(启用静态加密时)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- aescbc: # 或 aesgcm / secretbox
keys:
- name: key1
secret: <base64 编码的 32 字节密钥>
- identity: {} # ★ 兜底:回退为不加密
⚠️ identity: {} 兜底项的含义:它在列表最后时,用该密钥加密的数据可解密,但以其他方式(如旧版本)写入的 Secret 仍是明文。所以**"启用了加密"不等于"所有 Secret 都加密了"**。必须逐个检查值的开头,不能假设一致。
判断流程(报告里要照此写):
1) 是否配置了加密
grep -n 'encryption-provider-config' /mnt/df/etc/kubernetes/kube-apiserver.yaml
ls -l /mnt/df/etc/kubernetes/encryption-config.yaml 2>/dev/null || echo "★ 无加密配置 → Secret 为 base64 明文"
2) 逐个检查值的形态
echo 'TXlQYXNzd29yZDEyMw==' | base64 -d; echo # → MyPassword123 (状态①)
⚠️ encryption-config.yaml 里的 secret: 字段就是密钥本体,拿到它等于能解开集群里所有加密的 Secret。取证时该文件必须单独固定、单独编号,并在报告中提示委托方处置完毕后立即轮换。
三种状态的判定口径与"哪些密文不值得花时间解"的取舍,与数据库侧一致,见数据库与配置文件加密。
2.6 SA token 泄漏的完整链条
这是 K8s 环境里最高价值的凭据,因为它不像口令那样可以"改了就没事"——泄露的 SA token 在有效期内可直接使用。
① token 从哪来
/var/run/secrets/kubernetes.io/serviceaccount/token (容器内,每个 Pod 都有)
/var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~projected/default/ (宿主机对应位置)
↓
② 怎么流出去
应用把 token 打进日志 / 报错栈 / 前端 / 第三方 SDK
容器被攻破后直接 cat
写进 ConfigMap / 环境变量后被 image 打进镜像
↓
③ 泄露后能干什么
★ 完全等同于该 ServiceAccount 在 RBAC 里的全部权限
绑定 cluster-admin 的 SA token 泄露 = 集群完全失守
↓
④ 取证怎么确认
审计日志里出现该 SA 的可疑操作 → 拿到 auditID
→ 回到 2.3 的 user.extra 拿到 pod-name / pod-uid
→ 定位到具体是哪个容器发起的
⚠️ sourceIPs 的陷阱必须讲清:当攻击者用 SA token 调 API 时,sourceIPs[0] 通常是 Pod 的节点 IP 或 Pod IP,不是攻击者的真实 IP。定位真实来源要靠 user.extra 里的 Pod 信息 + 节点上的应用日志 + 网络侧记录。
三、操作步骤
3.1 第 1 步:确认审计日志可用并确定分析时段
grep -nE 'audit-log-path|audit-policy-file|audit-log-maxage|audit-log-maxbackup|audit-log-maxsize'
/mnt/df/etc/kubernetes/kube-apiserver.yaml
ls -l --time-style=long-iso /mnt/df/var/log/kubernetes/
cp -p /mnt/df/var/log/kubernetes/audit*.log* /evidence/k8s/
wc -l /evidence/k8s/audit.log
★ 取时段边界(★ 报告里必须写"可分析时段",超出部分不能下否定结论)
for f in /evidence/k8s/audit.log.1 /evidence/k8s/audit.log; do
echo -n "$f 最早 "; head -1 "$f" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('requestReceivedTimestamp') or d.get('stageTimestamp'))"
echo -n "$f 最晚 "; tail -1 "$f" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('requestReceivedTimestamp') or d.get('stageTimestamp'))"
done
审计策略决定"能证明到哪一层":
| 策略里出现的情况 |
报告能得出的最强结论 |
level: Metadata |
只能说"某主体在某时访问了名为 X 的 Secret",不能说它读到了什么 |
level: Request |
能说"创建/修改了什么对象"(有 requestObject),读操作看不到内容 |
level: RequestResponse |
能说"创建了什么、读到了什么",证据力最强 |
未配置 --audit-log-path |
该集群从未记录审计,只能靠其他日志推断,必须在局限中说明 |
3.2 第 2 步:审计日志分析脚本(本文核心)
脚本目标:读 JSON Lines 格式的 audit.log,按可疑度打分排序,输出 Top N 可疑请求。评分规则针对容器环境的高危行为设计,分三层:资源类(改什么)、行为类(怎么改)、上下文类(请求体里写了什么)。
#!/usr/bin/env python3
K8s 审计日志可疑行为分析器(离线、只读)
用法:
python3 audit_score.py audit.log --top 30
python3 audit_score.py audit.log --user 'system:serviceaccount:ns:app' --top 50
python3 audit_score.py audit.log --since 2024-03-18T00:00:00Z --ip 198.51.100.24
import json, sys, re, argparse
from collections import defaultdict
---------- 资源类规则:(正则, 分值, 说明) ----------
资源类只取最匹配的一条,避免堆叠虚高。
RULES = [
# RBAC 提权:改完即集群管理员
(r"clusterrolebindings|clusterroles", 45, "触及集群级RBAC"),
(r"rolebindings|roles", 20, "触及命名空间级RBAC"),
# 凭据读取
(r"^secrets?$", 40, "访问Secret"),
(r"^configmaps?$", 12, "访问ConfigMap"),
# 容器执行:进容器即可读可改容器内一切
(r"^pods/(exec|attach|portforward)$", 50, "进入容器(pods/exec)"),
(r"^pods$", 15, "访问Pod"),
(r"^pods/(log|eviction|ephemeralcontainers)$", 10, "访问Pod子资源"),
# 节点代理:等价于脱离容器隔离
(r"^nodes$|^nodes/(proxy|log|exec)$", 30, "访问节点"),
(r"^persistentvolumes?$|^storageclasses?$", 12, "访问存储对象"),
# 元数据枚举 = 踩点
(r"^serviceaccounts?$", 25, "访问ServiceAccount"),
(r"^namespaces?$", 10, "访问命名空间"),
(r"^events?$", 5, "访问Event"),
# 准入与签名:绕过供应链防护
(r"^validatingwebhookconfigurations$|^mutatingwebhookconfigurations$", 25, "触及准入控制器"),
(r"^csidrivers?$", 20, "触及CSI驱动"),
# 常见的持久化载体
(r"^daemonsets?$", 30, "部署DaemonSet"),
(r"^cronjobs?$|^jobs$", 15, "部署定时任务"),
]
上下文类规则:在请求体上匹配,命中即叠加
DANGER_KEYS = [
(r'"privileged"\s*:\strue', 30, "privileged:true"),
(r'"hostPath"\s:', 30, "hostPath挂载"),
(r'"/var/run/docker.sock"|"containerd.sock"', 40, "挂载运行时socket"),
(r'"path"\s*:\s*"/etc/kubernetes', 40, "挂载/etc/kubernetes"),
(r'"path"\s*:\s*"/"', 20, "挂载宿主机根目录"),
(r'"hostNetwork"\s*:\strue', 20, "hostNetwork:true"),
(r'"hostPID"\s:\strue', 20, "hostPID:true"),
(r'"hostIPC"\s:\strue', 15, "hostIPC:true"),
(r'"cluster-admin"', 40, "绑定cluster-admin"),
(r'"image"\s:\s*"[^"]*(?::\d+|[0-9]{1,3}(.[0-9]{1,3}){3})', 15, "镜像指向IP/非标端口仓库"),
]
WRITE_VERBS = {"create", "update", "patch", "delete", "deletecollection"}
READ_SENSITIVE_RE = re.compile(r"secrets?|nodes?|serviceaccounts?")
SYSTEM_SA_RE = re.compile(r"^system:(serviceaccount:kube-system|node|controller)")
需要纳入分析的 stage;同一请求的多条 stage 记录由 auditID 去重后择优保留
KEEP_STAGES = ("ResponseComplete", "ResponseStarted", "RequestReceived", "")
def score_event(d):
"""对单条审计事件打分,返回 (总分, 命中规则列表)"""
hits, score = [], 0
obj = d.get("objectRef") or {}
res, sub = obj.get("resource") or "", obj.get("subresource") or ""
key = f"{res}/{sub}" if sub else res
verb = d.get("verb") or ""
user = (d.get("user") or {}).get("username") or ""
if (d.get("stage") or "") not in KEEP_STAGES:
return 0, []
# 1) 资源类
for pat, w, why in RULES:
if re.search(pat, key) or re.search(pat, res):
score += w
hits.append(why)
break
# 2) 行为类:读是常态,写才是行为
if verb in WRITE_VERBS:
score += 10
hits.append(f"写操作({verb})")
elif verb in ("get", "list", "watch") and READ_SENSITIVE_RE.search(res):
score += 8
hits.append("敏感读操作")
# 3) 系统 SA 做写操作 = 权限被异常使用(凭据被盗的典型表现)
if verb in WRITE_VERBS and SYSTEM_SA_RE.search(user):
score += 20
hits.append("系统SA执行写操作(权限异常)")
# 4) 鉴权拒绝密集出现 = 枚举试探
if (d.get("responseStatus") or {}).get("code") in (401, 403):
score += 6
hits.append("鉴权拒绝")
# 5) 请求体高危字段(仅 Request / RequestResponse 级别才有)
ro = d.get("requestObject")
if isinstance(ro, dict):
blob = json.dumps(ro, ensure_ascii=False)
for pat, w, why in DANGER_KEYS:
if re.sear