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 综合防御建议
说了这么多攻击手法,总得给点防御建议。我个人习惯从这几个维度来加固:
- 检查点全覆盖:不仅在
syscall指令处检查,还要在信号处理函数入口、vDSO 调用点、异常处理路径都加上检查 - 参数深度校验:不光检查系统调用号,还要检查参数的有效性、来源、上下文一致性
- 状态锁定:在检查通过后到系统调用执行前,锁定相关寄存器和内存,防止被篡改
- 信号处理隔离:对信号处理函数中的系统调用做更严格的限制,甚至禁止某些敏感系统调用
- 使用 seccomp-bpf:利用 BPF 程序的灵活性,在系统调用入口处做细粒度的过滤
嗯,说到底,系统调用逃逸这块,攻防双方都在不断进化。沙箱设计者要时刻保持警惕,把每个可能的入口都堵死。而作为安全工程师,理解这些手法,才能在实战中快速定位问题。
我记得有一次,一个同事问我:「为什么我加了 seccomp 还是被绕过了?」我让他检查一下信号处理函数,结果发现他忘了在信号处理上下文中也应用 seccomp 规则。这种细节,往往就是成败的关键。