背景

2018,可视作CPU发展历史的重要节点,就是因为爆出了影响范围最广的源自CPU硬件设计的安全漏洞:Meltdown和Spectre,有关这两个漏洞说明,参见我的其他文章。而本文的焦点pti,即专门用于mitigate Meltdown漏洞。

原理

页表/进程/Meltdown相关原理

从pti引入之前,内核中,每个进程拥有自己独立的地址空间->mm->页表,也就是说每个进程都有一个属于自己的页表。这个页表中包含两部分内容:

  1. 用户态地址空间。用于映射用户态地址的所有线性地址。拿x86_32(最简单)来说,其典型区间范围为1G-3G(教科书上常见)。
  2. 内核态地址空间。用于映射内核态地址的所有线性地址。拿x86_32来说,其典型区间范围为3G-4G(教科书上常见)。

也就是说,内核态和用户态使用同一张页表,那么在用户态和内核态之间进行切换时,是不用切换页表的。那么,内核态和用户态地址其实是在同一个TLB context中,即当在用户态运行时,TLB中可能缓存内核态的地址映射,如此,即给攻击者留下了安全缺口,这也是Meltdown漏洞攻击的主要原理。

这里还有几点需要说明

  1. 虽然,每个进程都有自己独立的地址空间和页表,但其实每个进程的页表的内核部分都应该是相同的,即每个进程页表中内核地址空间部分都是相同的。
  2. 内核有一个专门的页表init_mm,是用来跟每个进程的页表中的内核部分进行同步用的,当内核地址空间的地址映射发生改变时(比如vmalloc),内核有一定的机制能保证将改动同步到每个进程的页表中。详细原理和代码可以看看我的其他文章。 。

    pti

pti,即Page Table Isolation,previously known as KAISER。

其主要思想:让内核态和用户态使用独立的页表。确切的说,每个进程拥有两张页表:用户态页表和内核态页表,当用户态和内核态切换时,同时切换相应页表。

如此,由于使用独立页表,那么内核态和用户态地址则分别处于不同的TLB context中,如此,用户态再无法通过CPU的预测执行来访问内核态地址了,即可规避掉Meltdown漏洞。

主要知识点:

  1. 当运行在用户态时,使用用户态自己的页表,当切换到内核态时(通过系统调用、中断或异常),切换到内核态自己的页表;相反亦然。

  2. 用户态页表中包含很少的内核地址映射,这部分地址用于进入/退出内核,比如entry/exit functions和中断描述符表(IDT)。这些内核数据都被包含在了 cpu_entry_area 数据结构中,而这个数据结构被放在了fixmap区域(见我的其他文章)。

  3. 内核页表中包含用户页表中的全部拷贝,也就是说,内核页表中同时包含用户地址空间和内核地址空间的所有映射,因为内核中需要能访问用户地址空间。虽然如此,用户地址部分的映射还是在最顶层的页表中设置了NX位,这样能保证当从内核态切换到用户态时,如果没有切换页表,在进入到用户态执行第一条执行时即会触发用户态的crash。

  4. 当用户态分配内存时(创建新的地址映射,肯定是在内核中创建的~),内核首先创建相应的页表项(与没有pti时一致),区别在于,当内核创建PGD(页目录,顶层页表)entry时,除了设置当前(内核)页表中的entry,同时也会拷贝一份到相应的用户PGD table中,即内核和用户页表各自有自己的PGD,在PGD的所有下级页表都是共享的。即,内核和用户页表并非完全独立,其实只有PGD这一层独立,其他层都共享,如此能最大程度节省空间和维护成本。

开销

pti能migigate Meltdown漏洞,但却带来了额外的开销,主要在性能方面。

实际测试,运行unixbench标准测试集,在Intel典型CPU上性能损耗在15-25%之间。如果只运行unixbench syscall(即只测试系统调用性能),性能损耗尤其大,在40-70%之间。有关具体的性能损耗数据和原理,后面单独写文章说明。这里主要从概念层面列举pti开来的额外开销,主要分为两类:

  1. 内存占用开销。
  2. 性能开销。

内存占用开销包括:额外的PGD,占用1 page;cpu_entry_area额外占用2MB。总体来说很少,基本可以忽略。

性能开销就比较多了,这也是主要的关注点:

  1. 在用户态和内核态间切换时(系统调用、中断、异常),CR3操作(主要是mov to CR3)带来的性能开销。mov to CR3带来的性能开销以100 cycles位单位计,(实际测试开销更大)。
  2. 系统调用entry时,必须时用trampoline方式,这种方式依赖于更小的资源集合(比non-PTI的传统方式),所以只需要映射更少的内核数据到用户页表中。但是缺点是每次entry时,需要同时切换堆栈,带来额外的性能开销。
  3. 全局页disabled。在引入pti之前,所有的内核空间的page都设置位Global page,这样能让不同的进程共享内核地址空间对应的所有TLB entries,能在进程切换时减少TLB flush数量。全局页disabled后,必然会增加TLB miss,带来额外的性能开销。官方文档中说该开销很小,不应该超过1%,个人并不认同,通过实际测试看,实际性能开销应该比这个大不少。
  4. TLB flush。当用户态/内核态切换导致页表切换时(通过mov to CR3),会触发TLB flush,当没有PCID时,会Flush掉所有的TLB,这个操作带来的性能开销巨大,且不说TLB flush操作本身的性能开销,在所有的TLB entry都失效后,TLB misses带来的开销也是不容忽视的。
  5. PCID(Process Context IDentifiers, CPU特性,较新CPU基本都有)可以用来在页表切换时减少(或避免)TLB flush操作(通过设置CR3中的NO_FLUSH位,63位)。但是,当支持PCID时,上下文切换时就必须同时flush掉用户和内核页表对应的TLB entry,也就是比没有PCID支持的情况,在上下文切换时多了一些TLB flush操作。user PCID TLB flush操作会被延迟到exit to用户态时执行,如此能减小开销。
  6. 在进程创建时(fork),需要同时额外创建并初始化用户态页表,需要在fork()中处理。
  7. 在fork()时,每当通过set_pdg()来映射用户地址空间时,还需要同时更新用户空间的PGD,这样才能保证内核页表和用户页表总是映射相同的用户态地址空间。
  8. INVPCID指令可以用来flush指定PCID对应的TLB entry,但是一些硬件支持PCID,但不支持INVPCID,这种情况下,只能flush当前PCID对的entry,如果需要flush一个内核地址(比如vfree),则必须flush掉所有的PCID,而且必须在进行上下文切换时,通过mov to CR3的方式flush,如此带来的性能损耗也需要关注。