QEMU 逃逸入门

QEMU 逃逸本质上和用户态的 Pwn 题没有太大区别,只不过呈现形式略有不同。题目本身通常以一个 QEMU 模拟设备的形式进行呈现,该设备通常会实现一些功能并提供用户可操纵的 MMIO/PMIO 接口。选手通常需要编写一个与这些接口进行交互的程序并传到远程主机上运行以完成利用(类似于内核 Pwn)。

下面我们通过一道例题来了解 QEMU Pwn 题目的基本做法。

例题:BlizzardCTF2017 - Strng

注:题目环境可以在 Github 进行下载,登入用户名为 ubuntu,密码为 passw0rd

题目分析

首先查看启动脚本,可以发现其通过 -device strng 参数载入了一个自定义设备 strng

./qemu-system-x86_64 \
    -m 1G \
    -device strng \
    -hda my-disk.img \
    -hdb my-seed.img \
    -nographic \
    -L pc-bios/ \
    -enable-kvm \
    -device e1000,netdev=net0 \
    -netdev user,id=net0,hostfwd=tcp::5555-:22

直接将 QEMU 拖入 IDA 进行分析,首先通过字符串窗口找到 "strng" ,从而找到该设备的初始化函数:

可以看到该设备分别注册了 MMIO 与 PMIO 功能接口,并且在一些位置上放上了几个函数指针:

IDA 反编译出来的放置函数指针的位置怪怪的,这里直接看汇编源码:

接下来我们跳转到函数表中对应的函数进行分析,在 (u32*)opaque[701] 处存在一个 unsigned int 数组(这里我们定义为 opaque->buf),MMIO 的 read 主要是简单的读取 opaque->buf[(addr >> 2)] 上的 4 字节内容,看起来似乎可以存在一个越界读取,但是在 QEMU 内部会检查 MR 访问范围(addr)是否超过定义的内存范围,所以其实是没法进行越界读取的

opaque 参数其实就是设备加载时动态分配的 PCIDevice 类的一个自定义子类。

MMIO 的 write 功能则根据写入的地址不同提供了不同的功能(有点乱):

  • 地址为 0:将 (u64*)opaque[383] 处数据作为函数指针进行调用,参数为传入的值

  • 地址为 1 << 2:将 (u64*)opaque[384] 处数据作为函数指针进行调用,并将结果写入 opaque->buf[3]

  • 地址为 其他值 << 2:在 opaque->buf[(addr>>2)] 处写入传入的值

    • 若地址为 3 << 2,则会在此之前将 (u64*)opaque[385] 处数据作为函数指针进行调用,参数为 &((char*)opaque[2812]) ,并往 opaque->buf[3] 写入传入的值

PMIO 的 read 功能则是进行数据读取:

  • addr == 0 ,则返回 (unsigned int *)opaque[700] 的值。

  • addr == 4 ,则获取 (unsigned int *)opaque[700] 的值 v4,若低 2 位为 0 则返回 opaque->buf[(v4 >> 2)] 上数据。

若我们能够控制 (unsigned int *)opaque[700] 的值,则可以直接完成一个越界读。

PMIO 的 write 功能定义如下:

  • addr == 0,则将传入的值写入 (unsigned int *)opaque[700] ,因此结合 PMIO read 我们便可以完成越界读。

  • addr == 4,则获取 (unsigned int *)opaque[700] 的值 v4,若低 2 位为 0 则取 v5 = v4 >>2

    • v5 == 1,则调用 (u64*)opaque[384] 处函数指针,返回值写入 opaque->buf[1],参数见代码

    • v5 == 3,则调用 (u64*)opaque[385] 处函数指针,返回值写入 opaque->buf[3],参数见代码

    • v5 != 0,则将传入的值写入 opaque->buf[v5]

    • v5 == 1,则调用 (u64*)opaque[383] 处函数指针,参数为我们传入的值

漏洞利用

由于 PMIO read 功能的读取地址由 (unsigned int *)opaque[700] 决定,而该值可以通过PMIO write 写入 addr == 0 处进行修改,由于题目一开始便在 opaque 靠后的放置了一些函数指针,因此我们可以通过读取这些函数指针泄露 libc 基址。

同样地,当 addr == 4 时,PMIO write 会向指定地址 + 偏移处写入数据,而该偏移值为我们可控的 (unsigned int *)opaque[700],因此我们可以非常方便地劫持 opaque 上的函数指针,而这些函数指针又可以通过 MMIO write 与 PMIO write 进行触发,因此不难想到的是我们可以通过劫持这些函数指针来完成控制流劫持。

(unsigned int *)opaque[700] == 3 时,调用函数指针会传入一个 opaque 上地址作为第一个参数,而该处数据同样是我们可控的,因此我们可以在该处先写入字符串后再劫持函数指针为 system() 后直接调用即可完成 Host 上的任意命令执行。

交互方式

QEMU pwn 题会提供给我们一个 local Linux 环境,通常都有着 root 权限(除了一些套娃题目会要求选手先完成提权),通常我们我们需要使用 C 编写 exp,将其进行静态编译后传输到远程运行。有的题目也会提供本地编译环境(例如本题),这样我们便只需要传输 exp 的源代码到远程再编译运行即可。

首先说一下与题目进行交互的方式。QEMU pwn 的漏洞通常出现在一个自定义 PCI 设备中,我们可以通过 lspci 命令查看现有的 PCI 设备,在每个设备开头都可以看到形如 xx:yy.z 的十六进制编号,这个格式其实是 总线编号:设备编号.功能编号,当我们使用 lspci -v 查看 PCI 设备信息时,在总线编号前面的 4 位数字便是 PCI 域的编号。

通常我们可以看到一个未被识别的设备,这通常便是题目设备。这里我们可以看到 PMIO 地址为 0xc050,MMIO 地址(物理地址)为 0xfebf1000

对于 PMIO 交互方式,我们可以先通过 iopl(3) 获取交互权限,接下来直接使用 in()out() 系函数即可读写端口,需要注意的是端口地址应与读写长度对齐(例如读写 4 字节则端口地址需要对齐到 4),下面是一个例子:

MMIO 的交互方式则略有麻烦,因为 MMIO 本质上是直接读写对应的物理地址,不过我们可以通过 mmap() 映射 sysfs 下的资源文件来完成内存访问。以本题为例,通过 lspci 命令获取到的编号为 00:03.0,那么我们便可以通过 mmap() 映射 /sys/devices/pci0000:00/0000:00:03.0/resource0 文件直接完成 MMIO。类似于 PMIO,MMIO 的读写地址同样需要对齐到读写长度。下面是一个例子:

注:我们也可以通过映射 /sys/devices/pci0000:00/0000:00:03.0/resource1 文件的形式来以内存读写的形式完成 PMIO。

完整 exp 如下,执行了 cat ./flag 与弹计算器的命令:

REFERENCE

qemu pwn-Blizzard CTF 2017 Strng writeup

【HARDWARE.0x00】PCI 设备简易食用手册

Last updated