本文根据 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:
- 调用 func,把它缓存进 L1i;
- 以可测量的方式修改 func,例如改变返回值(改成
mov x0, 0x69); - 执行受害者代码(例如一次系统调用,或让出线程);
- 再次执行 func 并测量输出:如果返回
0x69,说明受害者代码把它驱逐出了缓存——这就泄露了受害者代码的信息。
RISC-V 核上的预取器会给 Modify+Recall 带来噪声,所以论文提出下一版 Call+ModifyCall:
- 调用 func 缓存它;
- 执行受害者代码;
- 修改并调用 func。
论文还识别并处理了 PLRU 与 L0 缓存造成的几种噪声来源(至少有一种修正被用进本文作者的 PoC,文中不展开)。
对单个缓存行的攻击可以扩展到整个 L1i:利用第 1 节建立的缓存-内存映射模型,分配一块与 L1i 等大的连续内存(组数 × 路数 × 行大小),让其中每个缓存行都有自己的 func。论文还演示了网站指纹识别、内核秘密泄露、Spectre IC 泄露和 Mbed TLS RSA 攻击等样例。简而言之:由于弱一致性,可以测量受害者代码造成的缓存驱逐。
3. 给系统调用做指纹
有了测量受害者代码驱逐的能力,怎么用它检测系统调用挂钩?方法:在一台可信设备上测量某个系统调用驱逐了哪些组,形成该系统调用的缓存指纹;如果后续测量持续、显著偏离已知良好基线,那么正在执行的代码很可能变了(或被挂钩)。
作者采用 Modify+Recall 变体(它消除了部分噪声)。测量算法:
- 分配一个 RWX 函数数组,大小等于 L1i 缓存;
- 把函数初始化为初始状态——每个函数占一个缓存行,并确保写好的函数冲刷到内存(至少 L2);
- 调用全部函数,确保它们被缓存进 L1i;
- 执行要采集指纹的系统调用(执行过程会造成指令缓存驱逐);
- 把函数修改成返回不同值;
- 重新调用每个函数,记录返回的是新值还是旧值。
结果是一个 [[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 的组都视为偏离基线。偏离也可以用可视化表示。另外从偏离导出两个更易观察大偏离的指标:
- 偏离幅度(deviation magnitude):所有 delta > 0 的 delta 之和;
- 最大偏离(max deviation)。
3.2 被测单元
测量系统调用必然要执行它,因此要选择如何调用,以瞄准系统调用(和挂钩)内的不同路径。例如传非法参数让系统调用快速失败,可能得到更小、更稳定的驱逐足迹。
被测单元:
nop:只执行 nop、无系统调用,作为对照;sysinfo:正常执行 sysinfo;bad_read:用非法文件描述符调用 read;read:从给定文件读 16 字节;getdents64:对给定目录执行 getdents64;bad_getdents64:缓冲区过小且文件描述符非法的 getdents64。
4. 概念验证
以下是 Raspberry Pi 5 上的测量。流程:
- 在已知干净的设备/内核上建立基线;
- 对每个场景:重启内核使其未受污染(也保证基线不依赖 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。
参考
- Ulrich Drepper, What every programmer should know about memory, 2007(PDF)
- ARM 文档:真实缓存示例
- 停车场类比
- Y. Yarom and K. Falkner, “FLUSH+RELOAD: A High Resolution, Low Noise, L3 Cache Side-Channel Attack”, USENIX Security 14
- M. Lipp 等, “ARMageddon: Cache Attacks on Mobile Devices”, USENIX Security 16
- P. Kocher 等, “Spectre Attacks: Exploiting Speculative Execution”, IEEE S&P 19
- M. Lipp 等, “Meltdown: Reading Kernel Memory from User Space”, USENIX Security 18
- Jin, Yu 等, GhostCache: Timer-and Counter-Free Cache Attacks Exploiting Weak Coherence on RISC-V and ARM Chips, 2025(论文)
- Diamorphine
下一页:代码虚拟化概览