尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

RBAC之外:PSA与Seccomp如何守住Kubernetes容器逃逸防线

发布时间:2026/9/26 13:05:12

资讯中心
01
ARTICLE

RBAC之外:PSA与Seccomp如何守住Kubernetes容器逃逸防线

RBAC之外:PSA与Seccomp如何守住Kubernetes容器逃逸防线
这两年给客户做Kubernetes集群安全巡检被问到最多的一个问题是RBAC 都配好了namespace 也隔离了为什么还要折腾 Pod 安全准入和 Seccomp 系统调用限制直到在一次攻击路径演练里我们通过一个未授权访问漏洞拿到 API Server 的匿名请求入口再从某个测试 ServiceAccount 创建出挂载宿主机目录的特权 Pod最后一路拿到了节点 root——大家才意识到网络层的防线根本没有拦到容器运行时的最里层。这篇文章把我在生产集群里落地 Pod 安全准入PSA和 Seccomp 的完整过程整理出来包括两类最让人头疼的报错定位思路以及从试点到全量推广时容易被打脸的场景。如果你正在做 K8s 安全加固或者已经收紧过策略但担心误伤业务这篇应该能给你一些可直接复用的判断。1. 先看懂攻击路径为什么RBAC之外还需要一道进程级防线1.1 一次未授权访问演练暴露的三个事实先聊一个真实场景。客户集群的 API Server 用负载均衡直接暴露了 6443 端口当时团队觉得反正有 RBAC别人拿不到 token问题不大。但这个集群开启过匿名认证而且没有显式禁用system:anonymous这个身份。我找了一台没有任何集群凭据的机器执行了一条很简单的命令kubectl --serverhttps://x.x.x.x:6443 get pods -A结果直接返回了 Pod 列表。原因是 API Server 给匿名用户绑定了system:discovery的角色而某些旧版本的聚合 API 或自定义 CRD 在处理请求时会让匿名身份读到一部分敏感信息。更麻烦的是攻击者拿到这个入口之后可以进一步枚举 ServiceAccount找到某个平时测试用的高权限 SA然后创建 Pod 挂载宿主机的根目录——整个链条一旦打通集群基本就等于裸奔。热搜词里反复出现kubernetes 未授权访问漏洞这类问题本身已经很老套但我想强调的是另一面即使你关掉匿名认证、把 RBAC 收紧到极致攻击者依然可以从另一个入口进来——某个业务容器里的命令执行漏洞或者供应链镜像投毒。真正的安全评估不能假设攻击者只从 API Server 正面发起攻击还要假设他已经拿到了容器内的代码执行权限。1.2 攻击链拆解从API请求到容器逃逸再到宿主机我把常见的攻击链路拆成四个阶段方便后面理解为什么需要层层设防。第一阶段获得容器内命令执行权限。方式可以是 WebShell、日志组件反序列化漏洞、被污染的镜像甚至只是有人把测试账号密码贴在了公开的配置仓库里。第二阶段容器内侦察。攻击者会查看挂载点、抓取/var/run/secrets/kubernetes.io/serviceaccount/token、检查自己是否有特权模式或敏感 capabilities。很多业务容器根本没有做最小化token 就明晃晃地放在那里。第三阶段容器逃逸。经典路径包括特权容器直接挂载宿主机磁盘、hostPath 挂载/、capabilities 里带了SYS_ADMIN或SYS_PTRACE、以及内核漏洞配合不受限制的系统调用。第四阶段宿主机横向移动。拿到 root 之后读取 etcd 证书、劫持 kubelet 端口、在所有节点上留后门都是常规操作。在这个链路里RBAC 和 NetworkPolicy 能管的只是第一、第二阶段的一部分。真正能在第三、第四阶段踩刹车的恰好是两样东西Pod 安全准入负责阻止危险 Pod 被创建出来Seccomp 负责在容器运行前收窄系统调用范围让逃逸武器失效。1.3 三道门的分工认证授权、安全准入、系统调用限制打个比方RBAC 是大厦前台的访客登记能不能进楼由它说了算Pod 安全准入是进入核心机房的安检口背包里不能带刀Seccomp 则是机房内部每个工位前的操作许可清单允许你按哪些按键、禁止按哪些按键。三者是纵深防御关系单靠任何一环都挡不住认真想进来的攻击者。所以在你动手配置 PSA 和 Seccomp 之前先建立这个认知它们不是重复的加固而是分别卡在 Pod 创建时API Server 侧和容器运行前kubelet 和 runtime 侧的两道独立闸门。接下来按这个顺序先说准入再说系统调用。2. Pod安全准入实操把restricted落进命名空间Label之前要想清楚的事2.1 为什么PSP退场PSA成为内置方案经历过 1.21 之前集群的同学对 PodSecurityPolicyPSP应该又爱又恨。PSP 看起来强大但策略定义极其复杂事件模型混乱而且它本质上依赖 RBAC 去绑定谁能使用这个策略而不是强制工作负载必须满足什么策略。结果就是大量集群开了 PSP 之后形同虚设或者运维每天都在给个别 Pod 加例外。Kubernetes 从 1.21 起弃用 PSP1.25 正式移除取而代之的是内置的 Pod Security AdmissionPSA。PSA 配合一套 Pod Security StandardsPSS把策略分成了三个档位privileged、baseline、restricted。你不再需要写复杂的 controller 逻辑只需要给命名空间打几个 LabelAPI Server 就会在 Pod 创建或更新时按档位检查。2.2 三个档位的差距到底在哪里很多文章只甩一张表但我在实际评估中发现真正需要关注的是档位里的默认值——因为大量 Helm Chart 在没显式设置安全上下文时这些默认值直接决定了你的迁移成本。级别核心限制典型适用场景privileged无策略限制等同于老集群裸奔系统组件、device plugin、网络插件 DaemonSetbaseline禁止特权容器、hostNetwork/hostPID/hostIPC、不安全的 sysctl 和卷类型要求部分 seccomp 约束非核心业务、第三方中间件restrictedbaseline 全部限制 runAsNonRoot、禁止 allowPrivilegeEscalation、rootfs 只读、seccomp 必须为 RuntimeDefault 或 Localhost对外业务、生产核心服务这里最容易被忽略的一点是baseline并不强制 seccomp只有restricted才强制要求seccompProfile为RuntimeDefault或Localhost。也就是说如果你想让 Seccomp 真正成为一种强制基线命名空间的 Label 就得落到restricted而不是baseline。另外restricted 要求 Pod 里每个容器都显式设置securityContext中的runAsNonRoot、privileged等字段不是实际不用 root 跑就行。很多业务镜像本来就用非 root 账号运行但因为 YAML 里没写这些字段在 restricted 下一样被拒。2.3 用命名空间Label完成灰度而不是一夜切换PSA 的三种模式是enforce、audit、warn。我第一次做迁移时直接给生产 namespace 打了enforcerestricted结果当天下午就有三个服务重建失败。后来把路径改成三步。第一步先用audit。给所有候选命名空间打上auditrestricted观察审计日志里有哪些权限违规。这个阶段不会拒绝任何 Pod只是记录。第二步再加warn。在audit基础上叠加warnrestricted让开发在kubectl apply时直接看到警告信息养成修改 YAML 的习惯。第三步最后才切enforce。把audit和warn改成enforce如果发现仍有异常立刻回退到warn模式再逐个 namespace 收口。实际操作命令大致是kubectl label ns app-prod \ pod-security.kubernetes.io/enforcerestricted \ pod-security.kubernetes.io/enforce-versionv1.28 \ pod-security.kubernetes.io/auditrestricted \ pod-security.kubernetes.io/audit-versionv1.28 \ pod-security.kubernetes.io/warnrestricted \ pod-security.kubernetes.io/warn-versionv1.28提示enforce-version参数值得单独强调。Pod Security Standards 的检查项会随版本演进如果你不固定版本默认取latest一旦集群升级策略行为可能漂移——某次升级之后突然多拦一批原本能通过的 Pod。还有个细节enforce只拦截新建和更新操作的 Pod存量 Pod 不受影响。这既是好事也是隐患——你以为策略生效了实际上老 Pod 已经绕过了必须主动滚动重建一遍才能验证真实效果。建议在灰度后期挑非业务低峰期做一次 Deployment 滚动。2.4 用一个小实验验证PSA真的在工作最直接的验证是创建一个违反规则的测试 Pod。在打了enforcerestricted的 namespace 里执行kubectl run test-privileged --imagenginx --restartNever \ --overrides{spec:{containers:[{name:nginx,image:nginx,securityContext:{privileged:true}}]}}API Server 会返回 Forbidden并提示违反了PodSecurity restricted:latest。即使把 privileged 去掉只要没写runAsNonRoot和seccompProfile同样会被拒绝。这个报错信息会明确写出违反了哪一条比 PSP 时代Pod 卡在 ContainerCreating 半天才发现策略问题的体验好太多。3. Seccomp原理与Kubernetes的三种配置姿势限制syscall不是玄学3.1 先理解Seccomp在容器安全里的角色Seccompsecure computing mode是 Linux 内核提供的一种系统调用过滤机制。本质上它给进程装了一道门禁每次进程发起系统调用时内核先经过一个 BPF 过滤器命中规则就放行或拒绝。容器逃逸的很多手段比如unshare创建新命名空间、mount挂载宿主机目录、reboot、kexec_load之类的高危操作最终都要落到对应的系统调用上。如果这些 syscall 在内核入口就被拦下很多已知逃逸利用链会直接失效。我通常用一个生活化类比来解释容器运行时containerd/docker相当于发给你一张门禁卡Seccomp profile 则是门禁系统里预设的权限清单。没在清单里的操作连门铃都按不响而不是等你进去之后再靠人员巡逻来拦截。3.2 三种配置姿势Unconfined、RuntimeDefault、Localhost在 Kubernetes 的 Pod 或 Container 的securityContext里seccompProfile支持三种typeUnconfined不配置任何 seccomp 过滤器等于不设防。只适合完全信任的系统组件或临时调试。RuntimeDefault使用容器运行时自带的默认 profile。containerd 内置的默认 profile 会禁用一组高危 syscall如 mount、reboot、kexec_load、模块加载等这是多数业务容器最合适的底线。Localhost引用节点本地文件系统中的自定义 profile。适合需要精细化白名单、或者需要放行运行时默认 profile 中没有的 syscall 的场景。需要注意Kubernetes 1.19 之前的配置方式是通过 annotationseccomp.security.alpha.kubernetes.io/pod1.19 之后统一改用seccompProfile字段到 1.27 这个字段已经 GA。我强烈建议新集群不要再参考老教程里的 annotation 写法统一使用字段方式。以 restricted 级别的 PSA 为例它要求seccompProfile必须是RuntimeDefault或Localhost不能是Unconfined。这实际上就是把容器运行时的默认安检变成了强制要求。3.3 RuntimeDefault的边界它不是万能密钥RuntimeDefault 很香但你要清楚它的边界。以 containerd 默认 profile 为例它确实禁掉了大约四五十个系统调用但很多真正危险的调用并不一定在列表里。而且对mount这类操作RuntimeDefault 是有条件放行——如果容器还带着CAP_SYS_ADMINcapability依然可能挂载敏感路径。这也是为什么我坚持 Seccomp 要和 capability 限制一起看不能单独指望一个 profile 挡住所有逃逸。另外RuntimeDefault虽然名字里有Default不同运行时实现并不完全一样。containerd 的默认 profile、CRI-O 的默认 profile、gVisor 沙箱运行时的逻辑都有差异。你在 A 环境测试通过不代表 B 环境一样安全至少在维护文档里要留下运行时版本信息。3.4 自定义Localhost profile从零编写到节点分发当业务确实需要放行 RuntimeDefault 之外的 syscall 时就要用Localhost。它要求你把 JSON 格式的 seccomp profile 放到每个节点的 kubelet seccomp 根目录默认是/var/lib/kubelet/seccomp/。kubelet 在创建容器前会读取这个文件把过滤器注入运行时。一个典型的自定义 profile 长这样{ defaultAction: SCMP_ACT_ERRNO, defaultErrnoRet: 1, archMap: [ { architecture: SCMP_ARCH_X86_64, subArchitectures: [SCMP_ARCH_X86, SCMP_ARCH_X32] } ], syscalls: [ { names: [ read, write, close, fstat, mmap, mprotect, munmap, brk, exit, exit_group, futex, sched_yield, nanosleep, set_robust_list, rseq, openat, newfstatat, getdents64, lseek, ioctl, prctl, arch_prctl, getuid, getgid, getpid, gettid, clock_gettime, sigreturn ], action: SCMP_ACT_ALLOW } ] }这个 profile 的意思是默认拒绝所有系统调用返回errno1EPERM只放行列表里的白名单调用。这里defaultAction用的是SCMP_ACT_ERRNO而不是默认放行安全优先宁可漏掉没写进去的调用也不给不受控的调用开口子。坏处也很明显第一次跑这种 profile 时你的业务容器大概率起不来因为 Java、Go、Nginx 运行时还会用到很多你没列进去的 syscall。我的习惯是先跑一段时间RuntimeDefault用strace或者 auditd 记录实际用到的 syscall再生成白名单。文件建好后要在所有节点同步并确认权限ls -la /var/lib/kubelet/seccomp/ chmod 644 /var/lib/kubelet/seccomp/my-profile.json然后在容器配置里引用securityContext: seccompProfile: type: Localhost localhostProfile: my-profile.json这里有个特别容易踩的坑localhostProfile是相对于 kubelet seccomp 根目录的路径不是绝对路径。写成/var/lib/kubelet/seccomp/my-profile.json是错的直接写my-profile.json才对。我在多个团队里见过这类报错后面第四章会展开。4. 一次failed to create pod sandbox报错的完整排查现场4.1 报错现场Pod一直ContainerCreating这套加固做完之后团队开始给核心业务分批应用 restricted 策略和自定义 profile。上线当晚有同学报告一个 Java 服务 Pod 一直处于ContainerCreatingdescribe出来看到下面这行报错failed to create pod sandbox: rpc error: code Unknown desc failed to create containerd task: failed to create shim task: failed to create task for container : ... open /var/lib/kubelet/seccomp/java-strict.json: no such file or directory: unknown这个错误字面意思很明确kubelet 或 containerd 在创建 sandbox 时找不到指定的 seccomp profile 文件。但我们明明 ssh 到节点上执行ls文件就在那里。于是开始排查。4.2 排查链路事件、kubelet日志、containerd日志、节点文件四层递进第一步把 Pod 事件完整拉出来kubectl describe pod java-service-xxx -n app-prod确认报错发生在容器重建那一刻而 Pod 本身没有进入 CrashLoopBackOff说明问题出在创建阶段不是业务进程崩溃。第二步登录节点看 kubelet 日志journalctl -u kubelet --since 10 minutes ago | grep -i seccomp注意如果集群有多个节点要先通过kubectl get pod -o wide确认报错 Pod 实际所在的节点别在错误的节点上查日志。第三步看 containerd 日志journalctl -u containerd --since 10 minutes ago | grep -i seccompkubelet 侧会记录配置文件缺失之类的信息containerd 侧可能记录更底层的系统错误。两个日志互相补充能帮你判断故障发生在哪一层。第四步回到节点文件系统验证ls -la /var/lib/kubelet/seccomp/ stat /var/lib/kubelet/seccomp/java-strict.json当时我们发现文件所有者是 root权限是600而不是644。原因是我当时直接用 root 登录后vim保存umask默认把权限写成了 600。kubelet 以 root 运行其实能读但 containerd shim 进程在某些配置下会以非 root 方式访问这个文件于是直接读取失败。还有一次更隐蔽文件权限是 644但/var/lib/kubelet/seccomp这个目录本身被设成了700。所以排查时别只看文件目录权限也要扫一眼。4.3 根因修复文件分发与权限规范化定位到权限问题之后修复很简单chmod 755 /var/lib/kubelet/seccomp chmod 644 /var/lib/kubelet/seccomp/java-strict.json然后在每个节点重新执行。但这暴露了另一个问题手工分发 seccomp profile 文件在多节点集群里非常不靠谱你永远会漏节点。后来我改用 DaemonSet 配合 hostPath 来保证每个节点都有这批 profile 文件用一个只负责复制文件的初始化容器跑一遍把 profile 拷贝到宿主目录或者更简单一点维护一个自动化分发任务统一下发并做文件指纹校验。这个案例也解释了为什么 Kubernetes 官方目前没有把 seccomp profile 做成 ConfigMap 自动注入因为 kubelet 在容器创建早期就需要读取这些文件而不是在容器内部读取。哪怕你把 profile 作为 Volume 挂载进容器也不会对沙箱创建阶段生效。这个认知能帮你少走很多弯路。4.4 修复之后如何验证从SecurityOpt到真实syscall测试修复完成、Pod 能起来之后能起来不等于策略生效还要验证 seccomp 真的在工作。验证分两步。第一步检查 runtime 层配置。如果节点用的还是 docker可以执行docker inspect container_id | jq .[0].HostConfig.SecurityOpt输出里能看到seccomp...这样的字段。如果用的是 containerd 的 crictlcrictl inspect container_id | grep -i seccomp能看到对应的 profile 信息。这一步确认的是配置已注入。第二步在容器里实测受限制的 syscall。以 RuntimeDefault 为例进入容器执行unshare通常会被拒绝kubectl exec -it java-service-xxx -- unshare --mount --propagation private /bin/bash大概率返回operation not permitted。这个实验只是验证 seccomp 生效不要把它当绝对结论因为不同 profile 的规则差别很大。5. 从试点到全量策略落地时最容易打脸的五个场景5.1 场景一一堆Helm Chart没写securityContext这是我见过最多的翻车现场。很多应用通过 Helm 安装values.yaml 里根本没有securityContext相关配置。你给 namespace 一打enforcerestricted开发重新helm upgrade直接 Forbidden。解决办法不是让开发逐个手改 YAML而是提前扫描一遍集群里所有 Deployment/StatefulSet 的 securityContext 覆盖率kubectl get deploy -A -o json | jq .items[] | {namespace: .metadata.namespace, name: .metadata.name, sc: .spec.template.spec.securityContext, containers: [.spec.template.spec.containers[].securityContext]}没有输出的就是裸奔负载。对这类负载要么统一在 Helm values 里补securityContext要么在迁移期内把它们所在 namespace 先降为 baseline最后再逐个 namespace 收口到 restricted。5.2 场景二DaemonSet类组件天然和restricted冲突网络插件Calico/Cilium、日志采集Fluent Bit/Filebeat、监控 Agent节点 exporter这类 DaemonSet普遍需要 hostNetwork、hostPID、特权模式或者加载 BPF。你不可能让它们全跑在 restricted 下。我的做法是把这些系统组件统一放进专用命名空间比如kube-system、ops-monitoring并显式给它们打上privileged标签。这不是纵容而是明确划分边界kubectl label ns ops-monitoring pod-security.kubernetes.io/enforceprivileged关键是控制特权 namespace 的数量而不是追求所有 namespace 都 restricted。生产业务区全 restricted基础设施区 privileged中间还有少数 baseline这个分层比一刀切更可持续。5.3 场景三GPU等device plugin需要特殊放行GPU 节点的设备插件比如 nvidia-device-plugin需要访问/dev/nvidia*依赖特定的 capability 和ioctl等系统调用。如果直接套 restricted设备插件肯定起不来。我在内部指导里通常建议device plugin 本身放在 privileged 命名空间运行它负责把 GPU 设备暴露给业务 Pod业务 Pod 不需要特权只需要在调度时通过 extended resource 申请nvidia.com/gpu。这样把设备管理和业务运行两个安全边界拆开既不影响功能又不会把特权能力意外下沉到业务容器。5.4 场景四seccomp白名单缺了动态库的syscall自定义 profile 最常见的翻车是容器能启动但一跑业务就崩。典型例子Java 应用启动要用到statx、clone3、recvmmsg、sendmmsgGo 应用在高并发下可能触发io_uring特定版本Python 则可能用到process_vm_readv。如果你白名单没覆盖这些表现就是容器启动正常但业务处理请求时莫名 EPERM日志里全是operation not permitted很容易让人误以为是普通权限问题查半天方向错了。这时候我推荐用strace或 auditd 采集实际 syscall。把 profile 临时改成SCMP_ACT_LOG或者先跑 RuntimeDefault 收集一段时间的实际调用再生成白名单。不要上来就写死最小白名单那是在给自己埋雷。5.5 场景五升级集群版本后策略漂移PSA 的检查规则随版本演进同一份 YAML 在 1.27 能过到 1.28 可能过不了。如果你的 Label 没写enforce-version策略版本默认是latest升级集群后行为就可能变化。建议在每个关键 namespace 的 Label 上固定版本号kubectl label ns app-prod pod-security.kubernetes.io/enforce-versionv1.28升级集群之前用kubectl apply --dry-runserver对新版本策略做预检不要等节点已经升级完才发现一堆 Pod 拉不起来。5.6 策略全量覆盖后的日常观测最后说下日常怎么看策略健康度。在 audit 模式下API Server 审计日志里会出现pod-security.kubernetes.io/audit-violations注解可以直接捞出来看kubectl get events -A | grep -i podsecurity在 warn 模式下开发执行kubectl apply时客户端会直接打印警告这本身就是很好的反馈通道。我建议把 warn 警告数量当作迁移期间的指标警告数量持续下降并趋近于零说明准备充分再切 enforce 心里就有底了。做完整套加固之后我最大的感受是安全策略最大的敌人往往不是绕过的攻击者而是它和业务之间那种说不清的对抗。PSA 和 Seccomp 把安全要求从口头约定变成了机器可执行的规则但前提是你自己先想清楚分层边界、灰度节奏和特例出口。如果你也在做集群加固不妨从最轻的一步开始给一个非核心 namespace 打上warnrestricted的标签跑上一周看看输出里的警告都长什么样。你会比看任何安全文档都更直观地理解你的业务里到底有多少裸奔的假设需要修正。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。