2020年 pwn2own 爆出来的一个提权漏洞,这里主要是学习一下 ebpf 的新检查机制的绕过方法
漏洞分析
漏洞原理
漏洞版本是 linux-5.6版本内核,该漏洞是在 commit 581738a681b6中引入。原因是其在 do_check
检测 条件跳转逻辑时,新添加了一个检测函数:
1 | static void __reg_bound_offset32(struct bpf_reg_state *reg) |
这里首先了解一下 bpf_reg_state
结构体,该结构体是用于虚拟寄存器的数据结构,这里需要关注的变量,已经在下面注释。
1 | struct bpf_reg_state { |
其中 var_off
是 tnum
类型,在 tnum
中有两个成员:
- value:某个bit为1表示这个寄存器的这个bit确定是1
- mask:某个bit为1表示这个bit是未知的
并且当 mask
为0的时候,表示该 tnum
是一个数字,值为 value
。
在上面 reg_bound_offset32
函数中分别调用了4个函数:tnum_range
、tnum_cast
、tnum_lshift
和 tnum_intersect
函数。这里重点分析以下 tnum_range
和 tnum_intersect
函数。
漏洞点就出现在这个 tnum_range
函数,原本 reg->umin_value
和 reg->umax_value
都是 64
位,但是在 reg_bound32_offset32
函数中传入的参数却只传入了各自的低32位。
假设 reg->umin_value=1
,reg->umax_value=0x1 0000 0001
,那么传入该函数时取低32位,就会各自变为 min=1
和 max=1
,这样当计算完 tnum_range
时 ,结果为chi=0
,bits=0
,delta=0
,min=1
。返回的 range
结果:range.value=1
和 range.mask=0
。
1 | struct tnum tnum_range(u64 min, u64 max) |
然后执行到 tnum_intersect
函数,假设 a.value=0
,计算后 v=1, mu=0
。最终得到的 var_off
就是固定值 1,也即不管寄存器真实的值是什么,在检测过程中都会将其当作是 1。但是,这个寄存器的值在真实执行时,我们却可以通过map
去设置,也即可以用来绕过bpf
的检查。
1 | /* Note that if a and b disagree - i.e. one has a 'known 1' where the other has |
触发流程
上面简述了该漏洞的一种触发情况。但是如何触发该漏洞呢。从源码中可以发现如下调用链:
1 | do_check->check_cond_jmp->reg_set_min_max->__reg_bound_offset32 |
下面结合源码大致讲解如下BPF
指令的 verify
过程:
1 | BPF_JMP32_IMM(BPF_JLE, BPF_REG_6, 5, 1) |
bpf_check
所有的 BPF
指令在真正执行前,都会先进行一个检查,防止指令中有恶意功能。程序首先会进入 bpf_check
函数。在这里会为指令建立一个检查的内存空间,然后依次检查指令的长度,检查eBPF
指令的控制流图,检测是否有死循环和不可能到达的路径。最后会调用一个重点函数 do_check_main
,该函数最后会调用 do_check
执行指令检测。
1 | int bpf_check(struct bpf_prog **prog, union bpf_attr *attr, |
do_check
在 do_check
函数中,会对每条指令进行更深入的检测。主要思想是根据指令的功能,来检测指令的原操作数,目的操作数是否符合相应功能的安全范围。
1 | static int do_check(struct bpf_verifier_env *env) |
check_cond_jmp_op
从上面 do_check
中可以看到对于条件跳转,最后会调用 check_cond_jmp_op
函数。这个函数主要分为两类,一类是比较条件是 寄存器类型,一类是比较条件是 立即数。其首先会调用 is_branch_taken
获取需要执行的分支,然后调用 push_stack
将当前不会执行的另一个分支压入栈中。最后会以 两个 bpf_reg_state
类型的 false_reg
和 true_reg
来标识分支的执行情况,并且调用 reg_set_min_max
函数用以修改 false_reg
和 true_reg
的值。
1 | static int check_cond_jmp_op(struct bpf_verifier_env *env, |
reg_set_min_max
从 check_cond_jmp
中可以看到其最后会为false_reg
和 true_reg
调用 reg_set_min_max
函数。在这个函数中,会根据跳转类型,执行对应的功能。最后其 会调用我们上面所讲的漏洞函数 __reg_bound_offset32
。
1 | /* Adjusts the register min/max values in the case that the dst_reg is the |
也就说 该指令 BPF_JMP32_IMM(BPF_JLE, BPF_REG_6, 5, 1)
,由于原操作数是立即数 5
,且是32位 JMP32
。所以最后能够进入 __reg_bound_offset32()
函数。
调试分析
然后需要考虑如何在执行这条指令前,使 BPF_REG_6
的 umin_value=1
和umax_value=0x100000001
。
首先从上面 reg_set_min_max
函数的功能中就可以看到其可以对输入的 true_reg
和 false_reg
的 min_value
和max_value
进行赋值。也即我们利用这个函数,只要参数设置得当就可以将一个 true_reg
和false_reg
的 uim_value 或 umax_value
设置为指定值。
这里直接看源代码,有一些不太清楚。所以下面直接通过调试来理清以下流程。
首先跟踪 BPF_JMP32_IMM(BPF_JLE, BPF_REG_6, 5, 1)
执行到如下代码。
1 | reg_set_min_max(&other_branch_regs[insn->dst_reg], |
这里查看结果如下:
1 | In file: /home/a1ex/Desktop/vul/cve-2020-8335/linux-5.6/kernel/bpf/verifier.c |
所以这里可以断定,我们需要修改 reg_set_min_max
函数的 true_reg
参数。那么需要在 check_cond_jmp_op
函数中找到能够修改 true_reg
结构体 umin_value
和 umax_value
的功能。
这里可以找到如下两段代码:
1 | //BPF_JGE和BPF_JGT可以修改true_reg的umin_value |
根据上面的分析,要修改 r6.umin_Value
就要调用 BPF_JGE或BPF_JGT
,要修改r6.umax_value
就要调用BPF_JLE或BPF_JLT
。上面函数中的 val
是我们输入代码的立即数。那么这里就能够通过构造合适的 val
来修改值。
修改r6.umin_value
首先可以利用如下指令,来修改r6.umin_vale
:
1 | BPF_JMP_IMM(BPF_JGE,6,1,1) |
这个指令,最后会跳转到 BPF_JGE
流程,然后val=1
,所以true_umin=1
。然后由于当初始化 r6
结构体时,r6.umin_value=0
,所以最后 r6.umin_value=1
。
1 | //初始时r6结构体如下: |
然后,由于用户层输入的 立即数imm
最多只能为32位,所以想要直接修改val
的值为 0x100000001
是不可能的。这里就需要借助一个其他寄存器,使另一个寄存器的 umax_value=0x100000001
,然后再将其值赋给 r6
。
修改r6.umax_value
这里首先讲解如何构造一个寄存器的umax_value=0x100000001
。
如果一个寄存器的值就是 0x100000001
,那么其umax_value
自然也为 0x100000001
。
1 | BPF_MOV64_IMM(8,0x1), //r8=0x1 |
这里通过上述指令,就能够将r8.umax_value=0x100000001
。
最终r8
的结构体如下:
1 | pwndbg> p/x (struct bpf_reg_state)*src_reg |
然后可以用如下指令,来将 r8.umax_value
赋值给r6
:
1 | BPF_JMP_REG(BPF_JLE,6,8,1) |
这个指令的源地址是寄存器r8
,所以其按理会调用check_cond_jmp_op
函数的如下部分:
1 | if (tnum_is_const(src_reg->var_off) || |
但是,调试时发现当我想跟进这个函数时,程序又直接跳转到下面立即数的处理部分:
1 | //若源操作数是一个立即数,也会调用reg_set_min_max函数修改false_reg和true_reg的范围 |
而其各参数如下:
1 | pwndbg> p/x insn->dst_reg //目的寄存器为r6 |
从参数上来看,仍然是执行的我们输入的指令。产生这种情况的原因,可能是由于编译器优化,将这两种情况进行了合并。但是,只要参数正确。那么就不会有问题。
最终,其会进入BPF_JLE
分支,val=0x100000001
,true_umax_value=0x100000001
,最终修改true_reg.umax_value=0x100000001
。
1 | pwndbg> p/x (struct bpf_reg_state)*true_reg |
触发漏洞
通过上面的方法,我们已经能够成功构造r6
的umin_Value和umax_Value
。然后就需要来触发这个漏洞。运用如下指令触发即可:
1 | BPF_JMP32_IMM(BPF_JNE,6,5,1) |
最后可以看到r6
的结构体如下,其值value=1
。说明我们的漏洞触发成功。
1 | pwndbg> p/x (struct bpf_reg_state)*true_reg |
漏洞利用
其实整体的利用思路和之前的CVE-2017-16995
很像,都是先利用漏洞绕过bpf_check
检测,然后在实际执行时执行恶意指令。
这里这个漏洞的作用总结来说就是能够使一个寄存器在模拟检测时其值固定为1
,然而在真实执行时其值可以被我们所控制为其他的合法值。
如何利用这个固定为1?可以思考如果利用r6
的值来做读取的索引,因为模拟检测时,bpf_check
认为其固定为1,而真实执行我们可以将该值改大,那么检测时bpf_check
认为读取索引合法,然后真实执行被改大了,则可以造成越界读取。
同理,我们可以实现一个任意地址写。有了这两个功能,那么提权的方法也就十分多样了。
cve-2017-16995绕过思路分析
但是,这里最开始想简单了,5.6版本的bpf
相比于4.4.110版本做了很多更改。影响比较大的就是对于模拟寄存器的结构体的修改,导致之前cve-2017-16995
的利用方法不能使用了。这里还是说明一下为什么之前的方法不能用吧。
按照之前的思路,这里r6.value=1
,那么可以直接构造以下指令,来使verifier
误认为程序进入了一个确定的exit(0)
分支,从而绕过对后续指令的检查。
1 | BPF_JMP_IMM(BPF_JNE, BPF_REG_6, 1, 2), |
按理说,由于 BPF_REG_6.value==1
,此时第一条指令的判断会恒成立,所以程序会直接执行exit(0)
函数,从而跳过了对后续恶意指令的检测。但是,调试情况却不是如此。经过调试发现原因如下:
首先,虽然经过之前的操作,已经使检测时 bpf_reg_6
的结构体如下:
1 | value = 0x1, |
但是,在执行BPF_JMP_IMM
检测时,会经过check_cond_jmp_op
函数如下代码:
1 | //首先调用is_branch_taken判断哪条分支成立,或是否有恒成立 |
可以看到其对分支有3种情况,如果有恒成立的分支,则直接返回了。如果并非恒成立,则调用push_stack
将当前不执行的分支压入栈内。
这里分析一下is_branch_taken
函数如下,直接分析BPF_JNE
情况。其会调用tnum_is_const
函数判断,否则将会直接返回-1
。然而在 tnum_is_const
中,会判断BPF_REG_6
的mask
是否有值,若有值则返回0。由于这里BPF_REG_6.mask
有值,所以会返回0。最终导致在check_cond_jmp_op
函数中,仍然有分支被压入栈内。
1 | is_branch_taken: |
然而,在检测BPF_EXIT_INSN
指令时,最后会调用pop_stack
从栈中弹出分支,若没有分支直接调用break
终止检查,若有分支还需要对分支进行检查。而由于在上面,已经压入了一个分支。所以,这里就会对后续的分支进行检查,所以,我们之前的利用思路在这里失败了。
1 | else if (opcode == BPF_EXIT) { |
如果另一个分支永远不执行,那么会将这部分代码填充为trap
指令。从而在真正执行时,虽然绕过检查了,但是会导致陷入死循环。
1 | /* instruction rewrites happen after this point */ |
ZDI-思路分析
bpf指令分析
1 | struct bpf_insn insns[]={ |
bpf_check绕过
1 | BPF_LD_MAP_FD(BPF_REG_1,3), //reg1=crtl_mapfd |
这段bpf
指令的作用是通过前面的漏洞绕过bpf
的检测,这里说明如下:
首先对各寄存器进行了初步赋值,这里重点说明对
reg1
赋值为了初始创建的两个map
中的当一个地址;然后将
reg9
赋值为reg6
,也即将reg6
的值赋值为crtlmapfd[0]
的值,由于并没有使用立即数赋值,所以这里并不会直接知道reg6
的值;然后将
reg6
与0
进行了一个BPF_JGE
的比较,这里就会使得reg6
的umin
为0x1
然后将
reg8
的值设置为0x100000001
,随后与reg6
进行一个BPF_JLE
的比较,这会使得reg6
的umax
为0x100000001
最后将
reg6
与reg5
进行一个比较,这里就会进入漏洞函数,最终触发漏洞,使得bpf_check
时 认为reg6
的value
为0x1
地址泄漏
前面的分析,已经说明了可以在bpf_check
时,使得reg6
的value
被认为0x1
。但是,在真实执行时,我们却可以通过map
对reg6
赋值为2。如下代码所示:
1 | BPF_ALU64_IMM(BPF_AND, 6, 2) |
这里真实执行时r6 = 2
,但在verifier
过程到这里 r6
会被认为是1,( 1 & 2 ) >> 1 == 0
,但是实际运行的时候 ( 2 & 2 ) >> 1 == 1
:
接下来让r6 = r6 * 0x110
,这样 verifier
过程仍然认为它是0,但是运行过程的实际值确实 0x110
:
1 | BPF_ALU64_IMM(BPF_MUL, 6, 0x110) |
我们获取一个map,叫它expmap
吧, r7 = expmap[0]
:
1 | BPF_MOV64_REG(7, 0) |
然后r7 = r7 - r6
,因为r7
是指针类型, verifier
会根据map的size来检查边界,但是verifier
的时候认为r6 == 0
,r7 - 0 == r7
,所以可以通过检查,但是运行的时候我们可以让r7 = r7 - 0x110
,然后BPF_LDX_MEM(BPF_DW, 8, 7, 0)
就可以做越界读写了:
1 | BPF_ALU64_REG(BPF_SUB, 7, 6) |
eBPF使用bpf_map
来保存map信息,也就是map_create得到的地址:
1 | struct bpf_map { |
在map_lookup_elem
的时候, 使用的是bpf_array
,它的开头是bpf_map
,然后value
就是map的每一个项的数组,也就是说bpf_map
刚好在r7
的低地址处(r7
是第一个value),这里查看内存可以知道map
在r7 - 0x110
的地方:
1 | struct bpf_array { |
于是我们就可以读写bpf_map
来做后续的利用。
首先是地址泄漏,bpf_map
有一个const struct bpf_map_ops ops
字段,当我们创建的map
是BPF_MAP_TYPE_ARRAY的时候保存的是array_map_ops
,这是一个全局变量,保存在rdata段,通过它可以计算KASLR的偏移。运行的时候可以在下面的wait_list
处泄漏出map
的地址:
1 | gef➤ p/a *(struct bpf_array *)0xffff88800d878000 |
任意内存写
我们可以用r7
写入 ops = 0xffffffff82016340 <array_map_ops>
, 改成我们自己的fake_ops
, 因为前面我们已经泄露出map 的地址了,那么完全可以用map_update_elem
伪造一个ops
, 然后改一下指针就可以劫持控制流了,zdi上的writeup 用了一个更好的办法。
1 | gef➤ p/a *(struct bpf_map_ops *)0xffffffff82016340 |
map_push_elem
会在 map_update_elem
的时候被调用, 它需要map
的类型是BPF_MAP_TYPE_QUEUE
或者BPF_MAP_TYPE_STACK
, 但是没有关系, map 上的任何内容都可以用 r7
来改,把map_type
改成BPF_MAP_TYPE_STACK
(0x17)之后,每次调用map_update_elem
时, 就会调用map_push_elem
1 | static int bpf_map_update_value(struct bpf_map *map, struct fd f, void *key, |
在 fake_ops
上, 我们把map_push_elem
改成map_get_next_key
一样的地址, 这里实际的map_get_next_key
是函数array_map_get_next_key
1 | uint64_t fake_map_ops[]={ |
array_map_get_next_key
实现在kernel/bpf/arraymap.c#L279
上, 传递给map_push_elem
的参数是value
(ring3 要update的数据)和 uattr
的 flags, 分别对应array_map_get_next_key
的 key
和 next_key
参数
1 | static int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key) |
加入我们运行 map_update_elem(mapfd, &key, &value, flags)
, 运行到 array_map_get_next_key
之后有
1 | index == value[0], next = flags , 最终效果是 *flags = value[0] |
value[0] 和 flags 都是 ring3 下传入的值,前面我们已经泄露了内核地址,于是就可以通过修改 flags
的值写任意内存啦。写入的index要满足(index >= array->map.max_entries)
, map_entries
可以用r7
改成0xffff ffff
这里index 和 next 都是 u32 类型, 所以就是任意地址写 4个byte.
具体的操作是
- 1 写 r7 改写 ops 到 fake_ops ( map_push_elem 改成
array_map_get_next_key
地址) - 2 修改 map 的一些字段绕过一些检查
- spin_lock_off = 0
- max_entries =
0xffff ffff
- map_type =
BPF_MAP_TYPE_STACK
- 3 调用
map_update_elem
写内存
改modprobe_path 用root任意命令
可以任意地址写这个能力还是挺大的了,zdi 的writeup 上是通过搜索 init_pid_ns
, 找到当前的task_struct
, 然后写 cred 来获取一个 root shell。
既然已经可以任意地址写了,这里我的做法是改写modprobe_path
, 然后就可以用root 权限执行任意指令了,虽然不能起root shell, 但是也是可以达到提权目的了
/tmp
目录下生成 /tmp/chmod
和 /tmp/fake
, /tmp/chmod
可以改 /flag
文件的权限
1 | void gen_fake_elf(){ |
然后把modprobe_path
改成 /tmp/chmod
, 然后运行 /tmp/fake
就完事啦
1 | expbuf64[0] = 0x706d742f -1; |
EXP
1 |
|
1 |
|
参考
CVE-2020-8835 pwn2own 2020 ebpf 提权漏洞分析
[kernel exploit]CVE-2020-8835:eBPF verifier 错误处理导致越界读写
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/05/01/CVE-2020-8835-eBPF提权漏洞分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!