容器逃逸与提权路径阻断:深入 PSS 底层机制与 Admission Webhook 拦截实践

早上例行过了一遍核心集群的 API Server 审计日志,发现某个业务 Namespace 下出现了一连串被拒绝的 pods/execpods/create 请求。顺着链路查下去,发现是开发侧为了图方便,直接在一个 CI/CD 专用的 ServiceAccount 上绑定了过于宽松的 RBAC 权限。 在 K8S 环境里,安全边界从来不是靠口头约定的。今天刚好借着这个案例,把 K8S 底层的权限提权路径拆解一下,并谈谈如何通过 Pod Security Standards (PSS) 和 Admission Webhook 构建实质性的防御闭环。

一、RBAC 最小权限的伪命题与提权路径

很多人配置 RBAC 时,觉得只要不给 cluster-admin,不给 secrets 的读取权限就是安全的。但实际上,在 Kubernetes 中,只要拥有创建 Pod 的权限,如果没有严格的准入控制,就等同于拥有了 Node 的 Root 权限,进而可以接管整个集群。 看一眼开发试图下发但被拦截的 Pod YAML:

apiVersion: v1
kind: Pod
metadata:
  name: debug-pod
spec:
  hostNetwork: true
  hostPID: true
  containers:
  - name: shell
    image: alpine:latest
    securityContext:
      privileged: true
    volumeMounts:
    - mountPath: /host
      name: host-root
  volumes:
  - name: host-root
    hostPath:
      path: /
      type: Directory

如果集群只依赖基础的 RBAC 且允许下发上述资源,会发生什么?

  1. hostPID: true 打破了 PID Namespace 的隔离。

  2. privileged: true 让容器内的进程获取了 Node 宿主机上的所有 Capabilities。

  3. hostPath 直接将宿主机的根目录 / 挂载到了容器的 /host。 攻击者进入容器后,只需要执行一条命令: chroot /host nsenter -t 1 -m -u -n -i sh 这直接切换到了宿主机 init 进程(PID 1)的 Mount、UTS、Network、IPC 命名空间。此时,攻击者已经拿到了 Node 的 Root 权限,可以随意读取 /etc/kubernetes/pki/ 下的证书,或者复用 kubelet 的凭证与 API Server 通信,完成对整个集群的控制。 这就是为什么 RBAC 的最小权限原则(Least Privilege)必须配合更底层的资源控制。

二、从 PSP 到 PSS:内置的准入拦截机制

早年间我们用 PodSecurityPolicy (PSP) 来防范这种越权,但由于 PSP 的 API 设计过于复杂且容易导致权限混淆,官方在 1.25 版本彻底将其移除,替换为了 Pod Security Admission (PSA/PSS)。 PSS 不是一个独立运行的组件,它本质上是 kube-apiserver 内部的一个 Admission Controller。官方预定义了三个安全等级:PrivilegedBaselineRestricted。 为了在特定的 Namespace 阻断上述提权行为,我通常会在系统层面直接打 Label 强制启用 Restricted 模式:

kubectl label --overwrite ns cicd-pipeline \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=latest \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/warn=restricted

底层拦截逻辑分析: 当 API Server 接收到创建 Pod 的请求时,请求会经历 Authentication -> Authorization (RBAC) -> Mutating Admission -> Object Schema Validation -> Validating Admission 这个标准管道。 PSA 作用于 Validating Admission 阶段。它会提取目标 Namespace 的 Label,当检测到 enforce=restricted 时,会按严格的策略遍历 Pod Spec。只要发现 hostPID: true 或者 privileged: true,直接返回 HTTP 403。 在 API Server 的审计日志中,你可以清晰看到拦截过程产生的记录:

{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1",
  "level": "Metadata",
  "stage": "ResponseComplete",
  "requestURI": "/api/v1/namespaces/cicd-pipeline/pods",
  "verb": "create",
  "user": {
    "username": "system:serviceaccount:cicd-pipeline:deployer"
  },
  "responseStatus": {
    "metadata": {},
    "status": "Failure",
    "reason": "Forbidden",
    "code": 403
  },
  "annotations": {
    "pod-security.kubernetes.io/enforce-policy": "restricted"
  }
}

三、Admission Webhook:弥补 PSS 的盲区

PSS 解决了 “Pod 自身特权逃逸” 的问题,但它的粒度太粗了。如果我的需求是:

  1. 强制所有镜像必须从公司内部的私有仓库(例如 harbor.internal.com)拉取。

  2. 阻止未经授权的用户修改特定的 ConfigMap

  3. 动态检查某些资源配置是否符合安全合规标准。 这三个需求 PSS 都做不到,必须通过自定义的 ValidatingAdmissionWebhook 来实现。 Webhook 的本质是一个 HTTPS Server。API Server 在 Validating 阶段,会将请求封装成一个 AdmissionReview 对象,通过 POST 请求发给你的 Webhook,然后根据返回的 allowed: true/false 决定是否放行。 下面是一个拦截非私有仓库镜像的核心代码片段(Go 语言):

func servevalidate(w http.ResponseWriter, r *http.Request) {
    var body []byte
    if r.Body != nil {
        if data, err := io.ReadAll(r.Body); err == nil {
            body = data
        }
    }
    // 解析 API Server 发来的 AdmissionReview
    ar := v1.AdmissionReview{}
    json.Unmarshal(body, &ar)
    // 反序列化具体的 Pod 对象
    var pod corev1.Pod
    json.Unmarshal(ar.Request.Object.Raw, &pod)
    allowed := true
    var resultMsg string
    // 检查镜像前缀
    for _, container := range pod.Spec.Containers {
        if !strings.HasPrefix(container.Image, "harbor.internal.com/") {
            allowed = false
            resultMsg = fmt.Sprintf("Image %s is strictly forbidden. Use harbor.internal.com.", container.Image)
            break
        }
    }
    // 构造响应
    admissionResponse := v1.AdmissionResponse{
        UID:     ar.Request.UID,
        Allowed: allowed,
        Result: &metav1.Status{
            Message: resultMsg,
        },
    }
    ar.Response = &admissionResponse
    respData, _ := json.Marshal(ar)
    w.Write(respData)
}

要让这个 Webhook 生效,我们需要向集群提交 ValidatingWebhookConfiguration 配置:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: image-policy-validator
webhooks:
  - name: image-policy.k8s.internal
    clientConfig:
      service:
        name: security-webhook
        namespace: security-system
        path: "/validate"
      caBundle: 
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
    failurePolicy: Fail
    namespaceSelector:
      matchExpressions:
      - key: kubernetes.io/metadata.name
        operator: NotIn
        values: ["kube-system", "security-system"]

四、配置 Webhook 的高危陷阱

在上述 YAML 中,我特意写了两个关键配置,这也是无数人在生产环境踩过大坑的地方:

  1. namespaceSelector: 必须排除掉 kube-system 甚至你部署 Webhook 所在的 Namespace。

  2. failurePolicy: Fail: 代表如果 Webhook 服务不可用,API Server 会拒绝所有发往该 Webhook 的请求。 如果你的 Webhook 所在节点宕机,或者证书过期,且你没有排除核心 Namespace,会导致连 kube-system 下的组件(比如 CoreDNS 扩容、CNI 组件重启)都无法创建 Pod,整个集群陷入死锁。到那时,你连部署一个新的 Webhook 实例去恢复它都做不到,只能通过直接修改 API Server 的静态 Pod 配置文件绕过拦截。 安全体系的构建是一环扣一环的。RBAC 控制谁能访问,PSS 限制能运行什么形态的实体,Admission Webhook 则补齐了业务侧定制化的安全约束。把这三者结合好,才能在日常运维中从容应对各种不规范的操作和潜在的恶意越权。收拾完这堆审计告警,该去跟研发团队对齐新的权限管控流程了。