3. seccomp 机制详解:从原理到 BPF 实战
seccomp 这玩意儿,说白了就是 Linux 内核给你的一道「安检门」。我在 CTF 里见过太多选手,明明拿到了 shell,结果一执行 execve 就被 kill,一脸懵逼。嗯,这就是 seccomp 在干活。
咱们这一章,就把 seccomp 从里到外扒干净。你搞懂了它,后面讲绕过才有底气。
3.1 seccomp 是什么?为什么需要它?
seccomp 全称 Secure Computing Mode,是 Linux 内核从 2.6.12 开始引入的安全机制。它的核心能力就一句话:限制进程能调用的系统调用。
你想想看,一个 Web 服务器,它需要 read、write、open,但绝对不应该能调 ptrace 或 reboot。seccomp 就是干这个的——把攻击面缩到最小。
核心思想:最小权限原则。只给进程它真正需要的系统调用,其他的全部拦截。
在 CTF 的 Pwn 题里,出题人经常用 seccomp 来限制你:
- 不让开新进程(禁止
execve) - 不让读 flag 文件(禁止
open) - 不让调试(禁止
ptrace)
我遇到过一道题,它只允许 read、write、exit 三个调用。当时我拿到 shell 后,发现连 open 都调不了,只能靠 read 从已打开的文件描述符里偷数据。这就是 seccomp 的威力。
3.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 主要有两个:
| option 值 | 作用 | 说明 |
|---|---|---|
PR_SET_SECCOMP |
设置 seccomp 模式 | arg2 指定模式,arg3 指向 BPF 程序 |
PR_GET_SECCOMP |
获取当前 seccomp 模式 | 返回 0(未启用)、1(严格模式)、2(filter 模式) |
我个人习惯用 prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) 来加载 BPF 规则。注意第三个参数是一个 struct sock_fprog 指针,里面装着你的 BPF 程序。
注意:prctl 一旦设置了 seccomp 规则,子进程会继承这些规则。而且规则一旦加载,不能卸载,只能追加更严格的规则。所以写错了就得重来。
3.3 seccomp 的两种模式
seccomp 有两种工作模式,咱们直接看对比:
| 模式 | 宏定义 | 行为 | 适用场景 |
|---|---|---|---|
| 严格模式 | SECCOMP_MODE_STRICT |
只允许 read、write、_exit、sigreturn |
极简沙箱,CTF 中较少见 |
| Filter 模式 | SECCOMP_MODE_FILTER |
通过 BPF 规则自定义允许/拒绝 | CTF 主流,灵活可控 |
严格模式太死板了,CTF 里基本不用。咱们重点看 Filter 模式。
3.4 seccomp-filter 模式:BPF 规则编写
Filter 模式的核心,就是写一段 BPF(Berkeley Packet Filter)程序。没错,就是 tcpdump 抓包用的那个 BPF,内核把它拿来过滤系统调用了。
BPF 程序本质上是一段虚拟机指令,运行在内核空间。每条指令 8 字节,结构如下:
struct sock_filter {
__u16 code; // 指令码
__u8 jt; // 条件为真时跳转偏移
__u8 jf; // 条件为假时跳转偏移
__u32 k; // 通用参数
};
一个完整的 BPF 规则,通常包含这几步:
- 加载架构信息:检查是不是 x86_64,防止 32 位绕过
- 加载系统调用号:从 seccomp_data 中取出 nr 字段
- 判断并处理:根据系统调用号决定 ALLOW 还是 KILL
来看一个实际例子。这个规则只允许 read、write、open、exit:
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
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),
/* 加载系统调用号 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
(offsetof(struct seccomp_data, nr))),
/* 检查 read (0) */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* 检查 write (1) */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* 检查 open (2) */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_open, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* 检查 exit (60) */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* 默认拒绝 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
};
struct sock_fprog prog = {
.len = sizeof(filter) / sizeof(filter[0]),
.filter = filter,
};
这段代码看着长,其实逻辑很简单:
- 先检查架构,不是 x86_64 的直接杀掉
- 然后逐个比对系统调用号
- 匹配上的放行,没匹配的杀掉
小技巧:CTF 里经常用 seccomp-tools 这个工具来 dump 和反编译 BPF 规则。命令是 seccomp-tools dump ./pwn,能看到当前进程的 seccomp 规则长啥样。
3.5 seccomp 的返回值:ALLOW 还是 KILL?
BPF 程序的最后一条指令,必须是一个 RET 指令。返回值决定了内核怎么处理这个系统调用:
| 返回值宏 | 值 | 行为 |
|---|---|---|
SECCOMP_RET_KILL |
0x00000000 | 立即杀死进程(SIGSYS) |
SECCOMP_RET_TRAP |
0x00030000 | 触发 SIGSYS 信号,可被捕获 |
SECCOMP_RET_ERRNO |
0x00050000 + errno | 系统调用返回指定错误码 |
SECCOMP_RET_TRACE |
0x7ff00000 | 通知 ptrace 跟踪者决定 |
SECCOMP_RET_ALLOW |
0x7fff0000 | 放行 |
CTF 里最常用的是 KILL 和 ALLOW。但 ERRNO 也很有意思——我曾经遇到一道题,它把 open 返回 EPERM,而不是直接 kill。这样程序不会崩溃,但你就是打不开文件。这种「软拒绝」有时候比直接 kill 更恶心。
3.6 知识结构总览
下面这张图,把 seccomp 的整个知识体系串起来了:
3.7 避坑指南
我踩过不少 seccomp 的坑,挑几个典型的说说:
坑1:架构检查不能省
我曾经写过一段 BPF,没检查架构。结果 32 位程序用 int 0x80 调了 execve,系统调用号完全不一样,直接绕过了我的规则。从那以后,我每条规则开头必加架构检查。
坑2:BPF 指令数有限制
内核限制 BPF 程序最多 4096 条指令。CTF 里一般够用,但如果你要写一个白名单很长的规则,注意别超了。我见过有人写了 5000 多条,结果 prctl 直接返回 EINVAL。
坑3:seccomp 规则是继承的
父进程设置了 seccomp,子进程 fork 出来也带着同样的规则。而且你只能加严,不能放宽。所以如果你在 shellcode 里想 execve 一个新程序,得确保父进程的规则允许它。
3.8 小结
seccomp 机制,说白了就是内核给你的一道「系统调用防火墙」。你通过 prctl 加载一段 BPF 程序,内核每次拦截系统调用时,就跑一遍你的 BPF 逻辑,决定放行还是干掉。