沙箱机制概览: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 的号,攻击者可以通过切换架构来绕过。
典型的绕过手法是:
- 程序运行在 x86_64 模式,seccomp 禁了 59 号(execve)
- 攻击者通过
int 0x80切换到 i386 模式 - 此时系统调用号 11 对应 execve,但 seccomp 规则没检查这个号
- 绕过成功
所以,写 seccomp 规则时一定要检查 arch 字段。正确的做法是:先判断架构,再判断系统调用号。不同架构分别处理。
6. 知识体系总览
下面这张图把 seccomp 的核心脉络串起来了:
7. 实战中的避坑指南
最后分享几个我踩过的坑:
- BPF 指令数限制:内核默认最多 4096 条 BPF 指令。规则太复杂时会设置失败。我见过有人写了个 5000 条的白名单,结果 prctl 直接返回 EINVAL。
- 架构检查不能省:前面说了,不检查 arch 等于给攻击者留后门。哪怕题目只跑 x86_64,也要把架构检查写进去。
- seccomp 是进程级别的:设置后子进程会继承规则。如果你 fork 了子进程,子进程也受同样的限制。
- SECCOMP_RET_TRAP 的妙用:除了 ALLOW 和 KILL,还可以返回 TRAP。这会让内核向进程发送 SIGSYS 信号。调试时很有用——你能在信号处理函数里打印出被拦截的 syscall 号。