3. seccomp 规则分析:从 dump 到自定义编写

seccomp 规则分析,说白了就是搞清楚目标程序到底禁用了哪些系统调用。我刚开始接触 Pwn 题时,总觉得这步可有可无,直到有一次在 ARM64 的题目里栽了跟头——明明本地能打通,远程就是不行。后来一查 seccomp 规则,好家伙,open 被 ban 了,但 openat 还活着。嗯,从那以后我再也不敢跳过规则分析了。

3.1 用 seccomp-tools dump 规则

seccomp-tools 是我个人最常用的工具,没有之一。它能把内核里的 BPF 字节码反编译成人类可读的规则列表。安装很简单:

# 安装 seccomp-tools
$ gem install seccomp-tools

# 或者从源码编译
$ git clone https://github.com/david942j/seccomp-tools
$ cd seccomp-tools
$ make install

拿到一个二进制文件后,我习惯先看看它有没有 seccomp 保护:

$ seccomp-tools dump ./pwn_binary
# 输出示例
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x06 0xc000003e  if (A == AUDIT_ARCH_X86_64) goto 0008
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x15 0x00 0x03 0x0000003b  if (A == execve) goto 0007
 0004: 0x15 0x00 0x02 0x00000039  if (A == fork) goto 0007
 0005: 0x15 0x00 0x01 0x0000003a  if (A == vfork) goto 0007
 0006: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0007: 0x06 0x00 0x00 0x00000000  return KILL
 0008: 0x06 0x00 0x00 0x7fff0000  return ALLOW

这段输出什么意思?说白了就是:

  • 先检查架构,如果不是 x86_64 就直接放行(防止架构逃逸)
  • 如果是 x86_64,检查系统调用号
  • 遇到 execve、fork、vfork 就杀掉进程
  • 其他调用全部放行
小技巧: 我经常用 seccomp-tools dump ./pwn_binary 2>/dev/null 过滤掉 stderr 的干扰信息,只保留规则输出。

3.2 用 seccomp-tools 解析规则

光看 dump 还不够,有时候我们需要更结构化的信息。seccomp-tools 提供了 seccomp-tools disasm 子命令,能把 BPF 字节码反汇编成更清晰的格式:

$ seccomp-tools disasm ./pwn_binary
# 输出示例
A = arch
if (A != AUDIT_ARCH_X86_64) goto ALLOW
A = sys_number
if (A == execve) goto KILL
if (A == fork) goto KILL
if (A == vfork) goto KILL
ALLOW: return ALLOW
KILL: return KILL

我个人更喜欢这种格式,因为它去掉了行号和偏移量,直接展示逻辑分支。你想想看,如果规则有几十条,用 dump 看会眼花,但用 disasm 就能一眼看出哪些调用被 ban 了。

还有一个更高级的用法——seccomp-tools visualize,它能生成规则的流程图。不过这个功能依赖 Graphviz,需要先安装:

$ sudo apt-get install graphviz
$ seccomp-tools visualize ./pwn_binary -o rule.png

嗯,这里要注意:visualize 生成的图片是静态的,对于复杂规则(比如有嵌套条件判断的),图片会变得很乱。我一般只在规则少于 20 条时用这个功能。

3.3 自定义 seccomp 规则编写

分析别人的规则只是第一步,真正的高手要学会自己写规则。我常用的工具有两个:seccomp-tools 自带的 DSL 和 libseccomp 的 C API。

3.3.1 用 seccomp-tools DSL 编写

seccomp-tools 支持一种类似汇编的 DSL,写起来很直观。举个例子,假设我们要写一个规则:只允许 read、write、openat、mmap、exit_group,其他全部杀掉:

# 文件名: my_rule.asm
A = arch
if (A != AUDIT_ARCH_X86_64) goto KILL

A = sys_number
if (A == 0) goto ALLOW    # read
if (A == 1) goto ALLOW    # write
if (A == 257) goto ALLOW  # openat
if (A == 9) goto ALLOW    # mmap
if (A == 231) goto ALLOW  # exit_group
goto KILL

ALLOW: return ALLOW
KILL: return KILL

然后编译成 BPF 字节码:

$ seccomp-tools asm my_rule.asm -o my_rule.bpf

这个 .bpf 文件可以直接被 prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...) 加载。我在项目中遇到过好几次,有些 CTF 题目会要求选手自己构造 seccomp 规则来限制 shellcode 的行为,这时候 DSL 就派上用场了。

3.3.2 用 libseccomp C API 编写

如果你在写 C 代码,用 libseccomp 更合适。它提供了类型安全的接口,不容易写错系统调用号:

#include <seccomp.h>
#include <unistd.h>

int setup_seccomp() {
    scmp_filter_ctx ctx;
    ctx = seccomp_init(SCMP_ACT_KILL);  // 默认杀掉所有调用
    
    // 白名单模式:只允许 read, write, openat, mmap, exit_group
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
    
    // 加载规则
    seccomp_load(ctx);
    seccomp_release(ctx);
    return 0;
}
避坑指南: 我曾经在 ARM64 平台上直接用 x86_64 的系统调用号写规则,结果程序一运行就崩。不同架构的系统调用号完全不同,比如 x86_64 的 open 是 2,但 ARM64 的 open 是 56。写规则前一定要确认目标架构。

3.4 规则编写的高级技巧

写规则不只是列白名单那么简单。我总结了几种常见模式:

模式 说明 适用场景
白名单模式 默认 KILL,只放行特定调用 安全性要求高,功能明确
黑名单模式 默认 ALLOW,只 ban 特定调用 兼容性要求高,只需禁用少数危险调用
参数过滤模式 根据系统调用的参数决定放行或杀死 需要精细控制,比如只允许打开特定文件
架构感知模式 先检查架构,再根据架构执行不同规则 多架构兼容,防止架构逃逸

参数过滤模式比较高级,我举个例子。假设我们只允许打开 /tmp 目录下的文件:

A = arch
if (A != AUDIT_ARCH_X86_64) goto KILL

A = sys_number
if (A != 257) goto CHECK_OTHER  # 不是 openat 就检查其他调用

# 检查 openat 的第二个参数(文件名指针)
# 这里需要读取用户空间内存,seccomp 本身不支持
# 需要用 seccomp_data 结构体的 args 字段
# 但 seccomp-tools DSL 不支持这种高级操作
# 建议用 libseccomp 的 seccomp_rule_add_exact 配合 SCMP_FLTATR_CTL_OPTIMIZE

CHECK_OTHER:
# ... 其他规则
核心要点: seccomp 的 BPF 过滤器只能检查系统调用的编号和参数值(整数),不能读取参数指向的内存内容。所以「只允许打开 /tmp 下的文件」这种需求,seccomp 本身做不到,需要结合其他机制(比如 Landlock、SELinux)来实现。

3.5 规则分析实战:一个典型 CTF 题目

我记得有一次打 CTF,题目给了个 ARM64 的二进制,seccomp 规则 dump 出来是这样的:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x01 0x00 0xc000003e  if (A == AUDIT_ARCH_AARCH64) goto 0003
 0002: 0x06 0x00 0x00 0x00000000  return KILL
 0003: 0x20 0x00 0x00 0x00000000  A = sys_number
 0004: 0x15 0x00 0x03 0x00000038  if (A == 56) goto 0008  # open
 0005: 0x15 0x00 0x02 0x00000039  if (A == 57) goto 0008  # openat
 0006: 0x15 0x00 0x01 0x0000003c  if (A == 60) goto 0008  # exit
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0008: 0x06 0x00 0x00 0x00000000  return KILL

分析一下:

  • 只允许 AARCH64 架构,其他架构直接杀掉
  • ban 掉了 open(56)、openat(57)、exit(60)
  • 其他调用全部放行

嗯,这里有个坑:exit 被 ban 了,但 exit_group(94)还活着。所以 shellcode 里不能用 exit,得用 exit_group。另外,open 和 openat 都被 ban 了,但 read、write、mmap 都还在。这意味着我们可以用 mmap 映射文件到内存,然后直接读内存——前提是文件描述符已经存在。

我当时就是利用了这个漏洞:程序启动时已经打开了 flag 文件(fd=3),虽然 open 被 ban 了,但 read 还能用,直接 read(3, buf, 0x100) 就把 flag 读出来了。

3.6 本章知识结构

下面这张图总结了 seccomp 规则分析的核心流程:

seccomp 规则分析核心流程 获取目标二进制 seccomp-tools dump 解析规则(disasm/visualize) 判断规则模式(白名单/黑名单/参数过滤) 制定绕过策略 常见绕过思路 • 利用未 ban 的替代调用 • 架构逃逸(32位/64位切换) • 利用已打开的文件描述符 • 参数篡改(如 open → openat) 常用工具 • seccomp-tools (dump/disasm/asm) • libseccomp (C API) • strace (运行时跟踪系统调用)

这张图展示了从拿到二进制到制定绕过策略的完整链路。你想想看,每一步都有对应的工具和技巧,缺一不可。

个人习惯: 我每次分析 seccomp 规则时,都会先 dump 出来,然后用 disasm 看逻辑结构,最后用 strace 跑一遍程序确认实际调用了哪些系统调用。三管齐下,基本不会漏掉关键信息。