← 返回信息流
AI 资讯Hacker News·4 天前

Linux x86-64系统调用内存间接调用检测技术解析

原标题:System call instrumentation on Linux/x86‑64 using memory‑indirect calls, part I

速览

本文探讨了在Linux x86-64架构下,通过内存间接调用技术对系统调用进行插桩和检测的具体实现方案。该技术为系统安全监控、性能分析及恶意软件检测提供了新的底层技术手段。作为系列文章的第一部分,详细阐述了其核心原理与初步实现。

AI 深度解读

Linux/x86-64 系统调用插桩:基于内存间接调用的探索(上)

背景

在 Linux x86-64 用户态环境中,对系统调用(System Calls)进行插桩(Instrumentation)是一项极具挑战性但也极具价值的任务。作者此前开发的 libsystrap 库提供了一种简单的系统调用插桩方案,但其实现存在显著的“双重陷阱”(double-trap)开销问题。

具体而言,libsystrap 将原有的系统调用指令替换为 ud2 指令。ud2 是一条非法指令,执行时会触发 SIGILL 信号陷阱。随后,程序必须在信号处理程序(Signal Handler)中重新执行原本的系统调用。这一过程导致了第二次陷阱,并引入了许多棘手的边缘情况(Tricky cases),严重影响了性能。

近年来,该领域出现了一些有趣的研究成果,旨在解决这一痛点,包括:

  • Liteinst:提出了“指令欺骗”(Instruction Punning)的概念。
  • E9Patch:与指令欺骗密切相关,虽不专门针对系统调用,但技术同源。
  • zpoline:专门针对系统调用插桩的研究,后续还有 lazypolineK23 等改进版本以提高鲁棒性。

这些研究共同面对的核心问题源于 Intel 指令编码的一个“纯粹意外”:所有有用的跳转指令(Jump Instructions)至少占用 5 个字节,而许多需要被修补的指令(如系统调用指令 syscall)通常只有 2 个字节。因此,如果试图用一条跳转指令替换一个 2 字节的系统调用,空间不足的问题便凸显出来。

核心内容

指令欺骗(Instruction Punning)与 E9Patch 的思路

“指令欺骗”的核心思想是:如果我们有一个包含 2 字节系统调用指令(如 syscall,机器码为 0f 05)的指令序列,我们可以利用下一条指令的字节来构建跳转偏移量。

假设原始指令序列如下:

... 0f 05 xx yy zz ...

如果我们将其替换为相对跳转指令 e9(E9 是 x86 中 32 位相对短跳转的机器码前缀),序列变为:

... e9 WW xx yy zz ...

在这里,xxyyzz 字节保持不变,因为它们属于下一条指令的一部分。我们可以修改 WW 字节。WW xx yy zz 将被解释为一个 32 位的位移量(Displacement)。理想情况下,我们将一段“跳转门”(Trampoline)代码放置在该位移量指向的内存地址处。

然而,由于 x86 是小端序(Little-endian)架构,WW 是最低有效字节。这意味着跳转目标地址的高位部分被固定,只有低 8 位(256 字节范围)可以微调。这要求采用一种统计方法:只要高序字节不为零或非常小,我们就有很大机会跳转到足够远的地方,落在可用的内存区域。如果运气不好,则必须回退到生成信号的方法(如 ud2)。

E9Patch 论文提出了一些复杂的复合指令欺骗变体,以增加在这种场景下的覆盖率,避免使用 ud2 等陷阱方法。这种分散的跳转门布局需要大量的虚拟地址空间(每个补丁点大约需要一页),但可以通过虚拟内存技巧将多个跳转门共置在同一个物理页面上。

zpoline 的巧妙设计与局限性

zpoline 的方法更为简洁,且不依赖欺骗或统计方法。其核心技巧是利用 %rax 寄存器中存储的系统调用号(通常是一个小的非负整数)。

作者指出,我们可以总是将 2 字节的系统调用替换为:

ff d0 call *%rax

这条指令会生成一个对一个小非负地址的调用,因为 %rax 必须持有系统调用号。这非常巧妙,但代价是必须在内存的最低页(地址 0 附近)映射一些指令。这破坏了硬件强制执行的防止空指针访问的保护机制。

zpoline 论文建议通过以下两点来缓解此问题:

  1. 使用 Intel 内存保护密钥(Memory Protection Keys),使该内存区域仅可执行(Execute-only)。
  2. 通过将返回地址与记录已知被修补系统调用位置的位图或哈希表进行验证,来捕获“跳转到空指针”的 Bug。

然而,这种方法仍有不足:

  • 许多处理器不支持内存保护密钥。
  • 验证返回地址需要消耗时间。
  • 在 Linux 上,映射低内存需要系统特权。
  • 如果 buggy 代码在 %rax 中放入一个高值并调用系统调用,程序行为将不可预测;而在内核中,这会干净利落地返回 ENOSYS 错误。

探索 x86 分段机制(Segmentation)

zpoline 的工作启发了作者思考:是否可以通过探索指令编码的其他角落,找到具有不同权衡的类似技巧?作者一直对 x86 的分段特性着迷,因此决定在此方向进行探索。

所有 x86 处理器,即使是 64 位处理器,也始终启用某种形式的分段。在保护模式下,所有内存访问首先通过全局描述符表(GDT,系统级)或局部描述符表(LDT,通常每进程一个)进行翻译,选择线性虚拟地址,然后再通过页表进行第二层翻译。Linux 允许通过 modify_ldt() 系统调用修改进程的局部描述符表。

作者希望找到一种 2 字节的形式,能够通过该表间接寻址,从而实现系统调用插桩。

初步尝试的失败:

作者最初乐观地认为可以使用 2 字节的“远调用”(Long Call,AT&T 语法为 lcall,Intel 语法为 call far)。例如,天真地希望使用类似以下的指令:

ff 18 lcall *(%rax)

通过存储在 %rax 中的选择子(Selector)指向的 LDT 条目来执行模拟/插桩的系统调用。然而,lcall 指令的实际行为并非如此。

这里存在几个误解:

  1. 寄存器大小:在像 %rax 这样的全宽寄存器中存储 16 位选择子是很奇怪的。
  2. 命名混淆:“lcall” 中的 “l” 代表 “long”(远),而 “LDT” 中的 “L” 代表 “Local”(局部),两者无关。

x86 寻址机制回顾:

  • 近调用(Near Call):如 ff d0 call *%rax,其目标操作数不是指针,而是当前代码段内的 64 位偏移量。在平坦内存模型(Flat Memory Model,32 位 Unix 和 64 位模式的标准选择)中,段基地址为 0,因此偏移量等同于线性地址。
  • 远调用(Far Call):目标由偏移量和 16 位段选择子共同指定。选择子索引到 GDT 或 LDT 中的段描述符表(包含基地址/限制/权限等信息)。选择值的低 3 位用于选择表,剩余 13 位用于索引表项(每表最多 8192 项)。

间接调用的两种形式:

在 Intel ISA 中,间接跳转和调用有两种形式:

  1. 寄存器间接(Register-indirect):如 ff d0 call *%rax。直接调用寄存器中存储的地址(偏移量)。
  2. 内存间接(Memory-indirect):如 ff 10 call *(%rax)。这是一个双重间接过程:从寄存器 %rax 指向的内存位置加载目标地址,然后调用该地址。该内存位置被称为“跳板”(Stepping stone)。

内存间接调用也分为“近”和“远”两种形式:

  • ff 10 call *(%rax):内存间接近调用。
  • ff 18 lcall *(%rax):内存
查看原文 →humprog.org