本文根据 tmp.0ut 第五期中 PinkNoize 的文章编译整理,非逐字翻译;文中观点均属于原作者。

原文:Detecting syscall hooks with side-channels

文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:4797

0. 引言

许多 rootkit 通过挂钩系统调用或其他函数来隐藏文件/进程,或建立秘密通道。检测 rootkit 很难,因为它们可以修改操作系统、让系统对你说谎。这篇文章演示一种无需 root 就能检测系统调用挂钩的方法:滥用硬件优化上的一个怪癖。作者还发布了工具 l1thography,方便读者自己尝试。

1. 缓存

与 CPU 速度相比,DRAM 很慢。为了弥合快 CPU 与慢内存之间的差距,CPU 设计者加入少量快速 RAM 临时保存 DRAM 内容,这就是缓存。作者推荐阅读 Ulrich Drepper 的《What Every Programmer Should Know About Memory》做整体了解。

系统里常见的缓存包括:L0 缓存(μop 缓存)、L1、L2、L3、系统级缓存、TLB(翻译后备缓冲)等(还有一些未文档化)。典型层级是:CPU ← L1i/L1d ← L2 ← L3 ← DRAM。

1.1 指令缓存

本文关注 L1 指令缓存(L1i)。指令访问与数据访问的模式不同,所以它们通常各自有缓存(一般只在 L1 层)。这样一类缓存可以优化数据访问模式,另一类优化指令访问模式。

在 x86 等架构上,指令缓存与数据缓存有强一致性(strong coherence),会自动保持同步,因此自修改代码(SMC)无需手工管理缓存(假设 RWX 权限):

let mutable_func = |_| -> u32 {
    return 0;
};
// 把 mutable_func 加进指令缓存
mutable_func();
// 修改 mutable_func 里的代码
mutable_func = |_| -> u32 {
  return 1;
};
let x = mutable_func(); // x = 1

而在 ARM 或 RISC-V 这类弱一致性架构上,指令与数据缓存不需要保持同步,CPU 不需要为 SMC 管理缓存失效,复杂度更低。但这会让 SMC 出现反常行为:例如同样代码在弱一致性系统上运行,x 很可能等于 0 而不是 1——修改后旧函数仍在缓存里,执行的还是旧代码。记住这一点,后面会用到。

要让代码按预期运行,需要冲刷写缓冲区(确保新指令写入到达内存),再作废指令缓存(清掉陈旧指令)。

1.2 组相联

CPU 缓存要把任意内存地址映射到一块小缓存上,这种映射方式叫放置策略(placement policy)。最常见的是组相联缓存(set-associative),因为它的命中率与 CPU 面积之比很高。

组相联缓存按 n 个组(set)组织,每组有 m 个位置(路/way)放缓存行。每个地址映射到一个组,但可以放在该组任意空闲的一路上。

缓存的几何可以用数组描述(例如 ARM 文档里 32KB 缓存为 4 路、256 组、行 32 字节):cache[4][256][32]。除了数据本身,还要存标签、脏位/有效位等元数据。

地址到缓存的映射:行大小 32 字节,所以地址低 5 位是行内偏移;组索引用 8 位(256 组);剩余高位是标签:

                 Tag                 |       Set      |   Line
Bit 31                           13                5           0

当某组所有路都被占满时,由替换策略决定淘汰哪一路:伪随机替换、轮转(round robin)、伪 LRU(PLRU,估计最久未使用,比真 LRU 省空间)等。被移除/替换的事件叫驱逐(eviction),后文简称驱逐。作者提到停车场类比可以帮助理解。最后注意:索引缓存用的地址可以是物理地址或虚拟地址,即 VIPT(虚拟索引、物理标签)或 PIPT(物理索引、物理标签);本文用不到,但对针对任意地址的缓存攻击有用。

2. 缓存里的“圣诞幽灵”

缓存会改变读写时序:读一个地址“快”,说明它在缓存里;“慢”则不在。于是可以根据时序泄露缓存状态,这类攻击称为缓存侧信道。基于访问时间的缓存状态泄露是许多攻击的基础,包括 Flush+Reload、ARMageddon、Spectre 与 Meltdown。

由于细粒度计时器助长了这些攻击,CPU 设计者开始限制计时器访问;作为回应,无计时器缓存攻击被开发出来,本文要探索和使用的正是其中之一。

2.1 GhostCache

GhostCache 是一篇研究弱一致性系统上无计时器缓存侧信道的论文。如前所述,它利用一个事实:被缓存的指令执行旧版本,未被缓存的指令执行新版本——攻击者因此可以判断自己的缓存指令是否被受害者代码驱逐。

攻击假设的环境:

  • 攻击者控制 RWX 内存;
  • 攻击者能在调用受害者代码前后执行代码;
  • 系统是弱一致性的。

先初始化环境:在 RWX 内存里创建返回已知值的函数:

0x5550: func:
        mov x0, 0x420
0x5554  ret

第一版攻击叫 Modify+Recall:

  1. 调用 func,把它缓存进 L1i;
  2. 以可测量的方式修改 func,例如改变返回值(改成 mov x0, 0x69);
  3. 执行受害者代码(例如一次系统调用,或让出线程);
  4. 再次执行 func 并测量输出:如果返回 0x69,说明受害者代码把它驱逐出了缓存——这就泄露了受害者代码的信息。

RISC-V 核上的预取器会给 Modify+Recall 带来噪声,所以论文提出下一版 Call+ModifyCall:

  1. 调用 func 缓存它;
  2. 执行受害者代码;
  3. 修改并调用 func。

论文还识别并处理了 PLRU 与 L0 缓存造成的几种噪声来源(至少有一种修正被用进本文作者的 PoC,文中不展开)。

对单个缓存行的攻击可以扩展到整个 L1i:利用第 1 节建立的缓存-内存映射模型,分配一块与 L1i 等大的连续内存(组数 × 路数 × 行大小),让其中每个缓存行都有自己的 func。论文还演示了网站指纹识别、内核秘密泄露、Spectre IC 泄露和 Mbed TLS RSA 攻击等样例。简而言之:由于弱一致性,可以测量受害者代码造成的缓存驱逐。

3. 给系统调用做指纹

有了测量受害者代码驱逐的能力,怎么用它检测系统调用挂钩?方法:在一台可信设备上测量某个系统调用驱逐了哪些组,形成该系统调用的缓存指纹;如果后续测量持续、显著偏离已知良好基线,那么正在执行的代码很可能变了(或被挂钩)。

作者采用 Modify+Recall 变体(它消除了部分噪声)。测量算法:

  1. 分配一个 RWX 函数数组,大小等于 L1i 缓存;
  2. 把函数初始化为初始状态——每个函数占一个缓存行,并确保写好的函数冲刷到内存(至少 L2);
  3. 调用全部函数,确保它们被缓存进 L1i;
  4. 执行要采集指纹的系统调用(执行过程会造成指令缓存驱逐);
  5. 把函数修改成返回不同值;
  6. 重新调用每个函数,记录返回的是新值还是旧值。

结果是一个 [[bool; SETS]; WAYS] 驱逐状态数组。

3.1 建立启发式

单次测量噪声大且不稳定。作者用老办法:多次采样取平均;并把每组各路求和(组 x 路 0 的驱逐与组 x 路 2 等价),得到 [f64; SETS] 平均每组的驱逐数。可视化时每个字符代表 256 组之一,高度代表该组的平均驱逐数(作者偏爱 l1thography 的 ANSI 彩色显示,能表示更多取值)。

为了比较新测量与基线,需要为每个组动态决定“足够接近”的阈值。作者测量每个组的方差/标准差,据此为每组挑选阈值,既能过滤噪声大的组,又能发现驱逐数稳定的组的变化。在 Raspberry Pi 5 上实验后采用 3 个标准差:

delta = | baseline[set].mean - test[set].mean | - 3 * baseline[set].stddev

任何 delta > 0 的组都视为偏离基线。偏离也可以用可视化表示。另外从偏离导出两个更易观察大偏离的指标:

  1. 偏离幅度(deviation magnitude):所有 delta > 0 的 delta 之和;
  2. 最大偏离(max deviation)。

3.2 被测单元

测量系统调用必然要执行它,因此要选择如何调用,以瞄准系统调用(和挂钩)内的不同路径。例如传非法参数让系统调用快速失败,可能得到更小、更稳定的驱逐足迹。

被测单元:

  • nop:只执行 nop、无系统调用,作为对照;
  • sysinfo:正常执行 sysinfo;
  • bad_read:用非法文件描述符调用 read;
  • read:从给定文件读 16 字节;
  • getdents64:对给定目录执行 getdents64;
  • bad_getdents64:缓冲区过小且文件描述符非法的 getdents64。

4. 概念验证

以下是 Raspberry Pi 5 上的测量。流程:

  1. 在已知干净的设备/内核上建立基线;
  2. 对每个场景:重启内核使其未受污染(也保证基线不依赖 KASLR 等随机变化);先评估干净内核作为基线健全性检查;再加载内核模块并评估。

4.1 基线

$ ./l1thography --samples 500000 baseline ./baseline-4-real-dis-time

每个被测单元(nop、sysinfo、bad_read、read、getdents64、bad_getdents64)各花约 82 秒(getdents64 约 105 秒)完成基线采样,输出各组的驱逐指纹图。

4.2 评估

干净内核本身也有噪声:例如 bad_read 偏离幅度 0.457、1 处差异;read 与 getdents64 为 0;bad_getdents64 偏离幅度 2.041、3 处差异。对策是同时看一个系统调用及其 bad 变体的一致偏离——真正挂钩时,映射到挂钩入口点的那些组在两种情况下都需要执行,会被同时标记。

系统调用表劫持(Diamorphine)

Diamorphine 通过挂钩 getdents64 隐藏目录。实测:

Testing getdents64
Deviation magnitude: 5.856
Max deviation: 0.791
Diffs: 21

Testing bad_getdents64
Deviation magnitude: 4.892
Max deviation: 0.701
Diffs: 14

两个测试场景都出现大量差异。

ftrace 挂钩

测试用一个“把 sysinfo.procs 减 100”的 dummy sysinfo 挂钩,用 -Os 编译以最小化 L1i 污染;挂钩编译后约 5 个缓存行(不含 memset、copy_from_user、copy_to_user 与 ftrace 开销):

Testing sysinfo
Deviation magnitude: 20.541
Max deviation: 1.680
Diffs: 29

5. 局限

  • 需要弱一致性 CPU(关闭 icache 一致性后的 ARM/RISC-V);快速检测显示 AWS/GCP 的 ARM 核似乎启用了缓存一致性;
  • 需要支持同时读、写、执行的内存(据作者所知 Apple Silicon 不支持);
  • 需要知道 CPU 缓存大小与放置策略;
  • 基线必须在同一 CPU、内核与指纹器上建立;内核升级或重新编译指纹器很可能需要重新建基线;
  • 很小的系统调用挂钩可能躲过检测。

代码

CLI 程序 l1thography 位于 https://codeberg.org/PinkNoize/l1thography

参考

  1. Ulrich Drepper, What every programmer should know about memory, 2007(PDF
  2. ARM 文档:真实缓存示例
  3. 停车场类比
  4. Y. Yarom and K. Falkner, “FLUSH+RELOAD: A High Resolution, Low Noise, L3 Cache Side-Channel Attack”, USENIX Security 14
  5. M. Lipp 等, “ARMageddon: Cache Attacks on Mobile Devices”, USENIX Security 16
  6. P. Kocher 等, “Spectre Attacks: Exploiting Speculative Execution”, IEEE S&P 19
  7. M. Lipp 等, “Meltdown: Reading Kernel Memory from User Space”, USENIX Security 18
  8. Jin, Yu 等, GhostCache: Timer-and Counter-Free Cache Attacks Exploiting Weak Coherence on RISC-V and ARM Chips, 2025(论文
  9. Diamorphine

上一页:为了乐趣与反取证构造多格式 ELF

下一页:代码虚拟化概览