参考 Phrack 文章 VM escape - QEMU Case Study [1] 对 QEMU 信息泄露漏洞 CVE-2015-5165 和堆溢出漏洞 CVE-2015-7504 进行调试分析并编写 Exploit 代码,本文主要分析其中的 RTL8139 网卡信息泄露漏洞 CVE-2015-5165。
0x01. 环境搭建
1.1 宿主机创建
在 VMware Workstation 中创建 Ubuntu 20.04 虚拟机,并为虚拟机的 CPU 开启虚拟化引擎相关选项,使之支持嵌套虚拟化,以便对 QEMU 进行调试分析。
安装好 Ubuntu 之后,可以先将源设置为国内的开源镜像网站,之后执行如下命令更新系统组件:
sudo apt-get update |
编译 QEMU 需要 Python 2,因为 Ubuntu 20.04 中只有 Python 3,所以需要自行安装 Python 2:
sudo apt-get install python2 |
编译 QEMU 所依赖的其他库:
sudo apt-get install zlib1g-dev libglib2.0-dev libpixman-1-dev |
1.2 QEMU 编译
git clone git://git.qemu-project.org/qemu.git |
如果出现以下错误,给文件 commands-posix.c
增加头文件 <sys/sysmacros.h>
即可解决 [2]。
/usr/bin/ld: qga/commands-posix.o: in function `dev_major_minor': |
1.3 虚拟机创建
QEMU 编译完成之后,需要创建一个用于调试漏洞的虚拟机。为了调试方便,这里安装 Ubuntu 20.04 Server 版本(比较新的 Ubuntu Server 没有 32 位的版本,但这里建议安装一个 32 位的系统,因为后面的 PoC 和 Exploit 都是针对 32 位环境编写的),相关命令如下所示 [3]:
./qemu-img create -f qcow2 ~/Desktop/vm/ubuntu.img 10G |
这里还需要安装一个 VNC Viewer [4],以便远程访问虚拟机,下载 deb 安装包后使用如下命令进行安装:
sudo dpkg -i VNC-Viewer-6.20.529-Linux-x64.deb |
之后就可以通过 VNC Viewer 来访问虚拟机了。
0x02. 内存映射
和 Host 操作系统一样,Guest 操作系统中的每个进程都有自己的虚拟地址空间,这里称之为 Guest Virtual Address,即 GVA;通过进程自身的页表(Page Table),Guest 操作系统可以将 GVA 转换为对应的 GPA(Guest Physical Address)。
Guest 操作系统的 GPA,实际上是对应的 QEMU 进程中映射的虚拟内存,即 HVA(Host Virtual Address);Host 操作系统同样通过对应进程的页表,最终将其转换为对应的 HPA(Host Physical Address)。
待 Ubuntu Server 虚拟机安装完毕后,可以通过如下命令启动该虚拟机:
x86_64-softmmu/qemu-system-x86_64 -enable-kvm -m 2048 -drive \ |
这里给虚拟机分配了 2GB 的内存,可以在对应的 QEMU 进程中找到对应的虚拟内存:
ps -e|grep qemu |
关于 Guest Virtual Address 到 Host Virtual Address 的转换,Phrack 的文章没怎么解释,在网上找到另一篇文章 [5] 解释的比较清楚(以 64 位系统为例):
每个页面的大小为
4096
字节,即1 << 12
;基于
/proc/pid/pagemap
可以查看进程任意 Virtual Page 的状态,包括是否被映射到物理内存以及在物理内存中的 Page Frame Number(PFN)等;pagemap
文件为每个 Virtual Page 存储64
位(即8
字节)的信息,数据格式如下:
Bits 0-54 page frame number (PFN) if present |
对任意的虚拟地址
address
,基于address / 4096
可以计算出该虚拟地址在pagemap
文件中的索引值,address / 4096 * 8
即对应的文件偏移值;对任意的虚拟地址
address
,address % 4096
即虚拟地址在对应的内存页中的偏移值;基于物理内存的 PFN 以及页内偏移,就可以计算出对应的物理地址;
获取虚拟地址对应的物理地址的代码如下:
|
根据文档 [6] 可知,只有拥有 CAP_SYS_ADMIN
权限的进程才可以读取到 PFN,否则虽然可以打开 pagemap
文件,但是读取到的 PFN 将会是 0
。
Since Linux 4.0 only users with the CAP_SYS_ADMIN capability can get PFNs.
In 4.0 and 4.1 opens by unprivileged fail with -EPERM. Starting from
4.2 the PFN field is zeroed if the user does not have CAP_SYS_ADMIN.
Reason: information about PFNs helps in exploiting Rowhammer vulnerability.
编译好程序之后将其上传到 QEMU 虚拟机中以 root
身份执行,打印出物理地址为 0x617192a0
:
sudo ./a.out |
在宿主机中使用 GDB 附加到 QEMU 进程,可以看到虚拟机中的物理地址实际上就是 QEMU 进程为虚拟机分配的内存所在的 Host Virtual Address 的偏移地址:
sudo gdb qemu-system-x86 4407 |
0x03. 漏洞分析
3.1 漏洞简介
CVE-2015-5165 是 QEMU 在模拟 Realtek RTL8139 网卡时存在的一个漏洞,具体为文件 hw\net\rtl8139.c
中的函数 rtl8139_cplus_transmit_one
在发送数据时没有检查 IP 数据包头部的长度 hlen
与整个 IP 数据包的长度 ip->ip_len
之间的关系,导致在计算数据长度的时候存在整数溢出:
/*uint16_t*/ ip_data_len = be16_to_cpu(ip->ip_len) - hlen; |
利用该漏洞可以把越界读取到的数据通过网络发送出去。
3.2 基础知识
3.2.1 Ethernet Frame Format
OSI(Open Systems Interconnection)将网络协议分为七层,从上往下依次为:
- 应用层
- 表示层
- 会话层
- 传输层
- 网络层
- 数据链路层
- 物理层
以太网帧(Ethernet Frame)在数据链路层传输,格式参考下图中的灰色部分 [7]:
相关字段解释:
- DST / SRC 为目标 / 源的 MAC 地址
Length / Type:
- 如果值小于等于
1500
,则表示 Payload 的长度 - 否则表示 Payload 数据所使用的协议,比如
0x0800
表示 IP 协议(这里指 IPv4)
- 如果值小于等于
Payload 的 MTU(Maximum Transmission Unit)为
1500
字节,当数据超出 MTU 时需要进行分片处理
3.2.2 IP Packet Format
IP 数据包(这里指 IPv4)在网络层传输,格式参考下图 [7]:
相关字段解释:
- IHL(Internet Header Length)表示 IP Header 的长度,最大可以是
0b1111 * 4 = 60
字节 - Total Length 表示整个 IP Packet 的长度,最大可以是
65535
字节 - IP Data 的最大长度为
65535 - 20 = 65515
字节- 此时 IP Header 的长度为
20
字节,Options 字段的长度为0
字节
- 此时 IP Header 的长度为
3.2.3 TCP Segment Format
TCP 报文在传输层传输,格式参考下图 [7]:
和 IP 数据包一样,TCP 报文头部的长度由 Header Length
字段指明,最大可以是 0b1111 * 4 = 60
字节,在 Options
字段为空的情况下头部长度为 20
字节。
3.3 漏洞分析
漏洞位于文件 hw\net\rtl8139.c
中的函数 rtl8139_cplus_transmit_one
,相关代码如下:
|
这里尝试从 Ethernet Frame 中解析 IPv4 数据包,在计算 IP 数据包中的数据长度时,在进行减法运算前并没有比较两个操作数的大小关系,通过触发整数溢出使得 ip_data_len
的最大值可以是 0xFFFF
。
紧接着是发送数据包,如果是 TCP 数据( IP_PROTO_TCP
)且数据量过大(设置了 CP_TX_LGSEN
标记),则会进行分片处理,即切分成多个 IP 数据包进行发送;此时 ip_data_len
将被用于计算 tcp_data_len
的值:
/* pointer to TCP header */ |
随后对 tcp_data_len
长度的数据按照 tcp_chunk_size
的大小进行分片发送:
int is_last_frame = 0; |
这里封装好的 Ethernet Frame 通过函数 rtl8139_transfer_frame
发送,函数部分代码如下:
static void rtl8139_transfer_frame(RTL8139State *s, uint8_t *buf, int size, |
可以看出,当设置了 TxLoopBack
标记时,会直接调用 rtl8139_do_receive
接收数据,数据会写入到接收缓冲区中。
0x04 漏洞利用
4.1 RTL8139 网卡简介
QEMU 模拟的 RTL8139 网卡在发送和接收数据时,内部代码分支的走向很大程度上依赖于网卡的状态,对应的结构体为 RTL8139State
(位于文件 hw\net\rtl8139.c
中):
typedef struct RTL8139State { |
RTL8139State
结构体中的许多字段实际上就是 RTL8139 网卡内部的寄存器,关于这些寄存器的描述,可以参考厂商 Realtek 提供的 Datasheet 手册 [8],下图为 Phrack 文章 [1] 提供的介绍(这里为 RTL8139 网卡在 C+ 模式下的寄存器介绍):
+---------------------------+----------------------------+ |
- TxConfig:发送数据相关的配置参数
- RxConfig:接收数据相关的配置参数
- CpCmd:C+ 模式相关配置参数,比如:
- CplusRxEnd 表示启用接收
- CplusTxEnd 表示启用发送
- TxAddr0:Tx descriptors table 相关的物理内存地址
- 0x20 ~ 0x27:Transmit Normal Priority Descriptors Start Address
- 0x28 ~ 0x2F:Transmit High Priority Descriptors Start Address
- RxRingAddrLO:Rx descriptors table 物理内存地址低 32 位
- RxRingAddrHI:Rx descriptors table 物理内存地址高 32 位
- TxPoll:让网卡检查 Tx descriptors
关于 Descriptor
的定义,同样可以参考厂商 Realtek 提供的 Datasheet 手册 [8],下图为 Transmit Descriptor
的定义:
Phrack 文章 [1] 给出的结构体的定义如下:
struct rtl8139_desc { |
4.2 Port Mapped I/O
CPU 可以通过以下两种方式和外设进行交互(这里不讨论 IRQ、DMA 等其他交互方式):
- Memory Mapped I/O 即 MMIO
- Port Mapped I/O 即 PMIO
MMIO 将外设的内存和寄存器直接映射到系统的地址空间中(这部分空间通常是保留给外设专用的),这样 CPU 通过普通的汇编指令即可和外设进行交互;而 PMIO 则将外设的内存和寄存器映射到隔离的地址空间中(PMIO 地址空间的大小为 64KB),CPU 通过 in
和 out
指令和外设进行交互。
在 Windows 下,可以通过设备管理器查看设备的 PMIO 地址范围,下图为 VMware SVGA 3D 的 PMIO 地址区间之一:
在 Linux 下可以使用 pciutils 中的 lspci
查看设备的 PMIO 地址区间 [9],这里测试用的 Ubuntu Server 已经自带了 pciutils,只需要在启动时添加 RTL8139 网卡即可,启动命令如下:
x86_64-softmmu/qemu-system-x86_64 -enable-kvm -m 2048 -drive \ |
这里最后一行的作用是把 Ubuntu Server 虚拟机的 22 端口转发到主机的 2222 端口,方便主机通过 SSH 访问虚拟机(VNC Viewer 无法复制粘贴),在主机中执行以下命令即可连接虚拟机:
ssh vmusername@127.0.0.1 -p 2222 |
通过 lspci
命令可以看到 RTL8139 网卡的 PMIO 的起始地址为 0xC000
,大小为 256
字节:
lspci |
4.3 PMIO 读写
通过结构体 RTL8139State
的成员 bar_io
的交叉引用可以定位到函数 pci_rtl8139_realize
,这里对 PMIO 和 MMIO 进行了初始化操作:
static void pci_rtl8139_realize(PCIDevice *dev, Error **errp) |
PMIO 的读写函数可以从变量 rtl8139_io_ops
中找到:
static const MemoryRegionOps rtl8139_io_ops = { |
PMIO 写函数 rtl8139_ioport_write
的定义如下:
static void rtl8139_ioport_write(void *opaque, hwaddr addr, |
写的长度可以是字节、字、双字,这里以字节为单位的 PMIO 写函数为 rtl8139_io_writeb
,定义如下:
static void rtl8139_io_writeb(void *opaque, uint8_t addr, uint32_t val) |
当往 TxPoll
写入数据时,可以触发 C+ TxPoll normal priority transmission
,即调用函数 rtl8139_cplus_transmit
,定义如下:
static void rtl8139_cplus_transmit(RTL8139State *s) |
该函数会循环调用 rtl8139_cplus_transmit_one
,也就是存在漏洞的函数!
4.4 漏洞触发
弄清楚漏洞的原理之后,编写 PoC 就比较简单了!对 Linux 和硬件接触不多的初学者(比如笔者自己),建议尝试理解每一行代码的作用,遇到不懂的概念就 Google 一下,代码不 Work 就 Debug 一下,在这个过程中可以学到很多新的知识,这也正是分析该漏洞的出发点。
在主机中可以通过 GDB 附加到 QEMU 进程 qemu-system-x86
进行调试,触发漏洞的位置如下:
调试过程中遇到的几个坑:
(I) 在构造数据包时,Ethernet Frame 的源 MAC 地址、目标 MAC 地址需要填充为 QEMU 虚拟机 RTL8139 网卡的 MAC 地址,通过 ifconfig -a
命令可以查看本机所有网卡的数据;笔者一开始使用的 ifconfig
命令,结果偏偏没有打印 RTL8139 网卡的信息,导致填充了错误的 MAC 地址,通过调试 QEMU 进程才发现 MAC 地址不一致;
(II) Phrack 文章 [1] 提供的 Exploit 代码中 rtl8139_tx_desc
是栈上的局部变量,实际测试时发现获取不到在内存中的物理地址(Guest Physical Address),改为从堆上动态申请内存即可;调试发现是笔者自己实现的获取物理内存地址的代码有问题,因为栈的地址很高,转换成有符号数是一个负数,所以在调用 fseek
的时候需要处理好符号问题,否则 fseek
会失败;
if (!fseek(fp, (unsigned long)addr / PAGE_SIZE * 8, SEEK_SET)) |
(III) 在 QEMU 虚拟机测试 PoC 时,发现打印接收到的数据的时候进程 Crash 了,从打印出来的调用栈来看,应该是接收缓冲区溢出了:
sudo ./a.out |
调试发现 Phrack 文章 [1] 末尾给出的代码存在一个 Bug,而这个 Bug 居然没有人发现,笔者搜索了国内相关的技术文章,发现都照搬了这个 Bug 。其他人没有发现这里的问题,可能是由于分析环境的不同所造成的:
- 笔者的 QEMU 虚拟机中安装的是 Ubuntu 官方发行的 Server 版本
- 其他文章中的 QEMU 虚拟机中安装的是临时编译的 Linux 系统
对该 Bug 的分析如下:
- 函数
rtl8139_cplus_transmit_one
在发送分片后的 Ethernet Frame 时,数据包的大小是1514
字节;
int tcp_chunk_size = ETH_MTU - hlen - tcp_hlen; |
- 因为是发给本机的数据,所以执行流程经由
rtl8139_transfer_frame
进入rtl8139_do_receive
,这里会检查接收缓冲区是否还有多余的4
字节空间用于填充 Checksum ;
uint32_t rx_space = rxdw0 & CP_RX_BUFFER_SIZE_MASK; |
- Phrack 文章 [1] 对接收缓冲区的设置位于函数
rtl8139_desc_config_rx
,可以每一个ring / descriptor
关联的缓冲区的大小是RTL8139_BUFFER_SIZE
即1514
字节,但是dw0
标志中设置的大小却是USHRT_MAX
即65535
;
void rtl8139_desc_config_rx(struct rtl8139_ring *ring, |
- 这样的设置显然是不对的,这会导致可以通过函数
rtl8139_do_receive
中的缓冲区大小检查,后面在写入 Checksum 时会导致堆块越界写,这就是导致 QEMU 虚拟机中 PoC 进程 Crash 的原因;
参考 Phrack 文章的代码,笔者重写的一份用于测试 CVE-2015-5165 的完整 PoC 代码如下:
|
运行 PoC 后,在接收到的中间某些数据包中可以看到泄露的数据:
4.5 漏洞利用
Phrack 文章 [1] 漏洞利用的思路为:在泄露的数据中搜索保存了 ObjectProperty
对象的堆块(可能是已经被释放的堆块),通过读取 ObjectProperty
对象中保存的函数指针来泄露模块 qemu-system-x86_64
的基地址。
结构体 ObjectProperty
的定义如下:
|
这里 get / set / resolve / release
保存的值均为函数指针。
利用步骤:
- 结构体
ObjectProperty
的大小为0x50
字节,因此包含 metadata 的堆块的大小为0x60
字节,可以根据这一信息去搜索泄露的数据中存在的堆块; - ASLR 不会对地址的低
12
位进行随机化处理,因此可以以相关函数地址的低12
位为特征进行搜索,以计算出模块qemu-system-x86_64
的基地址; - 统计泄露的数据中出现的
uint64_t
类型的数据0x00007FXXYYZZZZZZ
,其中7FXXYY
出现次数最多的数据,就是 QEMU 虚拟机物理内存的结束地址;
基于前面的 PoC 代码,笔者重写的一份用于测试 CVE-2015-5165 的完整 Exploit 代码如下:
|
Exploit 测试结果如下:
0x05. 分析小结
第一次分析 QEMU 的漏洞,整体感觉还挺有意思的,CVE-2015-5165 这个漏洞本身简单易懂,如果了解网卡基本工作原理的话,Exploit 编写也不是很难。
0x06. 参考文献
[1] http://www.phrack.org/papers/vm-escape-qemu-case-study.html
[2] QEMU commands-posix.c patch - <sys/sysmacros.h>
[3] https://dangokyo.me/2018/03/02/qemu-escape-part-1-environment-set-up/
[4] https://www.realvnc.com/en/connect/download/viewer/
[5] https://shanetully.com/2014/12/translating-virtual-addresses-to-physcial-addresses-in-user-space/
[6] https://www.kernel.org/doc/Documentation/vm/pagemap.txt
[7] TCP/IP Illustrated, Volum 1, The protocols, Second Edition, Kevin R. Fall, W. Richard Stevens