ARTICLE DETAIL

资讯详情

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

seccomp 系统调用过滤:容器安全与沙箱实战

seccomp 系统调用过滤:容器安全与沙箱实战 1. seccomp 到底解决了什么问题值得花时间上手吗第一次接触 seccomp 的人多半是在读容器安全文档或者翻 Linux 手册页时撞见这个词的。它的全称是secure computing mode直译过来叫安全计算模式但这个名字其实有点误导——它不是一个模式而是一套让进程自己给自己上枷锁的内核机制。你可以在程序启动的早期告诉内核接下来我只允许调用这几个系统调用其他的一律按我指定的方式处理。内核会为这个进程挂上一段 BPF 字节码从此每次进入系统调用前都要先过这道关卡。这听起来像个偏门特性实际上它已经是现代 Linux 安全体系的基石之一。你手机里的浏览器渲染进程、你跑的每一个 Docker 容器、Chrome 和 Firefox 的沙箱、systemd 的部分服务隔离背后都有 seccomp 在默默工作。只不过大多数人平时感知不到因为运行时已经帮你把 profile 配好了。为什么我建议后端、运维、安全方向的人都花点时间真正上手一遍因为 seccomp 是少数几个投入产出比极高的底层技能。你不需要重写业务代码不需要改架构只要在进程启动时多写几十行配置或几百行 C 代码就能把一个潜在的提权漏洞的利用面从几百个系统调用压缩到十几个。攻击者拿到一个任意代码执行漏洞后第一件事往往是调execve起个 shell、调socket反弹连接、调ptrace注入别的进程——而这几件事恰恰是 seccomp 最容易拦住的。我把这篇文章的目标读者分成三类第一类是写 C/C 或 Go 的服务端开发者想给敏感进程加一层自保第二类是运维和容器平台的同学需要理解 Docker 默认 profile 为什么那样设计、怎么定制第三类是做安全研究或者 CTF 的人需要读懂、绕过或者构造 seccomp 规则。三类人需要的深度不一样我会从原理讲到实操代码都保证能跑你可以边看边在虚拟机里试。注意seccomp 相关的调试有相当一部分操作比如prctl、ptrace、直接写 BPF在有权限限制的环境里会被拦建议在本地虚拟机或者一台可以随意折腾的测试机上操作别一上来就在生产机器上改。1.1 从一个真实场景说起容器里那些不该存在的系统调用设想一个典型场景你部署了一个只负责图片压缩的微服务它读一张图、算出缩略图、写回对象存储业务逻辑里根本用不到网络监听、加载内核模块、挂载文件系统。但默认情况下这个进程跑在容器里它理论上有能力调用几百个系统调用包括mount、init_module、kexec_load、ptrace、reboot这些高危操作。当然容器本身有 namespace 和 capabilities 做了一层隔离很多操作会被拒绝。但被拒绝和根本不存在是两回事。前者是运行时检查后再返回 EPERM后者是内核在系统调用入口就直接把进程干掉。seccomp 属于后者——它更早、更硬、更不容易被绕过。内核社区近年来披露过不少和 namespace、capabilities 相关的提权问题而如果进程压根不允许调用那些相关的系统调用这类漏洞的利用链在第一步就断了。这就是 seccomp 的核心价值它不是在做权限判断而是在做能力裁剪。权限判断是你有权限做这件事吗能力裁剪是你连发起这件事的入口都没有。两者叠加纵深防御才成立。很多团队做安全加固时只盯着 capabilities 和白名单把 seccomp 当成可选实际上 seccomp 才是那个兜底最狠的角色。再举个更贴近日常的例子。你在写一个解析用户上传文件的程序用的是某个历史包袱很重的第三方库。这个库有没有隐藏的后门你无法百分百确定。但如果你在进程启动时就声明这个进程永远不许调用execve和socket那么即使这个库真有问题攻击者也没法直接起 shell 或者连外网。这种即使代码被攻破损失也有限的思路就是 seccomp 给普通开发者带来的最实际的好处。1.2 seccomp、capabilities、LSM 三者到底怎么分工刚上手的人最容易混淆的就是这几个概念我一开始也绕了很久。它们的检查时机和粒度的差别决定了你该在哪一层动手。capabilities把传统 root 的超级权限拆成一堆细粒度开关比如CAP_NET_ADMIN、CAP_SYS_MODULE。它管的是你能不能做某类特权操作检查发生在具体操作内部粒度是能力而非系统调用。LSMLinux Security Module是一个内核钩子框架SELinux、AppArmor 都挂在这里。它做的是基于路径、标签、上下文的访问控制比如这个进程能不能写/etc/shadow。检查点在系统调用进入内核之后、真正操作资源之前。seccomp则站在最前面检查的是系统调用号和参数本身和对象、路径、权限统统无关。它不看你是谁、有没有权限只看你想调哪个系统调用、参数是什么。正因为它不依赖任何上下文所以执行开销极低几乎就是一段 BPF 程序跑一遍。用一句不那么严谨但很好记的话概括capabilities 管能做什么事LSM 管能碰哪些东西seccomp 管能说哪些话。三者不冲突是叠加关系。你完全可以在一个容器里同时限制 capabilities、加载 AppArmor profile、再挂一层 seccomp 过滤器。这里有个实操中的坑很多人以为开了 seccomp 就万事大吉结果发现攻击者用允许的系统调用组合照样能搞事比如用允许的openwrite往某个敏感文件写内容。seccomp 只能限制调用哪个系统调用它不检查参数指向的内存内容是不是合法路径。这也是为什么 seccomp 必须和其他机制配合不能单打独斗。1.3 strict 模式和 filter 模式别选错seccomp 从诞生到现在有两个截然不同的模式搞混了会浪费很多时间。strict 模式是最早的形态通过prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT)开启。开启之后这个进程能用的系统调用只剩read、write、_exit准确说还有sigreturn其他任何调用都会直接触发 SIGKILL。它简单、绝对但基本没法用于真实业务。这个模式现在几乎只在极简沙箱或者教学演示里出现。filter 模式才是日常说的 seccomp通过prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prog)或者seccomp()系统调用加载一段 BPF 程序。它在 strict 的基础上做了彻底扩展可以按系统调用号、按参数值、甚至按调用者的指令指针做条件判断返回值也可以选择放行、返回错误码、杀死线程、杀死进程、通知另一个进程等等。Docker、Chrome、systemd 用的全是这个模式。下面这张表帮你快速建立直觉维度strict 模式filter 模式开启方式prctlSECCOMP_MODE_STRICTprctl/seccomp() BPF 程序可放行调用仅 read/write/_exit/sigreturn任意按规则决定条件判断无系统调用号、参数、架构、IP返回值选项只有放行或杀死整个进程ALLOW/ERRNO/TRAP/KILL_THREAD/KILL_PROCESS/USER_NOTIF/LOG是否需要特权不需要通常需要no_new_privs或CAP_SYS_ADMIN典型用户教学、极端沙箱容器运行时、浏览器、服务加固选哪个不用纠结只要不是做演示一律用 filter 模式。本文后面提到的 seccomp没有特别说明都指 filter 模式。2. 动手之前必须搞清楚的几件事在写第一行过滤代码之前有几项准备工作如果跳过后面会反复卡住。我见过不少人卡在为什么我的过滤器加载失败、为什么规则没生效上追根究底都是下面这几个基础点没确认。2.1 先确认内核支持和关键配置项seccomp 从 Linux 3.5 开始就有基础支持但一些关键特性依赖具体版本和编译选项。我列一个最低要求对照表你可以用uname -r看内核版本用grep查内核配置如果/boot/config-$(uname -r)存在的话。特性最低内核版本相关配置项filter 模式基础3.5CONFIG_SECCOMP_FILTER架构检查seccomp_data.arch3.5同上SECCOMP_RET_KILL_PROCESS4.14同上seccomp()独立系统调用3.17CONFIG_HAVE_ARCH_SECCOMP_FILTERSECCOMP_FILTER_FLAG_TSYNC多线程同步3.17同上SECCOMP_RET_USER_NOTIF用户态接管5.0CONFIG_SECCOMPSECCOMP_GET_NOTIF_SIZES等辅助操作5.0同上大多数 4.x 以上的发行版都满足基础需求。如果你只是想给一个进程加白名单3.5 之后的任何内核都够用如果想用 TSYNC 做多线程同步这个很关键后面会细讲得 3.17 以上想玩用户态拦截再考虑 5.0 以上。查配置项的办法是# 方式一看发行版保留的配置 grep -i seccomp /boot/config-$(uname -r) # 方式二内核把 config 暴露在 /proc 里多数发行版开启 zcat /proc/config.gz | grep -i seccomp # 方式三直接查运行中的内核是否支持 filter grep Seccomp /proc/self/status第三条命令会输出类似Seccomp: 0或Seccomp: 2的内容。0 表示没开启 seccomp stakeholder 的 1 表示 strict 模式2 表示 filter 模式。这个字段在你调试容器进程时特别有用——docker exec进去看这个值就能知道容器到底有没有挂 seccomp profile以及挂的是哪种。这是个非常实用的排错小技巧我几乎每次怀疑过滤器没生效时第一件事就是看它。提示有些精简发行版或者定制内核会关掉 seccomp尤其是一些追求极致性能的嵌入式系统。上手前先确认一遍否则后面所有实验都会以诡异的方式失败。2.2 关键数据结构seccomp_data 长什么样filter 模式的 BPF 程序和普通抓包用的 BPF 很像但它能访问的数据被限定在一个叫struct seccomp_data的结构里。理解这个结构是写好过滤器的前提因为它决定了你能检查什么。struct seccomp_data { int nr; /* 系统调用号 */ __u32 arch; /* 架构标识如 AUDIT_ARCH_X86_64 */ __u64 instruction_pointer; /* 触发调用的指令地址 */ __u64 args[6]; /* 六个系统调用参数 */ };几个要点值得展开说。nr是系统调用号比如 x86-64 上read是 0、write是 1、execve是 59这些值可以用ausyscall --dump或者查asm/unistd_64.h头文件拿到。arch是架构标识写过滤器时必须检查它原因下面会专门讲。args是六个参数注意它们是原始寄存器值如果参数是个指针你能拿到指针地址本身但拿不到它指向的内容——这是 seccomp 的一个重要限制也是它不能替代 LSM 的原因。BPF 能访问的偏移量就是通过offsetof算出来的。比如取系统调用号BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr))取第一个参数低 32 位BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0]))这里有个新手很容易踩的坑参数是 64 位的但 BPF 的累加器在经典 BPF 里是 32 位所以想检查完整 64 位参数得分高位、低位两次加载再比较不能一次搞定。很多人写完发现明明参数不对却还是放行了八成就是只比了低 32 位而高 32 位被攻击者做了手脚。2.3 工具箱三件套帮你少写一半代码裸写 BPF 字节码数组既痛苦又容易错实际项目里我推荐按需要挑工具。libseccomp是官方推荐的用户态库把允许/禁止某个系统调用、带什么参数条件、返回什么动作抽象成函数调用内部帮你生成 BPF。它可读性极好跨架构处理也帮你做了绝大多数场景我都优先用它。缺点是引入了一个运行时依赖对静态编译要求极高的场景需要权衡。strace是排查利器。你可以在开发阶段先用strace -f -c统计一个进程到底调用了哪些系统调用、各自的频次据此来制定白名单。这比拍脑袋想它会用什么调用靠谱得多。seccomp-tools是一个专门解析 seccomp 过滤器的工具Ruby 写的很多 CTF 选手熟悉它可以 dump 一个进程当前挂载的 BPF 程序并反汇编成人类可读的规则。当你接手别人的容器、看不懂它挂了什么 profile 时这个工具能快速帮你还原真相。我把三者的定位总结一下工具主要用途典型命令/接口libseccomp编写过滤器简化开发seccomp_rule_add()strace摸清目标进程的系统调用行为strace -f -c ./targetseccomp-tools反查已有过滤器内容seccomp-tools dump ./target我个人的工作流通常是这样的先strace跑一遍摸清底数再用 libseccomp 写规则写完后用一个故意越界的测试用例验证拒绝路径是否正确触发最后用 seccomp-tools 或/proc/pid/status确认过滤器确实挂上了。3. 从零手写第一个 seccomp 过滤器理论铺垫够了直接上手写代码。我建议第一次一定用裸 BPF 手写一遍哪怕后面都用 libseccomp因为你只有这样才会真正理解SECCOMP_RET_*的语义和架构检查的必要性。3.1 过滤器程序的基本骨架任何 filter 模式的 seccomp 程序都遵循同一个骨架定义一个sock_filter数组、用sock_fprog包起来、通过prctl加载。下面是最小可运行版本它只做一件事——检查架构然后放行所有系统调用。#define _GNU_SOURCE #include stdio.h #include stddef.h #include unistd.h #include sys/prctl.h #include linux/seccomp.h #include linux/filter.h #include linux/audit.h #include sys/syscall.h static int install_arch_check(void) { struct sock_filter filter[] { /* 加载 arch 字段 */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), /* 如果 arch AUDIT_ARCH_X86_64跳过下一条 */ 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_RET | BPF_K, SECCOMP_RET_ALLOW), }; struct sock_fprog prog { .len (unsigned short)(sizeof(filter) / sizeof(filter[0])), .filter filter, }; /* 关键一步设置 no_new_privs否则非特权进程无法加载过滤器 */ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror(prctl(PR_SET_NO_NEW_PRIVS)); return -1; } if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prog)) { perror(prctl(PR_SET_SECCOMP)); return -1; } return 0; } int main(void) { if (install_arch_check()) return 1; printf(filter installed, still alive\n); return 0; }编译命令gcc -O2 -Wall -o arch_check arch_check.c跑起来应该正常输出。这段代码虽然什么都没禁但它示范了两个必须记住的动作先PR_SET_NO_NEW_PRIVS再PR_SET_SECCOMP。顺序反了会报 EACCES。3.2 为什么架构检查不是可选项上一节我特意把架构检查放在最前面不是凑数。seccomp 过滤器是跟着进程走的而进程理论上可以切换执行模式。在 x86-64 上一个进程既可以用 x86-64 的系统调用号也可以通过 32 位兼容模式用 x86 的系统调用号俗称 int 0x80 路径。问题来了同一套系统调用号在不同架构下含义完全不同。比如在 x86-64 上nr59是execve但在 32 位 x86 上nr59是execve吗——恰好也是但很多其他号就不一样了。更麻烦的是 x32 ABIx86-64 上的 32 位指针模式它的系统调用号带一个__X32_SYSCALL_BIT0x40000000标志位。如果你只检查系统调用号、不检查架构攻击者可以切到另一种 ABI用一个在你的规则里是被允许的号去触发一个完全不同、甚至危险的系统调用。这就是经典的seccomp 绕过手法之一。所以架构检查是所有生产级过滤器的第一条规则没有例外。正确做法是先确认arch等于自己编译时的目标架构不等就直接 KILL然后再进入系统调用号的判断逻辑。另外如果你同时要覆盖 x86-64 和 x32 两种调用路径需要单独处理__X32_SYSCALL_BIT/* 若 nr 带 x32 标志则清除后再判断或直接拒绝 */ BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),3.3 返回值语义六种动作各有各的用途BPF 程序最终要返回一个 action告诉内核这个系统调用该怎么办。很多人只知道 ALLOW 和 KILL其实中间几档在日常调试里非常有用。返回值含义典型用途SECCOMP_RET_ALLOW放行白名单内的调用SECCOMP_RET_ERRNO(0)返回 0 但不真正执行伪造成功少见SECCOMP_RET_ERRNO(n)返回错误码 n如 EPERM温和拒绝程序可继续跑SECCOMP_RET_TRAP触发 SIGSYS 信号想捕获并自定义处理时SECCOMP_RET_LOG放行并记录灰度上线只观测不拦截SECCOMP_RET_KILL_THREAD杀死当前线程旧版默认的 KILLSECCOMP_RET_KILL_PROCESS杀死整个进程4.14 推荐语义更完整我在实际落地时最常用的是两档ERRNO(EPERM)用于灰度阶段因为它不会直接把进程干掉只让对应调用失败返回错误码方便观察业务有没有受影响、有没有漏网调用KILL_PROCESS用于最终的严格模式一旦越界立刻终止不给任何回旋空间。SECCOMP_RET_LOG这个档位值得单独强调它是灰度的绝佳工具。你可以先挂一个全部 LOG的过滤器跑一段时间观察日志里到底出现了哪些系统调用据此把白名单列全再切成真正的拦截。这样能极大降低上线就把服务打挂的风险。提示SECCOMP_RET_ERRNO返回错误码时其实还可以利用 data 字段传一个自定义值配合TRAP和SIGSYS处理器可以做更精细的用户态决策。SECCOMP_RET_USER_NOTIF5.0更进一步把决定权完全交给另一个进程这是现在很多沙箱产品的新玩法值得单独研究。3.4 一个能真正拦住东西的完整例子光检查架构没有说服力我们来写一个真正禁止execve的过滤器验证它是否生效。规则很简单架构对 → 检查系统调用号 → 是execvex86-64 上是 59就返回 EPERM否则放行。#define _GNU_SOURCE #include stdio.h #include stddef.h #include unistd.h #include errno.h #include sys/prctl.h #include linux/seccomp.h #include linux/filter.h #include linux/audit.h #ifndef __NR_execve #define __NR_execve 59 #endif static int install_no_execve(void) { struct sock_filter filter[] { 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)), BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execve, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM SECCOMP_RET_DATA)), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), }; struct sock_fprog prog { .len (unsigned short)(sizeof(filter) / sizeof(filter[0])), .filter filter, }; if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror(nnp); return -1; } if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prog)) { perror(seccomp); return -1; } return 0; } int main(void) { if (install_no_execve()) return 1; printf(about to try execve...\n); execl(/bin/ls, ls, NULL); /* 如果 execve 被拦会走到这里 */ perror(execl); printf(execve was blocked as expected\n); return 0; }跑起来你会看到execl返回失败errno是EPERM进程本身还活着。这就是ERRNO档位的意义——程序可以捕获失败、优雅降级。如果把返回值改成SECCOMP_RET_KILL_PROCESS进程会在尝试execve的瞬间被 SIGSYS 干掉连perror都执行不到。这里的 BPF 跳转逻辑值得细看BPF_JUMP(..., JEQ, __NR_execve, 0, 1)的意思是如果等于execve向前跳 0 条即执行下一条也就是返回 EPERM否则跳过 1 条即跳到最后的 ALLOW。这种命中就落入惩罚分支的写法比反过来写更不容易出错我养成了习惯就这么写。3.5 用 libseccomp 把冗长的 BPF 收起来裸 BPF 写小规则还行规则一多就没法看。libseccomp 把刚才那套逻辑压缩成几行而且自动帮你处理架构检查和 BPF 生成。#include seccomp.h #include errno.h #include stdio.h int main(void) { scmp_filter_ctx ctx; /* 默认动作允许。也可以用 SCMP_ACT_ERRNO(EPERM) 做默认拒绝 */ ctx seccomp_init(SCMP_ACT_ALLOW); if (!ctx) return 1; /* 禁止 execve命中返回 EPERM */ seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(execve), 0); /* 禁止 socket条件仅当第一个参数 AF_INET(2) 时拒绝 */ seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(socket), 1, SCMP_A0(SCMP_CMP_EQ, 2)); /* 加载并释放 */ if (seccomp_load(ctx) 0) { perror(seccomp_load); return 1; } seccomp_release(ctx); printf(libseccomp filter active\n); return 0; }编译需要链接 libseccompsudo apt install libseccomp-dev # Debian/Ubuntu sudo dnf install libseccomp-devel # Fedora/RHEL gcc -O2 -Wall -o demo demo.c -lseccomplibseccomp 最舒服的地方是SCMP_A0这类宏让你能精确表达某个参数等于某个值不用自己算 64 位。它还支持比较运算SCMP_CMP_GT、SCMP_CMP_MASKED_EQ等做参数白名单时特别方便。比如限制openat只允许特定 flags、限制ioctl只允许特定请求码都靠它。我一般写规则时的顺序是先用seccomp_init(SCMP_ACT_ALLOW)起步默认放行只禁危险项验证没问题后如果安全要求高再切换成seccomp_init(SCMP_ACT_ERRNO(EPERM))的白名单模式默认拒绝只放开必需的。前者稳妥、改动小后者安全但容易漏掉调用把服务打挂一定要配合SCMP_ACT_LOG先观测。4. 容器与编排环境里怎么用自己写 C 程序是小范围场景真正的高频使用场景是容器。这里面的门道和手写程序不太一样因为容器运行时帮你承载了 profile 的加载。4.1 Docker 默认 profile 到底禁了什么Docker 从很早的版本开始就给容器挂了一个默认的 seccomp profile路径通常在/etc/docker/seccomp.json或者编译进 daemon。这个 profile 的策略是默认放行、黑名单拦截——它列出了约 40 多个被明确禁止的系统调用命中就返回 EPERM。被默认拦掉的典型调用包括mount、umount2、pivot_root、init_module、finit_module、delete_module、kexec_load、reboot、swapon、swapoff、ptrace在现代版本里对特定 capability 场景有调整、add_key、keyctl、request_key等等。这些要么涉及内核和宿主要么是经典的提权跳板普通业务基本用不到。你可以用这条命令验证容器里的 seccomp 状态docker run --rm alpine sh -c grep Seccomp /proc/self/status # 输出 Seccomp: 2 表示 filter 模式已启用如果想看默认 profile 的具体内容docker info | grep -i seccomp # 找到 profile 路径后 cat 出来看 cat /etc/docker/seccomp.json | head -50默认 profile 的意义在于够用且不折腾。绝大多数业务不需要定制。但有两类情况必须动它一是你的程序确实需要某个被默认禁用的调用比如某些需要ptrace的调试工具、某些 JVM 或数据库的特定优化路径二是你的安全要求高于默认想收紧成白名单。4.2 定制一份自己的 profileprofile 是 JSON 格式核心结构是defaultAction加几条syscalls规则每条规则指定names调用名数组、action动作、可选的args参数条件。下面是一份精简的可运行示例策略是默认拒绝、白名单放行{ defaultAction: SCMP_ACT_ERRNO, architectures: [ SCMP_ARCH_X86_64, SCMP_ARCH_X86, SCMP_ARCH_X32 ], syscalls: [ { names: [ read, write, exit, exit_group, rt_sigreturn, brk, mmap, munmap, mprotect, close, fstat, poll, lseek, arch_prctl, getpid, gettid ], action: SCMP_ACT_ALLOW }, { names: [ execve, execveat, fork, vfork, clone ], action: SCMP_ACT_ERRNO, errnoRet: 1 } ] }注意几个细节。defaultAction用SCMP_ACT_ERRNO就是白名单模式没列出来的统统拒绝。architectures一定要把SCMP_ARCH_X32也列上否则在某些内核上 x32 调用会绕过你的规则这又回到架构检查那个老话题。errnoRet指定返回的错误码1 是 EPERM。写好之后加载docker run --rm \ --security-opt seccomp/path/to/my-profile.json \ alpine sh -c echo hello这里有个非常关键的坑白名单模式下漏掉任何一个必需的系统调用容器都会以莫名其妙的方式失败。可能是启动阶段就报权限错误可能是运行一段时间后某个路径才崩。所以我的实操流程是两阶段上线第一阶段把defaultAction设成SCMP_ACT_LOG跑一段完整业务包括冷启动、压力测试、优雅退出收集日志里出现的所有系统调用把它们补进白名单第二阶段再切成SCMP_ACT_ERRNO正式拦截。需要特别提醒的是不同运行时的默认 profile 不一样。Docker、containerd、CRI-O、Podman 各有各的默认值还有 Kata Containers 这类虚拟化方案可能根本不走普通 seccomp 路径。跨运行时迁移时一定要重新验证别默认换个引擎也一样。4.3 Kubernetes 里通过 securityContext 下发K8s 场景下 seccomp 的配置方式随着版本演进变动过好几轮这是很多人踩坑的地方。最早1.19 之前seccomp 是通过annotation指定的形如seccomp.security.alpha.kubernetes.io/pod: runtime/default。从1.19开始引入了securityContext.seccompProfile字段到1.25之后 annotation 方式逐步废弃正式推荐用字段方式。现在写新配置就统一用字段。一个字段方式的示例如下apiVersion: v1 kind: Pod metadata: name: hardened-app spec: securityContext: seccompProfile: type: Localhost localhostProfile: profiles/hardened-app.json containers: - name: app image: myapp:1.0 securityContext: allowPrivilegeEscalation: false capabilities: drop: [ALL]seccompProfile.type有几个取值RuntimeDefault用运行时默认 profile、Unconfined不启用、Localhost用节点上一个自定义文件。如果只想要默认防护写RuntimeDefault就够了它对应到 Docker/containerd 的默认 profile。用Localhost时有个必须知道的约束localhostProfile的路径是相对于 kubelet 配置的 seccomp 根目录的通常是/var/lib/kubelet/seccomp而且 profile 文件必须预先部署在每个可能调度到该 Pod 的节点上。你没法像 ConfigMap 那样把 profile 塞进集群再自动分发。这意味着你必须有一套节点配置管理流程DaemonSet、Ansible、镜像预置都行来保证文件就位。新手最常见的失败就是YAML 写对了但 Pod 起不来一查日志是找不到 profile 文件。还有个细节allowPrivilegeEscalation: false和 seccomp 是绝配。前者会自动设置no_new_privs位而 seccomp 的加载本来就依赖这个位。两个一起用既满足加载前提又顺手堵住了 setuid 提权路径。5. 排错手册与几个容易翻车的细节seccomp 的问题有个共同特点报错信息极其不友好。进程可能毫无征兆地挂掉可能返回一个看不懂的错误码可能只在特定架构或特定线程上出问题。这部分我把自己踩过的坑和排查套路整理出来应该能帮你省下大量时间。5.1 常见现象与定位路径先给一张速查表覆盖我最常遇到的几类问题。现象可能原因定位手段prctl返回 EACCES没先设no_new_privs或进程无权限检查调用顺序和Seccomp状态进程莫名 SIGSYS 死亡命中KILL_PROCESS/TRAPdmesg看 audit 日志过滤器加载成功但没效果多线程只对一个线程生效用TSYNC标志同步容器启动报权限错误白名单漏了必需调用切SCMP_ACT_LOG收集某调用一直返回 EPERM参数比较写错误伤检查 64 位参数的比较逻辑x86-64 正常但 32 位程序崩没做架构检查或漏了 x86补AUDIT_ARCH_I386分支用了新动作但内核不认内核版本过低查版本与特性对照表拿到 SIGSYS 死亡时dmesg里的 audit 记录是关键线索通常会打印出触发过滤器的系统调用号、进程名和 PIDsudo dmesg | tail -20 # 典型输出audit: type1326 ... syscall59 ... commmyapp ...看到syscall59这种编号对照系统调用表就知道是哪个调用被拦了59 是execve。这个技巧我用得最多因为很多崩溃现场根本没有 stderr 输出。5.2 多线程与 TSYNC最容易被忽略的陷阱seccomp 过滤器是挂在线程上的不是挂在进程上的。这意味着如果你在主线程挂了过滤器然后用pthread_create起了新线程理论上新线程应该继承父线程的过滤器——大多数情况下确实会继承但这事有个前提正规的线程创建路径会继承。真正麻烦的是在挂过滤器之前就已经存在其他线程的情况。最典型的是 JVM 这类启动就拉起一堆线程的运行时。如果你在一个多线程进程里只对当前线程加载过滤器其他已经存在的线程不受任何约束攻击者完全可以利用那些漏网线程绕过防护。解决方案是SECCOMP_FILTER_FLAG_TSYNC3.17它会在加载过滤器时把所有其他线程也同步上。用seccomp()系统调用时带上这个标志#include linux/seccomp.h #include sys/syscall.h struct sock_fprog prog { /* ... */ }; if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, prog) 0) { perror(seccomp TSYNC); }用 libseccomp 的话更省事加个属性即可seccomp_attr_set(ctx, SCMP_FLTATR_CTL_TSYNC, 1); seccomp_load(ctx);TSYNC 的行为是要么所有线程都成功同步要么整体失败并且一个线程都不挂。这个原子性很重要否则会出现部分线程有防护、部分没有这种隐蔽的半吊子状态。我在给任何多线程服务加 seccomp 时第一件事就是确认 TSYNC 打开了。注意TSYNC 有个已知限制——它不能同步已经进入 seccomp 状态的线程也不能给不同线程挂上冲突的过滤器。设计上有时候会考虑让所有线程挂同一套过滤器来规避复杂性这是个值得记住的简化策略。5.3 性能和灰度上的取舍心得最后一个话题聊点经验层面的东西。seccomp 的性能开销到底有多大结论是单次调用开销极小但高频调用场景要留个心眼。每次系统调用都要多跑一遍 BPF 程序BPF 程序本身是简单比较和跳转执行成本在纳秒级。对于普通业务每秒几万次系统调用这点开销可以忽略。但如果你在做高频 IO、syscall 密集型的服务比如某些网络中间件、数据库或者 BPF 规则特别长累积开销就值得测一下。我一般用perf stat或者简单的time对比开关 seccomp 前后的差异确认在可接受范围内再上线。另一个经验是关于规则维护。白名单模式最大的敌人不是性能而是后续代码变更。今天你的服务不用某个调用明天引入一个新库这个库在某个冷门路径上恰好需要一个白名单外的调用然后线上就炸了。所以我的做法是白名单里预留一部分低危但常用的调用比如各种get*、fstat家族不要把规则抠得太死同时把 seccomp 的拒绝日志接入监控告警一旦有新的拒绝出现就能立刻发现而不是等用户投诉。我还想强调一遍灰度那个套路因为它真的救命先SCMP_ACT_LOG、再SCMP_ACT_ERRNO、最后才考虑KILL_PROCESS。每一档都至少跑过一个完整的业务周期包括那些半夜才触发的定时任务、月初才跑的对账脚本确认没有意外拒绝后再升级。这个流程虽然慢但比一把梭上严格模式、出事再回滚稳得多。踩过几次坑之后我再也不会跳过灰度这一步。至于后续怎么扩展我个人比较看好的方向是SECCOMP_RET_USER_NOTIF——它把决策权从内核 BPF 转移到用户态进程意味着你可以用任何语言、任何逻辑来做系统调用的准入判断甚至可以动态调整规则。现在不少沙箱工具都在往这个方向走值得单独开一篇来写。
返回列表