1. seccomp 到底是什么:给进程一张被剪过的菜单
第一次接触 seccomp 的人,十个里有八个会把它和 SELinux、AppArmor、能力(capability)体系搞混。这很正常,因为它们都在回答同一个问题——"这个进程到底能干什么"。但它们的思路完全不在一个层面上。能力体系管的是"你能不能碰这台机器上的特权资源",SELinux/AppArmor 管的是"你能不能碰这个文件、这个端口、这个 socket",而 seccomp 管的东西更底层也更暴力:它管的是这个进程能不能调用某一个系统调用(syscall)。
换个生活化的说法。能力体系像是给你发了一张门禁卡的权限等级,SELinux 像是规定了你能进哪几扇门,而 seccomp 直接把整栋楼里的走廊砍掉了一大半——你想去的那个房间,连走过去的路都没了。
为什么这种"砍走廊"的做法有价值?因为在容器和沙箱的世界里,攻击面的大头从来不是应用层的逻辑漏洞,而是内核暴露给进程的那三百多个系统调用。一个 Web 服务正常跑起来可能只用到五六十个 syscall,剩下两百多个就是纯粹的、白白摊开给攻击者的接口。内核每年都在修各种系统调用的边界检查问题,而每一次修复都意味着:只要你的进程还能调到那个 syscall,你就在暴露面上。seccomp 做的事情就是把这个暴露面从"全部"压缩到"我实际需要的那几个"。
seccomp 全称是 secure computing mode,2005 年进入内核主线,比大多数人想象的要早。但它真正被大规模用起来,是因为 Chrome 浏览器——Chrome 是 seccomp-bpf 最早的重量级用户,它把渲染进程整个塞进一个极其严格的白名单沙箱里,渲染进程被攻破了也很难直接跳到内核提权。后来 Docker 把它变成了默认配置的一部分,systemd 也支持给服务挂 seccomp 过滤,整个生态才算真正跑通。
适合读这篇的人大概是三类:一是要把自己的服务往容器里塞、发现默认策略太松想收紧的运维和后端同学;二是做沙箱、插件系统、在线判题、代码执行环境这类需要跑"不可信代码"的开发者;三是纯粹对 Linux 安全机制好奇、想搞明白grep Seccomp /proc/pid/status那个字段到底怎么来的。三类人关心的细节不完全一样,但下面这些东西对谁都用得上。
1.1 两种模式,以及被内核悄悄扩展的那一半
内核里的 seccomp 其实有两个模式,差别大到像是两个东西硬凑在一个名字下。
SECCOMP_MODE_STRICT,严格模式。这个模式下进程能用的系统调用只剩四个:read、write、_exit、sigreturn。就这么多,没有任何商量余地。你想想这意味着什么——open不能用,malloc底层要的brk/mmap不能用,甚至连exit_group都没有。这东西的原始动机是给计算型沙箱用的:给一个进程挂在已有的文件描述符上做纯计算,读写都走现成的 fd,算完就死。除了这种极端场景,严格模式基本没有实用价值。
SECCOMP_MODE_FILTER,过滤模式。这才是今天大家说"seccomp"时真正指的东西,也是seccomp-bpf这个说法的来源。它允许你挂一段经典的 BPF 程序上去,内核在每次系统调用入口处执行这段程序,程序返回一个动作值,告诉内核"放行"、"返回错误码"、"杀掉进程"或者"打个日志再放行"。因为过滤逻辑跑的是 BPF,它是有约束的:只能读struct seccomp_data里的字段(系统调用号、架构、指令指针、六个参数),不能解引用指针,不能有循环,指令数有上限。这个约束恰恰是它的安全基础——一段能随便读内存的过滤程序本身就是新的攻击面。
很多人以为 seccomp 就是这两个模式了,其实内核这些年一直在过滤器模式上做增量扩展:SECCOMP_RET_LOG让调试变得可行,SECCOMP_RET_USER_NOTIF允许把决策权交给用户态的守护进程(回归到类似 ptrace 的模型但性能好得多),SECCOMP_FILTER_FLAG_TSYNC让过滤器能同步到线程组里的所有线程,SECCOMP_FILTER_FLAG_NEW_LISTENER配合 user notification 使用。这些扩展在实际工程里非常重要,后面会单独讲。
1.2 为什么是 seccomp,而不是继续堆权限检查
一个自然的问题是:我都已经有 capability 剥离、namespace 隔离、只读文件系统、AppArmor 了,为什么还要专门上 seccomp?
答案是方向性。那一堆机制绝大多数是"黑名单 + 局部限制"的思路:我列出你不能做的事。而 seccomp 是唯一一个能让你把默认姿态翻过来、变成"白名单 + 默认拒绝"的机制。内核支持几百个系统调用,任何一份黑名单都注定漏——ptrace禁了还有process_vm_readv,经典的 socket 调用禁了还有各种新加的 io_uring、pidfd、openat2。你永远追不上内核加新 syscall 的速度。
反过来,白名单的思路是:我只列出这五个 syscall 允许,其余一律杀掉。内核明天新增一百个 syscall,对我也没影响,因为它们在白名单外。这种"默认拒绝"的姿态在安全上是根本性的区别。
还有一点很实在:它便宜。seccomp 的检查走的是 BPF 解释/JIT 路径,一次系统调用的额外开销通常在纳秒到几十纳秒量级,跟ptrace那种每次调用都要切两次上下文、几百微秒起跳的方案完全不是一个成本级别。这意味着你可以在生产环境的每一个进程上都开它,而不用太担心性能。这是它能成为容器默认配置的技术前提。
2. 在内核接口层手写第一个过滤器
想真正搞懂 seccomp,绕过 libseccomp 直接用prctl手写一遍是最好的方式。这段代码在生产里基本不会出现,但它能让你看清所有东西。
2.1 prctl、seccomp 系统调用,以及调用顺序这件事
最经典的接口是:
#include <sys/prctl.h> int prctl(int option, ...); // PR_SET_SECCOMP, SECCOMP_MODE_FILTER, struct sock_fprog *内核 3.17 之后又多了一个独立的系统调用seccomp(),功能更全,支持 flags:
#include <linux/seccomp.h> int seccomp(unsigned int operation, unsigned int flags, void *args); // SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC | ... , struct sock_fprog *但这里有个顺序上的硬要求,也是新手第一个必踩的坑:在绝大多数情况下,你必须先设PR_SET_NO_NEW_PRIVS,才能装过滤器。原因不难理解——no_new_privs保证这个进程及其子进程永远不会通过execve一个 setuid 二进制来获得新权限。如果没有这个保证,攻击者可以往一个已经装了过滤器的进程里execve一个 setuid 程序,而过滤器在新程序上依然生效……等等,这听起来反而是好事?
反过来想就明白了:内核担心的是你绕过过滤器的合法性。装过滤器是一个降权操作,降权必须自愿且不可逆。而如果一个进程能通过 execve setuid 程序获得CAP_SYS_ADMIN,它就有了撤销或者无视过滤器的能力。所以内核要求:要么你先把no_new_privs打开(承诺不通过 execve 提权),要么你当前就持有CAP_SYS_ADMIN。少了这个前提,prctl会直接返回EACCES,而且错误信息非常不友好,很多人对着EACCES查半天以为是权限位的问题。
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("PR_SET_NO_NEW_PRIVS"); return 1; }提示:
no_new_privs一旦设置就无法撤销,它是 per-thread 的,并且会被子进程继承。这是设计使然,不是 bug。
2.2 BPF 程序长什么样:从 seccomp_data 到指令序列
过滤器程序的核心数据结构是这个:
struct seccomp_data { int nr; // 系统调用号 __u32 arch; // 架构标识,如 AUDIT_ARCH_X86_64 __u64 instruction_pointer; // 触发调用的用户态 IP __u64 args[6]; // 六个参数 };你能读到的就是这些。注意args是原始寄存器值,不是解引用后的内容——你无法通过过滤器判断open("/etc/shadow")里的字符串,因为那需要访问进程内存,BPF 做不到。想按路径过滤得用SECCOMP_RET_USER_NOTIF把决策交给用户态。这是 seccomp 一个常被低估的表达力边界。
经典的 BPF 写法长这样:
#include <linux/seccomp.h> #include <linux/filter.h> #include <linux/audit.h> #include <sys/prctl.h> #include <sys/syscall.h> #include <unistd.h> #include <stddef.h> #define ARCH_NR AUDIT_ARCH_X86_64 #define SC_ALLOW(nr) \ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (nr), 0, 1), \ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) static struct sock_filter filter[] = { /* 先校验架构,防止 32 位兼容调用绕过 */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ARCH_NR, 1, 0), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), /* 加载系统调用号 */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), SC_ALLOW(__NR_read), SC_ALLOW(__NR_write), SC_ALLOW(__NR_exit), SC_ALLOW(__NR_exit_group), /* 默认:杀掉整个进程 */ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), }; static struct sock_fprog prog = { .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])), .filter = filter, };这段代码里有几个地方值得停下来看。
第一行不是在做权限判断,是在做架构校验。x86_64 内核可以运行 i386 的二进制,同一个系统调用号在不同架构下含义完全不同——__NR_read在 x86_64 是 0,在 i386 也是 3,但很多号是对不上的。如果过滤器不检查arch就只看nr,攻击者可以构造一个 32 位调用,让nr落在你的白名单里,实际执行的却是另一个调用。这是一个真实存在过的绕过手法,必须防。
第二个细节是BPF_JUMP(..., 1, 0)的语义。i386 和 x86_64 上的经典 BPF 跳转偏移是相对于下一条指令的,(jt, jf) = (1, 0)表示条件成立时跳过 1 条继续,不成立时跳到下一条。写错了就全盘错乱,而且错得很安静——过滤器照样装上,就是不按你想的工作。
第三个是默认动作用了SECCOMP_RET_KILL_PROCESS而不是老的SECCOMP_RET_KILL_THREAD。区别在多线程程序里体现:前者杀整个进程,后者只杀触发的那一个线程。现代沙箱基本都用前者,因为一个线程被杀而其他线程继续跑,很容易让程序进入一种奇怪的半死状态,反而更难排查。
2.3 返回值语义:每一种动作的代价
过滤器返回的动作值决定了内核后续行为,这几种的取舍很需要经验:
| 动作 | 常量 | 实际行为 | 适用场景 |
|---|---|---|---|
| 允许 | SECCOMP_RET_ALLOW | 正常执行 | 白名单命中 |
| 杀进程 | SECCOMP_RET_KILL_PROCESS | 整个进程组被杀,SIGSYS | 生产环境默认拒绝 |
| 杀线程 | SECCOMP_RET_KILL_THREAD | 只杀当前线程 | 极少数兼容场景 |
| 返回错误 | SECCOMP_RET_ERRNO | 系统调用返回指定 errno | 想让程序优雅降级 |
| 触发信号 | SECCOMP_RET_TRAP | 发送 SIGSYS,可捕获 | 需要自己处理违规 |
| 追踪 | SECCOMP_RET_TRACE | 交由 ptrace 决策 | 调试、兼容层 |
| 记日志 | SECCOMP_RET_LOG | 放行并写审计日志 | 灰度上线、摸底 |
| 用户态决策 | SECCOMP_RET_USER_NOTIF | 交给监听进程 | 复杂策略、按路径过滤 |
SECCOMP_RET_ERRNO的低 16 位是错误码数据,写法是SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)。这里有个非常隐蔽的坑:SECCOMP_RET_DATA是0x0000ffff,如果你不 mask 就直接或进去,高位会污染动作值,结果变成一个完全不同的动作。我见过有人写SECCOMP_RET_ERRNO | EINVAL,EINVAL 是 22,没超范围所以侥幸没出事,但换个大一点的 errno 就炸了。
另一个需要想清楚的是:到底该用 KILL 还是 ERRNO。直觉上觉得 ERRNO 更"温柔",让程序返回一个错误继续跑。但从安全角度,KILL 往往才是对的选择。原因有两个:一是程序收到一个它从没预期的 errno,很容易走进未定义状态——一个沙箱化过的库突然发现mmap返回EPERM,它可能直接崩溃,也可能带着错误的假设继续跑;二是 ERRNO 给了攻击者反复试探的机会,它可以慢慢观察哪个调用返回什么错误,逐步摸清你的策略。KILL 直接掐断,信息暴露最少。
实际工程里的折中做法:灰度阶段用 LOG,正式环境用 KILL。LOG 会放行并记录,你能在生产流量下摸清真实需要的 syscall 集合,等确认稳定了再切到 KILL。
3. 用 libseccomp 把复杂度压下去
手写 BPF 有很多实际问题:指令数有上限,规则多了容易超;不同架构要写不同分支;条件组合(比如只允许ioctl的某个 request)写起来极其啰嗦。libseccomp 就是来解决这些的,它提供一层 C 抽象,让你用"规则"而不是"指令"来描述策略。
3.1 安装与最小可用程序
安装很简单,主流发行版都有包:
# Debian / Ubuntu sudo apt install libseccomp-dev # RHEL / Fedora sudo dnf install libseccomp-devel一个最小可用的沙箱程序:
#include <seccomp.h> #include <unistd.h> #include <stdio.h> #include <errno.h> #include <string.h> int main(void) { /* 默认动作:不在白名单里的全部杀进程 */ scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS); if (ctx == NULL) { perror("seccomp_init"); return 1; } /* 白名单,按需要一条条加 */ seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(close), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(fstat), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0); /* 载入内核 */ if (seccomp_load(ctx) < 0) { perror("seccomp_load"); seccomp_release(ctx); return 1; } seccomp_release(ctx); /* 从这里开始,过滤器已经生效 */ write(STDOUT_FILENO, "sandbox is live\n", 16); /* 这一句会触发 kill */ printf("this will kill the process\n"); return 0; }编译:
gcc -o sandbox sandbox.c -lseccomp跑一下,你会看到打印完 "sandbox is live" 之后就没了,退出码是-SIGSYS(在 shell 里显示为 159 或类似)。因为printf底层需要brk或者mmap来扩展缓冲区——注意write直接调用没问题,printf就不行。这个对比很好地说明了白名单式沙箱的一个核心难点:你允许的 syscall 集合和 libc 实际需要的集合之间,往往差着十万八千里。
seccomp_init的默认动作、SCMP_ACT_ALLOW/SCMP_ACT_KILL_PROCESS/SCMP_ACT_ERRNO/SCMP_ACT_LOG/SCMP_ACT_NOTIFY这几个常量,跟上一章的内核返回值是一一对应的,libseccomp 只是帮你做了打包。
3.2 白名单的建立过程比策略本身更重要
新手最容易犯的错是:打开 libseccomp 文档,凭直觉列一堆"看起来该允许的" syscall,然后上生产。结果就是随机崩溃,而且崩溃的位置每次都不一样——因为程序的 syscall 使用往往跟数据规模、并发度、内存分配时机相关。
正确的做法是让程序自己告诉你它需要什么。
第一步,先不加任何限制,用strace记录一次完整运行:
strace -f -e trace=all -o /tmp/app.trace ./your_app-f是关键,它会跟踪所有 fork 出来的子进程和线程,否则你看到的只是一部分。跑的时候尽量覆盖各种路径:正常请求、异常输入、大文件、高并发、优雅关闭。因为这些分支用到的 syscall 完全不一样。
第二步,从 trace 里提取去重后的 syscall 列表:
grep -oP '^\d+\s+\K[a-z_0-9]+' /tmp/app.trace | sort -u你会得到一个可能几百行的列表。这就是你的候选白名单。注意这里只是"候选",因为 trace 覆盖不到所有路径,直接照抄上线还是会翻车。
第三步,用 LOG 模式灰度。libseccomp 支持把默认动作设成SCMP_ACT_LOG,这样不在白名单里的调用会被放行并记录到审计日志,但不会杀进程。跑一段时间,从 auditd 里捞出所有被记录的事件,补齐白名单。这一步基本是必须的,尤其对长期运行的服务。
3.3 架构过滤:那个不写就会翻车的默认行为
libseccomp 默认只添加本机架构。也就是说在 x86_64 上,seccomp_init之后 filter 里只处理AUDIT_ARCH_X86_64。这时候如果来了一个 i386 的系统调用,会怎么样?
答案取决于你的默认动作。如果默认是SCMP_ACT_KILL_PROCESS,那它会被杀掉——安全。但如果默认是SCMP_ACT_ALLOW(也就是你写的是黑名单),那这个 32 位调用就被放行了,完全绕过你的所有规则。这就是为什么黑名单式的 seccomp 策略在 x86_64 上先天不安全。
如果你想显式处理多架构,可以这样:
/* 显式声明我们要处理哪些架构 */ seccomp_arch_add(ctx, SCMP_ARCH_X86); /* 32 位兼容 */ /* seccomp_arch_add(ctx, SCMP_ARCH_X32); */ /* x32 ABI,视情况 */但注意一个反直觉的事实:加上SCMP_ARCH_X86之后,你得为 32 位架构再写一遍规则,因为SCMP_SYS(read)在不同架构下展开成不同的号,libseccomp 会自动帮你翻译,但如果这个架构下你没有对应规则,默认动作照样会生效。很多人加了 arch 却没加规则,结果就是 32 位程序全被杀——这其实是安全的失败,但会让依赖多架构的场景出问题。
我的建议很直接:除非明确要跑 32 位程序,否则不要加SCMP_ARCH_X86,让默认 KILL 生效。这比加了一堆规则又没写全要安全得多。Chrome 当年的做法就是显式对非本机架构返回 kill,简洁有效。
4. 把 seccomp 装到真实服务上:一次完整的沙箱构建
前面都是零件,这一章把零件装成一台能用的机器。目标是一个常见的场景:我们在跑一段不可信的第三方代码(比如用户提交的插件、评测用的提交代码),希望它在自己的进程里跑,即使被攻破也碰不到宿主机。
4.1 从 strace 到白名单:一次真实的收敛过程
假设被沙箱的是一个用 C 写的计算型程序,读一个 fd 上的输入,算完写到另一个 fd。它的 strace 去重列表长得像这样(截取一部分):
brk close exit_group fstat futex mmap mprotect munmap newfstatat read rt_sigaction rt_sigprocmask set_robust_list set_tid_address write大概十五六个。这里面有几个值得注意的:
futex是 glibc 线程同步用的,即使你的程序是单线程,链接了 pthread 或者某些运行时也可能调它。mmap/mprotect/munmap/brk是内存管理四件套,malloc 底层就靠它们。想要彻底禁掉基本不可能,只能允许。set_tid_address、set_robust_list、rt_sigaction、rt_sigprocmask是线程启动期的一次性初始化调用,通常在main之前就执行完了。这就是为什么装过滤器的时机很关键:如果你在main里装,这些调用早就完成了,白名单里根本不需要它们。
这个观察引出一个非常实用的技巧:在程序启动的最早期装过滤器,白名单能小一大截。因为 libc 的初始化、动态链接器的重定位这些活儿都已经干完了。如果在main开头装,你甚至不需要允许mmap(如果程序不再动态分配内存)。用一个__attribute__((constructor))函数或者静态链接都可以把这个时机再往前推。
不过要注意,动态链接器的行为很复杂,在构造函数里装过滤器有时候会因为 libc 内部的延迟初始化而炸掉。稳妥的做法通常是:留出必要的内存管理 syscall,接受这一点点暴露,而不是为了极致的白名单牺牲稳定性。
4.2 no_new_privs、能力收缩与过滤器的叠加顺序
seccomp 从来不该单独用。一个完整的沙箱应该是这样的顺序:
1. rlimit 限制(内存、CPU 时间、进程数、fd 数) 2. 切换到独立用户 / 建立 namespace 3. 设置 no_new_privs 4. 剥离 capability(capset / prctl(PR_CAPBSET_DROP, ...)) 5. 关闭不需要的 fd 6. 安装 seccomp 过滤器 7. execve 目标程序(或者直接进入计算逻辑)顺序很重要。过滤器必须最后装,因为一旦装上,后面任何需要被禁止的 syscall 都用不了了——包括你需要用来设置过滤器的那些。如果你的流程是"先装过滤器再 execve",那白名单里还得给execve留位置,而execve恰恰是最需要限制的调用之一。
no_new_privs必须在装过滤器之前设,前面讲过原因。
capability 剥离和 seccomp 是互补的。seccomp 不区分调用者有没有权限——你允许了open,那不管你有没有权限,open都能走到内核的权限检查。而 capability 决定的是"通过了 syscall 之后你有没有资格"。两层叠加,才形成纵深。
4.3 用 seccomp_export_bpf 做裸机部署
libseccomp 有一个很多人不知道的实用功能:把编译好的策略导出成 BPF 字节码,这样可以脱离 libseccomp 运行库部署。
seccomp_export_bpf(ctx, fd); /* 导出二进制 BPF */ seccomp_export_pfc(ctx, fd); /* 导出人类可读的策略 */seccomp_export_pfc输出的东西长这样,非常适合做 code review:
# pseudo filter code start # filter for arch x86_64 (3221225534) if ($arch == 3221225534) # default action action KILL_PROCESS; # filter for syscall "read" (0) if ($syscall == 0) action ALLOW; ...这个格式可以直接贴进代码评审里,比看 C 代码直观得多。我在做沙箱策略变更的时候,会把 pfc 导出的结果作为变更单的一部分,让 review 的人能一眼看出这次改了什么。
另一个常见做法是把 BPF 字节码固化进程序,运行时直接prctl挂载,不依赖 libseccomp。对启动性能有要求的场景(比如每个请求起一个沙箱进程),省掉 libseccomp 的初始化和规则编译开销是有意义的。
5. 那些文档里不会写的坑
这一章是我自己踩过的、也见过别人踩的坑。每一条都真实消耗过时间。
5.1 EACCES 不是权限问题,是 no_new_privs 没设
seccomp_load返回 -1,errno是EACCES。第一反应一般是去查/proc/self/status里的Seccomp字段、查 capability,然后怀疑是不是容器环境把什么限制掉了。实际上九成九就是没设PR_SET_NO_NEW_PRIVS。
这个错误的误导性在于EACCES这个名字。它让人往"权限不够"的方向思考,而事实上内核是在说"你没有做出足够的承诺,我不能允许你降权"。理解这一点之后,看到EACCES就该直接去检查no_new_privs。
libseccomp 从某个版本开始会在seccomp_load内部自动尝试设置no_new_privs,所以用 libseccomp 反而可能遇不到这个问题。但如果你绕过它直接调prctl,这个问题就一定会遇到。
5.2 过滤器一旦装上就撤不掉,这影响你的调试方式
seccomp 过滤器是单向的:装上之后只能更严格,不能更宽松;不能修改,不能卸载。这在调试时非常难受——一旦过滤器生效,你连调试器都挂不上,因为ptrace被禁了。
应对办法是让程序在装过滤器之前留一个"逃生舱":比如检查一个环境变量,如果设置了就只加载 LOG 模式的过滤器;或者干脆在装之前 fork 一个子进程,父进程保持干净用于调试。
还有一个更优雅的做法:用SECCOMP_RET_USER_NOTIF实现可调试的沙箱。把决策权交给一个用户态 broker 进程,违规调用时 broker 收到通知,可以记录、可以返回错误、也可以在调试模式下直接放行。gVisor 和一些容器运行时就是这么干的。代价是需要维护一个额外的常驻进程,以及每次违规决策的 IPC 开销。
5.3 线程、fork 与过滤器的继承关系
这一块细节很多,而且很容易搞错。
过滤器是per-thread挂载的。也就是说,如果你在一个多线程程序的某个线程里装过滤器,其他线程不受影响。这通常不是你想要的,所以需要SECCOMP_FILTER_FLAG_TSYNC:
/* 用 seccomp() 系统调用而不是 prctl,才能带 flag */ seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &prog);TSYNC会把过滤器同步到调用线程所在的整个线程组,并且要求所有线程的现有过滤器兼容——不兼容的话会返回失败,并且在args里告诉你第一个冲突的线程 ID。这个报错信息在调试多线程沙箱时非常有用。
关于 fork:子进程会继承父进程的过滤器。关于 execve:过滤器在 execve 之后依然生效,这也是 seccomp 能作为"容器级保护"的基础。关于线程创建:新线程会继承创建者的过滤器。所以正确的姿势是——在主线程、创建任何其他线程之前装过滤器,或者用 TSYNC 显式同步。后者更稳妥。
5.4 语言运行时的 syscall 抖动:Go 和 JVM 是重灾区
用 C 写的程序,syscall 使用相对稳定;换成 Go 或者 Java,情况会完全不同。
Go 的运行时有一个"系统调用会阻塞整个 M 线程"的模型,所以它会频繁使用futex、epoll相关调用,而且 GC 会周期性地触发内存管理 syscall。更麻烦的是,Go 的运行时会在启动时探测 CPU 特性,可能用到一些你想不到的调用。写 Go 沙箱时,白名单通常要比等价功能的 C 程序大一圈。
JVM 更夸张。JIT 编译需要mmap带PROT_EXEC来生成可执行代码,GC 用mprotect管理内存页权限,大量使用信号(rt_sigaction)做 safepoint,还会用memfd_create之类的较新调用。而且不同 JVM 版本、不同 GC 策略之间,syscall 集合还不一样。给 JVM 做白名单沙箱,最务实的做法是宽松一些、把主要精力放在阻止那些真正危险的调用(ptrace、process_vm_*、socket系列、kexec_*、bpf等),而不是追求极致的最小集合。
还有一个通杀所有动态语言的坑:首字节码编译缓存。很多语言第一次运行会编译并写缓存文件,第二次就直接读缓存,两次运行的 syscall 集合完全不同。做 trace 的时候一定要覆盖"第一次运行"和"有缓存时运行"两种状态。
6. 过滤器生效之后,故障怎么定位
最难的不是把过滤器装上,而是装上之后出了问题怎么查。因为 seccomp 杀进程的方式很安静——没有 core dump(除非配置了),没有明确的错误信息,进程就是突然没了。
6.1 从 SIGSYS 到具体调用:一步步定位
当一个进程被SECCOMP_RET_KILL_PROCESS杀掉时,它会收到SIGSYS信号。如果你的程序装了 SIGSYS 处理器(注意:用SECCOMP_RET_TRAP才能捕获,KILL_PROCESS是杀完就走),或者在 shell 里能看到退出状态,第一步是确认"确实是被 seccomp 杀的":
# 退出状态 159 = 128 + 31,31 就是 SIGSYS ./your_app; echo "exit code: $?" # exit code: 159确认之后,要找出是哪个 syscall 触发的。几个办法,按好用程度排序:
第一种,用 LOG 模式复现。把默认动作临时改成SCMP_ACT_LOG(libseccomp 里就是seccomp_init(SCMP_ACT_LOG)),重新跑。违规的调用会被放行,但内核审计子系统会记录一条type=SECCOMP的审计事件。查日志:
# 需要 auditd 在跑 sudo ausearch -m SECCOMP -ts recent # 或者看内核日志 sudo dmesg | grep SECCOMP日志里会带上进程名、PID 和被拒绝的 syscall 号。用号去查名字:ausyscall 59之类的。
第二种,用SECCOMP_RET_TRAP+ SIGSYS 处理器。这种方式能拿到结构化的信息:
#include <signal.h> #include <sys/syscall.h> static void on_sigsys(int sig, siginfo_t *info, void *ucontext) { /* info->si_syscall 是触发调用的系统调用号 */ /* info->si_arch 是架构 */ fprintf(stderr, "blocked syscall: %d\n", info->si_syscall); _exit(1); } /* 安装处理器时必须带 SA_SIGINFO */ struct sigaction sa = { .sa_sigaction = on_sigsys, .sa_flags = SA_SIGINFO, }; sigaction(SIGSYS, &sa, NULL);这个办法的好处是不需要 auditd,而且信息直接就打出来了,特别适合在 CI 里跑。注意si_syscall是架构相关的号,跨架构时需要转换。
第三种,缩小复现范围。如果上面两招都不方便,可以把程序切成几段,逐段测,二分定位。粗暴但有效。
6.2 用最小复现单元锁定问题调用
定位到大致范围后,最好的做法是写一个最小复现程序。因为在大程序里,违规 syscall 的触发时机往往跟具体数据、并发状态相关,不稳定。把可疑的操作单独拎出来,在装好过滤器的环境里跑一遍,能非常快地确认。
/* minimal_repro.c:验证某个操作在你的白名单下能不能跑 */ #include <seccomp.h> #include <stdio.h> #include <fcntl.h> #include <unistd.h> int main(void) { scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0); seccomp_load(ctx); seccomp_release(ctx); /* 下面这两行,第一行没问题,第二行会杀掉进程 */ write(STDOUT_FILENO, "step 1 ok\n", 10); int fd = open("/etc/hostname", O_RDONLY); /* open 不在白名单 */ return 0; }这种最小复现程序还有个额外好处:可以直接沉淀成回归测试。每次改策略,跑一遍这个测试集,就能知道有没有不小心掐掉了某个必要调用。我一般会把常用语言运行时的最小复现集维护起来,改策略之前先过一遍。
一个经验性的细节:新加了SECCOMP_RET_LOG的灰度期至少覆盖一个完整的业务周期。如果业务有日报、有定时任务、有月末结算,那灰度期就得覆盖到这些。我见过最坑的一次是灰度了一周没问题,上线第二天凌晨定时任务挂了——因为那个任务用的是完全不同的代码路径。
最后分享一个我一直在用的小技巧:在沙箱进程里保留一个极窄的"自我诊断"出口。具体做法是允许一个特定的write到某个固定的 fd(比如 fd 3),然后在装过滤器之前把那个 fd 指向日志文件。这样即使沙箱崩溃、即使 stdout 已经被关闭或被限制了,沙箱代码依然可以在最后关头把关键状态写出去。它的成本是白名单里多一条write,而我们已经允许了write,所以实际上不增加暴露面。这个小设计在排查线上沙箱崩溃的时候救过我好几次,尤其是那种在容器里连 core dump 都没配好的环境。