Spectre CPU漏洞借着BPF春风卷土重来


By Jonathan Corbet

翻译整理: 极客重生

https://lwn.net/Articles/860597/

Hi,大家好,昨天不小心看到一篇文章,是关于BPF安全漏洞问题,BPF给内核新功能的开发带了很大灵活性,尤其是监控和网络方面,是Linux内核当前最火技术方向之一,但同时也带来新的安全隐患。 安全和灵活性,总是矛盾的,类似容器和虚拟机的安全之争,极客们的追求就是不断挖掘技术的可能性,让安全性和灵活性都可以满足。

对eBPF不熟悉可以参考:

Linux网络新技术基石 |eBPF and XDP

正文

自从披露 Spectre 硬件漏洞(2018年轰动世界的CPU漏洞)以来已经三年多了,但 Spectre 确实是一份不断给予的"礼物"。当硬件以可预测的方式运行时,编写正确且安全的代码就足够困难了,当处理器可以做随机和疯狂的事情时,问题会变得更糟。为了说明所涉及的挑战,只需查看本公告中描述的 BPF 漏洞即可,该漏洞 已在 5.13-rc7 版本中修复。

对 Spectre 漏洞的攻击通常依赖于处理器在预测模式下执行一系列在实际执行中不会发生的操作。一个典型的例子是超出范围的数组引用,即使代码执行了正确的边界检查。一旦处理器发现它错误地预测了边界检查的结果,错误的访问就会被取消,但预测模式下访问会在内存缓存中留下可用于窃取数据的痕迹。

在预测模式执行的攻击方面,BPF 虚拟机一直是一个特别值得关注的领域。大多数此类攻击依赖于寻找内核代码片段,当 CPU预测性执行时,该片段可以做出令人惊讶的事情;内核开发人员已经齐心协力消除这些碎片。但是 BPF 的存在是为了能够从在内核上下文中运行的用户空间加载代码;这允许攻击者制作自己的代码片段并避免梳理内核代码的繁琐任务。

BPF 社区已经做了很多工作来挫败这些攻击者。例如,数组索引与位掩码进行 AND 运算,因此无论它们可能包含什么值,它们甚至无法推测性地到达数组之外。但是很难预测处理器可能会做出令人惊讶的事情的每种情况。

漏洞

例如,考虑以下代码片段,该代码片段直接取自 Daniel Borkmann 修复此漏洞的提交:

    // r0 = 指向映射数组条目的指针
    // r6 = 指向可读栈槽的指针
    // r9 = 攻击者控制的标量
    1: r0 = *(u64 *)(r0) // 缓存未命中
    2:如果 r0 != 0x0 转到第 4 行
    3:r6 = r9
    4: 如果 r0 != 0x1 转到第 6 行
    5:r9 = *(u8 *)(r6)
    6: // 泄漏 r9

顺便说一下,这个补丁的变更日志(change log)是记录漏洞及其修复的一个很好的例子,值得一读:

From 9183671af6dbf60a1219371d4ed73e23f43b49db Mon Sep 17 00:00:00 2001
From: Daniel Borkmann <daniel@iogearbox.net>
Date: Fri, 28 May 2021 15:47:32 +0000
Subject: bpf: Fix leakage under speculation on mispredicted branches


验证器仅枚举有效的控制流路径并跳过在非推测域中无法访问的路径。
因此,它可能会遗漏预测错误分支上的推测执行下的问题。


例如,以下
精心设计的程序证明了类型混淆:


  // r0 = 指向映射数组条目的
  指针 // r6 = 指向可读堆栈槽的指针
  // r9 = 由攻击者控制的标量
  1: r0 = *(u64 * )(r0) // 缓存未命中
  2: 如果 r0 != 0x0 转到第 4 行
  3: r6 = r9 
  4: 如果 r0 != 0x1 转到第 6 行
  5: r9 = *(u8 *)(r6) 
  6: // 泄漏 r9


由于第 3 行运行 iff r0 = = 0 并且第 5 行运行 iff r0 == 1,验证器
得出结论,第 5 行的指针取消引用是安全的。但是:如果
攻击者训练两个分支都失败,从而
推测执行以下内容... 


  r6 = r9 
  r9 = *(u8 *)(r6) 
  // 泄漏 r9 


... 那么程序将取消引用一个攻击者控制的值,并可能
通过侧信道在推测执行下泄漏其内容。这需要
对分支预测器进行错误训练,这可能相当棘手,因为
分支是相互排斥的。然而,这样的训练可以
在用户空间中使用不
互斥的不同分支在一致的地址上完成。也就是说,通过在用户空间中训练分支... 


  A: if r0 != 0x0 goto line C 
  B: ... 
  C: if r0 != 0x0 goto line D 
  D: ... 


... 这样地址 A 和C 分别
与 PHT(模式历史表)中与 BPF 程序的
第 2 行和第 4 行相同的 CPU 分支预测条目发生冲突。非特权攻击者可以简单地
在 PHT 中暴力破解此类冲突,直到观察到攻击成功。


错误训练分支预测器的替代方法也是可能的
避免暴力破解 PHT 中的冲突。已经
证明了一种可靠的攻击,例如,使用以下精心设计的程序:


  // r0 = 指向 [control] 映射数组条目的指针
  // r7 = *(u64 *)(r0 + 0), training/attack phase 
  // r8 = *(u64 *)(r0 + 8), oob address 
  // [...] 
  // r0 = 指向 [data] 映射数组条目的指针
  1: if r7 == 0x3 goto line 3 
  2: r8 = r0 
  // 精心设计的条件跳转序列将第
  193 行中的条件分支与当前执行流程分开
  3: if r0 != 0x0 goto line 5 
  4: if r0 == 0x0 goto exit 
  5: if r0 != 0x0 goto line 7 
  6:如果 r0 == 0x0 转到退出
  [...]
  187: if r0 != 0x0 goto line 189 
  188: if r0 == 0x0 goto exit 
  // 加载任何缓慢加载的值(由于阶段 3 中的缓存未命中)... 
  189: r3 = *(u64 *)(r0 + 0x1200) 
  // ...并将其转换为已知的零以供验证者使用,同时
  在执行时缓慢保留加载的依赖项:
  190: r3 &= 1 
  191: r3 &= 2 
  // 推测性地绕过相位依赖项
  192: r7 + = r3 
  193: if r7 == 0x3 goto exit 
  194: r4 = *(u8 *)(r8 + 0) 
  // 泄漏 r4


可以看出,在训练阶段(phase != 0x3),第 1 行的条件发生了
变化为 false,因此带有 oob 地址的 r8 被覆盖
有效的映射值地址,我们可以在第 194 行中毫无问题地读出该地址
。然而,在攻击阶段,第 2 行被跳过,并且由于
第 189 行中的缓存未命中,其中映射值(归零后)添加到
阶段寄存器中,第 193 行中的条件由于
先前分支预测器训练,根据推测,它将
在 oob 地址 r8(此时未知的标量类型)加载字节,然后可能
会通过侧信道泄漏。


缓解这些问题的一种方法是“分支”一条无法到达的路径,这意味着
当前验证路径一直遵循 is_branch_taken() 路径
,我们将另一个分支推送到验证堆栈。鉴于这是
无法从非推测域访问,该分支的 vstate 被
明确标记为推测。之所以需要这样做,有两个原因:
i) 如果仅从推测执行中看到此路径,那么我们稍后仍
希望消除死代码以使用 jmp-1s清理这些指令,以及 
ii) 确保路径在非推测域中行走的路径不会从早期在
推测域中行走的路径中剪除。此外,为了稳健性,我们
在推测路径中将作为条件的一部分的寄存器标记为未知,
因为不应对其内容进行任何假设。


这里的修复减轻了前面描述的类型混淆攻击,因为
i) 正在探索的 BPF 程序中的所有代码路径以及
ii) 现有的验证器逻辑已经确保给定的内存访问指令
引用一个特定的数据结构。


在此范围内也已查看的此修复程序的替代方法是
使用 BPF_JMP_TAKEN 状态
以及方向编码(always-goto、always-fallthrough、unknown)在跳转指令处标记 aux->alu_state ,
以便混合不同的always-* 方向本身以及
always-* 与未知方向的混合会导致
验证器拒绝程序,例如具有像'if ([...]) { x = 0; } else 
{ x = 1; }' 和随后的 'if (x == 1) { [...] }'。对于无特权者,这
将导致只有单方向始终-* 采取的路径,并且
允许未知的采用路径,这样前者可以从条件
跳转修补到无条件跳转(ja)。与这里的这种方法相比,它
有两个缺点:i) 否则不执行任何
指针运算等的有效程序可能会被拒绝/破坏,以及 ii) 我们
需要关闭非特权的路径修剪,其中两个
在这项工作中可以通过将无效分支推送到验证堆栈来避免。


该问题最初是由 Adam 和 Ofek 发现的,后来
作为 Benedict 和 Piotr 的研究工作独立发现和报告的。
Fixes: b2157399cc98 ("bpf: prevent out-of-bounds speculation")
Reported-by: Adam Morrison <mad@cs.tau.ac.il>
Reported-by: Ofek Kirzner <ofekkir@gmail.com>
Reported-by: Benedict Schlueter <benedict.schlueter@rub.de>
Reported-by: Piotr Krysiuk <piotras@gmail.com>
Signed-off-by: Daniel Borkmann <daniel@iogearbox.net>
Reviewed-by: John Fastabend <john.fastabend@gmail.com>
Reviewed-by: Benedict Schlueter <benedict.schlueter@rub.de>
Reviewed-by: Piotr Krysiuk <piotras@gmail.com>
Acked-by: Alexei Starovoitov <ast@kernel.org>
---
 kernel/bpf/verifier.c | 44 ++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 40 insertions(+), 4 deletions(-)


diff --git a/kernel/bpf/verifier.c b/kernel/bpf/verifier.c
index af88d9b9c0143..c6a27574242de 100644
--- a/kernel/bpf/verifier.c
+++ b/kernel/bpf/verifier.c
@@ -6483,6 +6483,27 @@ struct bpf_sanitize_info {
   bool mask_to_left;
 };
 
+static struct bpf_verifier_state *
+sanitize_speculative_path(struct bpf_verifier_env *env,
+        const struct bpf_insn *insn,
+        u32 next_idx, u32 curr_idx)
+{
+  struct bpf_verifier_state *branch;
+  struct bpf_reg_state *regs;
+
+  branch = push_stack(env, next_idx, curr_idx, true);
+  if (branch && insn) {
+    regs = branch->frame[branch->curframe]->regs;
+    if (BPF_SRC(insn->code) == BPF_K) {
+      mark_reg_unknown(env, regs, insn->dst_reg);
+    } else if (BPF_SRC(insn->code) == BPF_X) {
+      mark_reg_unknown(env, regs, insn->dst_reg);
+      mark_reg_unknown(env, regs, insn->src_reg);
+    }
+  }
+  return branch;
+}
+
 static int sanitize_ptr_alu(struct bpf_verifier_env *env,
           struct bpf_insn *insn,
           const struct bpf_reg_state *ptr_reg,
@@ -6566,7 +6587,8 @@ do_sim:
     tmp = *dst_reg;
     *dst_reg = *ptr_reg;
   }
-  ret = push_stack(env, env->insn_idx + 1, env->insn_idx, true);
+  ret = sanitize_speculative_path(env, NULL, env->insn_idx + 1,
+          env->insn_idx);
   if (!ptr_is_dst_reg && ret)
     *dst_reg = tmp;
   return !ret ? REASON_STACK : 0;
@@ -8763,14 +8785,28 @@ static int check_cond_jmp_op(struct bpf_verifier_env *env,
     if (err)
       return err;
   }
+
   if (pred == 1) {
-    /* only follow the goto, ignore fall-through */
+    /* Only follow the goto, ignore fall-through. If needed, push
+     * the fall-through branch for simulation under speculative
+     * execution.
+     */
+    if (!env->bypass_spec_v1 &&
+        !sanitize_speculative_path(env, insn, *insn_idx + 1,
+                 *insn_idx))
+      return -EFAULT;
     *insn_idx += insn->off;
     return 0;
   } else if (pred == 0) {
-    /* only follow fall-through branch, since
-     * that's where the program will go
+    /* Only follow the fall-through branch, since that's where the
+     * program will go. If needed, push the goto branch for
+     * simulation under speculative execution.
      */
+    if (!env->bypass_spec_v1 &&
+        !sanitize_speculative_path(env, insn,
+                 *insn_idx + insn->off + 1,
+                 *insn_idx))
+      return -EFAULT;
     return 0;
   }
-- 
cgit 1.2.3-1.el7

在正常(非推测性)执行中,上述代码存在潜在问题。寄存器r9包含攻击者提供的值;该值在第 3 行分配给r6,然后在第 5 行用作指针。该值可以指向内核地址空间中的任何位置;这正是 BPF 验证器旨在防止的那种不受约束的访问,因此人们可能会认为该代码一开始永远不会被内核接受。

然而,验证器通过探索执行 BPF 程序可能采取的所有可能路径来工作。在这种情况下,没有可能的路径同时执行第 3 行和第 5 行。攻击者提供的指针的分配仅在r0包含零时发生,但该值将阻止第 5 行的执行。因此验证器得出结论:没有可以导致用户提供的指针被间接访问,并允许加载程序的路径。

但这种验证运行确定执行场景,预测执行中有着不同的规则。

上面代码片段中的第 1 行引用了攻击者会费心确保当前未缓存的内存,从而导致缓存未命中。然而,处理器将继续推测,而不是等待内存获取值,猜测任何涉及r0 的条件语句将如何执行。事实证明,这些猜测很可能是if条件(在第 2 行或第 4 行中)都不会评估为真,因此不会进行任何跳转。

这个怎么可能?通过猜测r0的值并检查结果,分支预测不起作用 ;相反,它基于该特定分支的最近历史。该历史记录存储在 CPU 的“模式历史表”(PHT)中。但是 CPU 不可能跟踪大型程序中的每条分支指令,因此 PHT 采用哈希表的形式。攻击者可以定位代码,使其分支与精心设计的 BPF 程序中的分支位于相同的 PHT 条目中,然后使用该代码训练分支预测器以进行所需的猜测。

一旦攻击者加载了代码,清除了缓存,并欺骗分支预测器做一些愚蠢的事情,战斗就结束了;CPU 将推测性地引用攻击者提供的地址。那么这只是以任何通常的方式泄漏结果的问题。这是一个有点乏味的过程——但计算机擅长遵循这样的过程而不会抱怨。

值得注意的是,这不是假设性的攻击。根据公告,当报告此问题时,多个概念证明被发送到 security@kernel.org列表。其中一些不需要训练分支预测器的步骤(上面链接的提交中提供了一个这样的步骤)。这些攻击可以读取内核地址空间中的任何内存;考虑到所有物理内存都包含在其中,因此可以泄露的内容没有真正的限制。由于非特权用户可以加载几种类型的 BPF 程序,因此不需要 root 访问权限来执行此攻击。换句话说,这是一个严重的漏洞。

修复

这种情况下的修复相对简单。而不是修剪验证者“知道”不会执行的路径,验证者将推测性地模拟它们。因此,例如,当检查r0为零的路径时 ,未固定的验证者只会得出结论,第 4 行中的测试必须为真,而不考虑替代方案。修复后,验证器将查看错误路径(包括第 5 行),得出正在使用未知指针的结论,并阻止程序加载。

这种变化有可能阻止加载之前可以运行的正确程序,尽管很难想象包含这种模式的真实世界的非恶意代码。当然,它会减慢验证过程,因为它需要检查正常程序执行中不会出现的执行路径--那些在我们的预测执行的世界里。

此修复已合并到主线中,可以在 5.13-rc7 版本中找到。此后,它已进入 5.12.13 和 5.10.46 稳定更新的版本,但(尚未)进入任何早期的稳定版本。通过此补丁,这些内核可以抵御另一个 Spectre 漏洞,但这个应该不会是最后一个。

- END -


大家好,我是极客君,鹅厂资深工程师,腾讯云网络核心成员,多次获得五星员工,专注实战技术和职场心得,分享技术的本质原理,校招,社招面试技巧和经验,希望搭建连接大学和工作的桥梁,帮你理解技术实际落地场景,不光帮你拿的BAT offer,还可以帮你获得高级工程师的视野,争取拿SP/SSP,希望帮助更多人蜕变重生,期待你的关注,有任何问题,大家都可以加我微信,探讨技术,大厂offer,转行互联网,还可以交个朋友。

没有进技术交流群小伙伴,可以进群交流

- END -


看完一键三连在看转发,点赞

是对文章最大的赞赏,极客重生感谢你

推荐阅读

深入理解编程艺术之策略与机制相分离

C语言登顶!|2021年7月编程语言排行榜

聊聊C语言和指针的本质


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值