LWN: Spectre 漏洞利用了BPF!
关注了就能看到更多这么棒的文章哦~
Spectre revisits BPF
By Jonathan Corbet
June 24, 2021
DeepL assisted translation
https://lwn.net/Articles/860597/
自 Spectre 硬件漏洞被披露以来,已经过去三年了,但 Spectre 仍在不断给我们新的意外。在硬件行为完全可以预测的情况下要想写出正确且安全的代码已经很困难了,而如果处理器会随机做出一些无法预测到的行为的时候,情况就变得更加困难了。为了说明这里会面临的挑战,我们只需要看看这个公告(https://lwn.net/ml/oss-security/CAHMfzJkhZ01FG62sfMdXayK_NwD3g=5NcpGmg+-PVZLBpjJ9Fw@mail.gmail.com/)中所介绍的 BPF 漏洞就好,这个漏洞本身已在 5.13-rc7 版本中得到了 fix。
针对 Spectre 漏洞的攻击一般是先要让处理器在 speculative mode 下执行一系列在现实代码中不会发生的操作。典型的例子就是访问超出数组范围之外的位置(out-of-range array reference),尽管代码本身已经正确地进行了边界检查。当处理器发现它错误地预测了边界检查的结果的时候,就会把之前进行的错误的访问撤销,但是这个 speculative access(预测性访问)会在 cache 中留下痕迹,这样就可以被用来探测有价值的数据了。
在抵御这类预测执行的攻击方面,BPF 虚拟机一直是一个特别值得关注的领域。大多数这类攻击都依赖于找到内核代码的某个片段,当 CPU 进行预测执行时,利用这段代码来做一些破坏性的工作。内核开发者已经做了不少努力来消除这样的代码片段。但是,BPF 的存在就是为了能够加载来自用户空间的、可以运行在内核上下文中的代码,这就使得攻击者能够制作他们自己的代码,而不用再从 kernel 代码中寻找攻击目标了。
为了挫败这些攻击者,BPF 社区已经做了很多工作。例如将数组索引(array index)与一个 bitmask 进行 AND 操作,这样一来访问数组的时候就不会越过数组边界了,即使 CPU 是在 speculative 模式并且这些索引是乱七八糟的值也不怕了。但人们还是很难列出处理器在各种情况下的处理方式,难免会遗漏一些场景。
The vulnerability
下面的代码就是一个例子,来自 Daniel Borkmann 对这个漏洞进行 fix 时提交的说明:
// r0 = pointer to a map array entry
// r6 = pointer to readable stack slot
// r9 = scalar controlled by attacker
1: r0 = *(u64 *)(r0) // cache miss
2: if r0 != 0x0 goto line 4
3: r6 = r9
4: if r0 != 0x1 goto line 6
5: r9 = *(u8 *)(r6)
6: // leak r9
顺便说一下,这个 patch 的 changelog 非常值得一读,在记录漏洞本身以及相关 fix 方面是一个非常棒的例子。
在正常(non-speculative)执行时,上述代码有一个潜在问题。寄存器 r9 包含的是攻击者提供的值,这个数值在第 3 行被赋值给 r6,然后在第 5 行被用作指针了。这个值可能是指向内核地址空间的任何地方的,这正是 BPF verifier 想要阻止的那种无拘无束的访问操作,所以人们可能会以为这段代码肯定不会被 kernel 接受并执行。
但是,BPF verifier 的工作机制是遍历执行 BPF program 的所有可能代码分支来判断是否接受。它的遍历中,并不会既执行第 3 行又执行第 5 行。因为攻击者提供的指针赋值操作只有在 r0 内容是 0 时才会发生,但这个值会导致第 5 行不会得到执行。BFP Verifier 因此得出结论,认为没有任何执行流程会导致把用户提供数据当作指针进行了访问,于是它就正常加载并执行了这个 BPF Program。
但是这个验证分析完全是按照正常执行的情况来进行的,在 CPU 根据 speculative 执行的过程中则并不遵照这种规则。
上面这段代码中的第 1 行中引用的这块内存,攻击者会想尽办法来让这段内存不存在于 CPU cache 中,这样第一行的访问就会触发一次 cache miss。而处理器并不会等待从内存中取到的数值,而是根据预测来进行执行后续与 r0 有关的代码。而实际上这些预测的结果有可能会导致两处 if 条件(在第 2 行或第 4 行)都被判断为 false,于是就不会进行跳转。
这种情况怎么可能会出现呢?原因是,branch prediction(分支预测)并不是通过猜测 r0 的值来进行判断来实现的。其实它是基于某个特定代码分支最近几次执行的历史来判断的。这个历史信息
存储在 CPU 的 "pattern history table"(PHT)中。但是,CPU 不可能跟踪记录某个大型程序中的每一条 branch 指令,所以 PHT 就采用了 hash table 的形式。攻击者会寻找一些代码,使得它的 branch 会被放在精心设计的 BPF program 中的 branch 相同的 PHT 条目位置内,然后使用该代码来调教 branch predicator,从而诱使其最终按攻击者的期望来进行预测执行。
一旦攻击者加载了这段代码,清除了 cache,并愚弄了 branch predicator 做出了啥事,那么攻击者的目的就达到了,CPU 会根据预测来访问引用攻击者提供的地址。后续只需要用一些常规方式来把结果暴露出来就可以完成攻击了。这个过程还是挺麻烦的,但是计算机天生就适合做这种重复执行的操作。
值得注意的是,这种攻击方式并不仅存在于假想之中。根据上面的公告,当这个问题被报出来的时候, security@kernel.org 列表上就收到了很多个 proofs-of-concept 的攻击方案。其中一些方案都不需要对 branch predicator 进行调教的步骤。这些攻击可以读到内核地址空间中的任意内存的内容,在所有物理内存都属于内核地址空间的系统上,相当于任何数据都可以被窃取到了。由于非特权用户也可以加载某些类型的 BPF program,所以攻击者都不需要 root 权限。换句话说,这是一个严重的漏洞(serious vulnerability)。
Closing the hole
针对这种问题的 fix 是比较简单易懂的。BPF Verifier 不要再去跳过那些它明知道不可能执行的代码分支就好,而是根据 speculative 的方式来模拟执行这些路径。这样一来,在看到 r0 为零的代码分支时,之前有问题的 verifier 会简单地得出结论认为第 4 行的判断结果一定是 true,而不再考虑其他可能。在 fix 后,verifier 仍然会走到 false 的分支里去(包括第 5 行),从而得出结论这里正在使用一个未知指针,从而禁止加载这个 BPF program。
这个改动有可能会导致之前能正常运行的正确代码也无法加载了,尽管很难想到有哪些正常代码会包含这类的代码 pattern。当然,这将会导致 verifier 进行检查时所花费的时间更长,因为它需要检查在正常程序执行中不可能出现的代码路径,但我们没有办法,谁让我们面对的是这些会进行 speculative 执行的 CPU 呢。
这个 fix 已经被合入了 mainline,可以在 5.13-rc7 版本中找到。此后,它又被合入了 5.12.13 和 5.10.46 这两个稳定版的 update 中,但目前(还)没有进入那些更早期的 stable kernel。有了这个 fix 的内核就可以防止另一个 Spectre 漏洞被利用了,但如果认为这是最后一个,那就太天真了。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~