22. 页表隔离 (PTI)

22.1. 概述

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

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

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

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

22.2. 页表管理

启用 PTI 后,内核管理两组页表。 第一组与没有 PTI 的内核中存在的单个集合非常相似。 这包括内核可以用于 copy_to_user() 之类的操作的完整用户空间映射。

虽然是 _complete_,但内核页表的用户部分通过在顶层设置 NX 位而被削弱。 这确保了任何错过的内核 -> 用户 CR3 切换都会在执行其第一条指令时立即导致用户空间崩溃。

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

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

PGD 级别的这种共享本质上也共享页表的所有较低层。 这留下了一个单一的、共享的用户空间页表集来管理。 一个要锁定的 PTE,一组访问位,脏位等...

22.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 进行更新,只要对用于映射用户空间的 PGD 执行 set_pgd() 时。 这确保了内核和用户空间副本始终映射相同的用户空间内存。

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

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

22.4. 可能的未来工作

  1. 我们可以更加小心,除非 CR3 的值实际发生变化,否则实际上不写入 CR3。

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

22.5. 测试

为了测试 PTI 的稳定性,建议使用以下测试程序,理想情况下并行执行所有这些操作

  1. 设置 CONFIG_DEBUG_ENTRY=y

  2. 在多个 CPU 上循环运行 tools/testing/selftests/x86/ 中的多个副本(排除 MPX 和 protection_keys),持续数分钟。 这些测试经常发现内核入口代码中的角落情况。 通常,旧内核可能会导致这些测试本身崩溃,但它们永远不应使内核崩溃。

  3. 在生成许多频繁的性能监控非屏蔽中断的模式下(顶部或记录)运行 ‘perf’ 工具(参见 /proc/interrupts 中的 “NMI”)。 这会练习 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 位二进制文件。 这是一条测试不足的代码路径,需要额外的审查。

22.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 失效,或者遗漏失效。