对eBPF模块中由于s32到u64的符号扩展问题导致的漏洞CVE-2017-16995的复现。
CVE-2017-16995最初是由Google project zero披露,并公开了相关poc,在2017年12月23日,Bruce Leidl公布了提权代码。在2018年3月中旬,Vitaly Nikolenko在推特上发布消息说Ubuntu 16.04存在高危漏洞,可以进行本地提权,同时公布了exp。整个过程仅利用精心构造的数据就可以劫持控制流,是属于Data-Oriented Attacks在Linux kernel上的一个典型应用。
eBPF模块
eBPF源于成型于BSD上的技术BPF(Berkeley Packet Filter),BPF是一个用于过滤网络报文(Packet)的架构,常用的抓包软件tcpdump,wireshark都基于整个模块对用户提供抓包接口。BPF根据规则过滤报文,将符合条件的报文由内核空间复制到用户空间。eBPF是基于原有的BPF,重新设计了一个新的BPF模块,在Linux 3.17加入到kernel/bpf中,新的BPF被命名为extended BPF,简称eBPF。BPF提供了一个内核与用户进行代码和数据传输的桥梁,用户可以使用eBPF指令字节码的形式编写代码并传入内核,通过相关事件触发内核执行用户传入的代码。可以注入代码必然存在安全隐患,eBPF制定了复杂的verifier机制,在运行用户代码之前,先要进行一系列的安全检查,采用模拟执行的方式进行检查,最大程度的防止eBPF代码在真实执行时发生攻击。
eBPF sample
Linux内核代码的samples/bpf目录下有bpf的使用示例,以一个简答的sample来说明一个eBPF过滤代码的编写过程。因为后续调试内核版本是v4.4.110,所以源码版本是v4.4.110。示例代码如下,整个过程分为三部分。这里涉及到的bpf_create_map,bpf_prog_load都是samplesz中自定义的函数,仅在samples中调用,利用系统调用syscall(NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr))和syscall(NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr))实现,后面介绍的这两个函数是内核真正实现和运行的函数源码。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/samples/bpf/sock_example.c |
1.首先调用bpf_create_map创建一个map,在attr结构体中指定map的类型、key和value的大小、最大容量,函数返回一个map_fd描述符。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/samples/bpf/libbpf.c#L21 |
2.调用bpf_prog_load将用户编写的eBPF代码prog加载至内核,attr结构体包含了指令的类型,指令首地址,指令长度,日志大小,日志级别等,然后会进行一系列检查,检查核心在于bpf_check函数,采用模拟执行的方式进行检查。这个下文中会有分析。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/syscall.c#L621 |
3.用户调用setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd)) < 0)将用户自定义的eBPF代码绑定到指定的socket上,此时代码已经拷贝至内核,由prog_fd指向bpf_prog的结构体维护。成功绑定后,对socket数据包执行eBPF代码,此时为真实执行。
eBPF指令集
eBPF指令集与我们常见的汇编指令有所不同,它拥有R0~R10共11个虚拟寄存器,它有一个栈,使用map结构与用户进行交互,前文中也提到调用bpf_create_map创建一个map。在64位下,R0~R10与CPU中的10个物理寄存器对应如下:
1 | R0 -- RAX |
每条指令对应的数据结构如下,在示例中prog的类型就是struct bpf_insn:
1 | https://elixir.bootlin.com/linux/v4.4.110/source/include/uapi/linux/bpf.h#L58 |
eBPF的操作码一共有8大类,一个code有8个bit,code的低三位代表了指令的类型:
1 | https://elixir.bootlin.com/linux/v4.4.110/source/include/uapi/linux/bpf_common.h#L6 |
eBPF verifier机制
检查机制核心在于bpf_check函数,一共有两次check,首轮检查的关键函数是check_cfg,对代码进行有向无环图检测,检查代码中是否有循环,以及跳转指令是否跳转到未知位置,第二轮检查由do_check实现。在进行两轮check之前,先执行了replace_map_fd_with_map_ptr函数,首先看一下这个函数,然后再看一下模拟执行的检查。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L2214 |
replace_map_fd_with_map_ptr
当指令类型为BPF_LD | BPF_IMM | BPF_DW且源寄存器值为1且下一条指令为全0时,该函数会对这条指令以及它的下一条指令进行imm替换,首先根据指令的imm获取bpf_map的fd,根据fd获取bpf_map的地址,然后将该指令的imm替换为map的地址的低32位,高32位赋值给下一条指令的imm。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L1990 |
关于检查机制,这里主要看一下与漏洞相关的do_check的检查逻辑。
do_check
寄存器初始化
首先初始化寄存器的状态,寄存器状态由结构体reg_state定义,它由一个枚举和联合类型组成,buf_reg_type定义了寄存器中存储的值的类型,包括初始化、指针、常量等。imm只有在操作数类型是立即数时才有用,此时寄存器类型为CONST_IMM或PTR_TO_STACK。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L129 |
init_reg_state函数初始化寄存器的状态,将所有寄存器的类型初始化为NOT_INIT,R10的type初始化为栈指针,R1类型初始化为指向buf_context的指针。
1 | https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L474 |
指令都存储在insns数组中,根据下标insn_idx的数值来获取每一条指令,检查的核心在for定义的无限循环中,insn_processed记录for循环执行的次数,最多执行32768次。首先获取指令的类型class,前面提到有8大类型,根据不同的指令类型有不同的处理方式。由于代码比较长,这里只关注与本漏洞相关的指令类型中的检查逻辑。首先看这几个check中用到的函数。
检查中的常用函数
check_reg_arg
首先是当寄存器作为操作数时,对寄存器进行检查的check_reg_arg函数,根据寄存器在指令中所处的位置(源操作数/目的操作数)分别对其type进行检查,当为读指令时,检查源操作数是否为可读;指令为写指令时,检查目的操作数是否可写。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L505 |
check_mem_access
regno是源寄存器,t是指令类型read/write,value_regno是目的寄存器,当指令对memory进行读写操作时,根据源寄存器类型分别进行不同的检查,中心思想是off不能超过memory的size范围,即不能溢出。当指令为写指令时,目的寄存器类型不能是常数或未知值;当指令为读指令时,目的寄存器类型置为UNKNOWN_VALUE。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L683 |
在for循环中对于每一类指令几乎都用到check_reg_arg函数,对内存读写指令会调用check_mem_access函数进行检查。
BPF_ALU中的BPF_MOV
for循环中首先检查类型为BPF_ALU中的指令,我们常用的MOV指令在此类指令中,类型为BPF_MOV,当指令为BPF_ALU时,调用check_alu_op函数进行检查,check_alu_op函数会根据指令的小类型比如BPF_MOV,BPF_ADD等类型分别进行处理,这里关注小类型为BPF_MOV的情况,函数会调用check_reg_arg对源操作数和目的操作数进行检查,如果源操作数是寄存器时,会直接将源寄存器的reg_state复制到目的寄存器中;如果源操作数是立即数,会将insn->imm复制到目的寄存器的imm中,insn->imm和reg_state的imm类型都是int类型,然后目的寄存器的类型设置为CONST_IMM.
1 | * check validity of 32-bit and 64-bit arithmetic operations */ |
BPF_JMP
如果指令为BPF_JMP类型,do_check函数将跳转指令分为四类情况,第一类是函数调用BPF_CALL指令,第二类是BPF_JA指令,第三类是退出指令BPF_EXIT,第四类是其他跳转指令。第四类跳转指令将进入函数check_cond_jmp_op进行检查。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L1893 |
第四类跳转执行check_cond_jmp_op中的检查逻辑。这个函数主要关注条件跳转中目的寄存器是立即数的情况。当跳转指令类型是BPF_JEQ或BPF_JNE跳转时,会检查目的寄存器是否为立即数,如果是立即数,会检查当前指令的imm与目的寄存器的imm是否相等,如果两个imm相等恒成立,就是确定性跳转,就直接跳转到pc+off继续执行。如果不是确定性跳转,则说明跳转的两个分支都有可能执行,这里将不符合跳转条件的分支记作分支A,符合跳转条件的分支记作分支B,函数会继续检查分支A,直至遇到BPF_EXIT指令,并将分支B(insn_idx + insn->off + 1)压入一个临时栈中。这里注意到两个imm的类型,目的寄存器类型是reg_state,成员imm的类型是int有符号整数,指令类型是_s32,是有符号整数,两个均为有符号整数。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c#L1192 |
BPF_EXIT
继续模拟执行分支A中的指令,当指令是BPF_EXIT指令时,,会执行do_check函数中process_bpf_exit中的逻辑,调用pop_stack检查栈中是否还有未检查的指令,如果有则将临时栈中的指令弹出继续模拟执行执行for循环中的检查逻辑;如果env->head == NULL则说明eBPF程序中BPF_EXIT是最后一条指令,所有指令检查完毕,函数返回-1,insn_idx<0跳出for循环,do_check模拟执行结束。
1 | //do_check |
__bpf_prog_run真正执行
当do_check检查结束后,就完成了eBPF的verifier机制的两轮检查,eBPF代码可以真正执行,真正执行调用 __bpf_prog_run函数,函数维护了一个jumptable跳转表,根据insn->code的类型跳转到不同的逻辑去执行代码。这个函数中有一个寄存器变量regs,它的类型是u64,即64位下的无符号整数类型。函数中涉及到的DST,SRC以及IMM类型如下,可以看到DST和SRC来源于regs,类型为u64,IMM来自类型为bpf_insn的insn,类型为s32。
1 | /* Named registers */ |
当出现赋值语句时,以前面的BPF_MOV指令为例,当执行ALU_MOV_K时,会将IMM由insn->imm(s32)有符号整型转成32位的unsigned int类型;当执行ALU64_MOV_K时,会将insn->imm(s32)有符号整型扩展为64位的unsigned int类型。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/core.c#L195 |
既然DST和IMM类型不一致,处理类型为BPF_JMP的指令也存在问题,当指令为JMP_JEQ_K时,会比较DST和IMM的数值是否相等,如果相等,则执行跳转分支insn += insn->off;同样在JMP_JNE_K中,当DST != IMM时,会执行跳转分支insn += insn->off。那这就存在一个问题,在模拟执行do_check的check_cond_jmp_op函数中,当指令为BPF_JNE或BPF_JEQ且目的操作数是imm时,会首先检查insn->imm和目的寄存器的imm是否相等,如果相等则函数认为不相等的分支不会执行,函数直接跳转到pc+off处继续执行检查逻辑,并没有将不相等的分支压入栈中,不相等的这一分支就直接越过了模拟执行的检查,因为函数认为这一分支根本不会执行到,只有在不确定条件跳转时才会先检查分支A,将分支B压入栈中,分支A执行到BPF_EXIT,再弹出分支B继续模拟执行。但是在真正执行时,目标寄存器DST的类型是u64,而IMM的类型是s32,当DST和IMM进行比较时,imm会由s32先符号扩展为s64,然后再由s64转换为u64,如果IMM是负数,转换后会曲解IMM的值,从而造成DST和IMM不相等,逃避检查的不相等的分支可以得到执行,而且它顺利通过了do_check中的检查。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/core.c#L473 |
利用过程
指令解码
首先看exp中关键的prog数组,将其转化为eBPF指令,每条指令的长度为8个字节:
1 |
|
prog一共有41条指令,脚本对指令[10],[18],[26]的指令类型无法识别,结合exp及其他博客对少数指令进行了修正:
1 | [0]: ALU_MOV_K(BPF_REG_9, BPF_REG_0, 0x0, 0xffffffff) |
指令执行过程
绕过check
分析指令[0]~[3],指令[0]将r9赋值为0xffffffff,指令[1]为条件跳转指令,当r9 == 0xffffffff时,继续执行下一条指令,当r9 != 0xffffffff跳转到指令[4]去执行,由于在模拟执行时,r9寄存器在指令[0]时被赋值为0xffffffff,do_check认为r9恒等于0xffffffff,就不会去检查指令[4]以及后面的指令,继续模拟执行指令[2],指令[2]将r0赋值为0,继续模拟执行,指令[3]是BPF_EXIT指令,临时栈中也没有其他分支,do_check检查结束,但其实do_check只检查了4条指令。但是在真正执行时,目的寄存器DST的类型为u64,大小是0x00000000ffffffff,IMM为类型为s32,值为0xffffffff,与DST比较时先进行符号扩展为0xffffffffffffffff,然后被认为是64位的无符号整数0xffffffffffffffff,从而导致DST != IMM,跳转条件成立,跳转到指令[4]去执行。
1 | [0]: ALU_MOV_K(BPF_REG_9, BPF_REG_0, 0x0, 0xffffffff) |
我们可以看到真正执行时DST和IMM的值,在函数__bpf_prog_run中下断点,程序执行至DST与IMM的比较,rbx指向的就是我们的指令1,那我们就可以判断rdx由指令movsxd rdx, dword ptr [rbx + 4]得到,movsxd为符号扩展传送指令,将IMM由s32扩展为u64,IMM也就是rdx的值变为0xffffffffffffffff:
那另外一个就是DST的值,DST的值是0x00000000ffffffff,DST不等于IMM,然后程序从本条指令中获取到off,跳转至指令[4]去执行。
获取map地址
根据前面对replace_map_fd_with_map_ptr函数的分析,可以知道这里将bpf_map的地址赋值给r9,第五条指令是为了符合LD_MAP_FD的下一条指令的要求。
1 | [4]: LD_MAP_FD(BPF_REG_9, map_addr) //r9 = map_addr |
获取map[0] ~ map[2]
获取map[0],存储在r6中:
1 | [6]: ALU64_MOV_X(BPF_REG_1, BPF_REG_9, 0x0, 0x0) //r1 = r9,即r1 = map_addr |
获取map[1],存储在r7中:
1 | [14]: ALU64_MOV_X(BPF_REG_1, BPF_REG_9, 0x0, 0x0) //r1 = r9 |
获取map[2],存储在r8中:
1 | [22]: ALU64_MOV_X(BPF_REG_1, BPF_REG_9, 0x0, 0x0) //r1 = r9 |
任意地址读写
指令[30]~指令[40]由map[0]的值的不同导致以下情况:
- 如果r6等于0即map[0] == 0,r3 = map[1];map[2] = r3;由于map[1]可控,因此可以进行任意地址读,将想要泄露的地址写入map[1]中,再读取map[2]的值进行泄露。
- 如果r6等于0即map[0] == 1,map[2] = rbp;将rbp写入map[2]中,可以利用map[2]来泄露栈地址。
- 如果r6不等于1即map[0] != 1,将map[2]的值写入map[1]指向的地址中,由于map[2]和map[1]我们都可控,可以利用这一分支进行任意地址写。
1
2
3
4
5
6
7
8
9
10
11[30]: ALU64_MOV_X(BPF_REG_2, BPF_REG_0, 0x0, 0x0) //r2 = r0 = idx3
[31]: ALU64_MOV_K(BPF_REG_0, BPF_REG_0, 0x0, 0x0) //r0 = 0
[32]: JMP_JNE_K(BPF_REG_6, BPF_REG_0, 0x3, 0x0) //if(r6 != 0): 跳转到指令[36]处执行,否则继续执行下一条指令
[33]: LDX_MEM_DW(BPF_REG_3, BPF_REG_7, 0x0, 0x0) //r3 = [r7]
[34]: STX_MEM_DW(BPF_REG_2, BPF_REG_3, 0x0, 0x0) //[r2] = r3
[35]: JMP_EXIT(BPF_REG_0, BPF_REG_0, 0x0, 0x0) //exit(0)
[36]: JMP_JNE_K(BPF_REG_6, BPF_REG_0, 0x2, 0x1) //if(r6 != 1):跳转到指令[39]处执行,否则继续执行下一条指令
[37]: STX_MEM_DW(BPF_REG_2, BPF_REG_10, 0x0, 0x0) //[r2] = r10 = rbp
[38]: JMP_EXIT(BPF_REG_0, BPF_REG_0, 0x0, 0x0) //exit(0)
[39]: STX_MEM_DW(BPF_REG_7, BPF_REG_8, 0x0, 0x0) //[r7] = r8
[40]: JMP_EXIT(BPF_REG_0, BPF_REG_0, 0x0, 0x0) //exit(0)
攻击过程
再看一下实际执行的情况,内核版本是4.4.110,直接使用了p4nda大佬编译的bzImage,文件系统随便用的某一个kernel pwn题的:
1 | / $ uname -a |
exp分为两部分,一部分为准备工作,创建map,加载eBPF代码以及绑定socket,和前面的sample介绍的流程类似,这里就不再赘述,只贴一下exp的函数调用流程:
1 | //prepare |
在进行exploit之前,首先介绍在exp中如何进行地址泄露以及任意地址读写,与eBPF指令[30]~[40]对应。首先是任意地址泄露,条件是map[0] = 0,将目标地址写入map[1],读取map[2]进行泄露:
1 | //map[0] = 0,map[1] = addr,map[2] = 0 |
然后是任意地址写,条件是map[0] != 0,然后将map[2]的值写入map[1]所在的地址中。
1 | //map[0] != 0,write map[2] to map[1] |
还可以泄露rbp栈指针,条件是map[0] == 1,读取map[2]的值,就是rbp。
1 | //map[0] = 1,get rbp from map[2] |
将rbp泄露后,由于Linux将内核态的进程堆栈和线程描述符thread_info这两个部分紧凑的存放在一个单独的区域,这块区域通常为两个页框,thread_info存放在这个内存区的开始,由rbp & ~(0x4000 - 1)获取thread_info的首地址,读取前8个字节即task_struct的地址。
1 | //https://elixir.bootlin.com/linux/v4.4.110/source/arch/x86/include/asm/thread_info.h#L55 |
通过这张图可以清晰的看出内核栈与thread_info,task_struct的关系,图来自于《深入理解Linux内核》
然后通过cred的偏移获取cred结构体地址,根据uid偏移获取uid的地址,最后将其修改为0进行提权,然后get shell:
1 | sp = get_sp(fp); |
最后exp执行效果:
1 | / $ id |
修复
漏洞影响版本是Linux Kernel Version 4.14-4.4 (主要影响Debian和Ubuntu发行版),在补丁中,在模拟执行的check_alu_op函数中,对于BPF_ALU64|BPF_MOV|BPF_K类型的指令操作数,将32位的立即数符号扩展为64位;对于BPF_ALU|BPF_MOV|BPF_K类型的指令操作数,0扩展至64位,与真正执行时保持一致。
参考
[exp] http://cyseclabs.com/exploits/upstream44.c
[exp] http://p4nda.top/2019/01/18/CVE-2017-16995/
[eBPF] https://www.ibm.com/developerworks/cn/linux/l-lo-eBPF-history/index.html
https://xz.aliyun.com/t/2212
http://p4nda.top/2019/01/18/CVE-2017-16995/
https://cert.360.cn/report/detail?id=ff28fc8d8cb2b72148c9237612933c11