Hello BPF !
0x00 BPF简介
BPF的全称是Berkeley Packet Filter,是一个用于过滤(filter)网络报文(packet)的架构。BPF是在1997年首次被引入Linux,当时的内核版本为2.1.75。准确的说,Linux内核中的报文过滤机制其实是有自己的名字的:Linux Socket Filter,简称 LSF。但也许是因为 BPF 名声太大了,连内核文档都直言LSF其实就是BPF。
LSF和BPF除了名字上的差异以外,还是有些不同的,首当其冲的分歧就是接口:传统的 BSD 开启 BPF的方式主要是靠打开(open)/dev/bpfX设备,之后利用 ioctl 来进行控制;而Linux则选择了利用套接字选项(sockopt)SO_ATTACH_FILTER/SO_DETACH_FILTER来执行系统调用。
由于主要是和过滤报文打交道,内核中(before 3.18)的 BPF 的绝大部分实现都被放在了net/core/filter.c下。
0x01 extended BPF
1.1 eBPF简介
自3.15版本内核,一个BPF的全新设计开始逐渐进入人们的视野,并最终(3.17)被添置到了kernel/bpf下,并最终被命名为extended BPF(eBPF)。为了后向兼容,传统的BPF仍被保留了下来,并被重命名为 classical BPF(cBPF)。
eBPF 带来的改变可谓是革命性的:一方面,它已经为内核追踪(Kernel Tracing)、应用性能调优/监控、流控(Traffic Control)等领域带来了变革;另一方面,在接口的设计以及易用性上,eBPF也有了较大的改进。
所有BPF功能都可以通过BPF syscall访问,该调用支持各种命令。在BPF的手册中,指出在当前实现中,所有bpf()命令都要求调用者具有CAP_SYS_ADMIN功能。但从Linux 4.4开始,任何用户都可以通过将eBPF程序附加到其拥有的套接字,来实现对其的加载和运行。
1.2 eBPF程序
eBPF使用的指令集有以下寄存器,分别对应硬件CPU的寄存器:
1 | R0 - rax ; R1 - rdi ; R2 - rsi ; R3 - rdx ; R4 - rcx |
每条指令格式:
1 | struct bpf_insn { |
eBPF:
- 为RISC指令系统
- 虚拟的寄存器对应于物理CPU的寄存器,且功能类似,如r0用于返回值
BPF的加载过程
- 用户程序调用*syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr))*申请创建一个map,在attr结构体中指定map的类型、大小、最大容量等属性。
- 用户程序调用syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr))来将我们写的BPF代码加载进内核,attr结构体中包含了指令数量、指令首地址指针、日志级别等属性。在加载之前会利用虚拟执行的方式来做安全性校验,这个校验包括对指定语法的检查、指令数量的检查、指令中的指针和立即数的范围及读写权限检查,禁止将内核中的地址暴露给用户空间,禁止对BPF程序stack之外的内核地址读写。**安全校验通过后,程序被成功加载至内核,后续真正执行时,不再重复做检查。
- 用户程序通过调用*setsockopt(sockets[1], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(progfd))*将我们写的BPF程序绑定到指定的socket上。Progfd为上一步骤的返回值。
- 用户程序通过操作上一步骤中的socket来触发BPF真正执行。
0x02 CVE-2020-8835
2.1 漏洞概述
eBPF有安全检查机制,会对输入的eBPF程序进行检测,然而CVE-2020-8835发现,其检查机制存在漏洞,使得攻击者可以绕过特定的检查并以此构造恶意攻击载荷进行提权。
2.2 漏洞分析
TL;DR
- 寄存器A从未知内存区域读取值,verifier认为A是不确定数(在实际中可以指定)
- 设置A的umin为1,umax为2^32+1
- 执行JMP32(如JNE 5, 1),执行完毕后,A被认为就是1(但其实可以为2)
- A(被认为是1)执行&2后>>1,在verifier中执行完后A变为0,实际中若是2,执行完后A变为1
- 后续使用一个超过offset limit乘A,verifier认为是0,但在实际中可造成溢出
详细分析
漏洞函数在Linux commit 581738a681b6中引入:
1 | static void __reg_bound_offset32(struct bpf_reg_state *reg) |
漏洞发生在eBPF的verifier
阶段,在这个阶段,内核会对通过bpf系统调用载入的eBPF程序进行模拟运行来检查合法性,以保证安全性。模拟运行期间使用bpf_reg_state来保存寄存器信息。
1 | struct bpf_reg_state { |
var_off是struct tmun类型,value的某个bit为1时,表示这个寄存器对应的bit确定为1;mask某个bit为1时,表示这个寄存器对应的bit是未知的,可能为1也可能为0。
分析如下跳转指令:
1 | BPF_JMP_IMM(BPF_JGE, BPF_REG_5, 8, 3) |
由这两行代码进行处理,false_reg
和true_reg
分别代表两个分支的状态,是**__reg_bound_offset32()**的64位版本:
1 | __reg_bound_offset(false_reg); |
当r5 >= 8
时,会跳转到pc + 3
的地方执行(true分支),那在false分支上,r5
肯定小于8。
__reg_bound_offset32()**在使用BPF_JMP32时调用,BPF_JMP是64bit的比较,BPF_JMP32**是比较低32bit:
1 | static void __reg_bound_offset32(struct bpf_reg_state *reg) |
tnum__range()*传入的参数只取低32bit,并创建一个新的TNUM*。漏洞发生的原因是tnum_range()**的实现方式有问题,计算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
。
接着进入**tnum_intersect()**,假设a.value == 0
,计算后v == 1
,mu == 0
,得到最后的var_off == 1
,即不管寄存器真实值如何,在verifier
过程中都会被当作1。
2.3 eBPF部分函数功能
分析序列:
1 | BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF), /* r9 = (u32)0xFFFFFFFF */ |
比较语句BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2),do_check在校验条件类跳转指令的时候,会判断条件是否成立,如果是非确定性跳转,说明接下来的2个分支都有可能执行,这时do_check会把下一步需要跳转到的指令编号(分支B)放到一个临时栈备用,这样当前指令顺序(分支A)过程中遇到EXIT指令的时候,会从临时栈中取出之前保存的下一条指令继续校验。如果跳转指令恒成立,就不会向临时栈中压入分支。
接下来看BPF_EXIT_INSN(),前段提到在校验EXIT指令时,会从临时栈中尝试读取指令,如果临时栈有指令,那就说明还有其他可能执行到的分支,需要继续校验,如果取不到值,则这条EXIT指令确实是BPF程序的最后一条指令,然后break跳出do_check的校验循环。
在示例代码中,由于条件恒成立,因此break出do_check,不再继续执行校验循环。
2.4 exploit分析
漏洞触发
首先创建一个array map,在eBPF指令中,让r9 = map[1]; r6 = r9
,r6
是用来调试漏洞的寄存器,通过这种加载方式,verifier
就不知道r6
的切确值了,这时候的var_off->value == 0
。
1 | BPF_LDX_MEM(BPF_DW, 6, 9, 0) |
可在map_create的时候下断点以获得map的地址值:
1 | static struct bpf_map *find_and_alloc_map(union bpf_attr *attr) |
在判断语句处,由于判断条件的设置,umin_value
会被设置成1:
1 | BPF_JMP_IMM(BPF_JGE, 6, 1, 1) |
r8 = 0x100000001
,再次通过跳转指令使得umax_value
为0x100000001:
1 | BPF_MOV64_IMM(8, 0x1) |
通过JMP32来触发漏洞:
1 | BPF_JMP32_IMM(BPF_JNE, 6, 5, 1) |
在**__reg_bound_offset32()**下个断点,参考文章里是在kernel/bpf/verifier.c:1038
, false_reg
函数执行前后值如下:
地址泄漏
令一开始r6 = 2
,verifier
过程到这里,r6
会被认为是1,( 1 & 2 ) >> 1 == 0
,但是实际运行的时候 ( 2 & 2 ) >> 1 == 1
:
1 | BPF_ALU64_IMM(BPF_AND, 6, 2) |
接下来让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 |
2.5 漏洞修复
删除了引入的*__reg_bound_offset32()*。
参考资料:
https://www.ibm.com/developerworks/cn/linux/l-lo-eBPF-history/index.html