Lec 23 熔断(Meltdown)
阅读论文:Meltdown: Reading Kernel Memory from User Space,Lipp 等,2018。
提醒:安全是操作系统的顶层目标之一。恶意用户代码是攻击的一大来源,内核的主要策略是隔离(用户/特权模式、页表、谨慎设计的系统调用、容器等)。如果这些都正确设置了,还能出什么问题?—— Meltdown 给出了一个令人不安的答案。
总览
- 为什么读这篇论文:页保护被绕过的微架构攻击
- 攻击核心代码(用户态)
- 前提:内核被映射进每个用户页表
- 两个被利用的微架构特性
- 推测执行(speculative execution)
- CPU 数据缓存 / TLB 缓存 与缓存计时
- Flush+Reload 旁路信道
- 完整的 Meltdown 攻击流程
- 为什么经常会失败
- 现实中如何被利用
- 防御:软件(KAISER/KPTI)与硬件
- FAQ 补充
- 论文重点图
一、为什么读这篇论文
- Meltdown 让恶意用户代码能够读取内核内存,尽管有页保护(PTE 权限位)—— 既出人意料又令人不安
- 它是近年一系列“微架构(micro-architectural)攻击”之一,利用了 CPU 隐藏的实现细节
- 可以修补,但人们担心还会有源源不断的同类“惊喜”
- 在 Linux 上可查看:
grep . /sys/devices/system/cpu/vulnerabilities/*
- 在 Linux 上可查看:
多提问!情况复杂且大多不可见 —— 只有 Intel 的设计者知道攻击为何成功/失败的细节,我们其余人都只是在猜测。
二、攻击核心代码(用户代码)
1. char buf[8192]
2. r1 = <一个内核虚拟地址>
3. r2 = *r1
4. r2 = r2 & 1
5. r2 = r2 * 4096
6. r3 = *(buf + r2)(这些代表机器指令。)
第 3 行用户代码试图用内核地址做 load —— 正常情况下这个 load 会触发缺页异常(page fault)。问题在于:异常被延迟了。
三、前提:内核被映射进每个用户页表
本文假设内核被映射进每个用户进程的页表中(PTE_U 清零):
2^64 +-----------------+
| kernel | 高地址:内核(PTE_U=0,禁止用户访问)
| ... |
| user | 用户内存从 0 开始
0 +-----------------+- 在这些攻击被发现之前,这几乎是通用做法
- 同时映射用户和内核可以让系统调用更快(无需切换页表,内核可直接用用户地址)
- 要点:即使被禁止访问,
*r1这个地址在物理上仍然是有意义的
四、被利用的微架构特性
1. 推测执行(speculative execution)
先抛开安全,看一段普通代码:
r0 = <某个地址>
r1 = load x // r1 是寄存器,x 是 RAM 中的变量
if (r1 == 1) {
r2 = *r0
r3 = r2 + 1
} else {
r3 = 0
}r1 = x若要从 RAM 取数,需要几百个周期if (r1 == 1)依赖这个 RAM 内容;若 CPU 必须停下来等,就太亏了- 于是 CPU 预测分支走向并继续执行,这就是“推测”
- 在
r1 == 1解析出来之前,CPU 可能预测为真,推测地执行r2 = *r0、r3 = r2 + 1
分支预测错了怎么办?
- 推测指令对寄存器的更新是暂定的,直到 commit 才生效
- 若发现分支预测错误,CPU 丢弃暂定更新,并从正确(else)分支重新执行
推测执行提升性能,因为它避免了等待慢速内存时的停顿。
如果推测执行了 r2 = *r0,但 r0 是非法指针呢?
- 若最终
x == 1,r2 = *r0本应抛异常 - 若最终
x == 0,r2 = *r0本不该抛异常 - CPU 只有在确认指令不会因预测错误被取消后,才会退休(retire)指令;并且按序退休,只有前面所有指令都退休后才知道没有更早的指令出错
- 因此:被推测执行的指令所引发的异常,可能在该指令“执行完”很久之后才真正发生
推测原则上是透明的:属于 CPU 实现,不在规范中可见,意在提升性能而不改变程序计算结果。CPU 通过撤销寄存器赋值、不对错误推测的指令抛异常来维持这种透明性。
术语:
- Architectural features(架构特性):手册里有、对程序员可见的东西
- Micro-architectural(微架构):手册里没有、意图不可见的东西 —— 芯片上数十亿晶体管的绝大部分
2. CPU 数据缓存 / TLB 缓存
core
L1: va,pa | data / TLB: va | pa
L2: pa | data
RAM- 缓存按 cache line(如 64 字节)划分;load miss 时取数并放入缓存
- L1 用虚拟地址低位做索引以求快
- CPU 必须同时查 L1 和 TLB:TLB 提供权限和物理地址(用于 L1 关联标签匹配)
- Intel L1 是 VIPT(Virtually Indexed, Physically Tagged)
- L1 miss 时:查 TLB,用物理地址查 L2
各级耗时:
| 操作 | 耗时 |
|---|---|
| L1 命中 | 几个周期 |
| L2 命中 | 十几到二十几个周期 |
| RAM | 300 周期 |
| TLB miss | 上百周期(要遍历页表树) |
(一个周期约 0.5 纳秒。)
系统调用返回用户态后,内核数据会遗留在 L1 缓存里(前提是页表同时映射了用户和内核)。 思考:为什么用户代码执行时 L1 含有内核数据是“安全”的?用户程序能直接从缓存里读出内核数据吗?—— Meltdown 正是攻击这个假设。
五、Flush+Reload 旁路信道
微架构其实并非完全不可见 —— 它影响指令/程序的耗时。一个有用的技巧是探测某个东西是否被缓存(即论文的 Flush+Reload):
想知道函数 f() 是否使用了地址 Z 处的内存:
- 确保 Z 不在缓存中(Intel 有
clflush指令;或加载足够多内存把其它东西挤出缓存) - 调用
f() - 记录时间(现代 CPU 可读周期计数器,Intel 用
rdtsc) - 从地址 Z load 一个字节(需要内存屏障保证 load 真的发生)
- 再次记录时间
- 若时间差 < (比如)50,说明第 4 步命中缓存 →
f()很可能用过 Z 处内存;否则没用过
六、完整的 Meltdown 攻击流程
// 用户内存
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 被回滚,但数据已留在缓存里>
<处理缺页异常>
// Flush+Reload 的 Reload 阶段
a = rdtsc
r0 = buf[0]
b = rdtsc
r1 = buf[4096]
c = rdtsc
if c-b < b-a:
低位很可能是 1即:用户代码可以根据两条缓存行中哪一条被加载(buf[0] vs buf[4096]),推断出内核数据的最低位。
三个要点:
r2 = *r1的异常被延迟到该 load 退休时才发生,这段时间足够让后续推测指令执行r2 = *r1似乎真的做了 load,即使 PTE 禁止,也把结果放进了 r2(只是临时的,退休时被异常回滚)r3 = buf[r2]把buf[]的一部分加载进缓存,即使对 r3 的修改因预测错误被取消 —— 因为 Intel 把缓存内容视为隐藏的微架构状态
乘以 4096 是为了让不同的字节值落到不同的缓存行,避免空间局部性带来的误判。要泄露完整一个字节,可对 256 个可能值各放一个 4096 字节的槽位。
七、为什么经常会失败
这个攻击确实有效、很多人复现过,但经常失败(论文 Listing 3/4 中每个 XX 就是一次失败,即第 5 行没有让任何缓存行被加载):
- 也许目标内核数据不在缓存,且因 PTE 权限没从 RAM 取来?
- 也许 load 在 RAM 取数完成前就退休并触发异常?
- 也许缓存冲突把数组挤了出去?
- 也许 TLB miss?机器上的其它活动?
- 也许“异常退休”与“数据返回”之间存在竞争,精确时序各异?
- 论文 6.2 节:若内核数据未缓存,约 10 字节/秒;重试有帮助(5.2 节,Listing 2)—— 也许内核数据未缓存时 CPU 会推测为 0
成功的条件并不清楚。
八、现实中如何被利用
- 攻击者需要在受害机器上运行自己的代码
- 分时系统:内核可能持有其他用户的秘密(密码、密钥),且内核可能映射了全部物理内存,包括其它进程
- 云:某些容器与 VMM 系统可能脆弱,可窃取其他云客户的数据
- 浏览器:在沙箱里运行不可信代码(如插件),插件也许能从内核偷出你的密码
为什么即使经常(甚至通常)失败仍对攻击者有用?
- 安全博弈的规则:防御方必须100% 获胜;攻击者哪怕只有 1% 成功也是灾难
- 例如能偷到 1% 用户的密码也很严重
- 而且攻击者可能能影响缓存里有什么(如发起系统调用、发网络请求)
Meltdown 没有已知的“真实世界”攻击案例,但它催生了大量补救工作。
九、防御
软件修复:
- 不要把内核映射进用户页表(论文称 KAISER,Linux 现称 KPTI)
- 代价:每次系统调用进出都要切换页表(RISC-V 版 xv6 就是这样工作的)
- 页表切换可能慢 —— 可能需要 TLB flush;PCID 可避免 flush,但仍有开销
- Meltdown 公开后许多内核很快采用了 KAISER/KPTI
硬件修复:
- 让推测 load 只返回被允许的数据:若 PTE 的 U/R/V 位清零,返回 0 而非真实数据
- 代价很小,因为 CPU 本来每次 L1 命中都要看 TLB 里的 PTE
- AMD CPU 一直就是这样工作的;现代 Intel CPU 似乎也做了(称为 RDCL_NO)
这些防御已部署且被认为有效。但令人不安的是:页保护原来并不是“铁板一块”。更多微架构惊喜不断出现 —— 根本问题只是可修复的 bug,还是策略上的错误?尚无定论。
十、FAQ 补充
- 新 CPU 还脆弱吗? 较新的 Intel CPU 不受 Meltdown 影响,但许多上一代 CPU 脆弱、需要 KAISER 等软件防御;AMD 被认为不受 Meltdown 影响。但还有其它相关攻击更难修复,需要软+硬件防御,引发了关于“隔离的软硬件契约”的讨论。
- 还有别的微架构旁路攻击吗? 很多。Meltdown/Spectre 是最早的,之后陆续发现许多(见 Spectre 词条)。MIT Course 6 现在开设了硬件安全课 6.5950。
- 如何防御旁路攻击? 旁路形式很多(EM 辐射、计时等)。加密代码会担心计时旁路、尽量写成常数时间。Spectre/Meltdown 是基于微架构特性泄露秘密的新类别。不太可能有“一招消除所有漏洞”的方案,但针对许多具体攻击类别已有有效防御。
- 为什么以前常把内核内存映射进用户页表? 让系统调用更快:无需切页表,内核可直接使用用户地址(如求值系统调用参数时)。
- KAISER 与 xv6 的页表有何关系? xv6 实现了一种 KAISER:用户页表不映射内核内存,用户/内核切换时切页表。所以(除一个小例外)没有用户地址指向内核内存,因此 Meltdown 对 xv6 无效。
- Meltdown 如何确保乱序执行发生? 被攻击的 Intel CPU 很可能会推测执行越过
mov以保持流水线满载。攻击并不完全可靠(Listing 3/4 中的 XX),一个原因可能是它只有在第 4 行mov命中 L1 时才工作良好。 - 怎么知道一条指令是 transient 的? 不知道,因为不知道 CPU 如何实现(Intel 闭源),无法预先知道某指令是否留下可测量的副作用 —— 只有有人想出利用方法才知道它是 transient 的。
- 如何修 CPU 来阻止 Meltdown? CPU 可在查 L1 的同时并行检查 TLB 中的权限,权限不通过时用 0 代替真实值,使后续推测指令看到 0,直到
mov退休(并抛异常)。 - Meltdown 与 Spectre 的关系? 都是利用推测执行的微架构旁路攻击,但利用点不同:Meltdown 利用数据访问绕过页表保护检查;Spectre 利用数据访问绕过数组边界检查。
- KASLR 是什么? 内核地址空间布局随机化,把内核 text 放在随机偏移,增加攻击者猜测内核函数位置的难度;但一旦知道偏移就能算出各函数地址。它让 Meltdown 稍微更难(要先猜偏移),但不是大障碍。KAISER 最初就是为防御针对 KASLR 的旁路攻击而开发的,恰好也能防 Meltdown。
- Meltdown 在野外被用过吗? 不清楚有真实攻击(攻击者也不会说)。
- KAISER/KPTI 的性能影响? 从可忽略到约 10%(IO 密集型负载),取决于具体负载。
- 现代 OS 有“敏感数据”概念以做更细粒度的页映射控制吗? 有相关努力,如
MAP_EXCLUSIVE。 - 微架构旁路如何被“持久化”和“清除”? 没有显式 API,这是 CPU 实现微架构特性的副作用。例如论文中 L1 缓存就是旁路信道被“持久化”的地方;某条缓存行可能因缓存冲突被替换而“清除” —— 这也是攻击不可靠的原因。
十一、论文重点图(Meltdown)
按要求,论文部分只记录图中最重点的内容。
图:构件总览(toy example / building blocks)
- 攻击由两块拼成:transient 指令序列 + 隐蔽信道(covert channel)
- transient 序列:推测地从内核地址 load 出秘密字节,再以该字节为索引访问探测数组(乘 4096 使不同值落入不同缓存行)
- 隐蔽信道:用 Flush+Reload 测量探测数组各元素的访问延迟,从而推断哪一个被缓存
图:核心攻击代码(Listing 1 / Listing 2)
- Listing 1:汇编实现 —— 访问内核地址得到字节值 →
* 4096散开到不同缓存行 → 访问探测数组使对应行进缓存 - Listing 2:重试 / 异常抑制版本 —— 把 transient load 包在异常处理(try/catch 或信号处理)中以抑制缺页异常,并重试以提高成功率
图:访问时间直方图(cycles vs 数组页索引)
- 横轴是探测数组的 256 个页索引,纵轴是访问周期数
- 绝大多数索引延迟高(200+ 周期,cache miss)
- 只有一个索引延迟很低(个位数周期,cache hit)—— 这个索引的位置就编码了被泄露的秘密字节值
图:地址空间布局
- 内核内存被映射进用户态虚拟地址范围,特权页在权限检查完成前可被推测执行访问 —— 这正是 Meltdown 的前提
数据:泄露率与错误率
- 在有利条件下泄露率约 3.2 KB/s,每字节错误率 < 1%
- 受系统噪声和缓存竞争影响会下降(内核数据未缓存时约 10 字节/秒)
下周 —— 考试!