搜索全站

输入至少 2 个字符,查找相关内容。

↑↓ 选择 Enter 打开Esc 关闭

K8s 审计日志与 ConfigMap/Secret

Secret 泄漏是国内企业容器化架构里最常见、也最难彻底根治的事故原因。 根因不是加密做得不好,而是 K8s 的设计目标从来不是"防泄漏",而是"防误显":

最后更新 2026-10-09版本 v2.0维护 DigiForensics查看历史 1

一、概述

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.search(pat, blob):
            score += w
            hits.append(why)

# 6) 主体加权
if user.startswit