CVE-2020-8835:Linux Kernel 信息泄漏权限提升漏洞 复现与分析

2020年03月31日, 360CERT监测发现 ZDI 在 Pwn2Own 比赛上演示的 Linux 内核权限提升漏洞已经被 CVE 收录。CVE编号: CVE-2020-8835。该漏洞由@Manfred Paul发现,漏洞是因为bpf验证程序没有正确计算一些特定操作的寄存器范围,导致寄存器边界计算不正确,进而引发越界读取和写入。该漏洞在Linux Kernelcommit(581738a681b6)中引入。

0x00 漏洞背景

2020年03月31日, 360CERT监测发现 ZDI 在 Pwn2Own 比赛上演示的 Linux 内核权限提升漏洞已经被 CVE 收录。CVE编号: CVE-2020-8835。该漏洞由@Manfred Paul发现,漏洞是因为bpf验证程序没有正确计算一些特定操作的寄存器范围,导致寄存器边界计算不正确,进而引发越界读取和写入。该漏洞在Linux Kernelcommit(581738a681b6)中引入。

在 Linux 内核 5.5.0 和更新版本中,bpf 验证器 (kernel/bpf/verifier.c) 没有正确限制 32 位操作的寄存器边界,导致内核内存中的越界读取和写入。该漏洞还影响 Linux 5.4 稳定系列,从 v5.4.7 开始,因为引入提交已向后移植到该分支。此漏洞已在 5.6.1、5.5.14 和 5.4.29 中修复。

虽然该漏洞的影响面有限,但是属于高危风险,研究该漏洞可以学习到大量Linux内核漏洞知识和bpf相关知识。

0x01 相关知识介绍

1.1 漏洞实例简介

  • 影响版本:v5.4.7 - v5.5.0 以及更新的版本,如5.6
  • 编译选项:CONFIG_BPF_SYSCALL,config所有带BPF字样的
  • 漏洞描述:在Linux Kernel commit(581738a681b6)中引入,kernel/bpf/verifier.c没有正确将64位值转换为32位(直接取低32位),使得BPF代码验证阶段和实际执行阶段不一致,导致越界读写
  • 补丁:patch 去掉 __reg_bound_offset32 函数及其调用

1.2 漏洞基本原理

当BPF程序的寄存器来自map(外部传递)时,若该寄存器出现在JMP32指令中,会被__reg_bound_offset32漏洞函数处理,导致verifier返回结果总为1

利用这个漏洞可以构造任意读写,越界读可以泄露内核基址、传入数据的基址;利用bpf_map_get_info_by_fd 函数构造任意4字节读,泄露task_struct地址,注意多核与单核的泄露方法有区别

通过伪造 stack_map_ops 函数表中 map_push_elem 指针为 queue_stack_map_get_next_key,并替换 bpf_map->ops指向伪造的 stack_map_ops 函数表,构造任意地址写4字节,修改进程 task_structcred 进行提权

1.3 eBPF相关知识

eBPF是extended Berkeley Packet Filter的缩写,起初是用于捕获和过滤特定规则的网络数据包,现在也被用在防火墙,安全,内核调试与性能分析等领域

eBPF程序的运行过程如下:在用户空间生产eBPF“字节码”,然后将“字节码”加载进内核中的“虚拟机”中,然后进行一些列检查,通过则能够在内核中执行这些“字节码”。类似Java与JVM虚拟机,但是这里的虚拟机是在内核中的

1.3.1 内核中的eBPF验证程序

允许用户代码在内核中运行存在一定的危险性。因此,在加载每个eBPF程序之前,都要执行许多检查。主要函数是bpf_check(),包含check_cfg()do_check_main()函数

  1. 调用check_cfg()——确保eBPF程序能正常终止,不包含任何可能导致内核锁定的循环。这是通过对程序的控制流图CFG进行深度优先搜索来实现的。程序需3个条件:a.所有指令必须可达;b.没有往回跳转的指令;c.没有跳的太远超出指令范围的指令

  2. 调用do_check_main()->do_check_common()->do_check()——内核验证器(verifier),模拟eBPF程序的执行,模拟通过后才能正常加载。在执行每条指令之前和之后,都需要检查虚拟机状态,以确保寄存器和堆栈状态是有效的。禁止越界跳转,也禁止访问非法数据

    验证器不需要遍历程序中的每条路径,它足够聪明,可以知道程序的当前状态何时是已经检查过的状态的子集。由于所有先前的路径都必须有效(否则程序将无法加载),因此当前路径也必须有效。 这允许验证器“修剪”当前分支并跳过其仿真。其次具有未初始化数据的寄存器无法读取,这样做会导致程序加载失败

    在遇到具有分支,例如if xxx goto pc+x这样的语句,内核会检测if判断的条件是否恒成立。若判断为恒成立或者恒不成立,则只分析相应的那一分支,而另一分支则不进行分析。没有被分析到的指令被视为dead code,会调用sanitize_dead_code()dead code全部替换为exit

  3. 验证器使用eBPF程序类型来限制可以从eBPF程序中调用哪些内核函数以及可以访问哪些数据结构。

bpf程序的执行流程如下图:

image.png

1.3.2 eBPF程序的载入

  1. bpf_insn —— 指令结构体

    image.png

    每一个eBPF程序都是一个bpf_insn数组,使用bpf系统调用将其载入内核

  2. bpf_prog_load —— eBPF程序载入的系统调用

    image.png

    用户层调用编写示例:

    image.png

0x02 实验环境

  1. 工具
    GDB,bpftools
  2. 环境
    测试版本:Linux-5.5.0 测试环境下载地址

0x03 复现过程

3.1 POC分析

poc(下载地址)如下:goto pc-1不能通过check_cfg检查,但还是被载入内核

image.png

漏洞原因:内核在检查程序合法性的过程中,第9句在检查时被判断为恒成立,之后的检查便只检查了第12句,第10和第11句被视为dead code,在之后的sanitize_dead_code()函数中被修改为goto pc-1

而没有想到的是,在实际执行的时候第9句实际上是恒不成立,因此就导致程序执行了goto pc-1。在实际执行跳转指令的时候,跳转的偏移会默认加1,因此实际上goto pc-1跳转到的地方不是自己的上一条,而是自己,这就导致程序空转,陷入死循环

模拟执行时,reg->smin_value0x10300000sval0x303030,可以看到这里会返回1,表示该if语句恒成立,下一个被检测的语句就变成了第12句,而第10和第11句就被patch成了goto pc-1

实际执行时,此刻的w00xCFD0,小于0x303030,就会导致真正在执行的过程中,内核会执行goto pc-1,导致空转,死循环

3.2 漏洞分析

3.2.1 寄存器结构体

模拟运行BPF指令时,用bpf_reg_state来保存寄存器的状态信息

image.png

示例:假如value010(二进制表示) , mask100 , 那么就是经过前面的指令的模拟执行之后,可以确定这个寄存器的第二个bit 一定是 1, 第三个 bit 在mask 里面设置了,表示这里不确定,可以是1或者是0。详细的文档可以在Documentnetworking/filter.txt 里面找到。

3.2.2 漏洞函数

__reg_bound_offset32() 用于处理跳转指令由commit 581738a681b6引入

image.png

3.2.3 跳转指令的处理

示例:对于跳转指令,例如指令BPF_JMP_IMM(BPF_JGE, BPF_REG_5, 8, 3),会采用__reg_bound_offset()函数(__reg_bound_offset32 的64位版本)来更新状态,false_regtrue_reg 分别代表两个分支的状态,即该if不成立时的reg和if成立时的reg

image.png

r5 >= 8 的时候,这条指令会跳到pc+3正确分支),r5 < 8时跳到错误分支

3.2.4 __reg_bound_offset32流程分析

说明__reg_bound_offset32 会在使用BPF_JMP32 指令时调用,ebpf 的BPF_JMP 寄存器之间是64bit比较的,换成BPF_JMP32 的时候就只会比较低32bit

接着看看__reg_bound_offset32()的过程:

image.png

漏洞:计算range 的时候直接取低32bit,因为原本的umin_valueumax_value 都是64bit的, 假如计算之前umin_value == 1umax_value == 1 0000 0001 , 取低32bit之后他们都会等于1,这样range计算完之后TNUM(min & ~delta, delta)min = 1delta = 0(chi == 0)

然后到tnum_intersect 函数, 假设a.value = 0 ,计算后的v == 1mu == 0 ,最后得到的 var_off 就是固定值1, 也就是说,不管寄存器真实的值是怎么样,在verifier 过程都会把它当做是1。

解释:看POC中0 & 1,开始r0赋值为具体值,经过第1条语句后变成不确定的值,这样经过verifier 过程之后r0.var_off->value就变成0了;另一种情况,如果r0是运行时载入的,那r0也是不确定的值,经过verifier 过程之后就被当做1了

  • 例1:

    image.png

  • 例2:创建数组array map,运行时将map[1]载入 r6,这时verifier不知道r6是什么,这时r6.var_off->value = 0

3.3 调试分析

首先创建array map,让 r9 = map[1],r6是用于测试漏洞的寄存器

image.png

因为r6 是从 map[0] load 进来的,实际运行的时候可以是任何值,但经过verifier操作后都被当做1

__reg_bound_offset32 下个断点,我这里是在kernel/bpf/verifier.c:1038false_regtrue_reg在函数执行前后值如下:

image.png

3.4 漏洞利用

前面的指令执行完后,再执行以下指令,一开始令 r6=2 *(实际值),但verifier后会被当做1

image.png

3.4.1 地址泄露

创建map,传入用户数据,这个结构是用户态与内核态交互的一块共享内存

key_size:表示索引的大小范围,key_size=sizeof(int)=4

value_size:表示map数组每个元素的大小范围,可以任意,只要控制在一个合理的范围

max_entries:表示map数组的大小,编写利用时将其设为1

image.png

bpf_create_map()实际调用map_create()来创建bpf_array结构,我们传入的数据放在value[]处:

image.png

泄露内核地址:读取bpf_map_ops *ops指针即可

泄露map_elem地址&exp_value[0]-0x110+0xc0(wait_list)处保存着指向wait_list自身(bpf_array中)的地址,用于泄露exp_value的地址

image.png

3.4.2 任意读

方法:利用BPF_OBJ_GET_INFO_BY_FD选项进行任意读。通过修改map->btf 指针为target_addr-0x58,读取map->btf+0x58处的32 bit值(map->btf.id

调用顺序BPF_OBJ_GET_INFO_BY_FD -> bpf_obj_get_info_by_fd() -> bpf_map_get_info_by_fd()

image.png

所以只需要修改 map->btftarget_addr-0x58,就可以把btf->idtarget_addr处的值)泄露到用户态info中,泄漏的信息在struct bpf_map_info 结构偏移0x40处,由于是u32类型,所以一次只能泄露4个字节。

利用代码如下:

image.png

3.4.3 查找task_struct

image.png

  1. 通过漏洞来搜索 init_pid_ns 结构的地址
    先搜索"init_pid_ns 字符串可以得到 __kstrtab_init_pid_ns 的地址;再搜索满足 target_addr + (int)*target_addr == __kstrtab_init_pid_ns 条件的 target_addr,target_addr - 4 即为 __ksymtab_init_pid_ns 地址;加上 init_pid_ns 结构的位置偏移即可,target_addr - 4 + (int)*(target_addr - 4) 即为 init_pid_ns 结构的地址。

  2. 通过pidinit_pid_ns查找对应pidtask_struct
    内核查找过程:通过 find_task_by_pid_ns 函数查找。

    image.png

    image.png

    image.png

3.4.4 任意写

  1. 调用 bpf_create_map() 构造 bpf_array 时,类型设置为BPF_MAP_TYPE_QUEUE 或者 BPF_MAP_TYPE_STACK 。(这样bpf_array->map->ops会被赋值为全局函数表queue_map_opsstack_map_ops,其中包含可利用的map_push_elem函数指针)。

  2. exp_value上布置伪造的array_map_ops,伪造的 array_map_ops 中将 map_push_elem 填充为map_get_next_key ,这样调用map_push_elem时就会调用map_get_next_key ,并将&exp_value[0]的地址覆盖到exp_map[0],同时要构造 map 的一些字段绕过某些检查。

    image.png

  3. 调用bpf_update_elem任意写内存

    image.png

    map_push_elem() 的参数是 valueuattr->flags,分别对应 array_map_get_next_key()keynext_key 参数,之后有 index = value[0]next = flags , 最终效果是 *flags = value[0]+1,这里indexnext 都是 u32 类型, 所以可以任意地址写 4个byte。

3.4.4 bpf_insn 说明

  1. r6 保存ctrl_value的地址,r7保存exp_value的地址,r8为偏移
  2. ctrl_map 保存输入的偏移,泄露的地址,以及执行覆盖伪造的array_map_ops操作
  3. exp_map 保存伪造的array_map_ops

image.png

3.5 整体思路

  1. 通过漏洞,使得传进来的偏移r8检查时为0,而实际为0x110
  2. &exp_value[0]-0x110,获得exp_map的地址,exp_map[0] 保存着array_map_ops的地址,可以用于泄露内核地址
  3. &exp_value[0]-0x110+0xc0(wait_list)处保存着指向自身的地址,用于泄露exp_value的地址
  4. 利用任意读查找init_pid_ns结构地址
  5. 利用进程pidinit_pid_ns结构地址获取当前进程的task_struct
  6. exp_value上填充伪造的array_map_ops
  7. 修改 map 的一些字段绕过一些检查
  8. 调用 bpf_update_elem任意写内存
  9. 修改进程task_struct 的cred进行提权。

提权成功
image.png

0x04 写在后面的一点话

初次搭建环境(青春版)时,qemu启动时出现了KVM kernel module: No such file or directory的问题,我花了很多天用来解决这个问题,最后发现是因为没有开启CPU intel虚拟化,这让我感觉自己像个傻子。

第二次完全自己搭建环境时,在内核编译和文件系统制作中也出现了很多棘手的问题,可以说整个复现卡在环境搭建部分很久。

CVE的复现不仅仅是一个漏洞学习的过程,还能很多环境搭建的知识,甚至可以说,其实在学完CVE的原理之后大致只是明白是这怎么回事,但是复现CVE的过程是更加艰难的,搭建环境就是第一座大山,差之毫厘谬以千里,这是最好的写照。

环境搭建完毕后,又会遇到更多的问题,别人可以执行的指令自己执行不了,别人可以运行的文件,自己运行失败,诸如此类,多多复现CVE学到的东西是很多的。

0x05 参考

CVE-2020-8835-通过不正确的 EBPF 程序验证导致 LINUX 内核权限提升
CVE-2020-8835: Linux Kernel 信息泄漏/权限提升漏洞通告
CVE-2020-8835:Linux eBPF模块verifier组件漏洞分析
黑客老外CVE-2020-8835:最新的linux内核提权root
Rick提权:CVE-2020-8835下的几种另类提权尝试

  • 发表于 2022-06-29 09:36:05
  • 阅读 ( 7391 )
  • 分类:漏洞分析

0 条评论

请先 登录 后评论
g0k3r
g0k3r

2 篇文章

站长统计