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 |
|