使用 eBPF 进行内核破解:一个爱情故事

2021-08-09 00:55:07

在 Grapl,我们相信为了构建最好的防御系统,我们需要深入了解攻击者的行为。作为该目标的一部分,我们正在投资进攻性安全研究。继续关注我们的博客,了解有关高风险漏洞、漏洞利用和高级威胁策略的新研究。在此处查找已发布的 CVE-2021-3490 的本地提权 (LPE) 概念证明:https://github.com/chompie1337/Linux_LPE_eBPF_CVE-2021-3490。它针对 Ubuntu 20.10 (Groovy Gorilla) 内核 5.8.0-25.26 到 5.8.0-52.58。和 Ubuntu 21.04 (Hirsute Hippo) 5.11.0-16.17。这篇博文旨在从漏洞利用开发人员的角度详细概述 eBPF。在这篇文章中,我介绍了:我对 eBPF 一无所知。我希望通过分享一个 PoC 以及我的开发经验,它可以帮助其他人开始使用 eBPF。 Berkeley Packet Filter (BPF) 最初是作为在内核中执行数据包过滤的一种方式而创建的。它的功能后来被重新设计和扩展,以创建扩展的伯克利数据包过滤器 (eBPF) [1]。简而言之,eBPF 为用户模式应用程序提供了一种无需编写内核模块即可在内核中运行代码的方法。使用 eBPF 与内核模块相比的所谓好处是易用性、稳定性和安全性。与纯用户模式程序相比,通过直接在内核中执行某些任务还可以获得性能改进。 eBPF 程序用于做很多事情,例如:跟踪、检测、挂钩系统调用、调试,当然还有数据包捕获/过滤。 eBPF 程序是用高级语言编写的,并使用工具链(例如 BCC [18])编译成 eBPF 字节码。 eBPF VM 使用一个简单的指令集,该指令集使用 11 个* 64 位寄存器、一个程序计数器和一个 512 字节固定大小的堆栈。九个寄存器是通用读写,一个是只读堆栈指针,程序计数器是隐式的 [2] 。指令集与 x86 类似,可对 64 位和 32 位值进行操作。

*从技术上讲,它使用了 12 个寄存器,但第 12 个寄存器是一个辅助寄存器,仅用于执行 ALU 卫生操作 [12]。用户模式应用程序使用 bpf() [14] 系统调用将字节码加载到内核中,其中 eBPF 验证器将执行许多检查以确保程序在内核中“安全”运行。这个验证步骤很关键——eBPF 为非特权用户公开了一条在 ring0 中执行的路径。加载程序后,用户模式应用程序将程序附加到“挂钩点”。挂钩点是内核中可以附加 eBPF 程序的地方 [5]。 eBPF 程序是事件驱动的,这意味着程序将在挂钩点发生某些事件时执行。经典用例是将 eBPF 程序附加到套接字,当数据写入时程序将在套接字中执行。如果设置了 kconfig 旋钮 CONFIG_BPF_JIT,则 eBPF 程序在验证和加载后会被 JIT 编译为原生汇编指令。否则,当程序执行时,它会在 eBPF 解释器中运行,该解释器解码并执行 eBPF 字节码指令。用户模式应用程序可以使用 eBPF 映射和 eBPF 辅助函数与内核中运行的 eBPF 程序交互并从中获取数据,这些函数通过 bpf() 系统调用进行访问。 sysctl 旋钮 kernel.unprivileged_bpf_disabled 决定是否允许非特权用户运行 eBPF 程序。如果未设置,则允许非特权用户将 eBPF 程序附加到用户拥有的套接字。在许多 Linux 发行版中,例如 Ubuntu,默认情况下未启用 unprivileged_bpf_disabled。因此,我决定更仔细地研究 eBPF,因为允许非特权用户在内核中运行代码是一个成熟的攻击面。我在上面提到过,用户模式进程可以使用 eBPF 映射与内核中的 eBPF 程序进行交互。它们也可以被多个 eBPF 程序用来相互交互。它们是具有任意数据结构的通用键/值存储 [6]。有多种类型的映射,包括:数组、队列和堆栈。

key_size - 用于索引元素的键的字节大小(用于数组映射) map_flags - 描述映射的特殊特性,例如是否应该预先分配整个映射内存。可以使用 BPF_MAP_CREATE 命令通过 bpf() 系统调用从用户空间创建和更改 eBPF 映射,使用 BPF_MAP_UPDATE_ELEM 命令更新,并使用 BPF_MAP_LOOKUP_ELEM 命令检索其内容。 eBPF 程序可以使用 BPF_MAP_CREATE 返回的文件描述符并调用 eBPF 辅助函数来访问 eBPF 映射,该函数将返回指向映射中值的指针。我编写的漏洞利用了 eBPF 验证器中的一个错误。因此,在深入研究漏洞之前,重要的是简要解释验证器的内部结构。验证器首先构建程序的控制流图。然后,它将通过每个可能的控制流来验证每条指令是否有效以及所有内存访问都是安全的 [3]。之后,它将向程序添加运行时检查。这个过程称为 ALU Sanitation,将补丁插入 eBPF 字节码,以确保在执行指针运算时不会违反运行时允许的内存范围 [4]。不能执行指针比较,只能向指针添加或减去标量值。 eBPF 验证器中的标量值是任何不是从指针派生的值。验证器跟踪哪些寄存器包含指针,哪些包含标量值。指针运算不能离开地图的“安全”边界。意思是,程序无法访问预定义地图内存之外的任何内容。为此,验证器会跟踪每个寄存器的值的上限和下限。

指针不能存储在映射中或存储为返回值,以避免内核地址泄漏到用户空间。验证器为每个可能的执行路径中的每个寄存器存储以下边界值,以确保没有越界内存访问: umin_value , umax_value 存储被解释为无符号时的寄存器的最小值/最大值 (64 bit) 整数 smin_value , smax_value 在解释为有符号(64 位)整数时存储寄存器的最小值/最大值。 u32_min_value , u32min_value 在解释为无符号(32 位)整数时存储寄存器的最小值/最大值。 s32_min_value , s32_max_value 存储寄存器的最小值/最大值,当解释为有符号(32 位)整数时。 var_off 包含有关已知寄存器位的信息。它存储在一个名为 tnum 的结构中,该结构包含两个 64 位字段: mask 和 value 。在掩码中设置的每一位都意味着该位的值是未知的。未设置位是已知的,它们的真实值存储在 value 中。例如,如果 var_off = {mask = 0x0; value = 0x1} ,寄存器的所有位都是已知的,并且已知寄存器的值为1。如果var_off = {mask = 0xFFFFFFFF00000000; value = 0x3} 表示寄存器的低 32 位已知为 0x00000003,高 32 位未知。

这些边界用于相互更新。特别是,如果 var_off 指示寄存器是已知常数,则更新最小/最大界限以反映已知值。我们稍后会看到为什么这很重要! ALU Sanitation 是一项功能,用于补充验证器的静态范围跟踪。如果寄存器的值在运行时未落在其预期范围内,则该想法是为了防止 OOB 内存访问。添加此功能是为了帮助减轻验证器中的潜在漏洞并防止投机攻击。对于涉及指针和标量寄存器的每个算术运算,都会计算 alu_limit。这表示可以添加到指针或从指针中减去的最大绝对值 [4]。在这些操作中的每一个之前,使用以下指令修补字节码: *patch ++ = BPF_MOV32_IMM ( BPF_REG_AX , aux - >alu_limit ) ; *补丁++ = BPF_ALU64_REG ( BPF_SUB , BPF_REG_AX , off_reg ) ; *补丁++ = BPF_ALU64_REG ( BPF_OR , BPF_REG_AX , off_reg ) ; *补丁++ = BPF_ALU64_IMM (BPF_NEG, BPF_REG_AX, 0); *补丁++ = BPF_ALU64_IMM (BPF_ARSH, BPF_REG_AX, 63); *补丁++ = BPF_ALU64_REG ( BPF_AND , BPF_REG_AX , off_reg ) ;注意off_reg 代表被添加到指针寄存器的标量寄存器,BPF_REG_AUX 代表辅助寄存器。运行时 off_reg 的值从 alu_limit 中减去并存储到 BPF_REG_AX 中。如果 off_reg > alu_limit ,则设置 BPF_REG_AX 的最高位(符号位)。如果 BPF_REG_AUX 中存储的差值为正,off_reg 为负,表示 alu_limit 和寄存器的值具有相反的符号,则 BPF_OR 操作将设置符号位。

BPF_NEG 操作将否定符号位。如果设置了符号位,则为 0,否则为 1。 BPF_ARSH 操作进行 63 位算术右移。这将用全 0 或 1(符号位的值)填充 BPF_REG_AX。根据上述操作的结果,BPF_AND 操作要么将 off_reg 置空,要么保持不变。这意味着如果 off_reg 超过 alu_limit ,或者如果 off_reg 和 alu_limit 具有相反的符号,则 off_reg 的值将被替换为 0,从而使指针算术运算归零。最近更新了 alu_limit 的计算方式 [15]。某些 Linux 发行版可能尚未采用新的实现。为完整起见,我将涵盖两者,并在下一节中重新讨论为什么差异很重要,因为它们变得相关。 alu_limit 由指针寄存器的边界决定。意思是,如果指针寄存器指向映射的开头,则减法的 alu_limit 为 0,加法的 alu_limit 等于映射的大小(减 1)。 alu_limit 随指针寄存器上的后续操作更新。 alu_limit 由偏移寄存器的边界决定。这意味着是否将运行时偏移寄存器的值与验证器静态范围跟踪期间计算的寄存器边界进行比较。

我对 eBPF 验证器的初步了解来自 Manfred Paul 这篇出色的博客文章,详细介绍了他对 CVE-2020-8835 的利用。我强烈建议检查一下!回想一下,eBPF 指令集可以对整个 64 位寄存器或仅低 32 位进行操作。因此,验证器范围跟踪包含寄存器低 32 位的单独边界: {u,s}32_{min,max}_value 。每次操作都会更新这些边界。每个操作都有两个跟踪功能,一个 64 位和一个 32 位计数器部分。两者都在函数 adjust_scalar_min_max_vals 中调用 64 位操作。 * /* 警告:此函数对 64 位值进行计算,但 * 实际执行可能发生在 32 位值上。因此,*像位移这样的东西在 32 位情况下需要额外检查。*/ static int adjust_scalar_min_max_vals ( struct bpf_verifier_env *env, struct bpf_insn *insn, struct bpf_reg_state *dst_reg, struct bpf_reg_state src_reg ) { ... case BdPFst_AND - >var_off = tnum_and (dst_reg - >var_off , src_reg .var_off ) ; scalar32_min_max_and (dst_reg , &src_reg ) ; scalar_min_max_and (dst_reg , &src_reg ) ;休息 ;案例 BPF_OR : dst_reg - >var_off = tnum_or (dst_reg - >var_off , src_reg .var_off ) ; scalar32_min_max_or (dst_reg , &src_reg ) ; scalar_min_max_or (dst_reg , &src_reg ) ;休息 ;案例 BPF_XOR : dst_reg - >var_off = tnum_xor (dst_reg - >var_off , src_reg .var_off ) ; scalar32_min_max_xor (dst_reg , &src_reg ) ; scalar_min_max_xor (dst_reg , &src_reg ) ;休息 ; ... } 漏洞 CVE-2021-3490 位于 BPF_AND 、 BPF_OR 和 BPF_XOR 操作的 32 位跟踪函数中。每个功能都是一样的。让我们来看看 BPF_AND 的违规代码的摘录: static void scalar32_min_max_and ( struct bpf_reg_state *dst_reg, struct bpf_reg_state *src_reg ) { bool src_known = tnum_subreg_is_const (src_reg - >var_off) bool dst_known = tnum_subreg_is_const (dst_reg - >var_off); struct tnum var32_off = tnum_subreg (dst_reg - >var_off) ; s32 smin_val = src_reg->s32_min_value; u32 umax_val = src_reg - >u32_max_value ; /* 假设 scalar64_min_max_and 将被调用,所以它安全 * 跳过更新已知 32 位情况的寄存器。 */ 如果 (src_known && dst_known ) 返回; ... }

如上面的代码片段所示,如果源寄存器和目标寄存器的低 32 位已知,则该函数会跳过更新 32 位边界。返回上面的注释指出这是可以的,因为 64 位对应物会处理它。我们来看看: static void scalar_min_max_and ( struct bpf_reg_state *dst_reg, struct bpf_reg_state *src_reg ) { bool src_known = tnum_is_const (src_reg - >var_off ) ; bool dst_known = tnum_is_const (dst_reg - >var_off) ; s64 smin_val = src_reg->smin_value; u64 umin_val = src_reg - >umin_value ; if (src_known && dst_known ) { __mark_reg_known (dst_reg , dst_reg - >var_off .value ) ;返回 ; } ... } 的确,我们可以看到如果src_known 和dst_known 为真,函数__mark_reg_known 会被调用。你能发现问题吗?在 scalar32_min_max_and 中, _known 变量是使用 tnum_subreg_is_const 计算的。 64 位对应 scalar_min_max_and 使用 tnum_is_const 。区别在于,如果寄存器的低 32 位是已知常量,则前者返回真,而后者仅在整个 64 位都是常量时才返回真。如果操作涉及低 32 位已知但高 32 位未知的寄存器,则违反注释中所述的假设。在函数 adjust_scalar_min_max_vals 中,在返回之前,通过调用以下三个函数最后一次更新目标寄存器的边界:这些函数中的每一个都有 32 位和 64 位对应项。我将只介绍 32 位情况,因为那是错误影响的内容。

static void __update_reg32_bounds (struct bpf_reg_state *reg) { struct tnum var32_off = tnum_subreg (reg->var_off); /* 最小有符号是最大(符号位) | min(other bits) */ reg - >s32_min_value = max_t (s32 , reg - >s32_min_value , var32_off .value | (var32_off .mask & S32_MIN ) ) ; /* 最大有符号是最小(符号位) | max(other bits) */ reg - >s32_max_value = min_t (s32 , reg - >s32_max_value , var32_off .value | (var32_off .mask & S32_MAX ) ) ; reg - >u32_min_value = max_t (u32 , reg - >u32_min_value , (u32 )var32_off .value ) ; reg - >u32_max_value = min (reg - >u32_max_value , (u32 ) (var32_off .value | var32_off .mask ) ) ;请注意,最小值边界设置为当前最小值或寄存器的已知值,以较大者为准。类似地,最大边界设置为当前最大值或寄存器的已知值,以较小者为准。然后,在 __reg32_deduce_bounds 中使用有符号和无符号边界相互更新。 /* 使用有符号的最小值/最大值来通知无符号,反之亦然 */ static void __reg32_deduce_bounds ( struct bpf_reg_state *reg ) { /* 从有符号的边界中学习符号。 * 如果我们不能跨越符号边界,那么有符号边界和 * 无符号边界 * 是一样的,所以结合起来。这甚至在 * 否定情况下也有效,例如 * -3 s<= x s<= -1 意味着 0xf...fd u<= x u<= 0xf...ff。 */ if (reg - >s32_min_value >= 0 || reg - >s32_max_value < 0 ) { reg - >s32_min_value = reg - >u32_min_value = max_t (u32 , reg - >s32_min_value , reg - >u32_min_value ) ; reg - >s32_max_value = reg - >u32_max_value = min_t (u32 , reg - >s32_max_value , reg - >u32_max_value ) ;返回 ; } ... } static void __reg_bound_offset (struct bpf_reg_state *reg) { struct tnum var64_off = tnum_intersect (reg - >var_off , tnum_range (reg - >umin_value , reg - >umax_value ) ) ; struct tnum var32_off = tnum_intersect (tnum_subreg (reg ->var_off), tnum_range (reg ->u32_min_value, reg ->u32_max_value)); reg - >var_off = tnum_or (tnum_clear_subreg (var64_off), var32_off); tnum_intersect 接受两个 tnum 并将两者传达的知识组合成一个 tnum 。让我们通过一个例子来完成这些步骤,这样我们就能理解为什么这是一个严重的漏洞。

假设我们有指令 BPF_ALU64_REG(BPF_AND, R2, R3)。该指令对寄存器 R2 和 R3 执行 AND 运算并将结果保存在 R2 中。 R2 有 var_off = {mask = 0xFFFFFFFF00000000; value = 0x1},表示已知低 32 位值为 1,高 32 位未知。因为寄存器的低 32 位是已知的,所以它的 32 位边界等于该值。 R3 有 var_off = {mask = 0x0; value = 0x100000002},意味着整个 64 位是已知的并且等于 0x100000002。如 adjust_scalar_min_max_vals 片段的第 12 行所示,函数 tnum_and 被调用。这将执行 AND 运算并将结果保存在目标寄存器 R2 的 var_off 中。回想一下,两个寄存器中的低 32 位都是已知的。 R3 的所有位都是已知的:高 31 位是 0,第 32 位是 1。这意味着 R2 剩下 var_off = {mask = 0x100000000;值 = 0x0}。这是因为 2 & 1 = 0(对于低 32 位),并且除了第 32 位之外的所有位都将被称为 0,因为 R3 在第 32 位中有一个 1。在下一行,调用 scalar32_min_max_and。我们已经知道这个函数会立即返回并且不会改变边界,因为两个寄存器的低 32 位都是已知的。然后 __update_reg32_bounds 被调用。这将设置 u32_max_value = 0 ,因为 var_off.value = 0 < u32_max_value = 1 的值。同样,它会设置 u32_min_value = 1 因为 var_off.value = 0 < u32_min_value 。签名边界也是如此。现在我们可以看到,在这种情况下,我们留下了一个寄存器,其中 {u,s}32_max_value = 0 < {u,s}32_min_value = 1 !

@@ - 7084 , 11 + 7084 , 10 @@ static void scalar32_min_max_and (struct bpf_reg_state *dst_reg , s32 smin_val = src_reg - > s32_min_value ; u32 umax_val = 3 src_max - u32 umax_val = 3 src_max - u32 umax_val = 3 src_max - u /* 3 src_max - u /* 3 2_reg_和 * 4 _ 和 * 4 将安全称为安全跳过更新已知 32 位情况的寄存器。- */ - if (src_known && dst_known ) + if (src_known && dst_known ) { + __mark_reg32_known (dst_reg , var32_off .value ) ; return ; + } 上面我们现在可以看到,如果源和目标寄存器的低 32 位是已知常量,则在返回之前在目标寄存器上调用 __mark_reg32_known。 /* 将寄存器的未知部分(变量偏移量或标量 * 值)标记为已知值 @imm。 */ static void __mark_reg32_known ( struct bpf_reg_state *reg, u64 imm ) { reg - >var_off = tnum_const_subreg (reg - >var_off , imm ) ; reg - >s32_min_value = (s32 )imm ; reg - > value.imm ; reg - >s32_s3_s3_s32 .....