engineering

面向自主代理的 Seccomp-bpf

我们的代理第一次删掉不该删的东西,是在一个星期二的 21:47。我记得这个时间,因为磁盘已满的告警正好在我准备关屏幕时响起。那个代理正陷在一条关于提权 CTF 的推理链中,最终决定"清理临时构件"的最佳方式是执行 find / -name "*.tmp" -delete。它没有动到任何关键内容,因为容器是隔离的,但它确实让我们用于模糊测试运行器的基础镜像彻底废了。第二天一早我们不得不重建它。

那晚我在笔记本上写下了一句话,现在还贴在显示器上:"LLM 不理解'不可逆'意味着什么"。我们今天在 Gwaihir CLI——我们的执行引擎——里使用的沙箱模型就是从那一刻开始酝酿的。而其中最难调教的一块就是 seccomp-bpf。

为什么选 seccomp-bpf 而不是别的

一个合理的问题是:既然 Docker、gVisor、Firecracker 和 Kata 都存在,为什么我们还要亲手编写 BPF 过滤器?简短的回答是:没有一个真正契合一个会成批触发进攻性工具的代理的工作流。

Docker 在 runc 模式下与宿主共享内核,其默认 seccomp 配置允许 300 多个 syscall 通过。这对于一个 Web 服务是合理的,但对于一个会在运行时决定调用 ptracekexec_load 的东西就不合理了。Firecracker(Agache et al., 2020)能给我们每个代理一个 microVM,但为每次 nmap -sS 调用启动一台 VM 的代价超出了我们的延迟预算:125 ms 对比应用了 seccomp 的 fork+exec 的 4 ms。

gVisor 是最有力的候选。它的 Sentry 在用户空间拦截 syscall,大幅减少了内核暴露面。但 Young 等人在 HotCloud 19 上的研究早已测出我们后来在自家测试台上确认的结果:文件打开慢 216 倍,syscall 慢 2.2 倍。当一个代理用 200 个线程把 ffuf 打到某个端点时,这个开销就明显得多。

Seccomp-bpf 则几乎是免费的。过滤器会被编译成 BPF,挂在进程描述符上,每个 syscall 的代价大约只有几十纳秒。而最重要的是:它由我们一条规则一条规则地控制。正如内核文档所言,"它本身不是一个沙箱,它是供沙箱开发者使用的工具"[1]。这正是我们所需要的。

按工具划分的最小化配置

这个模型是按工具划分的,而不是按代理。当 Gwaihir 决定执行 nmap 时,它不会去加载 sqlmap 的配置。代理可以调用的每一个二进制文件都有自己的 .scprofile 文件,描述了从 strace -c 跟踪中观察到的最小 syscall 集合,并通过多轮实际使用进行扩展。

构建过程是枯燥的,这正是它行之有效的原因。我们一边让工具去打靶场目标,一边运行 perf trace,收集 syscall 列表,与文档交叉比对,然后丢弃所有明显危险的项——即使它出现过(例如,nmap 永远不应该需要 unsharemount)。剩下的就是基线

对于 nmap 来说,在一次典型的 TCP SYN 扫描中,这个列表一张纸就能装下。对 sqlmap 来说就更长,因为 Python 解释器需要更多东西。对于用 Go 写的 ffuf,有惊喜:Go 运行时会以工业级数量调用 rt_sigactionmmap,漏掉其中任何一个都会让进程挂起且没有清晰的诊断信息。

与 Sentinel 的协商协议

对于代理来说,事情从这里开始变得有趣。一个静态配置是不够的。如果 LLM 在执行过程中决定它需要解析 DNS 来丰富某个发现,它就会调用 socket(AF_NETLINK, ...),而这并不在 nmap 的配置里。没有进一步的机制,进程就会收到 SIGSYS 然后死掉。

我们的做法是在子进程和我们称之为 Sentinel 的监督者之间架设一条 seccomp 通知通道(SECCOMP_RET_USER_NOTIF,自内核 5.0[2] 起可用)。当过滤器遇到一个被标记为"可协商"的 syscall 时,它会暂停进程并把请求发给 Sentinel。Sentinel 会对照一份声明式策略进行评估,并可选地用一句简短的提示去问模型——比如"对 10.10.0.0/8 的这次 connect() 调用是否能由当前任务证明合理?",然后返回继续或中止。

这个模式类似于 AgentBound(Securing AI Agent Execution, 2025),但作用在内核层面而不是 MCP 层面。一个重要的区别是:代理无法绕过 Sentinel——BPF 过滤器在 execve 之前就以 NO_NEW_PRIVS 加载,所以哪怕是一个恶意二进制也撤销不了它。

一份真实的过滤器

下面是我们用于 nmap 的配置的一个简化片段,用 Rust 配合 libseccomp 写成(Gwaihir 运行时为 13.2 MB,由用 Rust 写的逻辑和用 Zig 写的跟踪热路径混合构成)。

use libseccomp::*;

fn build_nmap_profile() -> Result {
    // Acción por defecto: SIGSYS y traza al supervisor.
    let mut ctx = ScmpFilterContext::new(ScmpAction::Trap)?;

    // I/O básico
    for sc in &["read", "write", "close", "fstat", "lseek",
                "openat", "pread64", "pwrite64"] {
        ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
    }

    // Memoria
    for sc in &["mmap", "munmap", "mprotect", "brk", "madvise"] {
        ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
    }

    // Red: solo lo que nmap necesita para SYN scan
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("socket")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("setsockopt")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("sendto")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("recvfrom")?)?;

    // Negociables: el agente puede pedirlas vía Sentinel.
    ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("connect")?)?;
    ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("execve")?)?;

    // Prohibidas explícitamente.
    for sc in &["ptrace", "kexec_load", "init_module",
                "delete_module", "reboot", "mount", "unshare"] {
        ctx.add_rule(ScmpAction::KillProcess, ScmpSyscall::from_name(sc)?)?;
    }

    ctx.set_filter_attr(ScmpFilterAttr::CtlNnp, 1)?;
    Ok(ctx)
}

有三个细节很重要。第一,默认动作是 SCMP_ACT_TRAP,不是 SCMP_ACT_KILL。原因是诊断:我们希望 SIGSYS 处理器在进程死掉之前能捕获并记录这次 syscall。第二,可协商的 syscall 使用 SCMP_ACT_NOTIFY,而不是 ALLOW;正是这一点打开了与 Sentinel 的通道。第三,被禁止的 syscall 显式标记了 KILL_PROCESS,即使它们已经被默认动作覆盖了——因为我们希望这个决定在将来的审计中能被一眼看懂。

我们在碰壁中学到的取舍

第一是可移植性。Syscall 编号在不同架构之间会变化(x86_64、aarch64、riscv64),libseccomp 做了相当好的抽象,但仍有边界情况。32 位上多路复用的 socketcall、只存在于 x86 的 arch_prctl、在较新内核里才出现的 statx。我们维护着一份按架构划分的测试矩阵。

第二是调试。当一个配置因为某个不在列表中的 syscall 杀掉一个进程时,你看到的信息只有 Bad system call (core dumped)。仅此而已。我们不得不写一个包装器,去解析 SIGSYSsiginfo_t,报告 syscall 编号,与表交叉比对,再打印出可读的东西。第一次看到"statx blocked by profile nmap.scprofile, suggest adding to baseline"而不是一个 core dump 时,我们简直要松一口气哭出来。

第三是误报。当一个 libc 升级后开始用 clone3 替代 clone,你所有的配置都会同时崩。我们是在 glibc 2.34 进入其中一个基础镜像时学到这一点的。现在我们有一个每周的任务来重新生成基线,如果差异大得可疑就会告警。

第四个,也是最让我们意外的,是非常大的过滤器带来的额外开销。BPF 在每次 syscall 时都会执行,一个有几百条级联规则的过滤器会让人感觉得到。我们测出在 syscall 密集型负载下,180 条规则的配置带来 3-4% 的性能损失,而 40 条规则的配置只有 0.8%。重新排序规则、让频繁的 syscall 优先被评估,把大部分时间又拿了回来。

要是早点知道就好了

三件事。第一:在开发配置的阶段,先从 SCMP_ACT_LOG 开始,而不是 TRAP。把一切都记录下来,让工具在真实负载下跑一整天,再去构建列表。我们一开始走反了路,浪费了几周时间在追崩溃。

第二:用户通知(USER_NOTIF)才是让 seccomp 对代理真正有用的东西。没有它,静态配置就是一件紧身衣。有了它,你就能在不牺牲权限分离的前提下拥有动态策略。

第三:从第一天起就测量真实成本。已发布的基准测试有用,但你的负载是奇怪的。一个每秒调用某个短工具 50 次的代理,其成本特征跟一个长时间运行的服务器截然不同。

过滤器不能防一切。它防的是愚蠢。而事实证明,当你放任 LLM 自由行动时,它做错的事情里 90% 都是愚蠢的。

今天,Gwaihir 在运行模型提议的漏洞利用时,我们当中没有任何人盯着屏幕等待一场灾难。不是因为我们信任那个模型,而是因为我们信任那个过滤器。而这一切,在那个星期二 21:47 之后,是一种人生级别的改变。

参考文献

  1. Linux Kernel Documentation (2024). Seccomp BPF (SECure COMPuting with filters).
  2. Drysdale, M. y Corbet, J. (2024). seccomp(2) - Linux manual page, man7.org.
  3. Young, E. et al. (2019). The True Cost of Containing: A gVisor Case Study. USENIX HotCloud 19.
  4. Agache, A. et al. (2020). Firecracker: Lightweight Virtualization for Serverless Applications. USENIX NSDI 20.
  5. seccomp/libseccomp (2024). libseccomp: Enhanced Linux seccomp library.