为什么需要系统调用过滤
第 40 篇讲 Capabilities 时我们看过特权能力的细粒度拆分,系列早前的安全篇还看过 SELinux 与 AppArmor 的强制访问控制;这一篇补上纵深防御的最内层:seccomp,直接在系统调用入口设卡。为什么还需要它?因为系统调用面是用户态进入内核的唯一大门,也是内核攻击面的总和:三百多个系统调用,每个背后都有参数解析、ioctl 子命令、兼容路径,历史上大量本地提权 CVE 都从某个系统调用的解析逻辑切入。进程一旦被攻破,攻击者的下一步动作必须再经过一次系统调用——在入口把不需要的调用直接砍掉,利用链就断在最窄处。
seccomp 的出身就是这套思路的自证:它诞生于「只跑计算、不给任何其他能力」的沙箱需求(网格计算场景),后来 Chrome 把渲染进程沙箱推向大规模生产,容器运行时再把默认 profile 装进每一台装了 Docker 的机器——今天 seccomp 是部署面最广的内核安全机制之一,只是大多数人感知不到它的存在。
理解它的定位还有一层:seccomp 是极少数「进程自愿入狱」的机制——过滤器由进程(或其启动者)安装,装上后对整个进程树生效且不可撤销。这个「自愿」属性决定了它的威胁模型:防的是「被攻破后的进程」与「恶意子进程」,而不是防「进程的主人」;要防后者,得靠 namespace、LSM 与外围基础设施的组合。
机制位置上补一句:seccomp 检查挂在系统调用入口路径上,先于绝大多数内核逻辑执行,开销是一次纳秒级的 BPF 求值——这是它能被整个容器生态默认开启的成本基础:安全机制要成为默认,必须先便宜到可以忽略。配套的审计通道也现成:记录类裁决会进入审计子系统,用常规审计工具就能检索 seccomp 事件,灰度期的观测有现成管子。
还有个反直觉的工程事实值得点名:过滤器由进程安装、随进程树传播——带过滤器的进程 fork 出的子进程自动继承,没有「暂停过滤」这种状态。容器场景因此有个温柔的副作用:镜像里的调试工具(strace 一类需要 ptrace 的)如果不被过滤器放行,进去也用不了——排查工具本身也要过闸,这是容器调试体验差异的经典来源,也是 profile 里常给 ptrace 开口子的原因与争议所在。
两种模式:STRICT 与 FILTER
SECCOMP_MODE_STRICT 是极简模式:进程从此只允许 read、write、_exit 与 sigreturn 四个系统调用,其余一律 SIGKILL,连 mmap、getpid 都不给。够狠够安全,但只适合纯计算型沙箱——现实中直接用它的地方很少,它的价值更多是「语义上界」:seccomp 能做到的隔离强度的下限示范。
真正的主力是 SECCOMP_MODE_FILTER 过滤模式:给进程挂一段 BPF 过滤器,每个系统调用进入内核时先过这段程序再决定放不放行。安装途径是先 prctl 设置 NO_NEW_PRIVS(禁止通过 execve 提权的经典通道),再 prctl 安装过滤器;较新内核也可以用专门的 seccomp 系统调用一步完成。两条铁律:安装需要 NO_NEW_PRIVS 或 CAP_SYS_ADMIN 二者居其一,堵住「父进程提权绕过过滤」的口子;过滤器一旦安装不可移除、只能叠加——后来者只能加得更严,这是单向门设计,防止沙箱逃逸走「卸载过滤器」的回头路。
再记三个工程细节。其一,seccomp 系统调用还提供查询类操作:探明内核支持哪些动作、读过滤器的基本信息,写通用工具时有用。其二,多线程程序的过滤器同步是真实痛点——seccomp 状态按线程生效,逐线程安装会留下竞态窗口,内核提供了同步标志让安装动作一次性铺到全部线程。其三,多过滤器叠加按「最严格结果胜出」合并,求值结果与安装顺序无关,不必纠结先后。
与 eBPF 的关系值得划清,第 9 篇讲 eBPF 演进时提过这层渊源:seccomp 用的是经典 BPF(cBPF)——就是当年 tcpdump 过滤表达式底下那套指令集,指令数受限、无循环、无共享内存、纯函数式求值;eBPF 是后来扩展出的新一代虚拟机,服务于跟踪与网络。两者同宗(指令编码风格一脉相承)但能力代差巨大——别把 seccomp 过滤器想象成「能干 eBPF 那些事」的东西,它只是一段几十条指令的裁决程序。
BPF 过滤器:结构、动作与语义
过滤器拿到的是一个固定布局的输入结构 seccomp_data:系统调用号 nr、架构号 arch(防止不同架构同号异义,正规过滤器第一条指令先校验架构)、触发时的指令指针、以及六个参数原值。经典写法是三级判断:先校验架构,再按系统调用号分支,必要时深挖参数值——比如只放行特定标志组合的 clone,或只对特定 futex 操作放行。
裁决结果由过滤器的返回值决定,语义从狠到松排开:RET_KILL_PROCESS 与 RET_KILL_THREAD 直接杀进程或线程;RET_TRAP 发 SIGSYS 信号,进程可以自己接住做记录或恢复;RET_ERRNO 是工程上最优雅的选项——被禁调用不炸进程,而是返回指定的 errno,应用要么走降级路径要么老实报错,兼容性伤害最小;RET_LOG 只记审计不拦截,是灰度上线的利器;RET_ALLOW 放行。多个过滤器叠加时按「最严格结果胜出」的规则合并,保证任何一层都只能收紧不能放松。
手写 cBPF 指令不现实,工程上用 libseccomp 的声明式接口(seccomp_rule_add 一族)生成,各语言也都有封装。更上层还有 systemd 的 SystemCallFilter= 单元属性,按预置的系统调用组(如 @system-service、@network-io)一键过滤——很多发行版的默认服务单元已经悄悄带上了它,seccomp 早已不是浏览器沙箱的专利,而是系统服务的默认姿势。另外提一个进阶动作:内核通知模式(RET_USER_NOTIF)把「放行与否」交给沙箱外的一个监管进程裁决,容器运行时与浏览器沙箱用它实现敏感调用的代理执行——这是过滤器语义里唯一打破「纯内核裁决」的口子。
systemd 的高层封装演示一下形态:
[Service]
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @obsolete
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
第一行放行系统服务常用调用组,第二行用波浪号明确拒绝特权与过时调用组,第三行把违例调用的返回值定为 EPERM,第四行只允许本机架构的调用(防 32 位兼容路径绕过)。这几行就是一台普通 Linux 服务器上给服务上 seccomp 的全部成本,值得进每个服务的默认配置。
过滤器本身的工程边界也记两条:指令数有上限(内核对过滤器长度设限,超大规则表要拆分或改用调用组思路),求值是线性的——几百条规则的过滤器每条系统调用都要走一遍,排序要按「高频调用在前」优化。封装库生成的过滤器已做了部分优化,手写规则表的人要对这两条边界有数。过滤器不是免费的无限开关阵列,它是每条系统调用都要过的一道闸。
Docker 默认 profile 挡掉了什么
容器运行时的默认 seccomp profile 是「黑名单式」:默认全放行,砍掉几十个高危系统调用。Docker 默认 profile 的代表性名单:keyctl、add_key、request_key(内核密钥管理,容器内毫无用处但提权链路常见)、perf_event_open(性能事件接口,历史漏洞不少)、bpf、kexec_load(换内核)、pivot_root、mount、umount2、reboot、swapon 与 swapoff、open_by_handle_at(越权文件访问的经典入口)、create_module 一族(内核模块注入)。此外不少调用是「保留但需特定特权」的组合拳:比如 mount 并未被一刀切,但非特权容器本来就拿不到对应的 capability,两层防线互相兜底。
黑名单本身也在随内核演进:内核每隔一阵会新增系统调用,运行时 profile 的惯例是「新增默认拒绝、人工评估后放行」,profile 文件于是成了跟着内核版本维护的活物。另一个典型条目的故事是 unshare 类调用:普通容器里它被限制,因为它能新建命名空间并触发能力重算,是容器逃逸链的常见起手——黑名单的每个条目背后几乎都对应一类已知的攻击套路,通读一遍默认 profile 的注释,是理解容器威胁模型最快的途径之一。
运维排障的语言也统一一下:容器里进程莫名收到 EPERM、又不像文件权限问题时,第一嫌疑就是 seccomp——排查顺序是先确认运行时的 profile 是否启用,再核对被拒的调用是否在拒绝名单里,最后用 strace 复现定位到具体调用。「EPERM 三查」:先查能力、再查 seccomp、最后查 LSM——三层各有一个「拒绝了但不解释」的惯犯,顺序对了排查速度翻倍。
这套黑名单的哲学是「默认可用加砍明显危险」:兼顾兼容性与安全底线,但天然防不住未知风险——黑名单只挡已知的坏。更严格的做法是反向的 Allowlist:只放行业务需要的系统调用,其余全拒。「默认拒绝」在安全模型上比「默认放行」高一个等级,代价是维护成本:内核新增系统调用(io_uring 系列、各类克隆变体)、C 库升级换了内部调用组合,白名单都得跟着改——白名单是持续运维的活物,不是一次性的安全检查项。
运维侧的检查入口有两个层次:容器层面看运行时的 seccomp profile 配置(Docker 与主流容器运行时都支持自定义 profile,K8s 生态里对应 securityContext 的 seccompProfile 字段);进程层面看 /proc/self/status 的 Seccomp 行(0 未启用、1 严格、2 过滤)确认生效状态。两者对不上时,先查「是谁的配置没落下去」。
白名单生成与纵深分工
从零写 Allowlist 有条务实路径:先黑盒采集。strace -f -qq -e trace=all -o app.strace ./app 跑全量功能测试,从 trace 里提取实际用到的系统调用全集(注意必须覆盖冷路径:错误处理分支、降级逻辑、启动早期的初始化,漏一条上线就是「偶发崩溃查一周」),再以此为底本生成过滤器,用 RET_LOG 模式灰度观察一段时间,确认没有遗漏调用后才切到 RET_ERRNO 或 KILL。容器场景可以站在运行时肩膀上:以默认 profile 为起点做减法,而不是白手起家;对交付型软件,把 profile 文件随制品分发并写清验证步骤,是近年主流软件的通行做法。
采集阶段的命令形态大致是:
strace -f -o trace.log ./app
grep -oE '[a-zA-Z_0-9]+\(' trace.log | tr -d '(' | sort -u > syscalls.txt
第二行从 trace 里粗提系统调用名集合,这份清单只能当草稿:strace 看不到没触发的分支,冷路径要补测,还要对照发行版服务在用的调用组增删,才可上线。审计侧再补一个灰度抓手:把过滤器先用只记录模式跑一周,用审计工具回查有没有被记下的意外调用,确认干净后再收紧动作。
定位上,seccomp 与邻居各守一层、互不替代:namespace 决定「看得到什么」(第 8 篇),Capabilities 决定「特权操作有没有权限」,LSM(SELinux/AppArmor)决定「对具体对象的访问是否放行」,seccomp 决定「哪些系统调用根本进不了内核」。四层全上才有纵深:攻击者先得选对系统调用(可能直接碰 EPERM)、再得有权限、再得过 LSM、就算全过还有内核漏洞要打。常见误读是「加了 seccomp 就安全」——它只是把面收窄;放行名单里的高危子系统(io_uring 一类)一旦出洞,防线照样穿。安全配置的评审清单里,seccomp 应该是「在列且有人认领维护」的一项,而不是「配了就忘」的装饰。
把 seccomp 放进生命周期看,最佳实践是「出厂自带、持续维护」:开发阶段用 strace 产出白名单草稿,CI 阶段用只记录模式回归验证,发布时随制品带上 profile,运行期跟随内核与依赖升级复核。安全配置不是上线前的一次性动作,seccomp 的维护成本花在哪,决定它是防线还是摆设。
小结
seccomp 的价值在于选对了战场:系统调用入口是所有攻击的必经窄门,在这里设卡的成本最低、收益面最大。STRICT 模式画出了隔离强度的语义上界,FILTER 模式用一段 cBPF 程序实现了生产级的灵活裁决,单向门与叠加语义保证了「只能收紧」的工程可靠性。
动作集里 RET_ERRNO 与 RET_LOG 是工程实践的主力:前者保住兼容性,后者铺灰度路径。黑名单(运行时默认)负责兜底兼容,白名单(关键服务)负责收紧面,两者按服务的暴露等级搭配,而不是二选一。
纵深分工记一张四层图:namespace 管可见性,Capabilities 管特权,LSM 管对象访问,seccomp 管入口。它是四层中最轻的一层——不依赖挂载、不需要特权守护进程、随进程传播——也因此最适合做成「每个服务出厂自带」的默认配置。下一站回到命名空间本身:把 PID、IPC、time 这些「剩下的小兄弟」一次讲完。
读者留言
COMMENTS 暂无还没有留言,来说第一句?