ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

seccomp实战:给Linux进程加一道系统调用级安全锁

seccomp实战:给Linux进程加一道系统调用级安全锁

seccomp 使用笔记:给进程上一把系统调用的锁

做服务端开发久了,你会发现一件很无奈的事:程序本身的代码写得再小心,依赖的第三方库、运行时里一个不起眼的回调,都可能在某次攻防中被利用。而攻击者一旦拿到了代码执行能力,接下来干的事就非常统一——调用系统调用。read、write、execve、socket、connect、openat,这些原本是程序正常工作的工具,到了攻击者手里就成了玩坏系统的钥匙。所以我在做安全加固时,重点盯住一个东西:seccomp(Secure Computing Mode)。它是 Linux 内核自带的一道系统调用过滤机制,用最朴素的话说,就是给进程划定“你能调哪些系统调用、不能调哪些”的范围,越界的直接拒绝。它跟防火墙、访问控制不是一码事,却又天然互补。这篇文章把我实际用 seccomp 的经验、踩过的坑、以及怎么设计一套可用又不误伤的过滤规则,完整整理出来,适合正打算给服务加一层系统调用级防护的同学参考。

1. seccomp 到底在解决什么问题

1.1 所有攻击最终都要落在系统调用上

安全界有个共识:漏洞利用只要到了执行阶段,就绕不开系统调用。你写了一个服务,里面有个字符串格式化漏洞,攻击者注入了一段 shellcode,那这段 shellcode 想在服务器上反弹一个 shell、读一个文件、或者外连一个端口,都得调用内核提供的接口。假如这段程序被限定成“只能做内存计算、只能操作自己已打开的文件描述符”,那攻击者就算拿到了执行权,也举步维艰。seccomp 就是在这个点上做限制的。

它的核心思想很简单:进程从用户态到内核态,唯一通道就是系统调用。我们没法在每个函数调用点做安全检查,但可以在内核入口处设置一道闸门,对每个系统调用进行校验。这个闸门的开关逻辑可以写成一段很小的过滤程序,交给内核执行。seccomp 最早是给高性能计算场景用的,后来在容器、浏览器沙箱、数据库等场景里被广泛采纳,可以说是现代安全加固里非常实用的一环。

1.2 seccomp 与传统权限模型的分工

很多人会问,我已经用普通用户跑服务了,文件权限也按最小化配置了,还有必要搞 seccomp 吗?答案是看你的威胁模型。传统权限模型,比如用户、组、文件权限、SELinux/AppArmor,控制的是“这个进程能访问哪些资源”。用户没权限读某文件,那就真的读不了。但它们拦不住的是:进程本身拥有的权限范围内的操作。比如服务已经能连接某个外部服务、能写某个日志目录、能创建子进程,攻击者只要复用这些“合法”能力,就可能造成实质损害。

seccomp 的定位不是替代它们,而是在更原子那一层——系统调用接口层——做约束。传统安全机制说的是一张资源清单,seccomp 给的是一张行为清单。两者叠加,进程的行为空间就被压缩得非常小。我在实践中比较喜欢的一种比喻是:传统安全是“给员工分配门禁卡”,seccomp 是“规定员工进了门之后只能走哪条走廊”。少一条走廊,攻击面就少一段路。

2. 两种模式:严格模式与过滤模式

2.1 严格模式(STRICT)为什么不够用

seccomp 最初提交到内核的时候,只提供了非常严格的模式:启用之后,进程只能使用 read、write、_exit 和 sigreturn 这几个系统调用,其他调用一律收到 SIGKILL。这个模式简单粗暴,只要进程还要打开文件、创建网络连接,就没法用了。所以它主要适合那些纯计算、或者逻辑已经完成只等结果输出的场景,实用价值有限,更多是作为概念基础和教学入口。

我最初学习 seccomp 时就是从严格模式入手,用它跑了一个纯内存排序的程序,验证进程确实被限制住了。但等我想给一个 Nginx 或者 MySQL 加保护时,严格模式完全施展不开,因为它们运行时需要大量系统调用,这个限制太不现实。后来转头研究过滤模式,才找到了真正适合生产用途的形态。

2.2 过滤模式(BPF)才是生产路子

过滤模式允许开发者传入一段自定义的 BPF(Berkeley Packet Filter)程序,内核会对每个系统调用执行这段程序,根据程序返回的结果决定放行、拒绝还是直接杀死进程。这种模式采用了 eBPF 的前身——经典 BPF(cBPF)语法,由一组非常底层的指令组成,但开发者一般不需要直接手写机器指令,而是用内核暴露的宏和辅助函数来组装规则。

过滤模式最大的价值在于灵活性:你完全可以只拦截execve,禁止进程再启动任何其他程序;只拦截socket和connect,禁止进程建立新的网络连接;只拦截openat,禁止进程打开新文件。以白名单方式,只放行业务确实需要的系统调用,其他全部默认拒绝,效果最严格。以黑名单方式,只拦特定几类高危调用,灰常适用于对程序行为不完全确定的遗留服务。我建议对自研服务用白名单,对不好改动的老系统用黑名单。

2.3 规则是怎么写出来的

写 seccomp 规则,实际就是在描述“对哪个系统调用、做什么判断”。常见判断有:系统调用号等于多少、参数是否符合条件(比如 socket 的 domain 是不是 AF_INET、connect 的目标端口是不是 1024)、最终动作是允许(SECCOMP_RET_ALLOW)、拒绝(SECCOMP_RET_ERRNO,给进程返回 EPERM 之类的错误)还是杀死(SECCOMP_RET_KILL_PROCESS)。

内核从 3.5 开始提供的 seccomp 机制,带上了一个seccomp()系统调用和一批宏,比如SECCOMP_RET_ACTION、SECCOMP_LOOPBACK这些都在头文件里能查到。加上 libseccomp 这个库之后,用 C 写规则就友好多了,它会自动帮你处理系统调用号在不同架构下的差异、参数大小端问题等。我平时写规则基本不直接碰指令,而是先用 libseccomp 写好过滤器逻辑,必要时再用seccomp_export_bpf()导出成 BPF 文件看看底层长什么样。

3. 实操:给一个服务加上 seccomp 限制

3.1 先弄清服务到底用了哪些系统调用

在真正写规则之前,必须先搞清楚目标服务运行时会调用哪些系统调用。千万别靠猜,猜的结果往往就是误杀老功能。我用的办法是 strace。先在测试环境把这个服务启动起来,跑一套典型的业务路径,然后用strace -c -f -p <pid>统计数据,等业务走完,再看哪些系统调用被调用了。

有个容易被忽略的点:启动阶段和运行阶段用的系统调用差别很大。比如动态库加载时要调mmap、openat、read,初始化时可能还要读配置文件、建日志目录,等真正稳定运行后,这些调用可能根本不会出现。所以统计系统调用时,一定要覆盖“完整生命周期”——从进程启动到处理一波典型请求,再到正常结束。我常把服务启动和日常流量分别统计,再合并成一个实际可用的清单。

3.2 用 C + libseccomp 写一个白名单过滤器

下面是我给一个普通 HTTP 服务写过的最小化过滤器。它允许的调用就三类:基础的内存与描述符操作、网络收发、进程退出。注意,这里为了演示简洁,我只列了代表性调用,真实环境你需要结合 strace 的结果补全。

#include <seccomp.h> #include <errno.h> #include <stdio.h> #include <unistd.h> int main(void) { scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ERRNO(EPERM)); 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(mmap), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(munmap), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigreturn), 0); // 网络相关 seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(socket), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(bind), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(listen), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(accept4), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(recvfrom), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sendto), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(shutdown), 0); // 进程结束 seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0); seccomp_load(ctx); seccomp_release(ctx); return 0; }

把这段过滤器编译进主程序后,在 main() 函数最前面、业务逻辑开始之前调用加载逻辑。如果服务是派生子进程的模式,更推荐在子进程里加载,这样每个 worker 都有独立限制。

3.3 验证规则到底生效没有

加载规则后,第一步不是继续开发,而是验证。最简单的验证方法是故意在业务代码里加一段会被拒绝的调用,比如system("echo test"),如果规则里没允许execve或clone,这段代码应该收到 EPERM 或者直接触发守护进程重启。我建议把这个验证步骤写进测试用例,而不是靠临时试。

更细的验证可以打开日志。如果设置了 seccomp 的SECCOMP_RET_LOG动作,内核会把被拦截的调用记到内核日志里。对于白名单模式,凡是日志里出现的系统调用,基本就是漏网之鱼。比如我一开始没放getrandom,程序启动时在做随机数初始化,结果内核日志里连续出现“seccomp: process … attempted syscall getrandom”之类的记录,补上之后程序才正常跑起来。这种日志驱动的方式比猜要快得多。

4. 容器场景里怎么用 seccomp

4.1 为什么容器更需要这层防护

容器虽然看起来像“轻量虚拟机”,但它共享宿主内核。一个容器里的进程如果拿到了不该有的能力,影响的可能是整个宿主机。Docker 默认对容器做了隔离,但默认 config 里仍然开放了大量系统调用,比如mount、ptrace、perf_event_open这些,在某些场景下可能成为逃逸路径。所以容器场景里,seccomp 不再是“锦上添花”,而是非常必要的纵深防御手段。

很多容器逃逸漏洞的共同点,就是通过某个危险系统调用拿到了宿主权限或敏感信息。如果 seccomp 一开始就没放行那个调用,漏洞自然就失去了利用条件。我接触过不少被攻击过的生产环境,复盘时都发现:如果当时在 seccomp 上卡得严一些,很多链根本走不通。

4.2 给容器配置自定义 profile

Docker 从早期版本就支持在运行容器时加载 seccomp profile。你可以把前面用 C 写的那套逻辑,改成 json 格式的 profile,然后通过参数传给 runc。我举个例子,下面是一个只允许 HTTP 服务所需系统调用的 profile 片段:

{ "defaultAction": "SCMP_ACT_ERRNO", "defaultErrnoRet": 1, "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_AARCH64"], "syscalls": [ { "names": ["read", "write", "close", "mmap", "munmap", "brk", "rt_sigreturn"], "action": "SCMP_ACT_ALLOW" }, { "names": ["socket", "bind", "listen", "accept4", "recvfrom", "sendto", "shutdown"], "action": "SCMP_ACT_ALLOW" } ] }

运行容器时,用--security-opt seccomp=/path/to/profile.json加载。如果你用的是容器编排平台,可以把 profile 内容放到对应字段里,或者使用默认 profile 再追加自定义策略。注意别漏了不同硬件架构的系统调用编号差异,所以 profile 里要写清楚架构列表。

4.3 默认 profile 与自定义怎么取舍

对绝大多数团队,我建议先使用容器运行时自带的默认 profile,它已经拦截了一批明显有风险的调用,比如直接操作内核模块相关的。但默认 profile 为了兼容各种镜像,放行的范围还是偏宽。比如你明明不会用到ptrace,默认 profile 可能仍然允许。所以在安全要求高的环境里,把默认 profile 当作底子,再按自己的服务裁剪,是更稳妥的做法。

我这里有个经验:先跑不带自定义 seccomp 的版本,用 strace 批量采集镜像启动和运行阶段的系统调用,形成一个“常用调用列表”,再把这个列表反哺到 profile 中。每次发版升级镜像后,重新统计一次调用集,防止新版本引入了额外的系统调用需求导致功能异常。

5. 踩坑记录与排查技巧

5.1 程序直接退出了,日志却干干净净

过滤模式里,如果规则动作选的是SCMP_ACT_KILL_PROCESS,进程会直接被杀掉,业务日志里可能什么都没有。我第一次在测试环境给一个 Java 服务加规则时,java 进程启动几秒就退出,翻遍了应用日志都没找到原因,最后在内核日志里看到seccomp相关的记录,才明白是 JVM 需要调用的某些系统调用没放行。从那以后,我写规则的前期版本统统用SCMP_ACT_ERRNO而不是 kill,宁可让调用失败返回错误,也不要整个进程消失。等稳定后再改成更严厉的动作。

5.2 漏放调用导致功能时好时坏

有一类问题更隐蔽:系统调用没被放行,但程序对该调用的失败有降级处理,于是功能没有完全挂掉,只是表现异常。比如某客户端库在发起连接前会先尝试ioctl干扰超时设置,如果被拒绝,它就静默跳过设置,结果就是网络连接超时时间不符合预期。这种问题通过业务日志很难发现,最好把 seccomp 的日志打开,把被拒绝的调用都记录下来,再对照代码逐个确认是否需要放行。

5.3 性能影响其实很小,但别忽视 BPF 复杂度

很多人担心 seccomp 会拖慢系统调用性能。实测下来,经典的 filter 模式对 CPU 负载的影响在微乎其微的级别,因为 BPF 程序短、判断简单。但如果规则过多或参数检查复杂,性能还是会有损耗。内核每次系统调用都要执行这段 BPF 程序,所以我的经验是:能放在规则前段快速放行的调用就放在最前面,常规调用尽快命中 ALLOW,避免为每个调用跑完一整串判断。

5.4 多线程和 fork 之后的继承问题

seccomp 规则是会继承的。进程 fork 之后,子进程保留父进程的过滤规则;执行 execve 之后,规则同样保留。这意味着你不必在每个 worker 或子进程里重新加载规则,但也意味着一旦加载,整个子进程树的行为都受影响。如果某个第三方组件是通过克隆子进程来执行外部命令的,你得提前确认这个子进程需要用到的系统调用是否都在白名单里,否则外部命令往往执行不成功。

5.5 排查工具速查表

场景工具/命令作用
看进程实际调用了哪些系统调用strace -f -c -p PID统计每个调用次数
看被 seccomp 拦下的记录dmesg | grep seccomp搜内核日志关键字
确认规则是否加载成功cat /proc/PID/status | grep Seccomp输出 0 表示未启用,1 为严格模式,2 为过滤模式
查看 BPF 规则细节grep Seccomp /proc/PID/status+ls /proc/PID/配合进程状态判断
导出 libseccomp 生成的 BPFseccomp_export_bpf查看最终下发规则

实际体会

用 seccomp 这几年,我最大的感受是:它不像防火墙那样配好就不管了,而是一个需要跟业务持续对齐的组件。每次更新依赖库、每次升级运行时、每次加功能,都应该重新过一遍系统调用清单。这个工作看起来繁琐,但性价比极高——相当于给服务装上了一条“行为边界”,攻击者就算突破了代码层,转身还要面对内核层这道墙。如果你还没试过 seccomp,我建议找一个小服务,先用黑名单拦住execve和socket试试,感受一下规则带来的变化,再逐渐过渡到白名单模式。少放一个调用,就是少给攻击者留一条路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进