沙箱机制概览:Linux seccomp 原理与 BPF 规则编写

说到 Pwn 题里的沙箱,seccomp 绝对是绕不过去的一道坎。我刚开始接触 CTF 时,总觉得这玩意儿就是个黑盒子——明明拿到了 shell,却连 open 都调不了,憋屈得很。后来啃了内核源码才明白,seccomp 说白了就是给系统调用装了个安检门。

这一章我们就把这个安检门拆开看看。从原理到实践,把 seccomp 的底裤扒干净。

1. seccomp 是什么?

seccomp(Secure Computing Mode)是 Linux 内核从 2.6.12 版本开始引入的安全机制。它的核心功能很简单:限制进程能调用的系统调用。

你想想看,一个普通用户态程序能调用的 syscall 有三百多个。攻击者拿到 shell 后,随便调个 execve 就能起新进程,调个 open 就能读 flag。seccomp 就是用来掐断这些路径的。

核心思想:seccomp 通过在内核态拦截系统调用,在调用真正执行前做一次策略检查。符合规则的放行,不符合的直接拒绝或杀死进程。

seccomp 有两种工作模式:

模式描述典型场景
严格模式(SECCOMP_MODE_STRICT)只允许 read、write、exit、sigreturn 四个系统调用早期 Chrome 沙箱、简单计算类题目
过滤模式(SECCOMP_MODE_FILTER)通过 BPF 规则自定义允许/拒绝的 syscall绝大多数 CTF Pwn 题、容器安全

严格模式太死板了,实际 CTF 里几乎见不到。我们重点聊过滤模式。

2. prctl 系统调用——seccomp 的入口

seccomp 的配置入口是 prctl 系统调用。原型长这样:

#include <sys/prctl.h>

int prctl(int option, unsigned long arg2, unsigned long arg3,
          unsigned long arg4, unsigned long arg5);

和 seccomp 相关的 option 有两个:

  • PR_SET_SECCOMP:设置 seccomp 策略
  • PR_GET_SECCOMP:查询当前 seccomp 状态

设置过滤模式时,arg2 传 SECCOMP_MODE_FILTER,arg3 传一个 struct sock_fprog 指针,里面装着 BPF 规则。

避坑指南:我曾经在写 exploit 时忘了 prctl 的返回值检查。seccomp 设置失败时 prctl 返回 -1,但程序不会 crash。结果我的 payload 在本地跑得好好的,远程却一直报错——因为远程内核版本低,不支持某些 BPF 指令。所以,生产环境一定要检查 prctl 返回值。

3. BPF 规则编写——seccomp 的灵魂

BPF(Berkeley Packet Filter)原本是网络包过滤用的。Linux 把它借来当 seccomp 的规则语言。说白了,就是一段运行在内核态的小程序,对每个系统调用做判断。

一个 BPF 规则由若干条指令组成,每条指令 8 字节:

struct sock_filter {
    __u16 code;     // 操作码
    __u8  jt;       // 条件为真时跳转偏移
    __u8  jf;       // 条件为假时跳转偏移
    __u32 k;        // 通用参数
};

seccomp BPF 的输入是 struct seccomp_data,里面最重要的字段是 nr(系统调用号)和 arch(架构标识)。

来看一个最简单的例子——只允许 read、write、exit:

struct sock_filter filter[] = {
    /* 加载系统调用号 */
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    /* 检查是否为 read (0) */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0, 4, 0),
    /* 检查是否为 write (1) */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 1, 3, 0),
    /* 检查是否为 exit (60) */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 60, 2, 0),
    /* 拒绝 */
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
    /* 允许 */
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
    .len = sizeof(filter) / sizeof(filter[0]),
    .filter = filter,
};

这段代码的逻辑很直白:加载系统调用号,依次和 0、1、60 比较。匹配了就跳到 ALLOW,否则 KILL。

注意:BPF 指令的跳转偏移是相对于当前指令的下一条。上面代码里,read 匹配后跳 4 条,实际上跳到了最后的 ALLOW。写 BPF 规则时跳转偏移算错是最常见的 bug,我至少在这上面栽过三次。

4. 白名单 vs 黑名单策略

seccomp 策略有两种设计思路:

策略做法优点缺点
白名单默认拒绝所有,只放行明确允许的 syscall安全性高,攻击面小规则编写繁琐,容易漏掉必要调用
黑名单默认允许所有,只拦截明确禁止的 syscall兼容性好,开发成本低容易遗漏危险调用,安全性差

我个人习惯在 CTF 题目里用白名单。原因很简单:黑名单总有漏网之鱼。比如你禁了 execve,但攻击者可能用 execveat 绕过。白名单则是一刀切——没写进规则的,一个都别想调。

不过白名单也有坑。我记得有一次写题目,白名单里只放了 read、write、open、mmap。结果选手的 exploit 一跑就崩——我忘了放 brk,glibc 的 malloc 底层依赖它。嗯,这种低级错误,犯过一次就记住了。

5. 架构感知——异架构绕过的关键

seccomp 检查时,seccomp_data 里有个 arch 字段,标识当前进程的 CPU 架构。比如 x86_64 是 AUDIT_ARCH_X86_64(0xC000003E),i386 是 AUDIT_ARCH_I386(0x40000003)。

为什么要关注这个?因为同一个系统调用号在不同架构下含义不同。比如 x86_64 的 execve 是 59,但在 i386 下是 11。如果 seccomp 规则只检查了 x86_64 的号,攻击者可以通过切换架构来绕过。

典型的绕过手法是:

  1. 程序运行在 x86_64 模式,seccomp 禁了 59 号(execve)
  2. 攻击者通过 int 0x80 切换到 i386 模式
  3. 此时系统调用号 11 对应 execve,但 seccomp 规则没检查这个号
  4. 绕过成功

所以,写 seccomp 规则时一定要检查 arch 字段。正确的做法是:先判断架构,再判断系统调用号。不同架构分别处理。

6. 知识体系总览

下面这张图把 seccomp 的核心脉络串起来了:

seccomp 知识体系 用户态程序 prctl(PR_SET_SECCOMP, ...) BPF 规则(struct sock_fprog) 白名单:默认拒绝 黑名单:默认允许 架构检查(arch 字段) 关键点:先判断架构,再判断系统调用号,防止异架构绕过

7. 实战中的避坑指南

最后分享几个我踩过的坑:

  • BPF 指令数限制:内核默认最多 4096 条 BPF 指令。规则太复杂时会设置失败。我见过有人写了个 5000 条的白名单,结果 prctl 直接返回 EINVAL。
  • 架构检查不能省:前面说了,不检查 arch 等于给攻击者留后门。哪怕题目只跑 x86_64,也要把架构检查写进去。
  • seccomp 是进程级别的:设置后子进程会继承规则。如果你 fork 了子进程,子进程也受同样的限制。
  • SECCOMP_RET_TRAP 的妙用:除了 ALLOW 和 KILL,还可以返回 TRAP。这会让内核向进程发送 SIGSYS 信号。调试时很有用——你能在信号处理函数里打印出被拦截的 syscall 号。