深入分析Ubuntu本地提权漏洞【CVE-2017-16995】

网友投稿 983 2022-05-30

前言:

2018年3月中旬,Twitter 用户 @Vitaly Nikolenko 发布消息,称 ubuntu 最新版本(Ubuntu 16.04)存在高危的本地提权漏洞,而且推文中还附上了 EXP -。

由于该漏洞成功在aws Ubuntu镜像上复现,被认为是0DAY,引起了安全圈同学们的广泛关注。大体浏览了 一下exp代码,发现利用姿势很优雅,没有ROP,没有堆,没有栈,比较感兴趣,不过等了几天也没发现有详细的漏洞分析,正好赶上周末,便自己跟了一下:)

技术分析

eBPF简介

众所周知,linux的用户层和内核层是隔离的,想让内核执行用户的代码,正常是需要编写内核模块,当然内核模块只能root用户才能加载。而BPF则相当于是内核给用户开的一个绿色通道:BPF(Berkeley Packet Filter)提供了一个用户和内核之间代码和数据传输的桥梁。用户可以用eBPF指令字节码的形式向内核输送代码,并通过事件(如往socket写数据)来触发内核执行用户提供的代码;同时以map(key,value)的形式来和内核共享数据,用户层向map中写数据,内核层从map中取数据,反之亦然。BPF设计初衷是用来在底层对网络进行过滤,后续由于他可以方便的向内核注入代码,并且还提供了一套完整的安全措施来对内核进行保护,被广泛用于抓包、内核probe、性能监控等领域。BPF发展经历了2个阶段,cBPF(classic BPF)和eBPF(extend BPF),cBPF已退出历史舞台,后文提到的BPF默认为eBPF。

eBPF虚拟指令系统

eBPF虚拟指令系统属于RISC,拥有10个虚拟寄存器,r0-r10,在实际运行时,虚拟机会把这10个寄存器一一对应于硬件CPU的10个物理寄存器,以x64为例,对应关系如下:

如一条简单的x86赋值指令:mov eax,0xffffffff,对应的BPF指令为:BPF_MOV32_IMM(BPF_REG_2, 0xFFFFFFFF),其对应的数据结构为:

其在内存中的值为:\xb4\x09\x00\x00\xff\xff\xff\xff。

关于BPF指令系统此处就不再赘述,只要明确以下两点即可:1.其为RISC指令系统,也就是说每条指令大小都是一样的;2.其虚拟的10个寄存器一一对应于物理cpu的寄存器,且功能类似,比如BPF的r10寄存器和rbp一样指向栈,r0用于返回值。

BPF的加载过程:

一个典型的BPF程序流程为:

1. 用户程序调用syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr))申请创建一个map,在attr结构体中指定map的类型、大小、最大容量等属性。

2. 用户程序调用syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr))来将我们写的BPF代码加载进内核,attr结构体中包含了指令数量、指令首地址指针、日志级别等属性。在加载之前会利用虚拟执行的方式来做安全性校验,这个校验包括对指定语法的检查、指令数量的检查、指令中的指针和立即数的范围及读写权限检查,禁止将内核中的地址暴露给用户空间,禁止对BPF程序stack之外的内核地址读写。安全校验通过后,程序被成功加载至内核,后续真正执行时,不再重复做检查。

深入分析Ubuntu本地提权漏洞【CVE-2017-16995】

3. 用户程序通过调用setsockopt(sockets[1], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(progfd)将我们写的BPF程序绑定到指定的socket上。Progfd为上一步骤的返回值。

4. 用户程序通过操作上一步骤中的socket来触发BPF真正执行。

BPF的安全校验

Bpf指令的校验是在函数do_check中,代码路径为kernel/bpf/verifier.c。do_check通过一个无限循环来遍历我们提供的bpf指令,理论上虚拟执行和真实执行的执行路径应该是完全一致的。如果步骤2安全校验过程中的虚拟执行路径和步骤4 bpf的真实执行路径不完全一致的话,会怎么样呢?看下面的例子:

第一条指令是个简单的赋值语句,把0xFFFFFFFF这个值赋值给r9.

第二条指令是个条件跳转指令,如果r9等于0xFFFFFFFF,则退出程序,终止执行;如果r9不等于0xFFFFFFFF,则跳过后面2条执行继续执行第5条指令。

虚拟执行的时候,do_check检测到第2条指令等式恒成立,所以认为BPF_JNE的跳转永远不会发生,第4条指令之后的指令永远不会执行,所以检测结束,do_check返回成功。

真实执行的时候,由于一个符号扩展的bug,导致第2条指令中的等式不成立,于是cpu就跳转到第5条指令继续执行,这里是漏洞产生的根因,这4条指令,可以绕过BPF的代码安全检查。既然安全检查被绕过了,用户就可以随意往内核中注入代码了,提权就水到渠成了:先获取到task_struct的地址,然后定位到cred的地址,然后定位到uid的地址,然后直接将uid的值改为0,然后启动/bin/bash。

漏洞分析

下面结合真实的exp来动态分析一下漏洞的执行过程。

Vitaly Nikolenko公布的这个exp,关键代码就是如下这个prog数组:

这个数组就是BPF的指令数据,想要搞清楚exp的机理,首先要把这堆16进制数据翻译成BPF指令,翻译结果如下:

在do_check上打个断点,编译运行,成功断了下来,先看一下调用栈:

首先看第一条赋值语句BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF),do_check中最终的赋值语句如下:

其中dst_reg为虚拟执行过程中的寄存器结构体,结构体定义如下:

可以看到该结构体有2个字段,第一个为type,代表寄存器数据的类型,此处为CONST_IMM,CONST_IMM的值为8.另外一个为常量立即数的具体数值,可以看到类型为int有符号整形。

我们在此处下断点,可以看到具体的赋值过程,如下:

$rsi+$rax处即为reg_state结构体,可以看到第一个字段为8,第二个字段为0Xffffffff。

然后我们跟进第二条指令中的比较语句BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2),do_check检测到跳转类指令时,根据跳转类型进入不通的检测分支,此处是JNE跳转,进入check_cond_jmp_op分支,如下图:

do_check在校验条件类跳转指令的时候,会判断条件是否成立,如果是非确定性跳转的话,就说明接下来2个分支都有可能执行(分支A和分支B),这时do_check会把下一步需要跳转到的指令编号(分支B)放到一个临时栈中备用,这样当前指令顺序校验(分支A)过程中遇到EXIT指令时,会从临时栈中取出之前保存的下一条指令的序号(分支B)继续校验。如果跳转指令恒成立的话,就不会再往临时栈中放入分支B,因为分支B永远不会执行,如下图:

第一个红框即为虚拟寄存器中的imm与指令中提供的imm进行比较,这两个类型如下:

可以看到等号两侧的数据类型完全一致,都为有符号整数,所以此处条件跳转条件恒成立,不会往临时栈中push分支B指令编号。

接下来看BPF_EXIT_INSN(),刚才提到在校验EXIT指令时,会从临时栈中尝试取指令(调用pop_stack函数),如果临时栈中有指令,那就说明还有其他可能执行到的分支,需要继续校验,如果取不到值,表示当前这条EXIT指令确实是BPF程序最后一条可以执行到的指令,此时pop_stack会返回-1,然后break跳出do_check校验循环,do_check执行结束,校验通过,如下图:

跟进pop_stack,如下图:

实际执行过程如下:

到此为止我们了解了BPF的校验过程,这个exp一共有41条指令,BPF只校验了4条指令,然后返回校验成功。

接下来我们继续跟进BPF指令的执行过程,对应的代码如下(路径为kernel/bpf/core.c):

其中DST为目标寄存器,IMM为立即数,我们跟进DST的定义:

跟进IMM的定义:

很明显,等号两边的数据类型是不一致的,所以导致这里的条件跳转语句的结果完全相反,以下为实际执行过程:

等号两边的值完全不一样,这里的跳转条件成立,会往后跳2条指令继续执行,和虚拟执行的过程相反。

接下来就是分析exp里面的BPF指令了,通过自定义BPF指令,我们可以绕过安全校验实现任意内核指针泄露,任意内核地址读写。

构造一下攻击路径:

1.申请一个MAP,长度为3;

2.这个MAP的第一个元素为操作指令,第2个元素为需要读写的内存地址,第3个元素用来存放读取到的内容。此时这个MAP相当于一个CC,3个元素组成一个控制指令。

3.组装一个指令,读取内核的栈地址。根据内核栈地址获取到current的地址。

4.读current结构体的第一个成员,或得task_struct的地址,继而加上cred的偏移得到cred地址,最终获取到uid的地址。

5.组装一个写指令,向上一步获取到的uid地址写入0.

6.启动新的bash进程,该进程的uid为0,提权成功。

Exp中就是按照如上的攻击路径来提权的,申请完map之后,首先发送获取内核栈地址的指令,如下:

之前提到过,BPF的r10寄存器相当于x86_64的rbp,是指向内核栈的,所以这里第一行指令将map的标识放到r9,第二条指令将r9放到r1,作为后续调用BPF_FUNC_map_lookup_elem函数的第一个参数,第三条指令将内核栈指针赋值给r2,第四条指令在栈上开辟4个字节的空间,第五条指令将map元素的序号放到r2,第六条指令取map中第r2个元素的值并把返回值存入r0,第七条指令判断BPF_FUNC_map_lookup_elem有没有执行成功,r0=0则未成功。成功后执行第9条指令,将取到的值放到r6中。继续依次往下执行,直到执行到下面的路径:

判断r6是否为0,为0说明是取栈地址的指令,这时会往下跳3条指令,继续执行第7条指令,将r10的内容写入r2,由于在执行第30条指令时r0指向map中的第二个元素,所以这时r2也指向这个元素,然后用户层通过get_value(2)取到了内核栈的地址,我们通过给BPF_STX_MEM(BPF_DW, BPF_REG_2, BPF_REG_10, 0)下断点,可以看到过程如下:

其中rax的值0xffff8800758c3c88即为泄露的内核栈地址(其实应该称为帧指针更准确)。

然后通过经典的addr & ~(0x4000 - 1)获取到current结构体的起始地址0xffff8800758c0000,然后构造读数据的map指令去读current中偏移为0的指针值(即为指向task_struct的指针):

其中addr为当前线程current的值0xffff8800758c0000,这样可以得到task_struct的地址,

过程如下:

其中rax的值即为指向task_struct的指针,可以看到和current结构体的第一个成员的值是一致的,都是0xffff880074343c00。

得到task_struct地址之后,加上cred的偏移CRED_OFFSET=0x5f8(由于内核版本不通或者内核的编译选项不同,都可能导致cred在task_struct中的偏移不同),组装读取指令去读取指向cred结构体的指针地址:

过程如下:

其中rax的值0xffff880074cb5e00即为从task_struct中读取到的指向cred的指针。

cred的地址得到了,再加上uid在cred中的偏移(固定为4)便得到了uid的地址0xffff880074cb5e04,然后构造写数据的map指令:

过程如下(由于第一次运行exp的时候,这里没断下来,所以下面的过程是第二次运行的过程,中间一些结构体的地址发生了稍微的变化):

提权成功:

到此整个漏洞利用完成,后面的部分写的有点仓促了,如果有错误的地方,还请各位朋友不吝赐教。

安全

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:除了吴京,《流浪地球》背后还有华为云的神助攻
下一篇:读书笔记—《销售铁军》随记5
相关文章