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 规则,通常包含这几步:

  1. 加载架构信息:检查是不是 x86_64,防止 32 位绕过
  2. 加载系统调用号:从 seccomp_data 中取出 nr 字段
  3. 判断并处理:根据系统调用号决定 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 的整个知识体系串起来了:

seccomp 知识结构图 seccomp 知识体系 seccomp prctl 系统调用 seccomp() 系统调用 prctl + seccomp 严格模式 Filter 模式 BPF 规则 架构检查 系统调用号匹配 返回值决策 KILL ERRNO TRAP ALLOW TRACE 核心流程 进程调用 prctl → 加载 BPF 规则 → 每次系统调用触发 BPF 检查 → 返回 ALLOW/KILL 等

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 逻辑,决定放行还是干掉。