资讯详情

资讯详情

Seccomp-BPF实战:系统调用白名单如何收窄攻击面

几年前我维护一个对公网开放的网关服务安全演练时攻击者通过一个反序列化漏洞拿到了权限。我第一反应不是急着修漏洞而是打开那个进程的 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_STRICT2.6.12只允许四个系统调用极简计算几乎没有实用价值SECCOMP_MODE_FILTER3.5用 BPF 指令定义规则日常服务加固、容器沙箱SECCOMP_SET_MODE_FILTER notify5.0父进程动态裁决沙箱平台、自动化分析工具日常做服务加固别想太多直接用 filter 模式。notify 模式我已经接触过一些它确实灵活但代码量和对进程生命周期的管理复杂度都上了一个台阶。2.2 Filter Action 决定拦截后的命运BPF 程序最终会返回一个 action系统调用被拦截后到底怎么处理全看这个值。最常用的几个Action值效果SECCOMP_RET_KILL_PROCESS0x80000000直接杀掉整个进程推荐默认用这个SECCOMP_RET_KILL_THREAD0x00000000只杀触发调用的线程可能留下半死进程SECCOMP_RET_TRAP0x00030000触发 SIGSYS用户态可以捕获做动态决策SECCOMP_RET_ERRNO0x00050000让系统调用返回指定 errno比如 EPERMSECCOMP_RET_LOG0x7ffc0000记录日志不改变执行结果SECCOMP_RET_ALLOW0x7fff0000放行白名单模式下默认 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 和状态文件摸清系统调用底细写白名单之前必须先知道程序真实依赖哪些系统调用。最直接的办法是 stracestrace -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_nanosleepclone3是很多新版本 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 服务开发者认真用起来。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →