标签: 容器安全

  • 构筑容器运行时的最后防线:Seccomp 细粒度拦截、AppArmor 隔离与 Falco 规则引擎深度实践

    容器的安全本质是内核边界的收敛。仅靠 Namespace 和 Cgroup 无法阻挡内核提权与容器逃逸。标准的企业级防御范式是:通过 Seccomp 白名单严格限制系统调用(Syscall),使用 AppArmor 收紧文件与 Capabilities 权限,最后引入 Falco 基于 eBPF 的规则引擎进行运行时审计。本文直接拆解这三层防御体系的底层机制与核心落地配置。

    第一道防线:基于 BPF 的 Seccomp 系统调用拦截

    容器共享宿主机内核,Linux 内核拥有 300+ 个系统调用,而一个普通的 Web 服务在运行期通常只需用到 40-50 个。多暴露一个 Syscall,就多一分提权逃逸的攻击面(例如著名的 CVE-2022-0185 依赖 unshare系统调用)。

    Seccomp(Secure Computing Mode)的底层本质,是将用户态配置的过滤规则,编译为经典的 cBPF(Classic BPF)指令,注入到内核的 task_struct 中。当进程触发系统调用时,内核会执行这套 BPF 字节码,根据返回值(如 SECCOMP_RET_KILL_PROCESSSECCOMP_RET_ERRNO)决定放行还是阻断。

    为什么原生的 RuntimeDefault 策略在核心业务中往往不够用?

    Kubernetes 1.22+ 默认启用了 RuntimeDefault seccomp profile(由 containerd/Docker 提供)。这套默认策略禁用了约 44 个高危系统调用(如 kexec_loadbpfptrace),在兼容性和安全性之间做了妥协。

    但在实际生产环境中,RuntimeDefault 存在两个极端问题:

    1. 防不住高级逃逸:它默认允许了 perf_event_openuserfaultfd 等复杂且频繁爆出漏洞的系统调用。

    2. 误杀特定底层组件:对于需要精细内存管理的组件(如使用 DPDK 的网络服务,或者绑定 NUMA 节点的数据库),mbindset_mempolicy 会被默认规则拦截,导致 Operation not permitted

    实战:自定义 Seccomp 白名单阻断逃逸链

    对于无状态的微服务,我们推崇“默认拒绝(Default Deny)”的白名单模式。以下是一个基于 K8s v1.28 的严格 Seccomp Profile 示例(需提前放置于 Kubelet 的 /var/lib/kubelet/seccomp/ 目录下):

    {
        "defaultAction": "SCMP_ACT_ERRNO",
        "architectures": ["SCMP_ARCH_X86_64"],
        "syscalls": [
            {
                "names": [
                    "read", "write", "openat", "close", "fstat", "mmap", "mprotect", "munmap",
                    "brk", "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "ioctl", "pread64",
                    "pwrite64", "readv", "writev", "sched_yield", "futex", "epoll_wait", "epoll_ctl"
                ],
                "action": "SCMP_ACT_ALLOW"
            }
        ]
    }
    

    在 Pod 定义中引用该 Profile:

    apiVersion: v1
    kind: Pod
    metadata:
      name: strict-secure-pod
    spec:
      securityContext:
        seccompProfile:
          type: Localhost
          localhostProfile: custom-microservice.json
    

    当被劫持的容器尝试调用不在白名单中的 execve 获取 Shell 时,内核会直接返回 EPERM,进程甚至没有机会进入内核态的主逻辑。

    第二道防线:AppArmor 的 MAC(强制访问控制)隔离

    Seccomp 控制的是“进程能对内核做什么”,而 AppArmor 控制的是“进程能对特定文件/网络做什么”。

    在容器场景下,我们通常使用 AppArmor 解决以下痛点:

    1. 防止修改容器内的关键文件(即使是以 root 用户运行)。

    2. 限制在容器内使用 apt-getcurl 等工具下载恶意 Payload。

    3. 封锁 /proc/sys 目录下的敏感写权限。

    在 K8s 中落地 AppArmor 需要将 Profile 加载到宿主机(使用 apparmor_parser -r),并通过注解绑定到 Pod。

    核心配置文件片段:针对 Nginx 容器的加固

    # /etc/apparmor.d/k8s-nginx-hardened
    #include <tunables/global>
    
    profile k8s-nginx-hardened flags=(attach_disconnected, mediate_deleted) {
      #include <abstractions/base>
      #include <abstractions/nameservice>
    
      # 基础文件读写
      / r,
      /** r,
    
      # 允许 Nginx 写入自身日志和 PID
      /var/log/nginx/** w,
      /run/nginx.pid w,
    
      # 严禁执行 shell 和提权相关的工具
      deny /bin/bash x,
      deny /bin/sh x,
      deny /usr/bin/curl x,
      deny /usr/bin/wget x,
    
      # 严禁向特殊文件系统写入
      deny /sys/** w,
      deny /proc/sys/** w,
    }
    

    将该策略应用于 Pod(注意:K8s v1.30 开始引入了原生的 apparmorProfile 字段,但在 v1.28 及以下仍需使用 Annotation):

    metadata:
      annotations:
        container.apparmor.security.beta.kubernetes.io/nginx: localhost/k8s-nginx-hardened
    

    第三道防线:Falco eBPF 规则引擎与异常行为审计

    静态防御(Seccomp/AppArmor)再严密,也总有遗漏(或由于业务过度妥协而开的后门)。此时我们需要 Falco(基于 eBPF 的云原生安全威胁检测引擎)作为兜底的监控探针。

    Falco 底层通过 eBPF(在 Kernel 5.15+ 环境推荐使用 modern_bpf 探针,无需编译内核模块)挂载到内核的 tracepoints(如 sys_enter_execve)。事件经过 Ring Buffer 推送到用户态引擎,匹配 YAML 定义的规则树。由于只读取不拦截,eBPF 对业务 QPS 的影响微乎其微(通常<2%)。

    落地实战:捕捉容器内的敏感凭据读取与 Shell 派生

    Falco (v0.36.1) 的精髓在于其强大的上下文关联能力。以下规则用于实时捕捉:非标准二进制在容器内读取 .kube/config/etc/shadow 的行为

    - macro: sensitive_files
      condition: fd.name in (/etc/shadow, /root/.kube/config, /var/run/secrets/kubernetes.io/serviceaccount/token)
    
    - macro: allowed_readers
      condition: proc.name in (kubelet, containerd)
    
    - rule: Read Sensitive File by Unauthorized Process
      desc: Detect unauthorized access to sensitive secrets within containers
      condition: open_read and sensitive_files and container and not allowed_readers
      output: "Sensitive file opened for reading (user=%user.name file=%fd.name command=%proc.cmdline container_id=%container.id image=%container.image.repository)"
      priority: WARNING
      tags: [filesystem, mitre_credential_access]
    

    当告警触发时,Falco 可以将日志推送到 stdout、Webhook 或 Kafka。此时配合排查日志,你能看到精确的 container_id 和导致告警的 proc.cmdline

    常见问题 (FAQ)

    Q1:应用接入自定义 Seccomp 后崩溃,日志只报 Operation not permitted,如何快速定位是哪个系统调用被拦截了?

    A:两种方案。第一种是查看宿主机的 dmesg 日志或 /var/log/audit/audit.log,搜索 SECCOMP 关键字,日志里的 syscall= 后面跟的数字就是被拦截的系统调用编号,对照内核源码的 unistd.h 即可查出具体调用。第二种是在测试环境中,将 Seccomp Profile 的 defaultAction 临时改为 SCMP_ACT_LOG,此时内核只记录日志不拦截,方便你在日志中一次性收集完整的系统调用基线。

    Q2:Falco 在高并发集群下频繁出现 Events dropped(丢弃事件)的告警,该如何调优?

    A:丢弃事件通常意味着系统调用产生速率超过了 eBPF Ring Buffer 到用户态的消费速率。首先,通过修改 Helm values 提升 Ring Buffer 大小:falco.modern_bpf.cpus_for_each_buffer=1(让每个 CPU 拥有独立 buffer)并增加 falco.syscall_buf_size_preset=8。其次,深度优化 Falco 规则集,移除或禁用高频但低价值的宏条件(比如极其频繁的 openat 且过滤条件不严格的规则)。

    Q3:我更新了宿主机上的 AppArmor 配置文件并重新 apparmor_parser -r 载入了,为什么正在运行的 Pod 行为没有受限制?

    A:AppArmor 策略是在容器创建(由 containerd 调用 runc)时,通过 prctl(PR_SET_APPARMOR) 将进程与特定的 Profile 绑定的。一旦进程启动,它的安全上下文就已固定。你需要彻底删除并重建该 Pod(让它走完整的拉起生命周期),新的 AppArmor 策略才能对该容器内的进程生效。

  • 深夜的 Exit Code 159:当“按需生成”的 Seccomp 白名单遭遇 rseq 系统调用与 Falco 绞杀

    凌晨3点被报警砸醒,核心交易集群爆发大规模 CrashLoopBackOff,大盘 QPS 呈断崖式下跌。快速拉取 Pod 状态,退出码清一色是 159。结论先行:安全团队在生产环境强推的所谓“零信任” Seccomp 白名单,漏掉了 rseq(Restartable Sequences)和 clone3 系统调用,导致高并发下底层 glibc/Go runtime 被内核直接发送 SIGSYS 斩首;更荒唐的是,排查期间我试图 kubectl exec 进容器抓 strace,直接触发了 Falco 的“防入侵联动”,把排查节点给 Cordon(不可调度)了。

    这是一起典型的、脱离一线业务真实运行机制的安全事故。所谓的“安全左移”,绝不能以牺牲系统可用性为代价。

    诡异的 159 退出码与案发现场

    业务线同学反馈:代码没动,配置没动,只有流量上来时 Pod 会随机暴毙。 查看 K8s 事件,没有任何 OOMKilled 的迹象,只有冰冷的退出码:

    $ kubectl get pod -n prod-core
    NAME                              READY   STATUS             RESTARTS   AGE
    trade-engine-7f89c4d5b-x2k9q      0/1     CrashLoopBackOff   12         15m
    trade-engine-7f89c4d5b-z8m2a      0/1     CrashLoopBackOff   15         15m
    
    $ kubectl describe pod trade-engine-7f89c4d5b-x2k9q | grep -A 5 "State:"
        State:          Waiting
          Reason:       CrashLoopBackOff
        Last State:     Terminated
          Reason:       Error
          Exit Code:    159
    

    Exit Code 159,稍微有点底层经验的人看到这个数字立刻就能反应过来:128 + 31 = 159。 在 Linux 中,Signal 31 是 SIGSYS(Bad system call)。这意味着进程尝试调用了一个内核不认识、或者被安全机制强行阻断的系统调用。

    直接切到宿主机,翻看内核日志:

    $ dmesg -T | grep audit | tail -n 3
    [Wed May 15 03:12:45 2024] audit: type=1326 audit(1715713965.123:4567): auid=4294967295 uid=1000 gid=1000 ses=4294967295 subj=unconfined pid=14325 comm="trade-engine" exe="/app/trade-engine" sig=31 arch=c000003e syscall=334 compat=0 ip=0x7f8a9b8c7d6e code=0x0
    [Wed May 15 03:12:47 2024] audit: type=1326 audit(1715713967.890:4568): auid=4294967295 uid=1000 gid=1000 ses=4294967295 subj=unconfined pid=14388 comm="trade-engine" exe="/app/trade-engine" sig=31 arch=c000003e syscall=435 compat=0 ip=0x7f8a9b8c7e10 code=0x0
    

    重点看两个数字:syscall=334syscall=435,架构是 arch=c000003e(x86_64)。 用 ausyscall 翻译一下:

    $ ausyscall x86_64 334
    rseq
    $ ausyscall x86_64 435
    clone3
    

    愚蠢的安全策略:当沙盒变成绞肉机

    为什么会突然拦截 rseqclone3? 查阅变更记录,安全团队在凌晨2点通过 Kyverno MutatingWebhook 给所有 namespace 强制注入了一个严格的 Seccomp Profile。

    这帮天才为了做到所谓的“最小权限”,使用了一款基于 eBPF 的动态追踪工具,在测试环境跑了 5 分钟业务,把这 5 分钟内捕获到的系统调用抓出来,直接生成了白名单。

    为什么这种做法极其致命?

    1. 并发场景下的特有调用rseq(Restartable Sequences)是 Linux 4.18 引入的特性,现代 glibc (>=2.35) 和 Go (>=1.19) 极度依赖它来实现无锁的 per-CPU 数据结构。在测试环境几 QPS 的负载下,线程根本不需要激烈竞争,运行时可能不会高频触发 rseq 相关路径;而到了生产环境的万级 QPS,底层 Runtime 一旦触发 rseq,直接撞在 Seccomp 的墙上。

    2. 致命的 Default Action:生成工具极其愚蠢地将默认拦截动作设置为了 SCMP_ACT_KILL_THREAD

    {
      "defaultAction": "SCMP_ACT_KILL_THREAD",
      "architectures": ["SCMP_ARCH_X86_64"],
      "syscalls": [
        {
          "names": ["epoll_pwait", "futex", "read", "write", "..."],
          "action": "SCMP_ACT_ALLOW"
        }
      ]
    }
    

    如果是防御性编程思维,遇到不在白名单的 Syscall,正确的阻断动作应该是 SCMP_ACT_ERRNO(配合返回 ENOSYS)。 如果配置为 ERRNO,当 glibc 调用 clone3rseq 被拒时,它会优雅地收到 ENOSYS(系统调用未实现),然后 Fallback(降级) 到老版本的 clone 或传统的加锁机制,业务顶多性能掉一点,绝对不会崩溃。 但配成 KILL_THREAD,内核连说话的机会都不给,直接一刀把线程砍了,进程当场暴毙(SIGSYS)。

    Falco 绞杀:排查过程中的二次伤害

    为了现场验证,我试图 kubectl exec 进其中一个还在 CrashLoop 边缘挣扎的 Pod,想挂个 strace 看下具体是哪段代码触发的:

    $ kubectl exec -it trade-engine-7f89c4d5b-x2k9q -n prod-core -- /bin/bash
    

    刚敲下回车,命令卡死。紧接着,监控大盘上该 Pod 所在的整个 Node 直接变成了 SchedulingDisabled,Node 上其余 40 多个正常 Pod 开始被强行驱逐(Evicted)!

    看了一眼系统安全群,机器人正在疯狂报警: [Falco Alert] Critical: Terminal shell in container detected. Pod: trade-engine... Rule: Terminal shell in container. Action: Webhook triggered -> Cordon & Drain Node.

    我特么当时血压就上来了。 安全团队部署的 Falco 规则引擎,配置了极度激进的 SOAR(安全编排自动化响应)。他们监测到 exec /bin/bash 操作,不分青红皂白(不区分发起方是 CI/CD、未知 IP 还是具有集群 admin 权限的 SRE 堡垒机),直接调用自建的 Webhook 把宿主机给 Cordon 并 Drain 了。

    这种缺乏上下文联动、且具有毁灭性控制平面权限的“自动化防御”,在生产环境就是一颗随时引爆的定时炸弹。如果黑客发现了这个规则,只需要伪造请求批量触发报警,就能利用你们自己的安全工具,把你们的生产集群主动瘫痪掉。

    技术结论与避坑建议

    直接停用 Kyverno webhook 恢复生产后,我给安全团队扔了复盘报告。容器运行时安全不是拿着开源扫描器生成个 JSON 就能上生产的,必须遵循以下底线:

    1. Seccomp 的平滑落地法则 永远不要在生产环境直接上 SCMP_ACT_KILL。第一阶段必须是 SCMP_ACT_LOG,跑满一个完整的业务高峰期,通过分析 dmesg 审计日志收集全量 Syscall。

    2. 正确理解 ENOSYS 的降级语义 拦截未知的现代系统调用(如 clone3, bpf, rseq),强烈建议将 Action 设置为 SCMP_ACT_ERRNO 并返回 ENOSYS。这符合 POSIX 标准,能让大多数现代编程语言的 Runtime 平滑降级到旧版系统调用,避免 SIGSYS 导致的血案。

    3. AppArmor / Falco 联动的爆炸半径控制 安全告警(Detection)和阻断(Enforcement)必须解耦。Falco 检测到异常 shell,可以告警,可以打 Tag,甚至可以隔离特定的 Pod 网络(NetworkPolicy),但绝不允许直接越权调用 K8s API 执行 Node 级别的破坏性操作(Cordon/Drain)。防御系统的权限必须遵循最小化原则。

    同类问题速查清单 (Troubleshooting Checklist)

    1. 如何确认是 Seccomp 导致的 SIGSYS?
    2. 检查 Pod 退出码是否为 159
    3. 登录所在 Node,执行 dmesg -T | grep audit | grep sig=31

    4. 如何翻译 Audit 日志中的 Syscall ID?

    5. 查看日志中的 syscall=XXXarch=XXX
    6. 使用 auditd 工具包翻译:ausyscall x86_64 (如 ausyscall x86_64 435 -> clone3)。

    7. 如何排查 Falco Webhook 导致的集群异常抖动?

    8. 检查 Falco 日志:journalctl -u falco | grep "Notice" 或查看 Falco-sidecar 日志。
    9. 检查 K8s 审计日志 (kube-apiserver audit log),过滤发出 Cordon/Evict 请求的 ServiceAccount,确认是否为安全告警组件的联动行为。

    10. 高频被遗漏的基础系统调用有哪些?

    11. rseq (334), clone3 (435), prctl (157 – 经常被 Java 线程管理需要), statx (332 – 很多新版 DB driver 依赖)。编写白名单时需格外留意。