前几天把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 许可协议。转载请注明出处!