23. 页表隔离 (PTI)

23.1. 概述

页表隔离(pti,以前称为 KAISER [1])是一种针对共享的用户/内核地址空间攻击(例如“Meltdown”(熔断)方法 [2])的防御措施。

为了缓解这类攻击, 我们创建了一套独立的页表, 仅在运行用户空间应用程序时使用。当通过系统调用、中断或异常进入内核时, 页表会切换到完整的“内核”副本。当系统切回用户模式时, 再次使用用户副本。

用户空间页表仅包含极少量的内核数据:仅包含进入/退出内核所需的内容, 例如进入/退出函数本身以及中断描述符表 (IDT)。有一些严格来说并非必需的东西也被映射了, 例如进入中断时的第一个 C 函数(参见 pti.c 中的注释)。

这种方法有助于确保在启用 PTI 时, 利用分页结构的侧信道攻击无法奏效。可以在编译时通过设置 CONFIG_MITIGATION_PAGE_TABLE_ISOLATION=y 来启用它。一旦在编译时启用, 就可以在引导时通过内核参数 ‘nopti’ 或 ‘pti=’ 来禁用它(参见 kernel-parameters.txt)。

23.2. 页表管理

当启用 PTI 时, 内核管理两套页表。第一套与没有 PTI 的内核中存在的单个页表非常相似。这包括用户空间的完整映射, 内核可以将其用于诸如 copy_to_user() 之类的事情。

尽管是_完整的_, 但内核页表的用户部分通过在最高级别设置 NX 位而被禁用了。这确保了任何遗漏的内核->用户 CR3 切换都会在执行第一条指令时立即导致用户空间崩溃。

用户空间页表仅映射进入和退出内核所需的内核数据。这些数据完全包含在 ‘struct cpu_entry_area’ 结构中, 该结构被放置在 fixmap 中, 这为每个 CPU 的该区域副本赋予了一个编译时固定的虚拟地址。

对于新的用户空间映射, 内核像往常一样在其页表中创建条目。唯一的区别在于内核在顶层 (PGD) 创建条目时。除了在主内核 PGD 中设置条目之外, 还在用户空间页表的 PGD 中制作该条目的副本。

这种在 PGD 级别的共享也自然而然地共享了页表的所有较低层。这留下了一套单一、共享的用户空间页表来进行管理。一个需要锁定的 PTE, 一套访问位、脏位等...

23.3. 开销

防护侧信道攻击非常重要。但是, 这种防护是有代价的

  1. 内存使用增加

  1. 现在每个进程都需要 order-1 的 PGD, 而不是 order-0。(每个进程额外消耗 4k)。

  2. ‘cpu_entry_area’ 结构的大小必须为 2MB 并且按 2MB 对齐, 以便可以通过设置单个 PMD 条目来对其进行映射。一旦内核解压, 这会消耗近 2MB 的 RAM, 但在内核镜像本身中不占空间。

  1. 运行时开销

  1. 在中断、系统调用和异常的进入与退出时, 必须进行 CR3 操作以在页表副本之间进行切换(不过当内核被中断时可以跳过)。对 CR3 的修改大约需要上百个周期, 并且在每次进入和退出时都是必需的。

  2. Percpu TSS 被映射到用户页表中, 以允许 SYSCALL64 路径在 PTI 下工作。这没有直接的运行时开销, 但可以说它开启了某些定时攻击场景。

  3. 对于未同时映射到内核和用户空间页表中的所有内核结构, 全局页将被禁用。MMU 的这一功能允许不同的进程共享映射内核的 TLB 条目。失去该功能意味着上下文切换后会有更多的 TLB 未命中。然而, 实际的性能损失非常小, 从未超过 1%。

  4. 进程上下文标识符 (PCID) 是一项 CPU 功能, 它允许我们在更改页表时通过在 CR3 中设置一个特殊位, 从而在切换页表时跳过刷新整个 TLB。这使得切换页表(在上下文切换或内核进入/退出时)成本更低。但是, 在支持 PCID 的系统上, 上下文切换代码必须将用户和内核条目都从 TLB 中刷新出去。用户 PCID TLB 刷新会推迟到退出到用户空间时进行, 从而将开销降到最低。有关 PCID/INVPCID 的详细信息, 请参阅 intel.com/sdm。

  5. 必须为每个新进程填充用户空间页表。即使没有 PTI, 共享的内核映射也是通过将顶层 (PGD) 条目复制到每个新进程中来创建的。但是, 有了 PTI, 现在有了两个内核映射:一个是内核页表中映射所有内容的映射, 另一个是用于进入/退出结构的映射。在 fork() 时, 我们需要复制这两者。

  6. 除了 fork() 时的复制之外, 每当对用于映射用户空间的 PGD 执行 set_pgd() 时, 也必须更新用户空间 PGD。这确保了内核和用户空间副本始终映射相同的用户空间内存。

  7. 在不支持 PCID 的系统上, 每次写入 CR3 都会刷新整个 TLB。这意味着每个系统调用、中断或异常都会刷新 TLB。

  8. INVPCID 是一种 TLB 刷新指令, 允许刷新非当前 PCID 的 TLB 条目。某些系统支持 PCID, 但不支持 INVPCID。在这些系统上, 只能从 TLB 中刷新当前 PCID 的地址。当刷新内核地址时, 我们需要刷新所有 PCID, 因此单个内核地址刷新将在下次使用每个 PCID 时需要进行一次刷新 TLB 的 CR3 写入。

23.4. 可能的未来工作

  1. 我们可以更谨慎一些, 除非 CR3 的值确实发生了改变, 否则不要实际写入它。

  2. 除了启动时切换外, 还允许在运行时启用/禁用 PTI。

23.5. 测试

为了测试 PTI 的稳定性, 推荐采用以下测试步骤, 理想情况下应并行执行所有这些步骤

  1. 设置 CONFIG_DEBUG_ENTRY=y

  2. 在多个 CPU 上循环运行所有 tools/testing/selftests/x86/ 测试(不包括 MPX 和 protection_keys)的多个副本, 持续几分钟。这些测试经常会暴露出内核进入代码中的边缘情况。通常, 旧内核可能会导致这些测试本身崩溃, 但它们绝不应该导致内核崩溃。

  3. 以会生成大量频繁性能监控不可屏蔽中断(参见 /proc/interrupts 中的 “NMI”)的模式(top 或 record)运行 ‘perf’ 工具。这会执行 NMI 进入/退出代码, 众所周知, 这会在未预期被中断的代码路径中触发漏洞, 包括嵌套 NMI。使用 “-c” 可以提高 NMI 的频率, 而使用带有独立计数器的两个 -c 则会促进嵌套 NMI 和更不确定的行为。

    while true; do perf record -c 10000 -e instructions,cycles -a sleep 10; done
    
  4. 启动一个 KVM 虚拟机。

  5. 在支持 SYSCALL 指令的系统上运行 32 位二进制文件。这是一个经过轻度测试的代码路径, 需要格外审查。

23.6. 调试

PTI 中的漏洞会导致几种不同特征的崩溃, 这里值得注意一下。

  • selftests/x86 代码失败。通常是 entry_64.S 中某个更冷门角落里的漏洞

  • 早期引导时的崩溃, 特别是围绕 CPU 启动时。映射中的漏洞会导致这些崩溃。

  • 第一次中断时崩溃。由 entry_64.S 中的漏洞引起, 例如搞砸了页表切换。也可能是由于错误映射 IRQ 处理程序进入代码引起的。

  • 第一次 NMI 时崩溃。NMI 代码与主中断处理程序是分开的, 可能会存在不影响正常中断的漏洞。这也是由于错误映射 NMI 代码引起的。中断进入代码的 NMI 必须非常小心, 并且可能是运行 perf 时出现崩溃的原因。

  • 在第一次退出到用户空间时内核崩溃。entry_64.S 漏洞, 或者未能映射某些退出代码。

  • 在中断用户空间的第一个中断时崩溃。entry_64.S 中返回用户空间的路径有时与返回内核的路径是分开的。

  • 双重错误:由于页中套页故障导致内核栈溢出。这是由在进入代码中访问未进行 pti 映射的数据引起的, 或者在调用未进行 pti 映射的 C 函数之前忘记切换到内核 CR3 引起的。

  • 用户空间在引导早期发生段错误, 有时表现为 mount(8) 无法挂载 rootfs。这些往往是 TLB 无效化问题。通常是使错误的 PCID 无效, 或者遗漏了无效化。