ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

seccomp实战:系统调用过滤与容器沙箱白名单构建

seccomp实战:系统调用过滤与容器沙箱白名单构建 1. seccomp 到底是什么给进程一张被剪过的菜单第一次接触 seccomp 的人十个里有八个会把它和 SELinux、AppArmor、能力capability体系搞混。这很正常因为它们都在回答同一个问题——这个进程到底能干什么。但它们的思路完全不在一个层面上。能力体系管的是你能不能碰这台机器上的特权资源SELinux/AppArmor 管的是你能不能碰这个文件、这个端口、这个 socket而 seccomp 管的东西更底层也更暴力它管的是这个进程能不能调用某一个系统调用syscall。换个生活化的说法。能力体系像是给你发了一张门禁卡的权限等级SELinux 像是规定了你能进哪几扇门而 seccomp 直接把整栋楼里的走廊砍掉了一大半——你想去的那个房间连走过去的路都没了。为什么这种砍走廊的做法有价值因为在容器和沙箱的世界里攻击面的大头从来不是应用层的逻辑漏洞而是内核暴露给进程的那三百多个系统调用。一个 Web 服务正常跑起来可能只用到五六十个 syscall剩下两百多个就是纯粹的、白白摊开给攻击者的接口。内核每年都在修各种系统调用的边界检查问题而每一次修复都意味着只要你的进程还能调到那个 syscall你就在暴露面上。seccomp 做的事情就是把这个暴露面从全部压缩到我实际需要的那几个。seccomp 全称是 secure computing mode2005 年进入内核主线比大多数人想象的要早。但它真正被大规模用起来是因为 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 | EINVALEINVAL 是 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 traceall -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. 剥离 capabilitycapset / 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返回 -1errno是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 3131 就是 SIGSYS ./your_app; echo exit code: $? # exit code: 159确认之后要找出是哪个 syscall 触发的。几个办法按好用程度排序第一种用 LOG 模式复现。把默认动作临时改成SCMP_ACT_LOGlibseccomp 里就是seccomp_init(SCMP_ACT_LOG)重新跑。违规的调用会被放行但内核审计子系统会记录一条typeSECCOMP的审计事件。查日志# 需要 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 都没配好的环境。
返回列表