标签: eBPF

  • 构筑容器运行时的最后防线: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 策略才能对该容器内的进程生效。

  • 凌晨三点的延迟突刺:生产环境残留 bpf_printk 引发的 XDP 吞吐断崖式坠落

    凌晨三点,网关集群告警狂飙,核心 API 的 P99 延迟从稳定的 2ms 瞬间击穿到 500ms,整体 QPS 从 8万掉到不足 1万,外部监控呈现大面积网络超时。结论先行:研发在灰度 eBPF/XDP 的防 DDoS 策略时,把带有 bpf_printk 的调试代码直接带上了生产线。在单机几十万 PPS 的网络快路径(Fast Path)里高频调用内核全局打印函数,导致网卡软中断处理被 trace_pipe 的自旋锁死死卡住。一句话:拿拖拉机的变速箱去匹配了 F1 的发动机。

    登入机器,Load Average 直接飙到 80+。第一反应是看系统瓶颈卡在哪。敲下 top,发现所有的 CPU 核心 si(软中断)和 sy(系统态)双双爆表,几乎没有 us(用户态)的占用。这说明 CPU 都在内核态原地打转,业务进程根本分不到时间片。

    看网络大盘,sar -n DEV 1 显示入向流量断崖式下跌,网卡层面的 rx_missed_errorsrx_dropped 正在以每秒上万的速度激增。网卡硬件队列被打满,上层拿不到包。

    是 XDP 程序里面的业务逻辑写了死循环,被 BPF 校验器(Verifier)漏放了吗?掏出 perf top 采样内核热点,破案了。霸榜的根本不是什么复杂的包解析逻辑,而是这几个刺眼的函数:

      38.45%  [kernel]  [k] __raw_spin_lock_irqsave
      25.12%  [kernel]  [k] bpf_trace_printk
      18.05%  [kernel]  [k] trace_event_buffer_commit
       5.20%  [kernel]  [k] bpf_prog_xxxxxxxxxxxx_xdp_drop_prog
    

    排名前三的符号占了超过 80% 的 CPU 周期,全在等锁和写 Trace 数据。看一眼研发提交的 BPF C 代码,果然在最核心的包头解析逻辑里藏着这么一行:

    SEC("xdp")
    int xdp_drop_prog(struct xdp_md *ctx) {
        void *data_end = (void *)(long)ctx->data_end;
        void *data = (void *)(long)ctx->data;
        struct ethhdr *eth = data;
    
        if (data + sizeof(*eth) > data_end) return XDP_PASS;
        if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;
    
        struct iphdr *ip = data + sizeof(*eth);
        if ((void*)(ip + 1) > data_end) return XDP_PASS;
    
        // 就是这行要了命
        bpf_printk("Received IP packet from %x, proto: %d\n", ip->saddr, ip->protocol);
    
        // ... 后续 DDoS 防御匹配逻辑 ...
        return XDP_PASS;
    }
    

    这不仅是一个 Bug,这是对高并发系统常识的蔑视。

    XDP (eXpress Data Path) 为什么快?因为它在网卡驱动层、甚至在网卡硬件里(硬件卸载)就完成了数据包的处理。此时连 Linux 网络的灵魂核心 sk_buff 都还没来得及分配,主打的就是一个无锁、零拷贝、极速。

    bpf_printk 是个什么东西?它底层调用的是 bpf_trace_printk,这是一个纯粹为本地开发调试设计的辅助函数。它会将格式化后的字符串写入 /sys/kernel/debug/tracing/trace_pipe。这是一个全局的 Trace 缓冲区,意味着什么?意味着加锁

    在每秒数十万次的发包频率下,XDP 驱动着各个 CPU 核心高速运转,结果到了这一行代码,所有的 CPU 瞬间撞上一堵墙,为了抢夺 trace_pipe 的全局自旋锁(Spinlock)而疯狂互相踩踏。原本 O(1) 的无锁数据面,被硬生生降维改造成了串行处理的锁地狱。XDP 直接变成了系统性能的黑洞。

    止血方案毫无技术含量可言,直接卸载挂载在网卡上的 BPF 程序:

    # 查看当前挂载的 XDP 程序
    ip link show dev eth0
    # 剥离 XDP
    ip link set dev eth0 xdp off
    # 或者用 bpftool 精准干掉
    # bpftool net detach xdp dev eth0
    

    敲下回车的一瞬间,QPS 瞬间恢复 8万,P99 延迟掉回 2ms,CPU 软中断使用率平滑回落至正常水位。

    如果你真的需要在 eBPF 的 Fast Path 中向用户态传递大量事件、日志或抓包数据,永远不要用 Printk。唯一正确的姿势是使用 BPF_MAP_TYPE_RINGBUF(内核版本 5.8+)或者 BPF_MAP_TYPE_PERF_EVENT_ARRAY。前者是多个 CPU 共享的无锁环形缓冲区,支持按页进行 mmap 映射,后者是 Per-CPU 的事件数组,两者都可以实现内核态到用户态的异步、批量、高性能数据投递。

    eBPF/XDP 性能排查清单

    1. 快速定位异常 BPF 占用: 使用 bpftool prog show 列出当前加载的程序,观察 run_time_nsrun_cnt,如果单次运行时间异常偏高,说明程序逻辑存在阻塞或严重不合理的开销。

    2. 确认 Trace Pipe 拥塞: 直接查看 cat /sys/kernel/debug/tracing/trace_pipe,如果在生产环境发现大量日志疯狂刷屏,必须立即排查哪个 BPF 程序残留了调试代码。

    3. 软中断热点分析: 遇事不决 perf top -g -e cpu-clock -K。如果是网络 IO 导致的软中断风暴,抓取内核态调用栈能最快暴露出是驱动问题、协议栈锁争用,还是 BPF 钩子的锅。

    4. 验证底层硬件丢包: 网络层如果抓不到包,不要死盯 tcpdump。用 ethtool -S eth0 | grep -i -E "drop|miss|err" 确认是不是数据包在网卡 Ring Buffer 阶段就被静默丢弃了。

  • 凌晨三点的 ksoftirqd 飙升:基于 XDP 与 eBPF 映射的无锁网卡级丢包实战

    传统 Netfilter/iptables 在应对千万级 PPS 小包洪泛时,会因大量 sk_buff 内存分配耗尽 CPU,引发严重软中断风暴。本文给出终极解法:基于 Kernel 5.15 编写 XDP 程序,在网卡驱动层实现零拷贝的 XDP_DROP,结合 eBPF Per-CPU Map 实现无锁统计,将单核拦截性能从 150 万 PPS 直接拉升至 800 万 PPS 以上。

    凌晨三点,监控大盘上的 API 网关 P99 延迟突然飙升到 5 秒以上,随之而来的是节点 Load Average 破百的刺耳警报。登录机器,敲下 top,一个老生常谈的惨烈现场:

    # top - 03:15:22 up 124 days,  4:12,  1 user,  load average: 102.14, 88.55, 48.23
    %Cpu0  :  0.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi, 100.0 si,  0.0 st
    %Cpu1  :  0.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi, 100.0 si,  0.0 st
    ...
      PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
        9 root      20   0       0      0      0 R  99.9   0.0   1245:10 ksoftirqd/0
       16 root      20   0       0      0      0 R  99.9   0.0   1244:52 ksoftirqd/1
    

    ksoftirqd 满载,典型的软中断风暴。切到 sar -n DEV 1 看一眼网卡流量,eth0 的 RX PPS(每秒接收包数)冲到了 450 万,但 RX kB/s 却只有区区 200MB/s。很明显,典型的 64 字节 TCP SYN 洪泛或 UDP 盲打。

    之前配置的 iptables -t raw -A PREROUTING -p tcp --syn -j DROP 规则明明在生效,为什么 CPU 还是被打死了?

    为什么常规 Netfilter 拦不住线速小包攻击?

    要理解这个问题,必须剥开 Linux 内核接收网络包的底层链路(RX Data Path)。

    当网卡接收到物理信号并转化为数据帧后,整个处理流水线如下:

    1. DMA 拷贝:网卡将数据帧写入内存的 Ring Buffer。

    2. 硬中断(Hard IRQ):网卡通知 CPU 数据已到达。

    3. 软中断(Soft IRQ):内核唤醒 ksoftirqd,通过 NAPI 机制轮询拉取数据。

    4. 内存分配(核心痛点):内核为每个数据包调用 build_skb() / alloc_skb() 分配核心数据结构 struct sk_buff,并进行元数据初始化。

    5. 协议栈与 Netfilter:包进入 GRO 处理,经过 TC 子系统,最终到达 Netfilter 的 PREROUTING 链(即 iptables 规则生效的地方)。

    在千万级 PPS 的小包场景下,步骤 4 的 alloc_skb 内存分配与释放开销占据了 CPU 超过 70% 的周期。哪怕你的 iptables 规则再精简,当包到达 Netfilter 时,内核已经把最耗时的脏活全干完了。这也就是为什么“墙内丢包”依然会导致系统雪崩。

    我们需要把防线前推,推到 sk_buff 分配之前。这就是 XDP(eXpress Data Path)大显身手的地方。

    XDP 实战:在 DMA 缓冲区直接执行裁决

    XDP 程序作为一个 eBPF 钩子,直接挂载在网卡驱动层(Native XDP)。包刚进内存,还没来得及包装成 sk_buff,我们的代码就可以直接读取裸数据(Raw Packet),并返回 XDP_DROP 让驱动原地丢弃。

    1. 编写防御性 eBPF C 代码

    防御性编程在 eBPF 中是强行规定的:BPF Verifier(校验器)会极其严苛地审查指针越界。如果不做边界检查,代码根本无法注入内核。

    以下代码实现了一个过滤特定端口(如 80 端口)恶意 SYN 包,并通过 Per-CPU Map 记录丢包数的 XDP 程序:

    // xdp_drop.c
    #include <linux/bpf.h>
    #include <linux/if_ether.h>
    #include <linux/ip.h>
    #include <linux/tcp.h>
    #include <linux/in.h>
    #include <bpf/bpf_helpers.h>
    
    // 定义 Per-CPU Array Map,用于无锁高频计数
    struct {
        __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
        __type(key, __u32);
        __type(value, __u64);
        __uint(max_entries, 1);
    } drop_cnt SEC(".maps");
    
    SEC("xdp_syn_drop")
    int xdp_prog_main(struct xdp_md *ctx) {
        // 获取包在内存中的起始与结束地址
        void *data_end = (void *)(long)ctx->data_end;
        void *data = (void *)(long)ctx->data;
    
        // 1. 解析以太网头
        struct ethhdr *eth = data;
        if ((void *)(eth + 1) > data_end) 
            return XDP_PASS; // 校验器要求:必须做边界检查
    
        if (eth->h_proto != __constant_htons(ETH_P_IP))
            return XDP_PASS;
    
        // 2. 解析 IP 头
        struct iphdr *ip = (void *)(eth + 1);
        if ((void *)(ip + 1) > data_end)
            return XDP_PASS;
    
        if (ip->protocol != IPPROTO_TCP)
            return XDP_PASS;
    
        // 3. 解析 TCP 头
        struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
        if ((void *)(tcp + 1) > data_end)
            return XDP_PASS;
    
        // 4. 拦截逻辑:如果是目标端口为 80 的 SYN 包
        if (tcp->dest == __constant_htons(80) && tcp->syn && !tcp->ack) {
            __u32 key = 0;
            __u64 *value = bpf_map_lookup_elem(&drop_cnt, &key);
            if (value) {
                *value += 1; // Per-CPU 更新,无需原子操作锁
            }
            return XDP_DROP; // 在网卡驱动层直接丢弃,不分配 sk_buff
        }
    
        return XDP_PASS;
    }
    
    char _license[] SEC("license") = "GPL";
    

    2. 编译与挂载

    确保系统安装了 clangllvm 以及内核开发包(当前环境 Kernel 5.15.x)。

    # 编译为 BPF 字节码
    clang -O2 -g -Wall -target bpf -c xdp_drop.c -o xdp_drop.o
    
    # 将 XDP 程序挂载到 eth0 网卡
    # 这里默认使用 native 模式,如果网卡驱动不支持,可加 force 强制使用 generic 模式(但性能退化)
    ip link set dev eth0 xdp obj xdp_drop.o sec xdp_syn_drop
    

    挂载瞬间,监控图表上的 ksoftirqd CPU 使用率从 100% 垂直断崖下跌到 5% 以内。Load Average 开始快速回落。

    闭环:基于 eBPF Map 的可观测性

    没有监控的运维就是在裸奔。包是丢了,丢了多少?我们需要读出 eBPF Map 里的数据。

    我们之前在代码里定义了 BPF_MAP_TYPE_PERCPU_ARRAY。为什么不用普通的 ARRAY?因为在多核网卡多队列场景下,多个 CPU 同时执行 XDP_DROP 并累加同一个内存变量,会引发严重的 Cache-line bouncing(缓存行伪共享)和原子锁竞争,反向拖垮性能。Per-CPU Map 为每个 CPU 核心分配独立的内存区域,完全无锁。

    使用 bpftool 工具读取数据:

    # 1. 找到我们的 map id
    bpftool map list
    # 输出示例:
    # 105: percpu_array  name drop_cnt  flags 0x0
    #      key 4B  value 8B  max_entries 1  memlock 4096B
    
    # 2. 导出特定 ID 的 Map 数据
    bpftool map dump id 105
    # 输出示例:
    [{
            "key": 0,
            "values": [{
                    "cpu": 0,
                    "value": 1548291
                },{
                    "cpu": 1,
                    "value": 1692831
                },
                ...
            ]
    }]
    

    你可以编写一个简单的 Python BCC 脚本或 Go 程序(基于 cilium/ebpf 库),定期拉取这个 Map 数据,聚合后暴露出 Prometheus Metrics,一套极轻量级的防 DDoS 可观测闭环就建立起来了。

    卸载 XDP 程序的命令也极其简单:

    ip link set dev eth0 xdp off
    

    常见问题 (FAQ)

    Q1:挂载时报错 RTNETLINK answers: Operation not supported 是怎么回事? 通常是因为你的网卡驱动不支持 Native XDP(例如某些老旧的虚拟化网卡或特定的老版本驱动)。解决办法是改用 generic 模式:ip link set dev eth0 xdp generic obj xdp_drop.o sec xdp_syn_drop。注意,Generic XDP 运行在 sk_buff 分配之后,性能收益大打折扣,但可用于测试逻辑。

    Q2:代码编译没问题,挂载时被 Verifier 拒绝,提示 invalid memory access eBPF 的 Verifier 极度保守。你不仅需要检查 ethhdr 的越界,任何通过偏移量访问内存的操作(比如通过 IP 头长度推导 TCP 头位置 struct tcphdr *tcp = (void *)ip + (ip->ihl * 4))之后,都必须紧跟边界检查 if ((void *)(tcp + 1) > data_end)。少写一行检查,Verifier 就会判定有越界风险而拒绝加载。

    Q3:XDP 把包在网卡层丢了,排查问题时我用 tcpdump 还能抓到这些包吗? 抓不到。tcpdump 基于 AF_PACKET 套接字,工作在内核协议栈层面。XDP 的介入时机早于它。如果你必须对这些丢弃的包进行采样分析,需要在 XDP 代码中利用 bpf_perf_event_output 将特定包的 Header 异步推送到用户空间(类似 AF_XDP 机制),但这会引入额外的上下文切换开销,建议通过开关或概率采样来控制。