Linux/x86-64上使用内存间接调用(徒劳?)的系统调用检测,第一部分

Lobsters Hottest 新闻

摘要

一篇技术博文,讨论在Linux/x86-64上检测系统调用的技术,包括指令双关、E9Patch、zpoline以及短指令修补的挑战。

<p><a href="https://lobste.rs/s/mi6osr/system_call_instrumentation_on_linux_x86">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/22 03:29

# 计算机科学漫谈 来源:https://www.humprog.org/~stephen/blog/2026/06/15/ 让思绪的列车转向,虚度宝贵时光 ## 2026年6月15日,星期一 ### 使用内存间接调用在Linux/x86-64上进行系统调用插桩(徒劳无功?),第一部分 我的 `libsystrap` 库(https://github.com/stephenrkell/libsystrap)提供了一种简单的在 Linux x86-64 用户空间中插桩系统调用的方法。然而,其当前的实现存在双重陷阱开销:系统调用变为 `ud2`,这会生成一个 `SIGILL` 陷阱。然后我们在信号处理程序内部执行系统调用本身,导致第二次陷阱和一些有趣的棘手情况(https://www.humprog.org/~stephen/blog/devel/clone-calling.html)。 近年来,这个领域出现了一些有趣的研究,包括 [Liteinst “指令拼凑”论文](https://dl.acm.org/doi/10.1145/3062341.3062344)、密切相关的 [E9Patch 论文](https://dl.acm.org/doi/abs/10.1145/3385412.3385972)(尽管两者并非专门针对系统调用插桩)、后来的 [“zpoline”论文](https://dl.acm.org/doi/abs/10.1145/3385412.3385972)(这绝对是),以及一些使后者更健壮的后续工作([lazypoline](https://adriaanjacobs.github.io/files/dsn24lazypoline.pdf)、[K23](https://dl.acm.org/doi/10.1145/3721462.3770772))。 所有这些方法要解决的核心问题纯属 Intel 指令编码的意外:所有有用的跳转指令至少是 5 字节长,而我们经常需要修补更小的指令,例如系统调用指令,它们(本质上)都是两个字节长。因此,如果你想用跳转替换系统调用,就会遇到问题。 指令拼凑的想法,简化得可怕并专门针对系统调用问题(它本来更通用),是这样的:如果我们有一个包含两字节系统调用的指令序列(这里使用 `syscall` 指令,`0f 05`) ``` ... 0f 05 xx yy zz ... ``` 那么当我们将其变成跳转或调用时,我们或许可以利用下一条指令的字节,因为它们构成了相对跳转偏移量的一部分。实际上,我们有一个自由字节可以操作: ``` ... e9 WW xx yy zz ... ``` 即我们保留 `xx`、`yy` 和 `zz` 字节不变,因为它们属于下一条指令,但我们可以改变 `WW`。`WW xx yy zz` 将被解释为 32 位位移,我们理想地只需将某种蹦床代码放置在该位移所落到的任何位置。 不幸的是,由于机器是小端序,`WW` 是最低有效字节,因此跳转目标固定,只有 256 字节的调整余地。这需要一种统计方法:只要高阶字节不为零或非常小,我们就有很大机会跳得足够远,落在某个可用内存上。否则,我们可以回退到像 `ud2` 这样产生信号的选项,或者做其他事情。E9Patch 论文提出了一些令人头晕的复合版本的指令拼凑,以在这些场景下提高其覆盖率,而无需诉诸类似 `ud2` 的陷阱方法。同时,蹦床的这种分散性质将需要大量的虚拟地址空间,每个修补点大约一页,但我们可以利用虚拟内存技巧将多个蹦床共存在同一物理页上(E9Patch 工具也这样做)。 zpoline 的想法更清晰,不依赖于拼凑或统计方法。它相当巧妙。我们始终可以将 2 字节系统调用替换为 ``` ff d0 call *%rax ``` ……这将产生对一个小的非负地址的调用,因为 `%rax` 必须持有系统调用号,即一个小的非负整数。这很简洁,但意味着你必须将一些指令映射在最低页(地址零),这破坏了标准硬件对空指针访问的强制保护。论文建议通过(1)使用 Intel [内存保护密钥](https://www.kernel.org/doc/html/latest/core-api/protection-keys.html) 使该内存仅可执行,以及(2)通过对照记录已知修补系统调用点的位图或哈希表验证返回地址,来捕获“跳转到空指针”的 bug。然而,这仍然不理想:许多处理器不支持内存保护密钥,验证返回地址需要时间,并且在 Linux 上映射低内存需要系统特权。如果错误的代码用 `%rax` 中的高值调用系统调用,该行为也不可预测,而内核会干净地失败(返回 `ENOSYS`)。 zpoline 的工作让我思考:我们能否通过探索指令编码的其他角落,找到具有不同权衡的类似技巧?在 x86 中,我一直对分段特性着迷,因此我倾向于探索那里。所有 x86 处理器,即使是 64 位,也总是以某种形式永久启用分段。在保护模式下,所有内存访问首先通过两个段描述符表(全局的(系统范围)和局部的(通常每个进程))之一进行转换。这些表选择线性虚拟地址,然后通过页表进行第二层转换。Linux 允许我们使用 `modify_ldt()` 系统调用修改进程的局部描述符表。我们能否找到一种 2 字节形式,通过该表间接寻址,以某种方式到达我们想要的系统调用插桩? 剧透:某种意义上可以,但并非如我所愿。尽管如此,我学到了很多,接近成功的尝试也足够有趣,值得深入探讨,而且可能还有一些值得拥有的好处。 这暴露了我一开始对 x86 分段的理解有多差,我乐观地希望可以使用两字节的“长调用”指令(AT&T 语法中的 `lcall`,Intel 中的 `call far`),也许(天真地)像这样: ``` ff 18 lcall *(%rax) ``` 通过存储在 `%rax` 中的选择子对应的 LDT 条目执行模拟/插桩的系统调用。遗憾的是,`lcall` 指令并非如此。 (在其他明显的警钟中,将 16 位选择子存储在全宽寄存器如 `%rax` 中是很奇怪的。此外,“lcall”中的“l”表示“long”,当然不是“LDT”中的“L”表示“Local”。我将解释这一切……) 稍微回溯一下:这个 `lcall` 指令允许我们调用不同的代码段,而不是通常的、停留在同一段内的“近调用”。我们认为的 x86 中的内存地址(无论是 16 位、32 位还是 64 位)实际上是一个段内的偏移——只是在线性内存模型(这是 32 位 Unix 环境的选择,并且在 64 位模式下是强制的)中,段基址恰好为 0。类似地,64 位模式下的间接近调用,例如 ``` ff d0 call *%rax ``` 将其目标操作数作为当前代码段内的 64 位*偏移*,而非指针。 远调用的目标不仅由偏移量指定,还由一个 16 位段选择子指定。它索引到段*描述符*(段的实际定义,大致是基址/界限对以及权限)的全局或局部表,表由选择子值的三个保留低位之一选择。每个表最多可包含 8192 个条目,占剩余的 13 位。 我应该回顾一下 Intel 汇编中一个不那么明显的点。所有间接调用都会跳转到某个内存位置,但有两种形式:寄存器间接和内存间接。后者是双重间接的,因为内存位置本身由寄存器指定。这是从中加载调用目标地址的内存位置;我称之为“垫脚石”位置,尽管可能有更好的术语。(据我所知,内存间接跳转和调用是整个 Intel ISA 中唯一的内存间接操作。) ``` ff d0 call *%rax ff 10 call *(%rax) ``` 上面的第一个做了显而易见的事情(寄存器间接):调用寄存器 `%rax` 中保存的地址(或者更确切地说,代码段内的偏移)。第二个增加了一层额外的间接:要调用的地址(抱歉,偏移量)本身是从内存中加载的:从地址(抱歉,偏移量)保存在寄存器 `%rax` 中的位置加载。 当然,有两种内存间接调用形式:近和远。 ``` ff 10 call *(%rax) ff 18 lcall *(%rax) ``` 没有简单的寄存器间接形式的远调用。你可能认为这是因为寄存器不够大,无法容纳完整的远地址,即段选择子和偏移量。然而,我们稍后会看到这并不能解释。 (有一种*绝对*远调用,其中完整的远地址作为立即操作数出现在指令中。但在 64 位模式下不可用。完整参考,以通常稍微晦涩的术语,可以在[这里](https://www.felixcloutier.com/x86/call)或[这里](https://www.intel.com/content/www/us/en/content-details/671200/intel-64-and-ia-32-architectures-software-developer-s-manual-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4.html)找到。) 下面的汇编程序帮助我确信自己理解了 x86-64 Linux 用户空间中远调用的机制。在这个例子中,“长”调用目标是一个 48 位操作数:一个 16 位选择子和一个 32 位偏移量。尽管我们在 64 位模式下,这个 16:32 是 `lcall` 的默认变体。(不过,你可以用 `rex.W lcall` 来读取 64 位偏移量,即总共 80 字节的操作数。但这个 16:32 形式就是为什么“放不进寄存器”不能作为解释……本来可以在 64 位模式下提供这种 16:32 调用的普通寄存器间接版本,但可以理解的是,没有提供。) ``` # How to make a "long call" (lcall) within a user program on x86-64. .data # global variable to hold our 6-byte address (2-byte segment selector, 4-byte offset) tgtlongaddr: tgtlongaddr_offs: .long 0 # offset -- only 32 bits! tgtlongaddr_sel: .word 0 # segment selector -- 16 bits .text .globl _start _start: # we want to make an indirect long call to 'exit'... # borrow our current code segment selector (on Linux it will be 0x33) movw %cs, tgtlongaddr_sel # set the offset as 'exit' lea exit, %rax movl %eax, tgtlongaddr_offs # make %rax point to our 6-byte global variable lea tgtlongaddr, %rax lcall *(%rax) exit: # we will have an extra $cs on the stack, owing to the far call, but we ignore it... # actually it's packed into one 64-bit word together with the 32-bit offset and some # padding in the high-order bits (0x3300401020 in the stack dump below) # Dump of assembler code for function exit: # => 0x0000000000401020 <+0>: mov $0x0,%rdi # 0x0000000000401027 <+7>: mov $0x3c,%rax # 0x000000000040102e <+14>: syscall # End of assembler dump. # (gdb) x /20ga $rsp # 0x7fffffffcdc8: 0x3300401020 0x1 # 0x7fffffffcdd8: 0x7fffffffd307 0x0 # 0x7fffffffcde8: 0x7fffffffd355 0x7fffffffd365 mov $0, %rdi # status 0 mov $60, %rax # exit syscall syscall (END) ``` 不幸的是,我也确信 2 字节的 `lcall` 形式对系统调用插桩没有用。就像 zpoline 使用 `call` 一样,通过 `%rax` 的远调用总是想要访问该寄存器中地址附近的内存。这个内存访问不是用于跳转本身,而是用于跳转目标的地址,这是次要的。实际上更糟,因为我们必须将底部页映射为可读,而不是仅可执行。 更拼凑地思考,还有 3 字节形式可能有所帮助,如果我们能安排不关心尾部字节的话。例如,有: ``` ff 58 nn lcall *0xnn(%rax) ``` 它将尝试从 `%rax` 中地址的小位移处的内存加载一个远指针。我们可以简单地保留该字节不动,允许指令重叠。但这没有帮助:这个位移总是很小,所以仍然无法将我们带出地址空间的底部页(除了可能向下回绕到地址空间的顶部,这更没用)。并且位移由恰好跟随陷阱点的重新解释的指令字节决定;我们无法控制它。 但随后灵光一现:有一些 6 字节形式可能有用,如果我们继续借用指令拼凑的想法的话。让我们回顾一下指令拼凑的样子,使用 5 字节的近直接调用。 `e8 WW nn nn nn` — `call 0xnnnnnnWW(%rip)` — 精确、可调、附近 — 用作跳转目标即蹦床 — 蹦床很可能唯一 — 不可重叠 在表格右侧我用五条信息注释了该指令。它引用的内存位置是精确的(仅从指令地址可预测),但又是可调的(因为我们能选择 `WW`,即偏移量的最低有效位)。我们总是访问一个“附近”的位置,即加减约 2GB(立即数 32 位位移),并且我们使用该位置作为跳转目标。该位置很可能对插桩点唯一,尽管在不太可能的情况下,两个这样的点的拼凑恰好落在相互附近的位置(不完全相等,但比如说相差几个字节),我们无法重叠我们想放置在那里的内容。这里是蹦床代码,通常蹦床的所有字节都需要不同,即不太可能相互重叠拼凑。 你脑海中关于 `%rip` 相对拼凑的画面大致是这样的,尽管缩放比例可笑地不正确。 ``` : : |____________________| |....|TT|............| \ :.|TT|...............: | zone ~2GB above :............|TT|... : | mostly unmapped, but quasi-randomly allocated for trampolines :____________________: / | x x | | patched text | the loaded binary whose text is patched |x x x | |____________________| (data segment somewhere around here too, not shown) :..............|TT|..: \ :...|TT|.............: | :....................: | zone ~2GB below similarly |____________________| / | | : : ``` 唯一与共享蹦床各有优缺点。唯一蹦床可以针对修补点定制(例如,它可以在该特定指令处内联所需的插桩),而共享蹦床需要某种程度上的通用性,但有助于节省内存。*可重叠*的共享蹦床甚至更好,因为它们可以打包到更小的内存范围。zpoline 的工作使用了其中一种,由一个 `nop`“滑道”——比如 512 字节的 `nop`(如果没有系统调用编号高于 511)——后跟一个通用处理程序组成。这种共享、可重叠形式的蹦床是必要的,因为许多修补点可能落在低 `%rax` 访问区域内的不同但附近的偏移处。同时,像 E9Patch 这样的系统使用唯一蹦床,但通过将许多蹦床共存在同一物理内存帧中来玩花招,以防止它们占用太多内存。只要它们在页内的偏移空间不重叠,就可以进行这种共存:然后同一帧被映射到其传入修补点所需的每个虚拟地址。虽然可能只需要很少的物理帧,但最终虚拟地址空间中仍会使用相当多的页面。这会污染进程的内存映射(特别是 `/proc/self/maps` 文件)。在许多上下文中这种噪音并不重要,但我倾向于避免它,因为如果你做低级编程(我经常做),它会很烦人。此外,泛化

相似文章

内联函数的调试信息

Hacker News Top

Alan Maguire 在 Linux 存储、文件系统、内存管理和 BPF 峰会上主持了一场会议,提议对 BTF 进行扩展,以存储有关内联函数的信息,从而通过 kprobes 实现对这类函数的内核跟踪。

Windows堆栈限制检查回顾,后续

The Old New Thing (Raymond Chen)

Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。

突破 RISC-V 模拟的极限

Hacker News Top

这篇博客文章探讨了如何通过在非 RISC-V 机器上使用提前重编译器来加速 RISC-V 执行,该重编译器通过尾调用连接基本块,并利用 Clang 的 preserve_none 调用约定,作为 Axiom 的 OpenVM 项目的一部分。

利用超长中断攻破系统管理模式

Hacker News Top

一种新的攻击技术通过使用一个极其耗时的单条指令使处理器核心失同步,从而攻破x86 CPU的系统管理模式(SMM)安全,使攻击者能够在另一个核心仍处于受保护环境之外时执行SMM代码。针对Zen 3 Ryzen处理器的概念验证(PoC)已提供。