qemu逃逸初探
前言
今天是19年的最后一天,本来想发点总结的,合计了一下发现没什么能说的,还是算了,做一下很久之前就想做的题。基本是复现ray-cp师傅的做题过程,中间还有些疑惑的地方。
基础知识
地址转换
大概看了三道题,题目都是自己写个设备,和qemu共同编译,之后指定这个设备作为device。通过设备的读写漏洞进行逃逸。
首先是qemu相关的内存映射,我们的虚拟机分配一块内存(在启动qemu的脚本中指定)给qemu进程,这块地址是我们虚拟机的虚拟地址,却是qemu作为一个模拟系统中的物理地址,这个地址再通过地址映射的方式分配给其中的进程使用。图如下所示(抄自ray-cp师傅)
1 | Guest' processes |
如果我们在qemu虚拟机里申请一段地址空间mem,则可以先用qemu里的地址映射计算出其在qemu物理内存地址(在qemu内部查看二进制程序的map基址加上申请地址的偏移),进而将这个地址作为偏移加上在VMWare内查看map得到的qemu进程基址算出mem在虚拟机的实际地址。
pci设备空间
pci设备有一个配置空间记录设备的详细信息。大小为256字节,前64字节是PCI标准规定的。前16个字节的格式是固定的,包含头部的类型、设备种类、设备的性质以及制造商等,格式如下:
比较关键的是其6个BAR(Base Address Registers),BAR记录了设备所需要的地址空间的类型,基址以及其他属性。BAR的格式如下:
设备可以申请两类地址空间,memory space和I/O space,用BAR的最后一位区别开。
当BAR最后一位为0表示这是映射的I/O内存,为1是表示这是I/O端口,当是I/O内存的时候1-2位表示内存的类型,bit 2为1表示采用64位地址,为0表示采用32位地址。bit1为1表示区间大小超过1M,为0表示不超过1M。bit3表示是否支持可预取。
而相对于I/O内存,当最后一位为1时表示映射的I/O端口。I/O端口一般不支持预取,所以这里是29位的地址。
通过memory space访问设备I/O的方式称为memory mapped I/O,即MMIO,这种情况下,CPU直接使用普通访存指令即可访问设备I/O。
通过I/O space访问设备I/O的方式称为port I/O,或者port mapped I/O,这种情况下CPU需要使用专门的I/O指令如IN/OUT访问I/O端口。
在MMIO中,内存和I/O设备共享同一个地址空间。 MMIO是应用得最为广泛的一种I/O方法,它使用相同的地址总线来处理内存和I/O设备,I/O设备的内存和寄存器被映射到与之相关联的地址。当CPU访问某个内存地址时,它可能是物理内存,也可以是某个I/O设备的内存,用于访问内存的CPU指令也可来访问I/O设备。每个I/O设备监视CPU的地址总线,一旦CPU访问分配给它的地址,它就做出响应,将数据总线连接到需要访问的设备硬件寄存器。为了容纳I/O设备,CPU必须预留给I/O一个地址区域,该地址区域不能给物理内存使用。
在PMIO中,内存和I/O设备有各自的地址空间。 端口映射I/O通常使用一种特殊的CPU指令,专门执行I/O操作。在Intel的微处理器中,使用的指令是IN和OUT。这些指令可以读/写1,2,4个字节(例如:outb, outw, outl)到IO设备上。I/O设备有一个与内存不同的地址空间,为了实现地址空间的隔离,要么在CPU物理接口上增加一个I/O引脚,要么增加一条专用的I/O总线。由于I/O地址空间与内存地址空间是隔离的,所以有时将PMIO称为被隔离的IO(Isolated I/O)。
查看pci设备
lspci命令用于显示当前主机的所有PCI总线信息,以及所有已连接的PCI设备信息。
pci设备的寻址是由总线、设备以及功能构成。如下所示:1
2
3
4
5
6
7
8ubuntu@ubuntu:~$ lspci
00:00.0 Host bridge: Intel Corporation 440FX - 82441FX PMC [Natoma] (rev 02)
00:01.0 ISA bridge: Intel Corporation 82371SB PIIX3 ISA [Natoma/Triton II]
00:01.1 IDE interface: Intel Corporation 82371SB PIIX3 IDE [Natoma/Triton II]
00:01.3 Bridge: Intel Corporation 82371AB/EB/MB PIIX4 ACPI (rev 03)
00:02.0 VGA compatible controller: Device 1234:1111 (rev 02)
00:03.0 Unclassified device [00ff]: Device 1234:11e9 (rev 10)
00:04.0 Ethernet controller: Intel Corporation 82540EM Gigabit Ethernet Controller (rev 03)
xx:yy:z的格式为总线:设备:功能的格式。
PCI 设备通过VendorIDs、DeviceIDs、以及Class Codes字段区分:
1 | ubuntu@ubuntu:~$ lspci -v -m -n -s 00:03.0 |
查看设备的内存空间:
1 | ubuntu@ubuntu:~$ lspci -v -s 00:03.0 -x |
可以看到该设备有两个空间:BAR0为MMIO空间,地址为febf1000,大小为256;BAR1为PMIO空间,端口地址为0xc050,大小为8。
我们还可以通过resource文件来查看内存空间
1 | ubuntu@ubuntu:~$ ls -la /sys/devices/pci0000\:00/0000\:00\:03.0/ |
resoucre
文件包含其它相应空间的数据,如resource0(MMIO空间)以及resource1(PMIO空间)
每行代表起始地址、结束地址以及标志位
1 | ubuntu@ubuntu:~$ cat /sys/devices/pci0000\:00/0000\:00\:03.0/resource |
qemu访问I/O空间
访问mmio
编写内核模块,可以在内核态访问mmio空间,demo如下:
1 |
|
用户态访问主要是通过映射resource0文件实现访问,给定地址可以直接赋值或者取值。
1 |
|
访问pmio
内核态访问使用in/out函数访问某个端口
1 |
|
用户态访问要先调用iopl
申请访问大于0x3ff的端口
1 |
|
QOM编程模型
QEMU提供了一套面向对象编程的模型——QOM(QEMU Object Module),几乎所有的设备如CPU、内存、总线等都是利用这一面向对象的模型来实现的。
有几个结构体比较关键,TypeInfo
、TypeImpl
、ObjectClass
以及Object
。
TypeInfo
是用户用来定义一个Type的数据结构,用户定义一个TypeInfo
,然后调用type_register(TypeInfo)
或者type_register_static(TypeInfo)
函数,就会生成相应的TypeImpl实例,将这个TypeInfo
注册到全局的TypeImpl
的hash
表中。
TypeImpl
的属性与TypeInfo的属性对应,实际上qemu就是通过用户提供的TypeInfo创建的TypeImpl对象。如下面定义的pci_test_dev
。
1 | static const TypeInfo pci_testdev_info = { |
当所有qemu总线、设备等的type_register_static
执行完成后,即它们的TypeImpl
实例创建成功后,qemu就会在type_initialize
函数中实例化其对应的ObjectClass
。
每个type
都有一个相应的ObjectClass对应,它是所有类的基类。
1 | struct ObjectClass |
用户可以定义自己的类,继承相应类即可。
1 | /* include/qom/object.h */ |
可以看到类的定义中父类都在第一个字段,使得可以父类与子类直接实现转换。一个类初始化时会先初始化它的父类,父类初始化完成后,会将相应的字段拷贝至子类同时将子类其余字段赋值为0,再进一步赋值。同时也会继承父类相应的虚函数指针,当所有的父类都初始化结束后,TypeInfo::class_init
会调用以实现虚函数的初始化,如下面的pci_testdev_class_init
:
1 | static void pci_testdev_class_init(ObjectClass *klass, void *data) |
最后是Object
对象
1 | struct Object |
之前提到的Type
以及ObjectClass
只是一个类型,而不是具体的设备。TypeInfo
结构体中有两个函数指针:instance_init
以及class_init
。class_init
负责初始化ObjectClass
结构体,instance_init
负责初始化具体Object
结构体。
the Object constructor and destructor functions (registered by the respective Objectclass constructors) will now only get called if the corresponding PCI device’s -device option was specified on the QEMU command line (unless, probably, it is a default PCI device for the machine).
Object类的构造函数与析构函数(在Objectclass构造函数中注册的)只有在命令中-device指定加载该设备后才会调用(或者它是该系统的默认加载PCI设备)。Object
示例如下:
1 | /* include/qom/object.h */ |
(QOM will use instace_size as the size to allocate a Device Object, and then it invokes the instance_init )
QOM会为设备Object
分配instance_size
大小的空间,然后调用instance_init
函数(在ObjectClass
的class_init
函数中定义):
1 | static int pci_testdev_init(PCIDevice *pci_dev) |
最后是PCI的内存空间,qemu使用MemoryRegion表示内存空间,使用MemoryRegionOps
结构体来对内存的操作进行表示,如PMIO
或MMIO
。对每个PMIO
或者MMIO
操作都需要相应的MemoryRegionOps
结构体,其中包含相应的read/write
回调函数。
1 | static const MemoryRegionOps pci_testdev_mmio_ops = { |
首先用memory_region_init_io
函数初始化内存空间(MemoryRegion结构体),记录空间大小,注册相应的读写函数等;然后调用pci_register_bar
来注册BAR等信息。需要指出的无论是MMIO还是PMIO,其对应的空间都需要显式的指出(即静态生命或动态分配),因为memory_region_init_io只是记录空间大小而并不分配。
1 | /* hw/misc/pci-testdev.c */ |
Blizzard CTF 2017 Strng
代码分析
最开始用type_init调用pci_strng_register_types初始化一个type_info,在结构体中要给出strng_init和class_init方法,之后调用type_register_static添加type
1 | type_init(pci_strng_register_types) |
strng_class_init初始化一个ObjectClass,赋值pci_strng_realize
;赋给设备一个vendor_id、device_id、class_id等唯一标识。
1 | static void strng_class_init(ObjectClass *class, void *data) |
strng_instance_init初始化一个Object实例,给函数指针赋值。
1 | static void strng_instance_init(Object *obj) |
pci_strng_realize首先用memory_region_init_io
函数初始化内存空间(MemoryRegion结构体),记录空间大小,注册相应的读写函数等;然后调用pci_register_bar
来注册BAR等信息。
1 | static void pci_strng_realize(PCIDevice *pdev, Error **errp) |
strng_mmio_ops
和strng_pmio_ops
给了读写mmio和pmio的函数
1 | static const MemoryRegionOps strng_mmio_ops = { |
最后看下设备的结构体,后面跟了三个函数指针。
1 | typedef struct { |
先来看下mmio的读写操作(反编译之后记得将opaque结构体转成STRNGState *类型方便查看).
mmio_read:如果addr
是4
的倍数,就返回regs[addr>>2]
,其他情况返回-1。
mmio_write:如果addr
是4
的倍数,取i
为addr>>2
。
- 如果
i
为1
,调用里面的rand
函数,参数为(opaque,i,val)
,结果存储在regs[1]
里。 - 如果
i
为0
,调用里面的srand
函数,参数为val
- 如果
i
为3
,调用里面的rand_r
函数,参数为®s[2]
,regs[3]
存储函数返回值,regs[i]
存储val
- 其他情况直接把
val
赋值给regs[i]
1 | uint64_t __fastcall strng_mmio_read(STRNGState *opaque, hwaddr addr, unsigned int size) |
再来看下pmio相关的读写操作。
pmio_read:如果addr是4
的倍数,idx取opaque的成员addr
,如果idx
是4
的倍数,直接返回regs[idx>>2]
,否则返回opaque->addr
pmio_write:
- 如果addr为
0
,直接将opaque->addr
赋值为val
- 如果addr为
4
的倍数,idx取opaque的成员addr
。v5
取idx>>2
,如果v5
为1
,调用rand(opaque,4,val)
,结果存放在regs[1]
;如果v5
为0
,调用srand(val)
;如果v5
为3
,调用rand_r(®[2],4,val)
;否则将val
赋值给regs[v5]
通过函数分析可以看到这里对于idx没有检查,我们可以用pmio_write(0,offset)
设置opaque->addr
为offset
,之后用pmio_read(offset)
读取offset>>2
的值实现任意读;或者先用pmio_write(0,offset)
设置opaque->addr
为offset
,再调用pmio_write(4,val)
实现regs[offset>>2] = val
的任意写。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71uint64_t __fastcall strng_pmio_read(STRNGState *opaque, hwaddr addr, unsigned int size)
{
uint64_t result; // rax
uint32_t idx; // edx
result = -1LL;
if ( size == 4 )
{
if ( addr )
{
if ( addr == 4 )
{
idx = opaque->addr;
if ( !(idx & 3) )
result = opaque->regs[idx >> 2];
}
}
else
{
result = opaque->addr;
}
}
return result;
}
void __fastcall strng_pmio_write(STRNGState *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
uint32_t idx; // eax
__int64 v5; // rax
if ( size == 4 )
{
if ( addr )
{
if ( addr == 4 )
{
idx = opaque->addr;
if ( !(idx & 3) )
{
v5 = idx >> 2;
if ( (_DWORD)v5 == 1 )
{
opaque->regs[1] = ((__int64 (__fastcall *)(STRNGState *, signed __int64, uint64_t))opaque->rand)(
opaque,
4LL,
val);
}
else if ( (unsigned int)v5 < 1 )
{
((void (__fastcall *)(_QWORD))opaque->srand)((unsigned int)val);
}
else if ( (_DWORD)v5 == 3 )
{
opaque->regs[3] = ((__int64 (__fastcall *)(uint32_t *, signed __int64, uint64_t))opaque->rand_r)(
&opaque->regs[2],
4LL,
val);
}
else
{
opaque->regs[v5] = val;
}
}
}
}
else
{
opaque->addr = val;
}
}
}
漏洞利用
根据上面实现的任意地址读写,我们可以用任意读泄露结构体后面的函数指针,因为这是在qemu的进程空间
,所以可以把它想象成VMware虚拟机里的一道普通glibc pwn,泄露的地址就是这个Bin(qemu)的函数libc地址,进而算出libc_base
。
getshell我们可以先往regs[2]
写入command
,之后覆写rand_r为system,调用mmio_write里分支为3
的地方即可
调试
启动脚本的命令如下,做了端口映射,方便scp传输,传输文件可以用scp -P5555 exp [email protected]:/home/ubuntu
1
2
3
4
5
6
7
8
9
10./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 \
调试卡了一会,环境为ubuntu 16.04
,gdb
版本7.1
出错,编译了gdb8
,在VMware查看进程号ps -aux | grep qemu
,attach上去sudo /usr/local/gdb/bin/gdb attach -q [pid]
即可,断点下在各个读写函数上1
2
3
4b *strng_pmio_write
b *strng_pmio_read
b *strng_mmio_write
b *strng_pmio_read
使用print *(STRNGState*)($rdi)
输出结构体(是利用完之后停的所有后面是system)
结构体存放在堆上,后面还有堆地址,可以泄露libc和heap地址。
这里有个神奇的地方,就是这个数组的空间是65*4
而不是64*4
,这个可以通过srandom
地址减去64*5
看到。因为read/write
操作的都是4字节
,所以我们泄露一个64位地址要用两次,最后部分写rand_r
为system
,在regs[2]
布置好参数,调用mmio_write的3
分支即可。(我的参数为cat /home/wz/flag
)
编译命令:1
gcc ./exp.c -m32 -static -o exp
exp.c
偏移为ubuntu 16.04
的libc-2.23
中得到的。
1 |
|
湖湘杯2019 pwn2
前言
这道题基本就是刚才那道题改编的,当时在去重庆玩的火车上学长告诉我他调了一遍发现很简单,先膜一下学长orz。
程序分析
mmio_read
和mmio_write
和之前相似,但是在结构体里不再有函数指针,新的结构体是下面这样的,最下面改成了一个QEMUTimer_0结构体,在read/write中调用的rand统统来自于plt
,因此不能通过覆写函数指针的方式劫持控制流。
1 | /* |
pmio_read
和pmio_write
也差不多,但是在pmio_write
的i!=0/1/3
的else
分支调用了timer_mod(&opaque->strng_timer, v4 + 100);
跟进去看发现最后有一个函数调用链。
1 | uint64_t __cdecl strng_pmio_read(STRNGState *opaque, hwaddr addr, unsigned int size) |
这个链是这样的:timer_mod
->timer_mod_ns
->timerlist_rearm
->timerlist_notify
->(timer_list->notify_cb)(timer_list->notify_opaque);
,因为STRNGState后面跟了这个结构体,所以可以直接覆写其中的cb
为system@plt
,opaque
为cat /home/wz/flag
的地址,为了方便,我还是把它写到了reg[2]
,然后泄露其地址。
1 | void __cdecl timer_mod(QEMUTimer_0 *ts, int64_t expire_time) |
exp.c
泄露地址拿gdb调下就好,这个里面没法ssh,所以exp需要打包到磁盘文件再从qemu启动本地读flag。打包命令为find . | cpio -o --format=newc > rootfs.cpio
,其他跟第一道题没什么区别,调试一下看看偏移就好
1 |
|
最终结果:
1 | Welcome to QEMU-ESCAPE |
数字经济线下RealWorld-Qemu-Escape
前言
俩月前的比赛,当时自闭了两天,docker逃逸在这里,browser的题在p1umer和e3pem
程序分析
在当时我对qemu一无所知的时候看到了-device rfid
,但是去IDA搜相关函数没搜到就放弃了,实际上二进制文件去掉了符号表,增大了分析难度,之所以要增加这个难度,就是因为这个题太简单了,直接给了个后门。
下面一步步开始分析。
首先没有符号表,我们搜下rfid
的字符串,根据之前的分析可以知道入口函数rfid_class_init
里会有字符串rfid_class_init
,所以根据引用可以找到rfid_class_init
,里面那一堆是各种id
,这个不必要再做区分,等会qemu里直接看设备基本就能对应上(或者找一个有符号表的qemu题按照偏移对照一下)
1 | __int64 __fastcall rfid_class_init(__int64 a1) |
在class_init里,一定要给个realize
函数,所以这里唯一一个函数指针可以推断出是pci_rfid_realize
1 | unsigned __int64 __fastcall pci_rfid_realize(__int64 pdev, __int64 errp) |
这时候再找个以前做过的qemu题,看看里面函数的参数,可以发现sub_31B892
这个函数有6个参数且有字符串rfid-mmio
,这就很显然这个函数是memory_region_init_io
,而里面的第三个参数就是rfid_mmio_ops
了。点进去看下,第一个函数指针是rfid_mmio_read
,第二个是rfid_mmio_write
。如此一来就找到了关键的read/write
函数。
1 | .data.rel.ro:0000000000FE9720 rfid_mmio_ops dq offset rfid_mmio_read |
先看rfid_mmio_read
,第二个参数为我们输入的地址,判断((addr >> 20) & 0xF) != 15
,后面比较两个字符串,后者为wwssadadBABA
,前者根据引用发现赋值来自rfid_mmio_write
,比较成功之后执行command
,看引用也来自rfid_mmio_write
,下面分析write函数。
1 | signed __int64 __fastcall rfid_mmio_read(__int64 a1, unsigned __int64 addr) |
rfid_mmio_write
函数的逻辑实际上就是个小菜单,(addr >> 20) & 0xF
作为result
。
如果result
为[0,5]
,就给input[idx]
赋不同的固定值,idx
为(addr >> 16) & 0xF
;如果result
为6
,就往command
里拷贝数据,src
为&n[4]
,而在程序开始我们*(_QWORD *)&n[4] = value;
将value赋值给了它,因此这里的memcpy
实际上等同于command[(unsigned __int16)arg11] = value
。
1 | _BYTE *__fastcall rfid_mmio_write(__int64 a1, unsigned __int64 addr, __int64 value, unsigned int size) |
综上所述,我们那两轮rfid_mmio_write
设置input
为wwssadadBABA
,设置command
为gnome-calculator
,最后调用rfid_mmio_read
触发system("gnome-calculator")
,弹出计算器
exp.c
1 |
|
小结
这三道题应该是逃逸类题目里最简单的类型,像是glibc pwn的数组越界,只要理解利用的原理就不难做,希望新的一年自己能学更多东西,努力追赶少年时的梦想。