Skip to content

6.1810 2024 第21讲:Meltdown

为什么是这篇论文?

  • 安全是操作系统的一个关键目标
  • 内核的主要策略:隔离
    • 用户/监控模式、页表、防御性系统调用等
  • 如果你把一切都设置正确了,还可能出什么问题?

Meltdown

  • 允许恶意用户代码读取内核内存,尽管有页面保护。
  • 令人惊讶和不安
  • 最近一系列“微架构”攻击之一
    • 利用了CPU隐藏的实现细节
  • 可修复,但人们担心会有源源不断的相关意外出现
    • Linux: grep . /sys/devices/system/cpu/vulnerabilities/*

这是攻击的核心(这是用户代码):

c
1.  char buf[8192]
2.  r1 = <一个内核虚拟地址>
3.  r2 = *r1
4.  r2 = r2 & 1
5.  r2 = r2 * 4096
6.  r3 = buf[r2]

第3行的加载会把内核数据加载到r2吗?

假设内核被映射在用户页表中,且PTE_U标志位被清除。

  • [图:0 到 2^64]
  • 在这些攻击被发现之前,这几乎是普遍做法。
  • 用户内存从零开始;内核在高地址。
  • 同时映射用户和内核使系统调用更快。
  • 重点是:*r1 是有意义的,即使被禁止。

那么这段代码怎么可能对攻击者有用呢?

  • 答案与一堆大多隐藏的CPU实现细节有关。
  • 推测执行(Speculative execution)和CPU缓存。

首先,推测执行。

  • 这还与安全无关。
  • 想象一下这段普通代码。
  • 这是类C代码;"r0"等是寄存器,"*r0"是内存引用。
c
r0 = <某个地址>
r1 = 从RAM加载x  // r1是寄存器;x是RAM中的变量
if(r1 == 1){
  r2 = *r0
  r3 = r2 + 1
} else {
  r3 = 0
}
  • 从RAM加载 "r1 = x" 需要数百个周期。
  • "if(r1 == 1)" 需要那个RAM内容。
  • 如果CPU必须暂停直到RAM获取完成,那就太糟糕了。
  • 相反,CPU会预测分支可能走向哪边,
    • 并继续执行。
  • 这被称为“推测”。
  • 所以在 "r1 == 1" 被解析之前,
    • CPU可能会推测性地执行 "r2 = *r0",
    • 然后是 "r3 = r2 + 1"。

如果CPU错误预测了一个分支,例如x结果是零怎么办?

  • CPU会刷新错误推测的结果。
  • 具体来说,CPU会恢复r2和r3的内容。
  • 并重新开始执行,在“else”分支中。

推测执行有助于提高性能,因为它有助于

  • 避免CPU在等待慢速内存时停顿
  • (其他慢速操作也一样,比如除法)。

如果CPU推测性地假设r1 == 1,但r0持有一个非法指针怎么办?

  • 如果结果是x == 1,"r2 = *r0" 应该引发一个异常。
  • 如果结果是x == 0,"r2 = *r0" 不应该引发异常。

CPU只有在确定指令不会因错误推测而被取消时才会“提交”它们。

  • 并且CPU按顺序提交指令,只有在所有
    • 先前的指令都已提交后,因为它只有到那时才
    • 知道没有先前的指令出错。
  • 因此,由推测执行的指令引起的错误可能在指令完成后一段时间才发生。

推测原则上是不可见的——是CPU实现的一部分,

  • 但不是规范的一部分。
  • 也就是说,推测旨在提高性能而不改变程序计算的结果——是透明的。
  • CPU通过在意识到推测错误时撤销寄存器赋值,并且不从错误推测的指令中引发异常,来使推测透明。

一些术语:

  • “架构特性”——CPU手册中的东西,对程序员可见。
  • “微架构”——手册中没有,旨在不可见。

另一个微架构特性:CPU数据和TLB缓存。

  • 核心
  • L1: va,pa | data / TLB: va | pa
  • L2: pa | data
  • RAM
  • 如果加载未命中,数据被获取,并放入缓存。
  • L1(“一级”)缓存是虚拟索引的,为了速度。
    • 系统调用返回用户空间后,会将内核数据留在L1缓存中。
    • (假设页表同时有用户和内核映射)
  • CPU必须同时查询L1和TLB,后者用于权限
    • 和L1关联性标签的物理地址,
    • 来决定加载是否在L1中命中。
    • (Intel L1是虚拟索引,物理标记 (VIPT))
  • L1未命中时:TLB查找,用物理地址进行L2查找。
  • 时间:
    • L1命中——几个周期。
    • L2命中——十几个或二十几个周期。
    • RAM——300个周期。
    • 一个周期是1/时钟频率,例如0.5纳秒。

当执行用户代码时,L1缓存可能包含内核地址和数据。

  • 例如,在系统调用返回后。

为什么在执行用户代码时L1包含内核数据是安全的?

  • 用户程序能直接从缓存中读取内核数据吗?

在现实生活中,微架构并非完全不可见。

  • 它影响指令和程序需要多长时间。
  • 对编写性能关键代码的人来说,它引起了极大的兴趣。
  • 对编译器编写者也是如此。
  • Intel等公司发布优化指南,包含一些细节,但不是全部。

一个有用的技巧:感知某物是否被缓存。

  • 这是论文的Flush+Reload。
  • 你想知道函数f()是否使用了地址Z处的内存。
    1. 确保地址Z处的内存未被缓存。
      • Intel CPU有clflush指令。
      • 或者加载足够多的内存位置以强制将其他所有内容从缓存中挤出。
    2. 调用f()
    3. 记录时间。
      • 现代CPU让你读取一个周期计数器。
      • 对于Intel CPU,是rdtsc指令。
    4. 从地址Z加载一个字节
      • (你需要内存栅栏来确保加载真正发生)
    5. 再次记录时间。
    6. 如果时间差小于(比如说)50,那么#4中的加载命中了,
      • 这意味着f()可能使用了地址Z处的内存。

回到Meltdown——这次更详细:

c
char buf[8192]

// Flush+Reload的Flush部分
clflush buf[0]
clflush buf[4096]

1.  r1 = <一个内核虚拟地址>
2.  r2 = *r1
3.  r2 = r2 & 1      // 推测执行
4.  r2 = r2 * 4096   // 推测执行
5.  r3 = buf[r2]     // 推测执行

<来自*r1的页错误;r2和r3被回滚,但缓存没有><处理页错误>

c
// Flush+Reload的Reload部分
a = rdtsc
r0 = buf[0]
b = rdtsc
r1 = buf[4096]
c = rdtsc
if b-a > c-b:
  低位可能是一个1

也就是说,你可以根据两个缓存行中哪一个被加载(buf[0] vs buf[4096])来推断内核数据的低位。

要点: 来自 "r2 = *r1" 的错误被延迟到加载提交时,

  • 这可能需要一段时间,为后续的推测指令执行提供了时间。

要点: 显然,"r2 = *r1" 确实执行了加载,即使PTE

  • 禁止它,并将结果放入r2,尽管只是暂时的,因为
  • 在提交时因错误而被恢复。

要点: "r3 = buf[r2]" 将buf[]的一些内容加载到缓存中,

  • 即使对r3的更改因错误推测而被取消。
  • 因为Intel将缓存内容视为隐藏的微架构。

攻击常常不成功

  • 列表3/4中的每个XX都是一次失败
    • 失败的原因尚不清楚。
    • 也许所需的内核数据不在L1缓存中?
      • 并且由于PTE权限而没有从RAM中获取?
      • 或者加载在RAM获取完成前就到达提交阶段?
    • 也许缓存冲突将数组踢出?
    • 也许TLB未命中或有冲突?
    • 也许机器上的其他活动?
    • 也许加载需要不同的时间来提交和出错?
  • 第6.2节说,如果内核数据未缓存,速度为10字节/秒
    • 重试有帮助——为什么?
    • 可能需要数千次重试——为什么?
  • 成功的条件尚不清楚。
  • 也许如果内核数据在L1中就可靠,否则就不可靠。

Meltdown如何在真实世界的攻击中使用?

  • 攻击者需要在受害者机器上运行他们的代码。
  • 分时系统: 内核可能有其他用户的秘密,例如密码、密钥。
    • 并且内核可能映射所有物理内存,包括其他进程。
  • 云: 一些容器和VMM系统可能易受攻击,
    • 所以你可以从其他云客户那里窃取数据。
  • 你的浏览器: 它在沙箱中运行不受信任的代码,例如插件,
    • 也许一个插件可以从你的内核中窃取你的密码。

然而,Meltdown尚未被知晓在任何实际攻击中使用过。

防御措施呢?

一个软件修复:

  • 不要在用户页表中映射内核。
    • 论文称之为“KAISER”;Linux现在称之为KPTI。
  • 需要在每次系统调用进入/退出时进行页表切换。
    • 这就是RISC-V xv6的工作方式。
  • 页表切换可能很慢——它可能需要TLB刷新。
    • PCID可以避免TLB刷新,尽管仍有一些开销。
  • 许多内核在Meltdown被知晓后不久就采用了KAISER/KPTI。

一个硬件修复:

  • 只从推测性加载中返回允许的数据!
    • 如果PTE_U/R/V被清除,返回零,而不是实际数据。
  • 这可能几乎没有成本,因为CPU无论如何都必须在每次L1命中时查看TLB中的PTE。
  • AMD CPU显然一直都是这样工作的。
  • 最新的Intel CPU似乎也这样做(称为RDCL_NO)。

这些防御措施已经部署并被认为是有效的;但是:

  • 页面保护结果并不稳固,这令人不安!
  • 更多的微架构意外正在出现。
  • 根本问题是可修复的错误吗?还是策略上的错误?
  • 敬请关注,这仍在发展中。

参考资料:https://googleprojectzero.blogspot.com/2018/01/reading-privileged-memory-with-side.htmlhttps://cyber.wtf/2017/07/28/negative-result-reading-kernel-memory-from-user-mode/https://eprint.iacr.org/2013/448.pdfhttps://gruss.cc/files/kaiser.pdfhttps://en.wikipedia.org/wiki/Kernel_page-table_isolationhttps://spectrum.ieee.org/computing/hardware/how-the-spectre-and-meltdown-hacks-really-workedhttps://lwn.net/Articles/741878/