学术研究报告:基于二进制重写的系统调用挂钩机制 Zpoline
本报告介绍一项发表于2023年USENIX年度技术会议(USENIX Annual Technical Conference,USENIX ATC)的研究,论文题为“Zpoline: A System Call Hook Mechanism Based on Binary Rewriting”。该研究由Kenichi Yasukata、Hajime Tazaki和Pierre-Louis Aublin(均来自IIJ Research Laboratory)以及Kenta Ishiguro(来自Hosei University)共同完成。
一、研究背景与目的
本研究属于操作系统与系统软件领域,具体关注于系统调用(System Call)挂钩机制。系统调用是用户空间程序与操作系统内核交互的核心接口,因此,能够在系统调用层面进行追踪或修改行为的“挂钩”(Hook)技术,对于实现程序追踪、沙箱、操作系统仿真、兼容性支持等众多应用至关重要。特别是,随着用户空间操作系统子系统(如高性能用户态网络协议栈、文件系统等)的兴起,研究者希望能有一种透明的方式,将现有的、未经修改的遗留软件无缝地“嫁接”到这些高性能的用户态子系统上,从而充分利用其性能优势。实现这种透明嫁接的关键,就是一个理想的系统调用挂钩机制。
然而,在广泛使用的x86-64架构的Unix-like系统中,现有的各种系统调用挂钩机制均存在显著缺陷,无法同时满足实际应用的需求。这些缺陷主要包括:1)挂钩开销巨大,导致被挂钩程序性能严重下降(如ptrace、int3信号、Syscall User Dispatch (SUD));2)无法做到穷尽式挂钩(Exhaustive Hooking),即不能保证捕获程序发起的每一个系统调用,从而在需要高可靠性的系统中存在风险(如LD_PRELOAD、以及部分二进制重写技术);3)在进行二进制重写时,会覆盖或修改原本不应被修改的指令,可能破坏程序语义;4)需要修改内核或加载额外内核模块,损害了系统的可移植性;5)需要用户程序的源代码,这在很多场景下不可行;6)依赖特殊修改的标准库,限制了库的选择范围且无法挂钩库外部直接发起的系统调用;7)无法在不修改内核源码的前提下,通过如eBPF等技术来改变或仿真系统调用的行为。
因此,本研究旨在解决上述问题,目标是在x86-64平台上,设计并实现一个能够同时克服以上所有缺点的系统调用挂钩机制。这便是Zpoline项目。
二、研究设计与详细流程
Zpoline的核心创新在于其基于二进制重写的巧妙设计,该设计围绕一个关键挑战展开:用于触发系统调用的syscall/sysenter指令长度仅为两个字节,而一条包含任意目标地址的跳转指令(jmp/call)通常需要更多字节(例如,64位绝对地址需要8字节)。传统的二进制重写技术因无法解决此问题,要么放弃部分替换导致非穷尽挂钩,要么占用额外字节破坏邻近指令,要么采用高开销的信号机制。
Zpoline的工作流程主要包括两大核心步骤:跳板代码(Trampoline Code)实例化 和 二进制重写(Binary Rewriting)。整个工作由两个工具实现:一个共享库libzpoline.so(用于动态链接程序)和一个特殊加载器zpoline_loader(用于静态链接程序),它们在用户程序主函数开始前完成设置。
步骤1: 跳板代码实例化 Zpoline首先通过mmap系统调用,在用户进程的虚拟地址0处分配一段内存。随后,在这段内存中构造特殊的跳板代码。构造逻辑基于x86-64的系统调用约定:在触发系统调用前,用户程序必须将系统调用号(例如,read为0,write为1)存入rax寄存器。Zpoline利用了这一约定。 跳板代码的内容是:从地址0开始,一直到系统中定义的最大系统调用号(在Linux 5.15中为448)为止的虚拟地址范围,全部用单字节的nop(空操作)指令(0x90)填充。紧接着最后一条nop指令,放置一小段能够跳转到用户自定义挂钩函数的代码。这样,整个跳板代码区域就像一片由nop构成的“斜坡”,执行流落入其中后会“滑行”至底部的跳转代码。
步骤2: 二进制重写 在跳板代码就绪后,Zpoline开始对目标用户程序的代码进行扫描和重写。它通过解析procfs获取进程的内存映射信息,遍历所有可执行的内存区域(包括主程序、libc、ld.so等共享库),寻找所有的syscall(操作码0x0f 0x05)和sysenter(操作码0x0f 0x34)指令。对于找到的每一条这样的指令,Zpoline将其替换为一条同样为两个字节的指令:callq *%rax(操作码0xff 0xd0)。 callq *%rax指令的行为是:将下一条指令的地址(即返回地址)压栈,然后跳转到rax寄存器所存储的地址处。根据系统调用约定,此时rax中存放的正是系统调用号。因此,执行这条指令的结果就是:程序会跳转到以该系统调用号为地址的虚拟内存位置——这正是之前布置好的跳板代码区域的某个nop指令处。
执行流程 当重写后的程序执行到原本是syscall的地方时,实际执行的是callq *%rax。假设程序要调用read(系统调用号0),rax为0,则执行流会跳转到虚拟地址0。由于地址0处是nop,执行流会顺序“滑过”后续的nop,最终到达跳板代码底部的跳转指令,从而进入用户定义的挂钩函数。挂钩函数执行完毕后,通过之前callq *%rax压入栈中的返回地址,可以正确地返回到原调用点。整个过程完全在用户态进行,无需陷入内核,且保证了所有通过syscall/sysenter触发的调用都能被捕获,实现了穷尽式挂钩。
关键技术实现细节
dlmopen函数将挂钩函数的核心实现加载到一个独立的链接器命名空间中,从而隔离了符号绑定,解决了递归调用问题。syscall/sysenter指令的地址。在挂钩函数入口处,通过检查调用者地址是否在位图中,来判定是否为合法的callq *%rax跳转,否则视为非法的空指针执行并终止程序。三、主要实验结果与分析
研究者通过微基准测试和应用性能测试,全面评估了Zpoline的性能和有效性,并与现有主流机制(ptrace, int3信号, SUD, LD_PRELOAD)进行了对比。
微基准测试:系统调用挂钩开销 测试测量了挂钩一个最简单的getpid系统调用(挂钩函数直接返回而不执行实际系统调用)所需的时间。结果(单位纳秒)如下:
nop“滑行”。研究者进一步测试了nop数量对开销的影响,显示开销随nop数量线性增长,但即使面对未来可能增加的更多系统调用(如3500个nop),其开销仍远低于其他确保穷尽挂钩的机制。应用性能测试:用户空间OS子系统集成 为了评估Zpoline在真实场景下的价值,研究者将其用于透明地将一个用户态TCP/IP协议栈(lwip,基于DPDK)应用于两个服务器程序:一个简单的HTTP服务器和Redis键值存储。测试比较了在不同挂钩机制下,应用程序与用户态协议栈结合后的性能。作为参考基线,也测试了应用程序使用原生Linux内核TCP/IP协议栈的性能。
四、结论与意义
本研究提出并实现了Zpoline,一种针对x86-64 CPU的、基于二进制重写的系统调用挂钩机制。Zpoline成功地同时实现了七个关键优势:低挂钩开销、穷尽式挂钩、不覆盖不应修改的指令、无需内核修改或额外模块、无需用户程序源码、不依赖特殊修改的标准库、可用于系统调用仿真。这些优势是现有任何单一机制所无法同时具备的。
其实验结果表明,Zpoline的挂钩开销比现有的、能保证穷尽挂钩且不破坏指令的机制低1-2个数量级。在将用户态OS子系统(如高性能网络栈)透明应用到现有程序的实际场景中,Zpoline带来的性能损失微乎其微(5-13%),而现有机制则会导致严重的性能退化(72-99%)。因此,Zpoline首次提供了一个实用的、高性能的手段,能够透明地将用户态OS子系统桥接到遗留软件上,极大地扩展了用户态OS子系统的适用范围和实用价值,有望推动高性能用户态系统软件的更广泛应用。
五、研究的亮点与创新
syscall/sysenter替换为callq *%rax,并利用系统调用约定(rax存放系统调用号)和预置在地址0的跳板代码,完美解决了用短指令跳转到任意地址的难题。这是本研究最核心的创新点。dlmopen解决命名空间冲突,通过XOM和位图检查有效维持了空指针访问的保护语义,且这些保护机制引入的开销极低。六、其他说明与局限性
研究也坦诚地讨论了Zpoline的局限性,包括:无法挂钩在Zpoline设置完成后才动态加载或生成的syscall指令(但可通过在线重写技术解决);默认无法挂钩通过vdso(虚拟动态共享对象)提供的系统调用(可通过禁用vdso解决);依赖于虚拟地址0可用,因此在某些系统(如默认无法映射地址0的OpenBSD、地址0被__PAGEZERO占用的macOS)上不可用;其设计依赖于x86-64指令对齐相对宽松的特性,故不直接适用于对指令对齐有严格要求的CPU架构(如ARM)。尽管如此,考虑到x86-64在服务器市场的主导地位,Zpoline仍具有极高的实用价值。
Zpoline是一项在系统软件挂钩技术领域具有显著创新性和高度实用价值的研究工作,为高性能用户态系统与现有软件生态的融合打开了新的通道。