几年前我维护一个对公网开放的网关服务,安全演练时攻击者通过一个反序列化漏洞拿到了权限。我第一反应不是急着修漏洞,而是打开那个进程的 seccomp 配置看了一眼,结果发现它几乎畅通无阻,socket、execve、mount 全都能调。那一刻起我认死了一个道理:漏洞迟早会有,真正决定防线扛不扛得住的是攻击面收得够不够窄。而Seccomp-BPF,就是我收攻击面时用得最顺手的工具。
Seccomp-BPF 全称 Secure Computing with Berkeley Packet Filter,是 Linux 内核提供的系统调用级沙箱机制。它让进程自己声明一套规则集:哪些系统调用能用,哪些碰了就杀、或者让它返回一个错误。它是内核原生能力,不需要额外依赖,性能损耗小到可以忽略。这篇文章会把 Seccomp-BPF 的原理、规则模型、三种挂载方式、调试方法和生产环境的坑一次讲透,适合后端开发、SRE、安全工程师,以及任何想把服务做硬一点的人。
不管你是第一次听说这个名词,还是已经在用容器默认策略,这篇文章都能给你一些可以马上落地的实践细节。下面我们直接切入正题。
1. 为什么说 Seccomp 是 Linux 上最被低估的安全防线
1.1 系统调用是内核的“咽喉要道”
进程在用户态做不了任何特权操作,想读文件、开网络、起线程、执行程序,都得通过系统调用向内核发起请求。系统调用数量虽然多,但对某个具体程序来说,日常真正用得到的往往只有几十个。
问题在于,传统权限模型管的是“谁有这个权限”。一个进程拿到了 1000 个用户权限或者几个 capabilities,内核就默认它可以使用所有相关的系统调用。攻击者一旦通过漏洞接管了进程,就继承了全部权限,权限模型此时形同虚设。
生活里有个很贴切的类比:门卫能验证你的工牌,但如果你把工牌给了别人,门卫也看不出来。Seccomp-BPF 相当于在大楼内部给每个房间再加一道锁,就算你拿了工牌,也进不了你根本不需要进的房间。权限模型回答“你是否有资格”,Seccomp 回答“你是否真的需要这个操作”。
1.2 Seccomp-BPF 到底挡住了什么,又挡不住什么
从实际效果看,Seccomp-BPF 主要能挡住三类风险:
- 远程代码执行后的后置操作,比如 shell 反弹需要 socket、connect,提权尝试需要 mount、setuid 相关的调用。
- 攻击者在被入侵进程里投放挖矿程序、持久化脚本等,会触发 execve、clone 等行为。
- 容器或主机上的恶意工具链,因为在白名单模式下任何未明确的系统调用都会直接被拒绝。
但 Seccomp 不是万能沙箱。BPF 程序只能检查系统调用号和标量参数,无法安全地解引用用户空间指针。比如 openat 的路径字符串,BPF 是读不到的。它能拦“调用 openat 这个动作”,但拦不了“打开某个具体路径”。同样,它也无法限制进程对已经持有的 fd 做任何合法操作。想限制文件路径、网络地址这些更细粒度的东西,需要配合文件权限、capabilities、LSM 这类机制去组合使用。
1.3 它和 Capabilities、用户命名空间的分工
这三个机制有人容易搞混。简单说:
- Capabilities 管特权位,粒度已经算细,但给错了就很危险,比如 CAP_DAC_OVERRIDE 给到进程,它基本等于绕过所有文件权限检查。
- 用户命名空间管 UID/GID 的映射,让非特权用户能获得 namespace 内的 root 身份,但完全不限制系统调用集合。
- Seccomp-BPF 管的是系统调用清单,属于最后一道闸。
在容器环境里,通常三管齐下:命名空间隔离、capabilities 裁剪、seccomp profile 限制系统调用。但我见过很多生产环境只做了前两项,进程还能随便调用 mount、execve,等于大门装了锁,窗户全开着。这里还要注意一个重点:如果进程后续通过 setuid 提权,Seccomp 规则在 exec 之后依然存在。配合 PR_SET_NO_NEW_PRIVS 一起用,能防止特权提升后的规则绕过。
2. 规则模型其实不复杂:模式、动作与 BPF 指令
2.1 三种过滤模式:选对场景再下手
Seccomp 从 2.6.12 开始有 strict 模式,只允许 read、write、_exit、sigreturn 四个系统调用,太粗暴,基本没法用于真实业务。3.5 内核加入了 filter 模式,也就是 Seccomp-BPF 的正式形态,可以自由定义规则。5.0 又加入了 notify 模式,允许父进程通过用户态感知子进程的系统调用请求并动态裁决,适合做沙箱平台,但对大多数应用来说,filter 模式就足够了。
| 模式 | 引入版本 | 核心机制 | 适用场景 |
|---|---|---|---|
| SECCOMP_MODE_STRICT | 2.6.12 | 只允许四个系统调用 | 极简计算,几乎没有实用价值 |
| SECCOMP_MODE_FILTER | 3.5 | 用 BPF 指令定义规则 | 日常服务加固、容器沙箱 |
| SECCOMP_SET_MODE_FILTER + notify | 5.0 | 父进程动态裁决 | 沙箱平台、自动化分析工具 |
日常做服务加固,别想太多,直接用 filter 模式。notify 模式我已经接触过一些,它确实灵活,但代码量和对进程生命周期的管理复杂度都上了一个台阶。
2.2 Filter Action 决定拦截后的命运
BPF 程序最终会返回一个 action,系统调用被拦截后到底怎么处理,全看这个值。最常用的几个:
| Action | 值 | 效果 |
|---|---|---|
| SECCOMP_RET_KILL_PROCESS | 0x80000000 | 直接杀掉整个进程,推荐默认用这个 |
| SECCOMP_RET_KILL_THREAD | 0x00000000 | 只杀触发调用的线程,可能留下半死进程 |
| SECCOMP_RET_TRAP | 0x00030000 | 触发 SIGSYS,用户态可以捕获做动态决策 |
| SECCOMP_RET_ERRNO | 0x00050000 | 让系统调用返回指定 errno,比如 EPERM |
| SECCOMP_RET_LOG | 0x7ffc0000 | 记录日志,不改变执行结果 |
| SECCOMP_RET_ALLOW | 0x7fff0000 | 放行 |
白名单模式下,默认 action 一般选 KILL_PROCESS,而不是 KILL_THREAD。KILL_THREAD 只杀线程,其它线程可能还在运行,这种半崩溃状态非常难排查。ERRNO 适合做兼容性处理,比如你想让某个调用“优雅失败”,就返回 EPERM 而不是把进程杀了。LOG 在灰度阶段非常好用,后面我会单独讲。
2.3 手写一段 BPF 指令看懂执行流程
Seccomp 用的是传统 cBPF,不是 eBPF。cBPF 指令由sock_filter结构体组成,用BPF_STMT和BPF_JUMP两个宏来定义。内核每次触发系统调用时,会把系统调用号、架构、参数等放进seccomp_data结构,然后执行这段 BPF 程序,得到一个 action。
最核心的逻辑就三步:先检查架构,再检查系统调用号,命中就允许,最后落到底部兜底。来看一个最小示例:
#include <stddef.h> #include <unistd.h> #include <linux/seccomp.h> #include <linux/filter.h> #include <linux/audit.h> #include <sys/prctl.h> #define 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[] = { /* 检查架构,不是 x86_64 直接杀 */ BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, arch)), BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, AUDIT_ARCH_X86_64, 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)), ALLOW(SYS_read), ALLOW(SYS_write), ALLOW(SYS_exit), ALLOW(SYS_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, }; int main(void) { if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("prctl(NO_NEW_PRIVS)"); return 1; } if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) { perror("prctl(SECCOMP)"); return 1; } write(1, "hello from seccomp\n", 19); _exit(0); }这个代码可以原样编译运行,不需要任何额外库。注意ALLOW宏:如果系统调用号匹配,就跳过下一条RET ALLOW的下一条?仔细看跳转逻辑,当相等时跳 0 条,继续执行下一条RET ALLOW;不相等时跳 1 条,跳过RET ALLOW,继续比较下一个。
这里有个新手容易忽略的点:架构检查不能省。x86 和 x86_64 甚至 arm64 的系统调用号定义都不一样,如果不检查架构,一个三十二位程序跑到六十四位内核上,很可能被规则误杀或者绕过规则。
3. 三种挂载方式,从手工原语到工程化库
3.1 方式一:prctl 接口手工挂载,学习阶段必看
上面的代码已经展示了最原始的挂载方式,核心只有两个函数调用。第一步prctl(PR_SET_NO_NEW_PRIVS, 1)非常关键,它告诉内核“这个进程及其子进程今后不能通过 execve 获得新的特权”。设置了这个标志之后,普通用户才能安装 seccomp 过滤器;否则你必须拥有 CAP_SYS_ADMIN 能力,这显然不适合做沙箱。
第二步prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)把 BPF 程序挂到当前进程上。挂载完成后,进程后续的每一次系统调用都会经过这段 BPF 手工检查。
编译运行:
gcc -o seccomp_demo seccomp_demo.c ./seccomp_demo如果想确认过滤器是否生效,可以看进程状态:
grep -E 'Seccomp|NoNewPrivs' /proc/self/status正常情况下会看到NoNewPrivs: 1、Seccomp: 2,其中Seccomp: 2代表 filter 模式已启用。这个方法最大的价值是让你理解底层原理,但生产环境直接手写 BPF 指令维护成本很高,架构偏移、指令上限、跳转边界,都是坑。
3.2 方式二:seccomp 系统调用与线程同步
Linux 3.17 之后提供了独立的seccomp()系统调用,比 prctl 更完整。同样的过滤器,挂载代码变成:
#include <sys/syscall.h> #include <linux/seccomp.h> if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) < 0) { perror("seccomp"); return 1; }严格说这种方式和 prctl 没有本质区别,但多了两个在生产环境很重要的能力:一个是指定 flag 做线程同步,一个是配合 TSYNC 标志。
默认情况下,在一个多线程进程里调用 seccomp() 安装过滤器,新过滤器只影响当前线程。其它线程依然保持原来的状态,这就意味着攻击者完全可能从其它线程突破你的限制。解决方案是安装时加上SECCOMP_FILTER_FLAG_TSYNC标志:
if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &prog) < 0) { perror("seccomp TSYNC"); return 1; }加了 TSYNC 之后,内核会尝试让线程组里所有线程同步加载新过滤器。如果其它线程已经有不同的过滤器,同步会失败。所以在多线程程序里,最稳妥的做法仍然是在程序早期、线程还没创建之前就把过滤器装上。
3.3 方式三:libseccomp,生产环境首选
手写 BPF 终究太原始。libseccomp 是官方封装库,它屏蔽了架构差异、系统调用号差异和 BPF 指令生成细节,让你用“规则”的方式描述策略。同样的白名单效果,用 libseccomp 写起来是这样:
#include <seccomp.h> #include <unistd.h> #include <sys/prctl.h> int main(void) { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS); if (ctx == NULL) return 1; seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigreturn), 0); seccomp_load(ctx); seccomp_release(ctx); write(1, "hello from libseccomp\n", 22); return 0; }编译时需要装 libseccomp 开发头文件:
gcc -o seccomp_lib seccomp_lib.c -lseccomplibseccomp 的好处在规则多的时候特别明显。手写 BPF 要自己管理跳转,规则一多很容易出错;libseccomp 可以做到每条规则独立描述,还能用seccomp_export_bpf()把最终生成好的 BPF 指令导出来给人审。生产环境我建议直接上 libseccomp,没理由自己手搓。
4. 调试与灰度:别拿 KILL 当第一选择
4.1 用 strace 和状态文件摸清系统调用底细
写白名单之前,必须先知道程序真实依赖哪些系统调用。最直接的办法是 strace:
strace -f -o /tmp/syscalls.log ./your_program # 查看统计 strace -c -f ./your_program-f很重要,否则子进程的调用会漏掉。拿到日志后,用一条命令提取所有系统调用名去重:
grep -oP '^[a-z_0-9]+' /tmp/syscalls.log | sort -u这样能得到一张初始清单。但注意 strace 本身会引入少量额外调用,同时动态链接库初始化、日志写入、故障恢复等路径未必都在这次运行里触发。所以这张清单是起点,不是终点。
拿到清单之后,典型的基础调用群可以对照下面这张速查表:
| 类别 | 常见系统调用 |
|---|---|
| 内存与运行时 | brk, mmap, munmap, mprotect, madvise, prctl |
| 线程与同步 | clone, clone3, futex, set_robust_list, rseq |
| 文件操作 | openat, close, read, write, pread64, pwrite64, lseek, fstat, newfstatat, readlinkat, statx, fcntl |
| 进程控制 | execve, exit, exit_group, wait4, kill, rt_sigaction, rt_sigprocmask |
| 网络(如需要) | socket, connect, bind, listen, accept4, setsockopt, getsockopt, sendto, recvfrom, sendmsg, recvmsg, shutdown, epoll_create1, epoll_ctl, epoll_wait |
| 时间 | clock_gettime, gettimeofday, nanosleep, clock_nanosleep |
clone3是很多新版本 glibc 在 x86_64 上默认用的线程创建入口,老白名单里如果只有clone没有clone3,程序会在某个不起眼的线程池初始化位置神秘崩溃。
4.2 规则太紧导致秒崩的排查流程
我在实际项目中最常遇到的故障是:过滤器一挂上去,进程立刻死。典型症状有几类:
- 进程启动就收到 SIGSYS 或直接被 kill,大概率是 execve、mmap、brk、rt_sigaction 这些基础调用被误杀。
- 动态链接库加载失败,说明 openat、readlinkat、statx 这些加载器依赖的调用没放行。
- 服务能启动但连不上网络端口,通常是 accept4、setsockopt、bind 被拦了。
- 日志会反复出现某个调用被拒,但进程不退出,这种场景多半是 ERRNO 配置导致程序不断重试。
排查顺序我建议如下:先dmesg | tail看内核日志里有没有 seccomp 相关记录;再用 strace 带-f跟踪子进程和线程;如果还定位不到,最简单的方法是临时把 KILL 改成 ERRNO 或 LOG,把拦截变成显式反馈,这是最快的定位手段。
我自己就犯过这类错误:给一个数据采集服务写白名单,一开始手滑把execve也杀了,服务每次启动都“嘎嘣”一下消失,dmesg 里全是seccomp: Process ... failed。实际上数据采集进程在主程序运行期间确实不需要 exec,但启动脚本和热更新路径都需要它。所以规则集一定要区分“启动阶段”和“运行阶段”,如果拿不准,先把所有进程启动相关调用都放行。
4.3 用 LOG 动作做灰度放量
等到规则差不多收紧了,别立刻切 KILL。我推荐一个非常稳的灰度流程:把默认动作先设成 LOG 或把怀疑有风险的少数规则设成 LOG,运行一段时间看日志。
LOG 动作不会终止执行,内核会把匹配到的系统调用记录进日志。如果你把某个本该 KILL 的规则临时改成 LOG,就能在日志里看到这类调用出现的频率、调用来源和上下文,再判断能不能直接收紧。
例如你在配置里将execve从 KILL 临时改为 LOG:
# 运行一段时间后检查 dmesg | grep seccomp | tail -50确认日志里没有意外的高频调用,再把 LOG 改回 KILL。需要注意的是,LOG 行为依赖内核开启CONFIG_SECCOMP_LOG,老内核可能需要检查一下。
4.4 性能实测:开销并没有想象中可怕
很多人一听到“每次系统调用都要跑一段 BPF 程序”就觉得慢。实际上 BPF 程序经过内核 JIT 编译后就是一段极短的机器指令,加上内核会做分支预测,大多数白名单规则在十几条指令内就能命中 ALLOW。每次系统调用多出来的开销是纳秒级,只是整个系统调用过程里很小的一部分。
我之前对某个高频读写服务做过对比:开启严格白名单后,吞吐下降约 1% 左右,CPU 占用增加可以忽略;在纯计算型场景里几乎没有可测量的差异。对比它带来的攻击面收窄,这个成本非常划算。
如果确实追求极致性能,有几条优化经验:把高频调用(read、write、futex 这类)放在指令序列最前面,尽量提前命中;规则集保持精简,不要把几百个无关调用塞进去;默认动作和显式规则能组合出很短的过滤链。
5. 生产落地的几个隐藏坑与工程化建议
5.1 过滤器是叠加的,不是替换的
Seccomp 过滤器有个特性经常让人意外:如果一个进程已经安装了过滤器,它再安装一个新的,两个过滤器是叠加关系,新过滤器先执行,旧过滤器后执行。所有过滤器的结果必须全部允许,系统调用才真正放行。换句话说,子进程的限制只会越来越严格,永远无法通过安装新过滤器来放宽。
这个特性对库作者和框架开发者是个重要提醒:不要在库代码里擅自安装 seccomp 过滤器,否则会严重限制调用方程序。在容器环境里,运行时先给容器装一层基础 profile,应用自己再叠加一层更严格的白名单,这是正常做法,双方不会互相冲突。
5.2 动态链接库与基础镜像版本漂移
系统调用集合不是一成不变的。同一个二进制,在不同内核、不同 glibc 版本、不同基础镜像下,实际使用的系统调用可能有差异。新版本 glibc 可能引入statx、newfstatat、rseq、clone3这些调用,而这些在老版本上根本不存在。如果白名单是基于旧镜像统计出来的,换镜像后服务就可能崩。
应对办法其实只有一个:把规则集当成代码一样管理,纳入版本控制,每次镜像升级、依赖库升级后,重新跑一遍 strace 和 LOG 灰度,确认系统调用清单没有变化。不要盲目复制网上的“通用白名单”,更不要图省事把规则裁到只剩三四十个调用,除非你的服务真的只需要三四十个调用。
5.3 别忽略 io_uring 这类旁路
Seccomp-BPF 有个典型的盲区:它只能拦截系统调用入口,拦不住系统调用内部递交的异步操作。io_uring 是一个典型例子,如果应用通过 io_uring 提交批量读写请求,内核处理这些请求时并不会逐个触发 seccomp 检查。你最多只能看到io_uring_setup和io_uring_enter这几个入口调用,内部每个操作是否被允许,seccomp 管不到。
如果程序不需要 io_uring,我建议直接在白名单里禁掉io_uring_setup、io_uring_enter、io_uring_register这一整组调用,从源头堵住这条旁路。如果程序确实依赖 io_uring 做高性能 IO,那就需要认识到 seccomp 在这条链路里的局限性,再结合其它安全机制去补位。
5.4 用配置文件驱动规则,灰度三步走
生产环境别把规则硬编码在 C 代码里,维护起来很痛苦。推荐的做法是定义一份独立的规则配置文件,比如 JSON 或 YAML,由启动脚本或一个轻量组件来加载。配置示意如下:
{ "defaultAction": "SCMP_ACT_KILL_PROCESS", "arch": "SCMP_ARCH_X86_64", "syscalls": [ {"names": ["read", "write", "close", "mmap", "munmap", "brk", "futex", "openat", "exit_group"], "action": "SCMP_ACT_ALLOW"}, {"names": ["execve"], "action": "SCMP_ACT_ERRNO(EPERM)"}, {"names": ["mount"], "action": "SCMP_ACT_ERRNO(EPERM)"} ] }这只是一个示意结构,实际接入时可以用 libseccomp 自己解析,也可以对接脚本生成 C 代码。配置文件进仓库,改动走评审,谁改的规则一清二楚。
灰度上线建议分三步走:
- 默认全放行,只对高风险调用开 LOG,观察一周,了解真实的系统调用使用情况。
- 把默认动作改成 LOG 或 ERRNO,只记录不制裁,让业务继续跑,同时积累拦截日志。
- 确认无误后切到默认 KILL,白名单模式正式生效。
这套流程最大的价值是让每一条收紧规则都有日志支撑,而不是拍脑袋决定。最后说一句我个人的体会:Seccomp-BPF 的门槛没有想象中高,收益却是实打实的。我最近一次给某个数据采集服务做加固,从 strace 采集到最终 KILL 上线,只花了大半天时间,其中一半时间花在区分启动阶段和运行阶段的系统调用上。如果你也想给服务加一层保险,建议从最小的、不是核心链路的服务开始试,规则文件纳入版本管理,每次镜像升级后重新回归一遍清单。真正踩过的坑不是不会写规则,而是太自信,一上来就把 execve 给杀了,结果服务直接起不来。这套机制值得每个 Linux 服务开发者认真用起来。