学习内核调试没有很久,如有错误,欢迎指出,本篇文章同步到了我的blog。
这个漏洞在2017年底被Google Project Zero团队的Jann Horn发现并修复,然而在2018年4月再次被国外安全研究者Vitaly Nikolenko发现,并可以对特定内核版本的Ubuntu 16.04进行提权,这个漏洞不包含堆栈攻击或者控制流劫持,仅用系统调用数据进行提权,是Data-Oriented Attacks在linux内核上的一个典型应用。
本文分析基于v4.4.110,可以从这里下载编译,也可以从这里在线阅读,本文涉及到的代码、镜像等可从这里下载。
之前在做pwnable.tw里的seccomp-tools一题时,曾经看过一部分bpf代码,但主要是为了逆向seccomp沙箱的规则。
BPF 的全称是 Berkeley Packet Filter,这是一个用于过滤(filter)网络报文(packet)的架构。Linux中常用的抓包软件tcpdump、wireshark都是基于这个模块来对用户提供抓包的接口的。在linux内核3.15以后,基于原有的BPF模块,Linux重新设计了BPF模块,并称之为extended BPF,简称EBPF。
EBPF主要可以为用户加载数据包过滤代码进入内核,并在收到数据包时触发这段代码。
一个常见的数据包过滤程序编写如下:
EBPF采用的指令集与内核使用的汇编指令不同,采用了一种基于bpf_insn数据结构的指令集,同时还维护了10个寄存器,一个栈,并且有与用户态交互的map结构。
首先是寄存器:
但内核寄存器的实现同EBPF模拟的栈一样,仍然依赖于栈上的临时变量,并不是直接映射为寄存器。后续将从代码层面分析。
接着是指令
熟悉seccomp-tools的同学可能发现,这个结构和seccomp的基本差不多。程序的功能主要取决于code这个字节,代表功能,其中code操作码共有8个比特,其中最低3个比特代表大类功能,从如下代码中看出EBPF共分7类功能,定义如下:
而对于各大类功能还可以从通过异或组成不同的新功能。具体的操作可以参考实现中的定义名,根据操作名就可以看出来每一种功能的大意了,我写了一个解码编码的小工具放在github连接中,可以用来翻译或者辅助编写EBPF程序。
dst_reg代表目的寄存器,限制为0~10,src_reg代表目的寄存器,限制为0~10,off代表地址偏移,imm代表立即数。
下面将从代码层面分析EBPF的运行流程。
这个系统调用首先调用map_create函数,这个函数就是之前分析的bpf模块整数溢出漏洞所在的函数,具体内容可以参照上一篇博客,其核心思想是对申请出一块内存空间,其大小是管理块结构体+attr参数中的size大小,为其分配fd,并将其放入到map队列中,可以用fd号来查找。此部分与本漏洞相关性不大。
map_create
这个系统调用用于将用户编写的EBPF规则加载进入内核,其中包含有多处校验。
首先进入bpf_prog_load函数中,首先[1]检查的ebpf license是否为GPL证书的一种,[2]检查指令条数是否超过4096,[3]处利用kmalloc新建了一个bpf_prog结构体,并新建了一个用于存放EBPF程序的内存空间。[4]处将用户态的EBPF程序拷贝到刚申请的内存中。[5]处来判断是哪种过滤模式,其中socket_filter是数据包过滤,而tracing_filter就是对系统调用号及参数的过滤,也就是我们常见的seccomp。最终到达[5]处开始对用户输入的程序进行检查。如果通过检查就将fp中执行函数赋值为 __bpf_prog_run也就是真实执行函数,并尝试JIT加载,否则用中断的方法加载。
下面进入加载的检查逻辑——bpf_check,首先在[1]处将特定指令中的mapfd换成相应的map实际地址,这里需要注意,map实际地址是一个内核地址,有8字节,这样就需要有两条指令的长度来存这个地址,具体可以看下面对这个函数的分析。[2]中借用了程序控制流图的思路来检查这个EBPF程序中是否有死循环和跳转到未初始化的位置,造成无法预期的风险。[3]是实际模拟执行的检测当上述有任一出现问题的检测,是检测的重点。
replace_map_fd_with_map_ptr函数中,可以看到当满足[1]、[2]两个条件时,即opcode = BPF_LD | BPF_IMM | BPF_DW=0x18,且src_reg = BPF_PSEUDO_MAP_FD =1时,将根据imm的值进行map查找,并将得到的地址分成两部分,分别存储于该条指令和下一条指令的imm部分,与上文所说的占用两条指令是相符的。满足上述两个条件的语句又被命名为BPF_LD_MAP_FD,即把map地址放到寄存器里,该指令写完后,下一条指令应为无意义的填充。
下面进行check过程中最核心的do_check函数,首先可以看到整个程序处于一个for死循环中,其中维护了一系列寄存器,其寄存器变量定义和初始化如下,可以看到寄存器的值是一个int类型,并且有一个枚举的type变量,type类型包括未定义、位置、立即数、指针等,初始化时会将全部寄存器类型定义为未定义,赋值为0。第十个寄存器定义为栈指针,第一个定义为内容指针。
check函数的处理方式是逐条处理,按照不同的类型分别做check。由于指令比较多,不一样赘述了,下面从两个攻击角度去展示程序是如何检测的。
退出指令定义为BPF_EXIT,这个指令属于BPF_JMP大类,可以看到当指令为该条指令的时候会执行一个pop_stack操作,而当这个函数的返回值是负数的时候,用break跳出死循环。否则会用这个作为取值的位置去执行下一条指令。对于这个操作的理解是,当遇到条件跳转的时候,程序会默认执行一个分支,然后将另外一个分支压入stack中,当一个分支执行结束后,去检查另外一个分支,类似于迷宫问题解决里走到思路的退栈操作。
查看一下pop_stack函数,函数中先判断env->head是否为0,如果是就代表没有未检查的路径了。否则将保持的state恢复。
然后看一下条件分支的处理代码check_cond_jmp_op,我们可以看到这个检查将跳转分成两种,第一种[1]处是JEQ和JNE,并且是比较的值是立即数的情况,此时就判断立即数是不是等于要比较的寄存器,进行直接跳转。第二种[2]处是其他情况,均需把off+1的值压入栈中作为另一条分支。
内存读写需要用到的指令主要是BPF_LDX_MEM或者BPF_STX_MEM两类。如下,当r7和r8的值可控就可以达到内存任意写,类似于mov dword ptr[r7],r8这样的操作。
接下来分析一下ST和LD有哪些限制,check_reg_arg[1]处检查寄存器是否访问寄存器的序号是否超过最大值10,如果是SRC_OP检查是否是未初始化的值。否则检查是否要写的地方是rbp,并将要写的寄存器值置为UNKOWN。然后是check_mem_access检查,该函数会根据读写类型检查dst或src的值是否为栈指针、数据包指针、map指针,否则不允许读写。:
以上情况,如果采用MOV这样的赋值指令去读写的话,寄存器类型会判定为IMM,而拒绝。另外一种是用BPF_FUNC_map_lookup_elem这样的函数调用返回,再赋给某个寄存器,然后再进行读写。而这种方法会在赋值时被设定为UNKNOWN而拒绝读写。
以上就是对于加载指令的全部检查,可以看到我们能想到的内存读写方法都是会被检测出来的。真正执行的时候代码在__bpf_prog_run中,其中可以看到所谓的各个寄存器和栈只是这个函数的局部变量:
程序维护了一个跳表,根据opcode来进行跳转,而函数中没有任何check,具体实现代码十分简单,就不赘述了。
可以发现程序的寄存器变量与check中的寄存器变量不太一样,此时是unsigned long long类型。
本漏洞的原因是check函数和真正的函数的执行方法不一致导致的,主要问题是二者寄存器值类型不同。先看下面一段EBPF指令:
第0条指令是将0xffffffff放入r9寄存器中,当在do_check函数中时,在[1]处会直接将0xffffffff复制给r9,并将type赋值为IMM。在第[1]条指令,比较r9==0xffffffff,相等时就执行[2]、[3],否则跳到[4]。根据前文对退出的分析,这个地方在do_check看来是一个恒等式,不会将另外一条路径压入stack,直接退出。
而在真实执行的过程中,由于寄存器类型不一样,在执行第二条跳转语句时存在问题:
而翻译成汇编就非常明显了:
可以看到汇编指令被翻译成movsxd,而此时会发生符号扩展,由原来的0xffffffff扩展成0xffffffffffffffff,再次比较的时候二者并不相同,造成了跳转到[4]处执行,从而绕过了对[4]以后EBPF程序的校验。
当[4]以后的程序不经过check以后,就可以对[4]的内容进行构造了,利用真正执行时无类型就可以达到内存任意读写了。
利用本人写的小工具对已有的EBPF程序进行解码,可以看到程序逻辑如下:
下面对这个程序进行分析:
首先,[0]~[3]已经分析过了下面对后续指令进行分析:
第[4]~[5]条语句可用由上面的map知识得到,第五条语句是填充语句,当执行完后,会将map的地址存放在r9寄存器中。
[6]~[13]语句的类C代码如下,即调用BPF_FUNC_map_lookup_elem(map_add,idx),并将返回值存到r6寄存器中,即r6=map[0]
[14]~[21]同理,将r7=map[1]。[22]~[29]为r8=map[2],而map的内容可以由用户态传入。
最后[30]~[40]分为三个不分,map[0] = 0时,将map[1]地址所指的内容,写到map[3]中,用户态可以通过读map[3]来得到这个值,因此是内存任意读功能。map[0]=1时,将rbp的值写入map[3]中,由此可以泄露内核栈地址。map[0]=2时,将map[3]的值写入map[2]地址中,由此是个内存任意写。
漏洞利用也非常简单,首先利用2功能读取内核栈地址,这样通过栈地址& ~(0x4000 - 1)可以得到内核线程task_struct的地址,而这个数据结构中的cred指针指向该线程的cred数据块,但是这个偏移会随内核编译的改变而改变,从gdb中看这个结构的方法是:
因此,利用0功能可以读出cred的地址,同理找出cred中的uid偏移
再利用2功能向该地址里写入0,就可以成功提权了。
[1] https://security.tencent.com/index.php/blog/msg/124
[2] https://www.ibm.com/developerworks/cn/linux/l-lo-eBPF-history/index.html
[3] https://www.jianshu.com/p/75b368f85dc6
[4] https://cert.360.cn/report/detail?id=ff28fc8d8cb2b72148c9237612933c11
[5] https://xz.aliyun.com/t/2212
[6] https://blog.csdn.net/qq_14978113/article/details/80488711
[7] https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/syscall.c
[8] https://elixir.bootlin.com/linux/v4.4.110/source/kernel/bpf/verifier.c
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2019-3-12 09:24
被pwnda编辑
,原因: 选错格式