过去,拦截 Linux 系统调用通常使用 ptrace。虽然 ptrace 更常用于调试目的,但可以利用 PTRACE_SYSCALL(甚至 PTRACE_SYSEMU)等待被跟踪进程进行系统调用,然后发送 PTRACE_GETREGS 和 PTRACE_SETREGS 来读取和写入与系统调用相关的寄存器,从而轻松监控系统调用。
尽管 Linux 内核始终具备监控、伪造、修改和限制系统调用的功能,但 ptrace 的一个明显问题是速度非常慢,因为它在每次系统调用时都会停止两次(除非使用 PTRACE_SYSEMU),并且无法以原生方式过滤特定的系统调用集。情况更糟的是,读写寄存器非常繁琐,并且很快就会遇到特定于体系结构的怪癖。
这就是 seccomp 用户通知发挥作用的地方,Christian Brauner 最近取得的进展使得能够以更优雅的方式拦截系统调用。由于添加了 BPF,可以对其进行编程以仅针对所需的系统调用返回,从而显著降低性能损失,并且被跟踪程序的未受影响部分几乎就像没有附加任何跟踪器一样运行。这与 strace 使用 --seccomp-bpf 选项作为减轻性能开销的方式类似,尽管它仍然使用 ptrace 作为主要功能。
**用例**
几年前,我编写了一个名为 copycat 的工具,它使用此机制动态拦截受监控进程所做的所有 open() 样式的系统调用,并根据某些规则返回请求的文件或完全不同的伪造文件。这在某些情况下非常有用,例如,当程序被硬编码为使用特定位置的配置文件时,但您希望使用其他位置。
打开文件的替换对应用程序完全透明,并且可以轻松地使用简单的环境变量进行配置。例如,以下代码段将欺骗 cat 输出 /tmp/b 而不是 /tmp/a:
```
COPYCAT="/tmp/a /tmp/b" copycat -- cat /tmp/a
```
实际上,幕后发生的事情比仅仅拦截系统调用要复杂得多。首先,还需要将文件描述符直接注入到被跟踪进程的文件描述符表中,否则伪造的文件仅在跟踪器进程中有效。
**seccomp unotify**
最初,seccomp 用户通知旨在用于容器用例,但我们可以同样轻松地将其用于普通进程,方法是采用古老的 fork+exec 模式。子进程只需使用 SECCOMP_SET_MODE_FILTER 注册 seccomp 过滤器,然后执行目标应用程序,而父进程充当主管并使用特殊的 SECCOMP_IOCTL_NOTIF_RECV 标志重复 ioctl 循环,该标志将在受监控进程尝试匹配的系统调用时产生。
要使此方法正常工作,必须满足一些特殊先决条件。首先,子进程需要放弃所有权限:
```c
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
```
这是必要的,否则非特权进程可能会使用附加了恶意过滤器的 setuid 程序执行 execve。
例如,此类恶意过滤器可能会导致尝试使用 setuid 将调用方的用户 ID 设置为非零值,以改为返回 0,而实际上并未进行系统调用。在主管端,由于内核错误,我们必须进行一些额外的检查,该错误不会在受监控进程退出时通知主管。从 Linux 6.11 开始,该错误已解决,因此在这种情况下,SECCOMP_IOCTL_NOTIF_RECV 循环就足够了,并且在子进程终止时将返回 ENOENT。但是,在旧版本的内核上,该 ioctl 会永远挂起,因此一个简单的解决方法是为 SIGCHLD 安装信号处理程序,使用 sigaction。请记住,只需在其中使用异步信号安全函数,特别是不要进行分配或锁定。或者,可以轮询注册 BPF 过滤器时返回的文件描述符。
最后,当主管处理通过 SECCOMP_IOCTL_NOTIF_RECV 收到的被拦截的系统调用时,struct seccomp_notif *req 包含所有系统调用的参数,作为其 data.args 数组的一部分。但事实并非完全如此,因为虽然适合一个寄存器的参数通常可以直接看到,但较大的参数(例如要打开的文件名)作为指针传递。因此,我们在此阶段获得的所有信息都是一个无用的指针,指向另一个进程的内存中。
```c
long syscall(SYS_open, const char *pathname, int flags, mode_t mode)
```
因此,我们最终不得不打开 /proc/$PID/mem 才能读取路径名。幸运的是,我们不会遇到 yama 安全策略的任何问题,因为 seccomp 操作已经要求我们在主管和受监控进程之间预定义关系,在这种情况下,这意味着一个是另一个的父进程。如果您觉得这很笨拙,那么当您考虑仅通过 PID 读取内存时,所有 TOCTOU 机会时,请稍等。在此 seccomp 可以帮助我们:只要我们在继续系统调用之前读取它,并使用 SECCOMP_IOCTL_NOTIF_ID_VALID 确认通知 ID 仍然有效,我们就安全了。
现在我们有了所有系统调用参数,我们可以决定是否允许它或修改它并使用不同的文件返回它。在这两种情况下,都需要使用 struct seccomp_notif_resp *resp 参数。如果我们想正常允许系统调用,则可以将其 flags 字段设置为 SECCOMP_USER_NOTIF_FLAG_CONTINUE,并使用 SECCOMP_IOCTL_NOTIF_SEND 发送回响应。
```c
resp->flags |= SECCOMP_USER_NOTIF_FLAG_CONTINUE;
resp->error = 0;
resp->val = 0;
ret = ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, resp)
```
但是,如果我们想修改系统调用参数,则会变得稍微复杂一些。在这种情况下,我们需要在主管端打开伪造的文件,假装最初打算的系统调用已成功,并将伪造文件的描述符号返回给被跟踪进程。除了存在一个巨大的问题,即文件描述符显然在目标进程中无效。
好奇的读者可能会想知道为什么我们不只是将系统调用参数重写为伪造的文件,然后让进程正常继续系统调用。同样,参数指向被跟踪进程内的内存。重写内存对进程来说不是透明的,但注入文件描述符是。这就是 SECCOMP_IOCTL_NOTIF_ADDFD 发挥作用的地方。它将原子地将文件描述符直接安装到目标进程的文件描述符表中,并将其作为系统调用的一部分返回。
```c
struct seccomp_notif_addfd addfd = {};
addfd.id = req->id;
addfd.flags = SECCOMP_ADDFD_FLAG_SEND;
addfd.srcfd = ret;
resp->error = 0;
resp->val = ret
ret = ioctl(listener, SECCOMP_IOCTL_NOTIF_ADDFD, &addfd);
close(addfd.srcfd);
```
在注入文件描述符后,我们可以在我们这边简单地关闭它。
**BPF 过滤器**
就目前而言,我们已经忽略了最重要的细节,即决定我们是否要拦截系统调用的 BPF 过滤器。我们始终可以使用 SECCOMP_USER_NOTIF_FLAG_CONTINUE 从我们的处理程序中正常继续系统调用,但 BPF 过滤器对于首先跳过此昂贵的往返过程至关重要。
虽然 eBPF 最近在跟踪和分析领域声名鹊起,但 seccomp 使用原始的未扩展 Berkeley 数据包过滤器。这两个指令集非常相似,Linux 内核实际上在内部将 BPF 转换为 eBPF 表示形式。由于 BPF 过滤器在内核空间中运行,因此会执行静态检查以确保它们不会崩溃并且会终止。对于无环程序,解决停机问题没有困难,因此 eBPF 验证器使用简单的深度优先搜索来检查这一点,即不允许循环。对于大多数体系结构,内核还可以将 eBPF JIT 编译为本地机器代码。
本质上,BPF 指令集有两个寄存器,A 和 X,但内核 C 定义将它们称为 BPF_K 和 BPF_X。我们可以使用 BPF_LD 指令(例如,使用 BPF_W 为 32 位宽)将数据加载到这些寄存器中,并且 BPF_JMP 指令允许我们根据将寄存器值与给定值进行比较来跳转。例如,BPF_JUMP(BPF_JMP+BPF_JGE+BPF_X, 42, jt, jf) 将增加指令指针 jt,如果 X 寄存器中的值大于或等于 42。否则,它将增加 jf。在每个指令之后,指令指针也将进一步增加 1。
使用 BPF_RET 指令,我们终于可以返回值,内核随后将使用该返回值来决定如何处理系统调用。因此,拦截特定长度为 len 的系统调用号集的 BPF 过滤器如下所示:
```c
int trap_syscalls(const int *nrs, size_t len, unsigned int flags) {
struct sock_filter filter[MAX_FILTER_SIZE];
int i = 0;
filter[i++] = BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, arch));
filter[i++] = BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, AUDIT_ARCH_X86_64, 0, 2);
filter[i++] = BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, nr));
filter[i++] = BPF_JUMP(BPF_JMP+BPF_JGE+BPF_K, X32_SYSCALL_BIT, 0, 1);
filter[i++] = BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL_PROCESS);
for (int j = 0; j < len; ++j) {
filter[i++] = BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, nrs[j], len - j, 0);
}
filter[i++] = BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW);
filter[i++] = BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_USER_NOTIF);
struct sock_fprog prog = {
.len = (unsigned short) i,
.filter = filter,
};
return seccomp(SECCOMP_SET_MODE_FILTER, flags, &prog);
}
```
这里有很多内容需要解释,所以让我们逐一介绍各个 BPF 指令。
```c
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, 0, 2)
```
前两个指令加载体系结构并检查它是否符合我们的预期。要了解为什么这很重要,我们可以直接参考官方文档中的常见陷阱部分:
在支持多种系统调用调用约定的任何体系结构上,系统调用号可能会根据特定的调用而有所不同。如果不同调用约定中的编号重叠,则过滤器中的检查可能会被滥用。始终检查 arch 值!接下来的两条指令检查类似的东西:
```c
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, nr))
BPF_JUMP(BPF_JMP+BPF_JGE+BPF_K, X32_SYSCALL_BIT, 0, 1)
```
arch 字段实际上并非对所有调用约定都是唯一的。例如,x86-64 ABI 和 x32 ABI 都使用 AUDIT_ARCH_X86_64,因此区分它们唯一的方法是检查 __X32_SYSCALL_BIT 是否已设置。此外,如果仅根据系统调用的确切 nr 拒绝系统调用,则恶意程序可以简单地设置 __X32_SYSCALL_BIT 以绕过此过滤器。
如果任何一项检查失败,跳转位置将是以下 BPF 指令,这会导致执行系统调用的进程立即终止。
```c
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL_PROCESS)
```
现在,所有有趣的样板都已完成,我们终于可以为所有传递的系统调用号插入 BPF 指令,以检查我们是否要拦截或仅仅传递该特定系统调用。
```c
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, nrs[j], len - j, 0)
```
跳转到拦截指令实际上是一个包含越界错误的混乱区域:跳转为真的分支实际上更像是 (len - 1) - j + 2 - 1。拦截指令是 for 循环后的第二个指令,因此我们必须从当前索引 j 相对跳转到 for 循环的末尾(索引为 len - 1),然后跳转到第二个指令,但再次减去 1,因为 BPF 在每个指令后都会自动将指令指针递增 1。
然后,最后的指令是从所有先前检查中的跳转目标。
```c
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW)
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_USER_NOTIF)
```
如果 for 循环中的任何检查都不匹配,则第一个指令将被执行,并简单地正常允许系统调用。第二个指令启动系统调用的拦截,并将返回到我们用户空间处理程序,该处理程序在带有 SECCOMP_IOCTL_NOTIF_RECV 标志的 ioctl() 循环中等待。为了安装 BPF 过滤器,我们只需在最后使用 SECCOMP_SET_MODE_FILTER。有关更完整的图片,请自行查看源代码,特别是 seccomp_exec.c 用于处理系统调用,seccomp_trap.c 用于注册 BPF 过滤器。Linux 内核源代码树中还有一个较小的示例,可以帮助您入门。
最后,应该强调的是,永远不要使用 seccomp unotify 来实现安全策略决策。这里隐藏的 TOCTOU 攻击会使这变得不可能,例如,如果主管发出 SECCOMP_USER_NOTIF_FLAG_CONTINUE 信号,系统调用实际上将继续,但进程仍然有机会在实际运行之前重写系统调用参数。但是,它仍然是一个非常棒的工具,可以以最小的性能影响拦截系统调用。