4、系统调用逃逸:系统调用号劫持、参数篡改、间接系统调用、信号处理逃逸

系统调用,说白了就是用户态程序跟内核打交道的唯一通道。沙箱要限制你,最直接的办法就是卡住这个通道。但道高一尺魔高一丈,攻击者自然也会在这上面做文章。我这些年做安全审计,碰到最多的逃逸手法,十有八九都跟系统调用有关。

为什么会这样?因为沙箱再怎么花里胡哨,最终还是要靠系统调用来落地。你想想看,文件读写、进程创建、网络通信,哪样离得开系统调用?所以这块是攻防的必争之地。

4.1 系统调用号劫持

每个系统调用都有一个唯一的编号,比如 x86_64 架构下 open 是 2,read 是 0。沙箱通常会在系统调用入口处检查这个编号,如果不在白名单里就直接拦截。

但这里有个坑——检查点在哪?

我见过一个真实案例,某沙箱在 syscall 指令执行前检查了 rax 寄存器(系统调用号存放处),但攻击者利用竞态条件,在检查通过后、syscall 执行前的一瞬间,通过另一个线程修改了 rax 的值。这就是经典的 TOCTOU(Time-of-Check Time-of-Use)攻击。

核心思路:让沙箱检查时看到一个合法的系统调用号,实际执行时却变成了另一个。

具体手法包括:

  • 多线程篡改:一个线程循环执行 syscall,另一个线程在检查通过后立即修改寄存器
  • 信号中断:利用信号处理函数在检查点和执行点之间修改上下文
  • 调试寄存器:通过 ptrace 或硬件断点来篡改执行流

嗯,这里要注意,现代内核已经对 syscall 指令做了不少加固,比如 syscall 会原子性地加载 MSR 寄存器中的值,但用户态到内核态的切换过程中,仍然存在微小的窗口期。

4.2 参数篡改

有时候系统调用号是合法的,但参数被动了手脚。沙箱可能只检查了系统调用号,却忽略了参数的有效性。

举个例子,open 系统调用(号 2)的第一个参数是文件路径指针。沙箱可能检查了这个指针指向的字符串是否在白名单内。但攻击者可以这么做:

// 攻击代码示例
char *safe_path = "/tmp/allowed_file";
char *evil_path = "/etc/shadow";

// 让指针先指向安全路径
char **path_ptr = &safe_path;

// 系统调用检查通过后,立即修改指针指向
// 这里用信号处理来模拟
signal(SIGALRM, handler);
ualarm(1, 0);  // 1微秒后触发信号

// 在信号处理函数中修改 path_ptr 指向 evil_path
syscall(SYS_open, *path_ptr, O_RDONLY);

我在项目中遇到过类似的案例。一个容器逃逸漏洞,攻击者利用 openat 系统调用的 dirfd 参数,传入一个指向宿主机文件系统的文件描述符,绕过了路径检查。说白了,沙箱只检查了路径字符串,却没检查文件描述符的来源。

我的建议:检查系统调用参数时,不仅要检查值本身,还要检查值的来源和上下文。比如文件描述符是否来自沙箱内部,指针是否指向用户态可写内存等。

4.3 间接系统调用

直接调用 syscall 指令太显眼了,沙箱很容易在指令层面做 hook。那怎么办?绕个弯子呗。

间接系统调用的思路就是:不直接执行 syscall,而是通过其他途径触发内核执行系统调用。常见手法有:

  • vsyscall/vDSO 页面:内核映射到用户态的一个特殊页面,里面包含了部分系统调用的实现。攻击者可以直接跳转到这个页面执行,绕过沙箱对 syscall 指令的监控
  • 信号处理:某些系统调用(如 rt_sigreturn)可以通过信号处理机制间接触发
  • 异常处理:故意触发一个异常(如除零、段错误),内核在处理异常时可能会调用系统调用
  • 门机制:在 x86 架构下,通过 call gate 或 int 0x80 等传统方式进入内核

你想想看,沙箱如果在 syscall 指令处下了断点,攻击者直接跳转到 vDSO 里的 __kernel_vsyscall 函数,沙箱就傻眼了。因为 vDSO 是内核提供的合法代码,沙箱总不能把内核代码也 hook 了吧?

注意:vDSO 中的系统调用实现通常包含 syscall 指令,但位置和调用方式可能与常规路径不同。沙箱如果只 hook 了 syscall 指令的固定地址,就可能被绕过。

4.4 信号处理逃逸

信号处理这块,说实话,是很多沙箱设计者容易忽略的盲区。信号处理函数运行在用户态,但它的执行上下文是由内核管理的。这就给了攻击者可乘之机。

我记得有一次做渗透测试,目标沙箱对系统调用做了严格的过滤,但允许注册信号处理函数。我注册了一个信号处理函数,然后在主循环中不断触发这个信号。信号处理函数里执行了 syscall,但沙箱的检查逻辑在信号处理上下文中失效了。

为什么会这样?因为沙箱的检查机制可能只覆盖了主执行流,没有覆盖信号处理函数的执行流。或者信号处理函数执行时,沙箱的上下文已经被破坏了。

常见的信号处理逃逸手法:

手法 原理 绕过对象
SIGRETURN 攻击 利用 rt_sigreturn 系统调用恢复被篡改的寄存器上下文 寄存器检查、系统调用号过滤
信号栈切换 在信号处理函数中切换到自定义栈,执行恶意代码 栈检查、代码段保护
信号嵌套 在信号处理函数中触发另一个信号,形成嵌套调用 单层检查逻辑
SIGSYS 欺骗 让沙箱误以为系统调用被拦截,实际通过信号处理执行了 系统调用拦截逻辑

我曾经遇到过一个特别巧妙的案例。攻击者注册了一个 SIGSYS 信号处理函数(这个信号是当系统调用被 seccomp 拦截时触发的)。然后他故意执行一个被禁止的系统调用,seccomp 触发 SIGSYS,信号处理函数中却执行了另一个系统调用。因为 seccomp 认为已经拦截了,不会再检查,结果就被绕过了。

避坑指南:我曾经在实现沙箱时,只检查了 syscall 指令处的系统调用号,没考虑信号处理函数中的系统调用。结果被攻击者用 SIGRETURN 攻击轻松绕过。后来我学乖了,在信号处理函数的入口和出口都加了检查点。

4.5 综合防御建议

说了这么多攻击手法,总得给点防御建议。我个人习惯从这几个维度来加固:

  1. 检查点全覆盖:不仅在 syscall 指令处检查,还要在信号处理函数入口、vDSO 调用点、异常处理路径都加上检查
  2. 参数深度校验:不光检查系统调用号,还要检查参数的有效性、来源、上下文一致性
  3. 状态锁定:在检查通过后到系统调用执行前,锁定相关寄存器和内存,防止被篡改
  4. 信号处理隔离:对信号处理函数中的系统调用做更严格的限制,甚至禁止某些敏感系统调用
  5. 使用 seccomp-bpf:利用 BPF 程序的灵活性,在系统调用入口处做细粒度的过滤

嗯,说到底,系统调用逃逸这块,攻防双方都在不断进化。沙箱设计者要时刻保持警惕,把每个可能的入口都堵死。而作为安全工程师,理解这些手法,才能在实战中快速定位问题。

我记得有一次,一个同事问我:「为什么我加了 seccomp 还是被绕过了?」我让他检查一下信号处理函数,结果发现他忘了在信号处理上下文中也应用 seccomp 规则。这种细节,往往就是成败的关键。