前几天把qemu逃逸相关的题目做了一些,算是初步入门了。最近会调试几个真实的qemu逃逸的漏洞,希望能借此了解qemu漏洞真实攻击面以及利用思路
漏洞描述
qemu-kvm默认使用的是-net nic -net user的参数,提供了一种用户模式(user-mode)的网络模拟。使用用户模式的网络的客户机可以连通宿主机及外部的网络。用户模式网络是完全由QEMU自身实现的,不依赖于其他的工具(bridge-utils、dnsmasq、iptables等),而且不需要root用户权限。QEMU使用Slirp实现了一整套TCP/IP协议栈,并且使用这个协议栈实现了一套虚拟的NAT网络。SLiRP模块主要模拟了网络应用层协议,其中包括IP协议(v4和v6)、DHCP协议、ARP协议等。
cve-2019-6778这个漏洞存在于QEMU的网络模块SLiRP中,该模块中的tcp_emu()函数对端口113(Identification protocol)的数据进行处理时,没有进行有效的数据验证,导致堆溢出。经过构造,可以实现以QEMU进程权限执行任意代码。
环境搭建
编译qemu
漏洞版本是3.1.50,但是从qemu项目中没有找到这个版本,所以往前找一个版本,最终找到3.1.0,然后使用如下命令编译qemu:
1 | git clone git://git.qemu-project.org/qemu.git |
编译出来qemu的路径为./qemu/bin/debug/naive/x86_64-softmmu/qemu-system-x86_64。
这里configure的命令--enable-debug将会保留调试符号,更利于调试。
编译文件系统
然后需要编译一个比较完整的文件系统,用以前做内核题时提供的文件系统很多命令都不全,所以这里还是建议自己编译一个更完整的。这里直接参考之前2020-geekpwn提供的文件,制作一个文件系统:
需要先安装debootstrap:
1 | sudo apt-get install debootstrap |
随后直接使用如下脚本制作文件系统:
1 | mkdir qemu |
内核文件
内核文件的编译,可以查看我之前写的关于内核pwn的相关文章,有提到如何编译内核。
启动环境
最后使用如下命令来启动qemu环境:
1 | #!/bin/bash |
启动后,qemu虚拟机ip=10.0.2.2,宿主机ip=10.0.15.2。
漏洞分析
这里先用poc文件,来从崩溃点往回溯找到漏洞点:
1 |
|
解释一下这个poc文件,socket连接宿主机,并不断调用write发送数据,直到发生堆溢出为止,qemu崩溃。
使用gdb attach上程序后,在tcp_emu崩溃后,使用bt查看程序调用栈如下:
1 | gdb-peda$ bt |
这里和其他师傅的调试时的结果不同,我这里的崩溃栈并不会直接在tcp_emu函数中发生。原因我猜测是可能我用的系统版本是20.10,环境变化导致。
所以这里想通过崩溃反推回漏洞点,还比较困难 : (
这里接着还是结合源码来分析吧。
源码首先分析slirp/tcp_subr.c中的tcp_emu函数:
1 | int tcp_emu(struct socket *so, struct mbuf *m) |
函数的参数so为当前连接的socket对象,m为当前的消息包传输层的消息结构体,分别如下:
1 | struct socket { |
当socket数据包类型为EMU_IDENT时,程序会在[1]处先将m->data中的数据拷贝至so_rcv->sb_wptr。so_rcv的定义为struct sbuf。mbuf是用来保存ip传输层的数据,sbuf结构体则保存tcp网络层的数据,定义如下:
其中重点关注sb_cc参数该参数是用于记录在sb_data中字符串的长度。
1 | struct sbuf { |
程序将m->data中的数据拷贝至so_rcv->sb_wptr后,会在[2]处更新当前sbuf的读写指针,以便后续接着写入。随后在[3]处会判断输入的消息字符串中是否没有\r或\n,如果有的话会进入[4]处。在[4]处,会使用snprintf来获得sb_data中的字符串长度,并将其返回给sb_cc,以此来更新sb_cc。最后就会进入[5]处,在此处会将写入指针进行更新。
但是,如果没有\r或\n,那么则不会执行[4],而是直接进入返回状态,也就是相当于没有更新sb_cc,也没有更新sb_wptr.
函数最后会释放m的堆块,并返回0。
那么接着,我们查看tcp_emu函数的交叉引用,在tcp_input函数中,发现了相关调用。
代码在slirp/tcp_input.c中:
1 |
|
这里首先可以看到使用了sbspace来检测so->so_rcv是否溢出,检查的方法是检查sb_datalen - sb_cc是否大于ti->ti_len。在上面提到sb_datalen表示当前消息缓冲区的总大小,sb_cc表示实际写入的字符串大小。ti结构体如下所示:
1 | /* |
ti->ti_len表示协议长度,那么这里的长度检查就是判断so_rcv的数据长度是否大于协议长度,如果满足则进入下一步。
随后会判断是否so->so_emu是否赋值,如果赋值,则执行tcp_emu。我们前面已经说明了tcp_emu此时会返回0,也就是最后并不会执行sbappend(so, m)函数。
漏洞点总结
上面对tcp_emu和tcp_input函数都有了说明,这里总结一下漏洞原因。
1、 首先进入tcp_input函数,会先调用sbspace来检查缓冲区是否溢出,根据(sb)->sb_datalen - (sb)->sb_cc计算剩余缓冲区长度;
2、随后设置了so->so_emu后,会进入tcp_emu函数;
3、 如果so->so_emu等于EMU_IDENT时,会调用memcpy(so_rcv->sb_wptr, m->m_data, m->m_len);拷贝ip传输层消息到tcp网络层消息;
4、随后会更新so_rcv->sb_wptr += m->m_len;;
5、如果m_data中不包含\r和\n时,则不会进入so_rcv->sb_cc = snprintf(so_rcv->sb_data,so_rcv->sb_datalen,"%d,%d\r\n", n1, n2);,那么sb_cc就永远为0,并且sb_wptr仍然为4中的值;
6、随后tcp_emu函数返回,继续进入下一次写;
7、第二次写时,又进入tcp_input,由于sb_cc=0,所以sbspace缓冲区检查将会顺利通过;
8、再次进入tcp_emu函数,继续执行memcpy,此时sb_wptr已经加上了第一次写入的长度,那么就会继续对缓冲区增加写入m_len长度的数据;
9、如果m_data仍然不包含\r和\n时,则sb_cc仍然等于0。
10、最后程序循环执行从7到9的步骤,在第8步中 mempcy导致了堆溢出。
漏洞触发
结合最开始提供的poc,说明一下如何触发该漏洞:
首先要想进入tcp_input的漏洞部分,需要保证ti->ti_len不为0,这个只要保证设置了地址协议族为AF_INET即可。
随后要想进入tcp_emu协议部分,需要保证so->so_emu为EMU_IDENT标识位。经过分析这需要保证tcp协议为标识协议Identification Protocol,该协议的简介如下:
“Identification Protocol(标识协议)”在 RFC 1413 中描述。实际上每个类 Unix 操作系统都带着一个默认监听 TCP 113 端口的 ident 服务器
所以,这里我们需要保证目标端口是113,然后该TCP协议就会自动被标识为EMU_IDENT。
漏洞利用
前面已经提到我们拥有一个堆溢出漏洞。一个堆溢出漏洞的利用,我想到的主要利用思路有两种:
1、 在后面布置含有函数指针的堆块,通过堆溢出修改目标函数指针,达到劫持控制流的思路;
2、 在后面布置含有读写指针的堆块,通过修改读写指针来实现任意地址写,然后在去劫持控制流。
但是,要想顺利的将一个目标堆块布置在我们的漏洞堆块后,需要实现一个堆风水布局,这里理想的方法是通过top chunk来分配这两个堆块,那么即能稳定实现两个堆块的先后顺序。但是为了实现从top chunk分配,我们首先需要拥有一个malloc原语来消耗多余的堆块。
malloc原语
这里和之前分析vmware dhcp逃逸时类似,都是需要去查看tcp\ip的其他功能中是否能够找到一个可控的malloc原语。首先复习一下IP协议:
Zero:Unused,置为0Do not fragment flag:表示数据包是否为分片数据包,当置为1时,表示未分片,简写为DF位More fragments following flag:表示后续还有没无分包,有的话置为1,简写为MF位Fragment Offset:当前数据包在整个大数据包中的偏移offset。
IP包的total_length用2字节表示,因此一个IP数据包最大为65535字节,一旦要发送大量数据时我们需要对数据包进行分段传输,IP协议各字段如下所示 。

接着,分析一下qemu中对于IP协议的处理,首先查看一下IP结构体,每个字段都能与上图中的IP图对照起来:
1 | /* |
随后,我们主要关注slirp/ip_input.c中的ip_input函数:
1 | /* |
对上面的代码,做一个简单的分析:
1、 在[1]处之前,都是对IP各标志位的检查,包括:对IP版本、数据包长度、消息长度、校验值和TTL的检查;
2、 在[2]处开始,对IP分片包进行处理,首先会从分片包队列中查找该报文是否为以前的IP分包序列,如果该报文是以前有的包文序列的分包,则进入包重组阶段;
3、 在包重组阶段中,会首先调用ip_reass进行包重组
4、 如果该报文不是分包序列,或者是第一个分包,则释放该分包,直接进行后续传输层协议处理的部分
我们接着看一下ip_reass是如何重组分包序列的:
1 | /* |
IP重组流程如下:
1、 修改消息结构体m中的消息的长度和剩余缓冲区的长度;
2、 判断现在IP片段是否是第一个分片包,如果是则调用m_get创建一个消息结构mbuf用于存储分片包,并对其进行相应的初始化设置,随后进入插入流程;
3、 在插入流程中,会调用ip_enq函数,将IP片段插入队列中,并更新当前的链表指针。
4、 如果在第2步中,不是第一个分片包,则找到当前要插入的位置,然后判断要插入的分片包是否有重复,没有重复的话再进入插入流程。
5、 如果接受到了最后一个分片数据包,那么函数会调用m_cat去整合前面接受到的全部分片数据包。
m_get函数创建一个重组队列,如下所示:
1 | /* |
在m_get函数中,可以看到如果在当前空闲链表中找不到空闲的消息结构体对象,则会调用g_malloc分配一个消息对象,大小为SLIRP_MSIZE=0x668。
总结:当IP报文含有分片标志位DF=0时,且空闲消息链表中没有剩余的空闲消息时,则会调用gmalloc分配一个0x668的消息对象。
那么,这里如果能够不断的去发送含有分片的IP报文,那么就能够在消耗完空闲消息链表后,实现gmalloc分配堆块。
任意地址写
上面,提出了malloc原语的用法。再结合之前堆溢出利用的常见思路,即可以先实现任意地址写。而首先任意地址写需要先找到一个含有写指针的结构体。这里选择的结构体,是我们在前面就已经提及的mbuf:
1 | struct mbuf { |
在mbuf中,可以看到有数据指针m_data以及长度m_len,符合要求。然后我们再去寻找一处能够对该指针进行写入的路径。如果能稳定控制该路径,那么我们通过堆溢出修改该结构体指针就有可能实现任意写。
这里对m_data写入的路径其实有很多,这里原作者选择的路径是 前面提到ip在接受到最后一个分片数据包时会调用m_cat对所有分片数据包进行整合:
1 | /* |
在m_cat中实现了将所有分片数据包整合到一个堆块的功能:
1、 首先调用m_inc检查当前m的缓冲区是否能够存储n消息,如果不能则会调用 g_realloc或者g_malloc增大当前m的缓冲区大小;
2、 如果大小满足,则会调用memcpy(m->m_data+m->m_len, n->m_data, n->m_len),将当前n->m_data的数据拷贝到m->m_data+m->m_len处。
这里的m和n都是m_buf结构体。如果我们可以通过堆溢出覆盖m->m_data、m->m_len和n->m_data,那么就能够向任意地址写入任意值。这里的m是分片链表的头节点,n是其中的分片数据包。
接下来,我们将堆溢出与这个任意地址写整合一下,梳理一下任意地址写真正的执行流程:
1、 先利用提到的malloc原语,堆喷到能分配top chunk;
2、 然后重新建立一个新的socket连接,此时会重新分配一个socket对象,也即会重新分配一个可能触发堆溢出的so_rcv堆块结构;
3、 随后,发送一个DF=0&MF=1的IP分片包,此时会从top chunk中分配一个m_buf存储该堆块m1,且该堆块在so_rcv之下;
4、 然后,使用同一个socket向113端口发送一个EMU_IDENT协议数据包,此时就会进入堆溢出流程,使用堆溢出修改m1->m_data;
4、 然后,发送一个DF=0&MF=0的IP分片包,会分配一个新的m_buf结构体m2,那么就会进入堆合并的流程。会从m2->m_data处拷贝数据到m1->m_data处,实现任意地址写。
泄漏地址
网络协议的洞想要泄漏地址,那么肯定需要找到一个能够发送返回包的路径,并且在返回数据中夹带我们需要的脏数据来实现地址泄漏。
这里原作者选择的是icmp返回包,原因其实很简单就是icmp包与tcp协议独立,且处理逻辑相对简单。
1 | /* |
从icmp_input函数中可以看到,在处理icmp echo数据包时,会直接调用icmp_reflect返回数据包,而返回的数据包文为之前ip_input传入的消息结构体m。所以如果能够将m->m_data数据指针指向一个伪造的icmp返回数据,那么就能在返回时泄漏我们伪造地址的相关数据了。
劫持控制流
劫持控制流的方法这里还是使用QemuTimer,在bss有个全局变量main_loop_tlg,类型为QEMUTimerList,其成员active_timers为QEMUTimer*类型,我们在堆上伪造这两个变量,覆写bss的全局变量,伪造cb为system@plt,opaque为参数地址,当expire_time过完就会触发命令执行。
1 | .bss:00000000012C3900 main_loop_tlg QEMUTimerListGroup_0 <?> |
1 | // util/qemu-timer.c |
漏洞调试
上面已经讲述了漏洞利用的相关方法,这里对漏洞关键点进行调试分析。
任意地址写
1 | 0x55be99d790ff <tcp_emu+176> mov rcx, qword ptr [rax + 0x30] |
可以看到通过堆溢出将m_data修改为指定地址。
1 | ping recv: |
在返回报文中可以明显看到heap和text地址
EXP
堆喷
堆喷的方法其实就是不断利用上面提到的malloc原语来不断分配堆块。难点就是在于组装IP和TCP协议报文头。这里需要将IP的分片标志位置为1。
1 | void spray_chunk(int size, uint16_t ip_id){ |
任意地址写
先通过堆喷保证后续的堆布局是紧邻的。然后创建一个socket连接s,在connect时即会创建socket结构体,也就是会分配so->so_rcv缓冲区。然后紧接着发送一个ICMP的分片协议数据包,此时会为该包数据分配一个mbuf结构体mbuf1,其会紧邻在so->so_rcv之后。然后通过write(s),不断堆溢出,修改mbuf1->m_data为target_addr。最后发送一个ICMP包并结束分片,此时会进行分片重组,则会执行memcpy(target_addr, data, data_len),实现任意地址写。
1 | void arbitray_write(uint64_t addr, int addr_len, uint8_t* write_data, |
泄漏地址
首先利用任意地址写,将m_data的低位改写为0x0b00,然后在其写入伪造的icmp包数据。随后创建一个rcv_socket接受icmp的返回数据。此时在多余的脏数据中就可以得到heap地址和qemu地址,但是这里的qemu地址的第4位和第5位不固定,也导致了exp不一定完全成功。
1 | void leak(uint64_t addr, int addr_len) { |
最终exp如下:
1 |
|
参考文献
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/10/24/CVE-2019-6788-Qemu逃逸漏洞复现与分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!