ebpf-pwn-A-Love-Story 复现
1 | / $ cat /proc/version |
1 | !/bin/sh |
- smep,smap,kaslr,pti
1 | !/bin/sh |
下载 5.11.16 的内核源码:Index of /pub/linux/kernel/v5.x/
漏洞分析
本题目没有内核模块,漏洞点为 CVE-2021-3490:
- CVE-2021-3490 是一个发生在 eBPF verifier 中的漏洞,由于 eBPF verifier 在校验位运算操作( 与、或、异或 )时没有正确地更新寄存器的 32 位边界,从而导致攻击者可以构造出非法的运行时寄存器值以进行提权
在 eBPF 对寄存器计算的指令中,分为64位和32位操作两部分
- 64位指令会对寄存器的64位全部进行操作
- 32位指令只会对寄存器的低32位进行操作
eBPF 程序的安全主要是由 verifier 保证的,verifier 会模拟执行每一条指令并验证寄存器的值是否合法,主要关注这几个字段:
smin_value、smax_value:64 位有符号的值的可能取值边界umin_value、umax_value:64 位无符号的值的可能取值边界s32_min_value、s32_max_value:32 位有符号的值的可能取值边界u32_min_value、u32_max_value:32 位无符号的值的可能取值边界
其中,这个寄存器中具体的值,会用如下结构体进行表示:
1 | struct tnum { |
value & mask表示这个寄存器中可以确定的值
用于检测指令合法性的函数为 do_check,该函数会遍历每一条指令并根据指令的不同类型进行不同操作,对于算术指令(BPF_ALU / BPF_ALU64)而言有如下调用链(模拟通过后才能正常加载)
1 | do_check() // 遍历每一条指令并根据类型调用相应函数处理 |
首先分析调整标量数据范围的 adjust_scalar_min_max_vals 函数:
1 | static int adjust_scalar_min_max_vals(struct bpf_verifier_env *env, |
本 cve 的漏洞点位于函数 scalar32_min_max_and,其中的 BPF_AND \ BPF_OR \ BPF_XOR 三类操作有问题
1 | static void scalar32_min_max_and(struct bpf_reg_state *dst_reg, |
- 在更新 32 位边界值时,如果两个寄存器的低 32 位都为
known那就可以直接跳过,因为程序认为 64 位时还会进行更新
1 | static void scalar_min_max_and(struct bpf_reg_state *dst_reg, |
- 在更新64位边界值时,若两个寄存器都为
known就直接调用__mark_reg_known(PS:64位和32位判断调用__mark_reg_known的条件不同,这也引发了漏洞) __mark_reg_known用于设置一个已经确定的寄存器,简单的调用tnum_const设置寄存器var_off为known,并给对应边界赋值
1 | static void __mark_reg_known(struct bpf_reg_state *reg, u64 imm) |
1 | static void ___mark_reg_known(struct bpf_reg_state *reg, u64 imm) |
在最后还会调用 __update_reg_bounds() 对比寄存器的 var_off 并更新边界值:
1 | static void __update_reg_bounds(struct bpf_reg_state *reg) |
1 | static void __update_reg32_bounds(struct bpf_reg_state *reg) |
1 | static void __update_reg64_bounds(struct bpf_reg_state *reg) |
- 计算方法如下:
- 最小边界值 =
[min_value , var_off.value | (var_off.mask & MIN) ]中的最大者 - 最大边界值 =
[max_value , var_off.value | (var_off.mask & MAX) ]中的最小者
- 最小边界值 =
但这样存在一个问题,若存在一个高32位 unknown 低32位 known 的寄存器:
- 在理论上,程序执行时
scalar32_min_max_and就能确定该寄存器的值,应该调用__mark_reg_known进行更新 - 但程序认为在
scalar_min_max_and中也能检查寄存器是否known,因此选择在scalar_min_max_and中调用__mark_reg_known,而scalar32_min_max_and中直接返回 - 核心问题就是,函数
scalar32_min_max_and和scalar_min_max_and中判断寄存器是否known的条件不同,导致原本应该执行__mark_reg_known的程序没有执行
如果有以下两个寄存器:
R2 = { .value = 0x1, .mask = 0xffffffff00000000 }:该寄存器低 32 位值已知为0x1,高 32 位不确定R3 = { .value = 0x100000002, .mask = 0x0 }:该寄存器 64 位值全部已知,为0x100000002
假如我们将 R2 与 R3 做与运算,其结果为 { .value = 0, .mask = 0x100000000 },详细调用过程如下:
- 首先执行
adjust_scalar_min_max_vals函数,随后会进入tnum_and函数- 该函数返回
R2.var_off = {mask = 0x100000000; value=0x0} - 由于
R2的高32位是不确定,导致0x100000002中高出32位的非“0”部分不确定,所以最终R2.var_off.mask = 0x100000000(仅有第32位不确定)
- 该函数返回
- 然后执行
scalar32_min_max_and检查寄存器32位的值的范围- 这里由于
R2和R3两个寄存器的低32位的值都是确定的,该函数直接返回
- 这里由于
- 接着执行
scalar_min_max_and检查寄存器64位的值的范围- 由于
R2寄存器第32位仍不确定,因此不会调用__mark_reg_known
- 由于
- 在末尾调用
__update_reg_bounds,这个函数会对R2的值做相应修改:- 设置
R2.u32_max_value=0x0(由于R2.var_off.value=0 < R2.u32_max_value=1) - 设置
R2.u32_min_value=0x1(由于R2.var_off.value=0 < R2.u32_min_value=1)
- 设置
- 最后执行
__reg_bound_offset函数,也不会改变R2的属性
因此经过该轮计算之后 R2 的最小值为 1,最大值为 0,而这显然是不合理的
测试样例如下:
1 |
|
1 | / $ ./exp |
入侵思路
核心思路参考:[漏洞分析] 【CVE-2021-3490】eBPF verifier 32 位边界计算错误漏洞分析与利用 (buaq.net)
利用漏洞构造一个最小边界值为 “1”、最大边界值为 “0” 的寄存器:
1 |
- 因为 R1~R5 有的时候要用来作为函数参数,所以这里在 R6 上构造
- 此时 R6 32 位边界值为
[1, 0],32位运行时值为0
构造运行时为 “1” 但 verifier 确信为 “0” 的寄存器:
1 |
- 构造出另一个 32 位边界值为
[0, 1],32位运行时值为0寄存器 R7 - 把寄存器 R6 和 R7 相加,得到新的 R6,边界值为
[1, 1],32位运行时值为0,于是便获得了一个运行时为 “0” 但 verifier 认为是 “1” 的寄存器 - 如果我们再给 R6 加上
1,从而使得边界值为[2, 2],但实际上的 32 位值为1 - 再将 R6 与
1做&运算,从而使得边界值为[0, 0],但实际上的 32 位值为1 - 最终 verifier 便会认为该寄存器的值变为 “0”,但其实际上的运行时值为 “1”
1 | 36: (07) r6 += 1 |
泄露内核基地址:
对于 BPF_MAP_TYPE_ARRAY 类型 的 map 而言,其 wrapper 为 bpf_array 类型(即 bpf_map 内嵌于该结构体中),数据则直接存放在其内部的 value 数组成员当中,因此在查找元素时我们获得的其实是一个指向 bpf_array 内部的指针
1 | struct bpf_array { |
- 因此我们只需要前向读取便能读取到
bpf_map,之后可以通过bpf_map的函数表泄露内核地址
理论上我们可以构造寄存器,使 verifier 将负数识别为 “0”,但实际上我们还要突破 ALU Sanitation 的检查:
- ALU Sanitation 是一个用于运行时动态检测的功能,通过对程序正在处理的实际值进行运行时检查以弥补 verifier 静态分析的不足
- 核心原理就是在 eBPF 程序中的每一条指令前面都添加上额外的辅助指令
1 | *patch++ = BPF_MOV32_IMM(BPF_REG_AX, aux->alu_limit - 1); |
- 其中
aux->alu_limit为当前指针运算范围,初始时为 “0”,与指针所做的常量运算同步 - 对于减法而言可读范围为
(ptr - alu_limit, ptr](这里保证了指针的偏移不会为负)
由于我们有运行时为 “1”,但 verifier 认为是 “0” 的寄存器,我们可以这样调整范围:
- 构造另外一个同样是运行时值为 “1”,但 verifier 认为是 “0” 的寄存器 R8(可以选择直接将 R6 拷贝给 R8)
- 令 R7 指向 map 第一个元素的第一个字节
value[0] - 将 R7 加上
0x1000(R7 = value[0x1000],alu_limit = 0x1000) - 将 R8 乘上
0x1000(R8 = 0x1000) - 执行
R7 -= R8,由于 verifier 认为 R8 为 “0”,因此alu_limit保持不变,但 R7 实际上已经指回了value[0] - 执行
R7 -= 0x110(R7 = value[-0x110],alu_limit = 0x1000)
1 |
构造任意读 RAA:
现在我们能够读写 bpf_map 中的数据,我们需要注意其中的 btf 指针:
1 | struct bpf_map { |
但函数 bpf_map_get_info_by_fd 被调用时,程序会把 bpf_map->btf.id 拷贝给用户空间
1 | static int bpf_map_get_info_by_fd(struct file *file, |
劫持 bpf_map->btf 即可完成 RAA:
1 |
- 前半部分使用相同的方法来绕过
alu_limit,后半部分尝试覆盖bpf_map->btf(这里的 0x58 是btf.id的偏移)
1 | static size_t read_arbitrary_addr_4_bytes(int map_fd, int idx){ |
构造任意写 WAA:
核心思想就是覆盖 bpf_map->ops 为 bpf_array.value(可控地址),并在 bpf_array.value 上伪造一个 fake ops 将 ops->map_push_elem 替换为 array_map_get_next_key
1 | static int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key) |
- 当
key小于map.max_entries时,key会被写入到next_key当中 - 如果正常调用
map_get_next_key:只能控制key但是next_key不能控制 - 如果通过函数指针
ops->map_push_elem进行调用:可以控制这两个参数
当我们更新 eBPF map 时,若 map 类型为 BPF_MAP_TYPE_QUEUE 或 BPF_MAP_TYPE_STACK,则函数 bpf_map->ops->map_push_elem 就会被调用,不过在函数 map_update_elem 中还有一个检查:
1 | static int map_update_elem(union bpf_attr *attr) |
1 | static inline bool map_value_has_spin_lock(const struct bpf_map *map) |
- 若 flags 设置了
BPF_F_LOCK标志位,则会检查map->spin_lock_off是否大于等于 0,因此这里我们还要将该字段改为一个正整数
1 |
- 前半部分使用相同的方法来绕过
alu_limit,后半部分尝试覆盖bpf_map中的各个条目:spin_lock_off = 0x2000(绕过map_update_elem中的检查)max_entries = 0xffffffff(为了满足key < map.max_entries的条件)map_type = 23(BPF_MAP_TYPE_STACK)(为了使bpf_map->ops->map_push_elem能被调用)ops = target_addr(设置写入的目标地址)
1 | void make_arbitrary_write_ops(int map_fd){ |
在获取以上所有组件之后,程序的入侵步骤如下:
- 泄露
map_ops_addr计算内核基地址 - 泄露
map_addr - 利用 RAA 扫描内存,泄露
current_task和current_cred - 覆盖
bpf_map->ops->map_push_elem,为 WAA 做准备 - 利用 WAA 覆盖
current_cred并进行提权
完整 exp 如下:
1 |
|
1 |
|