21. 页表隔离 (PTI)¶
21.1. 概述¶
页表隔离 (pti,以前称为 KAISER [1]) 是一种针对共享用户/内核地址空间攻击的对策,例如 “Meltdown” 方法 [2]。
为了缓解此类攻击,我们创建了一组独立的页表,仅在运行用户空间应用程序时使用。当通过系统调用、中断或异常进入内核时,页表会切换到完整的 “内核” 副本。当系统切换回用户模式时,再次使用用户副本。
用户空间页表仅包含最少的内核数据:仅包含进入/退出内核所需的内容,例如入口/出口函数本身和中断描述符表 (IDT)。有一些严格来说不必要的映射,例如进入中断时的第一个 C 函数(请参阅 pti.c 中的注释)。
这种方法有助于确保在启用 PTI 时,利用分页结构进行侧信道攻击不会起作用。可以通过在编译时设置 CONFIG_MITIGATION_PAGE_TABLE_ISOLATION=y 来启用它。一旦在编译时启用,可以使用 'nopti' 或 'pti=' 内核参数在启动时禁用它(请参阅 kernel-parameters.txt)。
21.2. 页表管理¶
启用 PTI 后,内核管理两组页表。第一组与没有 PTI 的内核中存在的单组页表非常相似。这包括内核可以用于 copy_to_user() 之类的操作的完整用户空间映射。
尽管是 _完整_ 的,但内核页表的用户部分通过在顶层设置 NX 位而被削弱。这确保任何错过的 kernel->user CR3 切换都会在执行其第一条指令时立即使用户空间崩溃。
用户空间页表仅映射进入和退出内核所需的内核数据。此数据完全包含在 'struct cpu_entry_area' 结构中,该结构位于 fixmap 中,fixmap 为该区域的每个 CPU 副本提供一个编译时固定的虚拟地址。
对于新的用户空间映射,内核像往常一样在其页表中进行条目。唯一的区别是内核在顶层 (PGD) 中进行条目时。除了在主内核 PGD 中设置条目外,还在用户空间页表的 PGD 中制作条目的副本。
PGD 级别的这种共享也固有地共享了页表的所有较低层。这留下了一组要管理的单一、共享的用户空间页表。一个要锁定的 PTE,一组访问位,脏位等...
21.3. 开销¶
防止侧信道攻击非常重要。但是,这种保护是有代价的
增加内存使用
现在每个进程都需要 order-1 PGD 而不是 order-0。(每个进程额外消耗 4k)。
'cpu_entry_area' 结构的大小必须为 2MB,并且必须与 2MB 对齐,以便可以通过设置单个 PMD 条目进行映射。一旦内核被解压,这将消耗近 2MB 的 RAM,但在内核映像本身中不占用空间。
运行时成本
必须在中断、系统调用和异常的入口和出口处进行 CR3 操作以在页表副本之间切换(尽管可以在中断内核时跳过它)。对 CR3 的移动大约需要一百个周期,并且在每个入口和出口处都需要。
Percpu TSS 被映射到用户页表中,以允许 SYSCALL64 路径在 PTI 下工作。这没有直接的运行时成本,但可以说它打开了某些定时攻击场景。
全局页对于未映射到内核和用户空间页表中的所有内核结构都被禁用。MMU 的此功能允许不同的进程共享映射内核的 TLB 条目。失去该功能意味着上下文切换后会有更多的 TLB 未命中。然而,实际的性能损失非常小,永远不会超过 1%。
进程上下文标识符 (PCID) 是一种 CPU 功能,允许我们在更改页表时通过在 CR3 中设置一个特殊位来跳过刷新整个 TLB。这使得切换页表(在上下文切换或内核入口/出口时)更便宜。但是,在具有 PCID 支持的系统上,上下文切换代码必须从 TLB 中刷新用户和内核条目。用户 PCID TLB 刷新会延迟到退出用户空间,从而最大程度地降低成本。有关 PCID/INVPCID 的详细信息,请参阅 intel.com/sdm。
必须为每个新进程填充用户空间页表。即使没有 PTI,共享内核映射也是通过将顶层 (PGD) 条目复制到每个新进程中来创建的。但是,对于 PTI,现在有 *两个* 内核映射:一个在映射所有内容的内核页表中,另一个用于入口/出口结构。在 fork() 时,我们需要复制两者。
除了 fork() 时的复制外,每当对用于映射用户空间的 PGD 执行 set_pgd() 时,还必须更新用户空间 PGD。这确保了内核和用户空间的副本始终映射相同的用户空间内存。
在没有 PCID 支持的系统上,每次 CR3 写入都会刷新整个 TLB。这意味着每次系统调用、中断或异常都会刷新 TLB。
INVPCID 是一条 TLB 刷新指令,允许刷新非当前 PCID 的 TLB 条目。某些系统支持 PCID,但不支持 INVPCID。在这些系统上,只能从当前 PCID 的 TLB 中刷新地址。刷新内核地址时,我们需要刷新所有 PCID,因此单个内核地址刷新将需要在下次使用每个 PCID 时进行 TLB 刷新 CR3 写入。
21.4. 可能的未来工作¶
我们可以更加谨慎地避免实际写入 CR3,除非它的值实际发生更改。
允许在运行时启用/禁用 PTI,而不是在启动时切换。
21.5. 测试¶
为了测试 PTI 的稳定性,建议执行以下测试过程,理想情况下并行执行所有这些测试
设置 CONFIG_DEBUG_ENTRY=y
在多个 CPU 上循环运行工具/测试/自测/x86/ 的多个副本(不包括 MPX 和 protection_keys),持续几分钟。这些测试经常会发现内核入口代码中的极端情况。一般来说,旧内核可能会导致这些测试本身崩溃,但它们永远不应该导致内核崩溃。
在生成许多频繁的性能监控不可屏蔽中断(请参阅 /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
启动 KVM 虚拟机。
在支持 SYSCALL 指令的系统上运行 32 位二进制文件。这是一个经过轻微测试的代码路径,需要额外的审查。
21.6. 调试¶
PTI 中的错误会导致一些不同的崩溃签名,这里值得注意。
自测/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) 无法挂载根文件系统。这些通常是 TLB 失效问题。通常是使错误的 PCID 失效,或者遗漏了失效。