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_struct
的 cred
进行提权
1.3 eBPF相关知识
eBPF是extended Berkeley Packet Filter的缩写,起初是用于捕获和过滤特定规则的网络数据包,现在也被用在防火墙,安全,内核调试与性能分析等领域
eBPF程序的运行过程如下:在用户空间生产eBPF“字节码”,然后将“字节码”加载进内核中的“虚拟机”中,然后进行一些列检查,通过则能够在内核中执行这些“字节码”。类似Java与JVM虚拟机,但是这里的虚拟机是在内核中的
1.3.1 内核中的eBPF验证程序
允许用户代码在内核中运行存在一定的危险性。因此,在加载每个eBPF程序之前,都要执行许多检查。主要函数是bpf_check()
,包含check_cfg()
和do_check_main()
函数
-
调用
check_cfg()
——确保eBPF程序能正常终止,不包含任何可能导致内核锁定的循环。这是通过对程序的控制流图CFG进行深度优先搜索来实现的。程序需3个条件:a.所有指令必须可达;b.没有往回跳转的指令;c.没有跳的太远超出指令范围的指令 -
调用
do_check_main()->do_check_common()->do_check()
——内核验证器(verifier),模拟eBPF程序的执行,模拟通过后才能正常加载。在执行每条指令之前和之后,都需要检查虚拟机状态,以确保寄存器和堆栈状态是有效的。禁止越界跳转,也禁止访问非法数据验证器不需要遍历程序中的每条路径,它足够聪明,可以知道程序的当前状态何时是已经检查过的状态的子集。由于所有先前的路径都必须有效(否则程序将无法加载),因此当前路径也必须有效。 这允许验证器“修剪”当前分支并跳过其仿真。其次具有未初始化数据的寄存器无法读取,这样做会导致程序加载失败
在遇到具有分支,例如
if xxx goto pc+x
这样的语句,内核会检测if判断的条件是否恒成立。若判断为恒成立或者恒不成立,则只分析相应的那一分支,而另一分支则不进行分析。没有被分析到的指令被视为dead code
,会调用sanitize_dead_code()
将dead code
全部替换为exit
-
验证器使用eBPF程序类型来限制可以从eBPF程序中调用哪些内核函数以及可以访问哪些数据结构。
bpf程序的执行流程如下图:
1.3.2 eBPF程序的载入
-
bpf_insn
—— 指令结构体每一个eBPF程序都是一个
bpf_insn
数组,使用bpf系统调用将其载入内核 -
bpf_prog_load
—— eBPF程序载入的系统调用用户层调用编写示例:
0x02 实验环境
- 工具
GDB,bpftools - 环境
测试版本:Linux-5.5.0 测试环境下载地址
0x03 复现过程
3.1 POC分析
poc(下载地址)如下:goto pc-1
不能通过check_cfg
检查,但还是被载入内核
漏洞原因:内核在检查程序合法性的过程中,第9句在检查时被判断为恒成立,之后的检查便只检查了第12句,第10和第11句被视为dead code,在之后的sanitize_dead_code()
函数中被修改为goto pc-1
而没有想到的是,在实际执行的时候第9句实际上是恒不成立,因此就导致程序执行了goto pc-1
。在实际执行跳转指令的时候,跳转的偏移会默认加1,因此实际上goto pc-1
跳转到的地方不是自己的上一条,而是自己,这就导致程序空转,陷入死循环
模拟执行时,reg->smin_value
为0x10300000
,sval
为0x303030
,可以看到这里会返回1,表示该if语句恒成立,下一个被检测的语句就变成了第12句,而第10和第11句就被patch成了goto pc-1
实际执行时,此刻的w0
为0xCFD0
,小于0x303030
,就会导致真正在执行的过程中,内核会执行goto pc-1
,导致空转,死循环
3.2 漏洞分析
3.2.1 寄存器结构体
模拟运行BPF指令时,用bpf_reg_state
来保存寄存器的状态信息
示例:假如value
是 010
(二进制表示) , mask
是100
, 那么就是经过前面的指令的模拟执行之后,可以确定这个寄存器的第二个bit 一定是 1, 第三个 bit 在mask 里面设置了,表示这里不确定,可以是1或者是0。详细的文档可以在Documentnetworking/filter.txt
里面找到。
3.2.2 漏洞函数
__reg_bound_offset32()
用于处理跳转指令由commit 581738a681b6引入
3.2.3 跳转指令的处理
示例:对于跳转指令,例如指令BPF_JMP_IMM(BPF_JGE, BPF_REG_5, 8, 3)
,会采用__reg_bound_offset()
函数(__reg_bound_offset32
的64位版本)来更新状态,false_reg
和true_reg
分别代表两个分支的状态,即该if不成立时的reg和if成立时的reg
当 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()
的过程:
漏洞:计算range 的时候直接取低32bit,因为原本的umin_value
和 umax_value
都是64bit的, 假如计算之前umin_value == 1
,umax_value == 1 0000 0001
, 取低32bit之后他们都会等于1,这样range计算完之后TNUM(min & ~delta, delta)
, min = 1
,delta = 0(chi == 0)
然后到tnum_intersect
函数, 假设a.value = 0
,计算后的v == 1
,mu == 0
,最后得到的 var_off
就是固定值1, 也就是说,不管寄存器真实的值是怎么样,在verifier 过程都会把它当做是1。
解释:看POC中0 & 1,开始r0赋值为具体值,经过第1条语句后变成不确定的值,这样经过verifier 过程之后r0.var_off->value
就变成0了;另一种情况,如果r0是运行时载入的,那r0也是不确定的值,经过verifier 过程之后就被当做1了
-
例1:
-
例2:创建数组
array map
,运行时将map[1]
载入 r6,这时verifier不知道r6是什么,这时r6.var_off->value = 0
3.3 调试分析
首先创建array map
,让 r9 = map[1]
,r6是用于测试漏洞的寄存器
因为r6 是从 map[0]
load 进来的,实际运行的时候可以是任何值,但经过verifier操作后都被当做1
在__reg_bound_offset32
下个断点,我这里是在kernel/bpf/verifier.c:1038
, false_reg
和true_reg
在函数执行前后值如下:
3.4 漏洞利用
前面的指令执行完后,再执行以下指令,一开始令 r6=2 *(实际值)
,但verifier后会被当做1
3.4.1 地址泄露
创建map,传入用户数据,这个结构是用户态与内核态交互的一块共享内存
key_size
:表示索引的大小范围,key_size=sizeof(int)=4
value_size
:表示map数组每个元素的大小范围,可以任意,只要控制在一个合理的范围
max_entries
:表示map数组的大小,编写利用时将其设为1
bpf_create_map()
实际调用map_create()
来创建bpf_array
结构,我们传入的数据放在value[]
处:
泄露内核地址:读取bpf_map_ops *ops
指针即可
泄露map_elem
地址:&exp_value[0]-0x110+0xc0(wait_list)
处保存着指向wait_list
自身(bpf_array
中)的地址,用于泄露exp_value
的地址
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()
所以只需要修改 map->btf
为 target_addr-0x58
,就可以把btf->id
(target_addr
处的值)泄露到用户态info中,泄漏的信息在struct bpf_map_info
结构偏移0x40处,由于是u32类型,所以一次只能泄露4个字节。
利用代码如下:
3.4.3 查找task_struct
-
通过漏洞来搜索
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
结构的地址。 -
通过
pid
和init_pid_ns
查找对应pid
的task_struct
内核查找过程:通过find_task_by_pid_ns
函数查找。
3.4.4 任意写
-
调用
bpf_create_map()
构造bpf_array
时,类型设置为BPF_MAP_TYPE_QUEUE
或者BPF_MAP_TYPE_STACK
。(这样bpf_array->map->ops
会被赋值为全局函数表queue_map_ops
或stack_map_ops
,其中包含可利用的map_push_elem
函数指针)。 -
在
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 的一些字段绕过某些检查。 -
调用
bpf_update_elem
任意写内存map_push_elem()
的参数是value
和uattr->flags
,分别对应array_map_get_next_key()
的key
和next_key
参数,之后有index = value[0]
,next = flags
, 最终效果是*flags = value[0]+1
,这里index
和next
都是 u32 类型, 所以可以任意地址写 4个byte。
3.4.4 bpf_insn
说明
r6
保存ctrl_value
的地址,r7
保存exp_value
的地址,r8
为偏移ctrl_map
保存输入的偏移,泄露的地址,以及执行覆盖伪造的array_map_ops
操作exp_map
保存伪造的array_map_ops
3.5 整体思路
- 通过漏洞,使得传进来的偏移
r8
检查时为0,而实际为0x110
- 将
&exp_value[0]-0x110
,获得exp_map
的地址,exp_map[0]
保存着array_map_ops
的地址,可以用于泄露内核地址 &exp_value[0]-0x110+0xc0(wait_list)
处保存着指向自身的地址,用于泄露exp_value
的地址- 利用任意读查找
init_pid_ns
结构地址 - 利用进程
pid
和init_pid_ns
结构地址获取当前进程的task_struct
- 在
exp_value
上填充伪造的array_map_ops
- 修改
map
的一些字段绕过一些检查 - 调用
bpf_update_elem
任意写内存 - 修改进程
task_struct
的cred进行提权。
提权成功
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下的几种另类提权尝试
- 本文作者: g0k3r
- 本文来源: 奇安信攻防社区
- 原文链接: https://forum.butian.net/share/1701
- 版权声明: 除特别声明外,本文各项权利归原文作者和发表平台所有。转载请注明出处!