今天要为大家介绍USENIX ATC 2023的最佳论文奖得主——zpoline: a system call hook mechanism
based on binary rewriting
在我们的印象里面,日本的科研人员似乎不太喜欢也不重视在国际学术会议上发表论文,大概是专注于造高达去了。可是这篇由日本的Internet Initiative Japan也就是IIJ(号称是日本第一家ISP)旗下的研究实验室和位于东京的Hosei University(私立法政大学)的研究人员发表的论文,研究了一个非常经典甚至可以说是老套的问题——怎么有效去对system call进行hook,还拿到了最佳论文,其中必然有点东西,让我们一起看看细节。
首先,作者总结了已有的system call hook机制的问题:
经典的方法(例如
ptrace
或者int3
指令)的性能开销太大(产生了执行的中断,还要系统来处理一堆上下文的问题);一部分基于二进制代码重写(例如2020年PLDI上的工作
E9Patch
,或者用LD_PRELOAD
来进行调用拦截)的技术不能保证100%拦截所有的system call;一部分二进制重写技术完备性不好,甚至没法保证在覆盖原有指令之后,还能让原始的执行流程不受影响地继续。
作者还特意指出,尽管BPF和eBPF已经给内核带来了很大的改善,但是目前在不修改内核代码的情况,还没法做到完善的system call hook(为什么?这里面并不是特别理解,欢迎我们的读者留言评论)。
不过读者可能会问,虽然原有的system call hook机制有一些缺陷,但是毕竟都工作了这么多年,也不是不能用,为啥还要改进。作者举了这么一个例子:在一些性能优化的场景下,通过system call hook把本来要进入kernel的执行流程给拦截下来,转而用一个更高性能的user-space子系统来模拟,可以大大减少从用户态到内核态的切换造成的性能损耗。在这种场景下,如果有更好的system call hook机制,肯定能够进一步提升性能。
本文作者设计了zpoline
这样一个新的system call hook技术,对x86/64的syscall
指令(二进制表示为0x0f 0x05)和sysenter
指令(二进制表示为0x0f 0x34)进行替换。由于这两条指令太短(只有2字节),直接将其重写为jmp/call
指令,就没法把跳转地址写进去(特别是对64位系统)。zpoline
是怎么解决这个问题的呢?它用callq *%rax
这样一条指令(也是两个字节,0xff 0xd0)来替换syscall
和sysenter
(参见下图)。而由于此时RAX寄存器里面存储的是被调用的syscall的编号(从0到最大448,Linux 5.15内核),所以zpoline
把从0开始的虚拟地址都填上了nop
指令,然后执行hook相关代码。
不过熟悉操作系统的读者可能会对这个“把虚拟地址0填上NOP”的操作有点疑问,比如在Windows 10系统中,VirtualAlloc
就不允许对地址范围小于0x10000的内存空间进行处理。作者也解释,zpoline
在FreeBSD 13.0、NetBSD 9.2 和 DragonFly BSD 6.0 上可以使用,但是在 OpenBSD 7.0 上没法启用,因为在 OpenBSD 7.0 上允许访问的最低的内存地址至少要大于等于一个内存页的大小。而在Windows系统中虽然不能访问地址为0的虚拟内存空间,但是在WSL2子系统上,这个trick是可以工作的~ 最后,对于macOS,由于0地址被__PAGEZERO
这样一个段占用了,所以zpoline
也没法使用。
讲完了设计,来看看zpoline
的优势,首先是性能优势,下表一目了然地展示出了zpoline
和诸如ptrace
或者int3
这种开销巨大的中断机制的性能差别。
作者用了两个服务器程序实际测试了不同的system call hook对吞吐量的影响,可以看到,zpoline
这个性能的优势也是明显的。
虽然作者在论文里面没提,但是我们还是可以在GitHub上找到zpoline
的实现,大家赶紧去测试一下吧(不要都去烧坩埚了!!!)
https://github.com/yasukata/zpoline
论文:https://www.usenix.org/system/files/atc23-yasukata.pdf