写在最前面
强网杯前的热身训练,这次比赛的 pwn 题难度适中,很适合我这种菜鸡来打。差一道 V8 就能 AK pwn,当时 V8 已经能实现 Sandbox 内任意读写,可是死活绕不出 Sandbox,赛后看了 Nu1L 的 exp 后发现原来这么简单,而且这个利用方式在一些议题里讲过并在去年 pwn2own 被人使用过,没 AK 真的非常可惜,只能对自己说菜就多练 :(
这道 BBOX 是 qemu 逃逸,想到博客好像还没单独的为这种类型的题目单独写一篇文章,所以这里就打算水一篇🤣
这道题目是我第二天下午才看的,不到三小时就秒了还捡了个三血(现在的新人连这么简单的题目都不屑于打了嘛😭),要是题目刚上时就打没准一血就有了😇
程序分析
在分析前还是先给一下我恢复符号后的伪代码,直接看就是一坨💩
virtsec_mmio_read
1 | __int64 __fastcall virtsec_mmio_read(void *opaque, unsigned __int64 offset) |
virtsec_mmio_write
1 | __int64 __fastcall virtsec_mmio_write(void *opaque, unsigned __int64 offset, unsigned __int64 value) |
virtsec_realize
1 | __int64 __fastcall virtsec_realize(_QWORD *opaque, __int64 a2) |
virtsec_execute_command
1 | void __fastcall virtsec_execute_command(virtsec_device_t *dev, unsigned int cmd) |
virtsec_unrealize
1 | __int64 __fastcall virtsec_unrealize(void *opaque, int a2) |
virtsec_find_block
1 | virtsec_block_t *__fastcall virtsec_find_block(virtsec_device_t *dev, u32 block_id) |
virtsec_allocate_block
1 | virtsec_block_t *__fastcall virtsec_allocate_block(virtsec_device_t *dev, unsigned int block_id, unsigned int size) |
virtsec_free_block
1 | void __fastcall virtsec_free_block(virtsec_device_t *dev, virtsec_block_t *blk) |
virtsec_cleanup_all_blocks
1 | __int64 __fastcall virtsec_cleanup_all_blocks(virtsec_device_t *dev) |
virtsec_find_block_index
1 | int __fastcall virtsec_find_block_index(virtsec_device_t *dev, unsigned int block_id) |
virtsec_swap_blocks
1 | void __fastcall virtsec_swap_blocks(virtsec_device_t *dev, unsigned int block_index, unsigned int i) |
virtsec_validate_block_size
1 | _BOOL8 __fastcall virtsec_validate_block_size(u32 reg_3068) |
virtsec_decrypt_block
1 | void __fastcall virtsec_decrypt_block(virtsec_device_t *dev, virtsec_block_t *blk) |
virtsec_reset
1 | void __fastcall virtsec_reset(virtsec_device_t *dev, int unused) |
题目概览
- 自定义 PCI 设备 virtsec 被编译进 QEMU,暴露一段 MMIO 寄存器与一段数据窗口供访客访问
- 关键回调:
- virtsec_mmio_read
- virtsec_mmio_write
- 命令分发 virtsec_execute_command
设备结构
- 内存布局(核心字段):
- dev->data_buffer :长度 256 字节,分成 16 个槽,每槽 16 字节,用于块数据
- dev->block_list[16] :块元数据数组,每个块记录 id/start/size/valid/enc_flag/freed 等
- dev->gift_func 与 dev->gift_str :紧邻 data_buffer 尾部的函数指针和字符串指针,供 gift 调用使用
- 槽定位:分配块时从槽 0..15 找第一个空槽, start = 16 * slot_index ,初始 size <= 16
寄存器映射
读寄存器(virtsec_mmio_read):
- 0x0 返回魔数 0x524F4953
- 0x4 返回常量 256
- 0x8 返回设备状态 dev->reg_3024
- 0x10/0x14/0x18/0x1C/0x20/… 返回对应设备字段(命令参数、当前块 ID、大小等)
- 0x28/0x2C 返回 write_counter/read_counter
- 0x30/0x34 返回 reg_3472/reg_3476 (在写路径中用作合并的两个块 ID)
写寄存器(virtsec_mmio_write):
- 0xC 执行命令: virtsec_execute_command(dev, value)
- 0x10 设置 dev->cmd_arg
- 0x14 设置 dev->current_block_id
- 0x18 设置 dev->dev_size
- 0x30/0x34 设置合并的两个块 ID
- 0x38 触发 gift 调用
数据窗口
- 地址区间: 0x1000..0x1FFF ,索引 index = offset - 0x1000
- 读:若 blk->valid && index < blk->size ,最多读 4 字节;若 index + 4 > blk->size ,仅读剩余
- 写:若 blk->valid && index + 4 <= blk->size ,写 4 字节;若跨 16 字节边界( index + 4 > 16 )则拆分写到当前槽与下一个槽起始
漏洞分析与利用
在 virtsec_execute_command 命令 4 合并 block 时复制无总边界检查
1 | size = block->size + blk->size; |
当 block 在槽 15( start=240,size=16 )且合并第二块 16 字节时,目标地址为 data_buffer[256] ,超出 256 字节缓冲区,覆盖紧邻的设备结构字段(包括 gift_func 与 gift_str )。这提供了一个稳定的 16 字节越界写原语到函数指针区。
总体策略:先泄露函数指针地址以确定 libc 基址,再利用“合并复制”把 16 字节一次性写到 data_buffer[256..271] ,覆盖 gift_func/gift_str ,最后触发寄存器 0x38 完成调用。
地址泄露:
- 分配块 ID 1..16,每个大小 16;依次合并 2..16 到块 1,使块 1 size=256 ,此时 data_buffer[256] 紧邻函数指针区
- 再分配块 17(16 字节),合并到块 1,使块 1 size=272
- 读取 index=256 与 index=264 可获得 printf 地址与 gift_str 地址
指针改写(必须用合并复制):
- 执行设备重置(命令 6),重建块 1 到 size=256 (合并 2..16)
- 分配一个临时块,写入 16 字节载荷 [system][binsh] (各 8 字节,低位在前)
- 合并到块 1,此时 16 字节复制到 data_buffer[256..271] ,稳定覆盖函数指针区
至于为什么指针改写为什么不能像越界 read 读取 printf 的地址直接改写,是因为 virtsec_mmio_write 的跨槽写实现有缺陷:当 index >= 17 且 index + 4 <= blk->size 时,会进入拆分逻辑,计算 n = 16 - index ,这个值变成负数并被当成无符号长度传给 memcpy ,导致一次极大的拷贝从栈地址读取,进入 glibc 的 AVX 路径(下面 gdb 的 vmovdqu [rsi+0x2000] 等),从而卡死或崩溃。
1 | R8 0xfffffffffffffff0 |
总的来说好像也没什么要操作的,简单题 :)
exp
1 | // musl-gcc exp.c --static -masm=intel -lpthread -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/ -o exp |
这个逃逸成功的界面有种莫名其妙的美 :)
写到最后
这周末的强网杯决赛将会是我的 last dance 了,回想我在 CTF 届的这两年半经历了很多挫折也取得了很多成就(实际上就学了一年多一点点,然后就一直吃老本原地踏步了🤣),从一个眼里看谁都是大佬的萌新到现在这根老油条。我发现我对比赛的热情在不断的流逝且已经所剩无几,我在赛场上时不再有以前那种狂热感,真想马上退役。感觉还是有很多遗憾,可是有遗憾的结局才是美好的 :)
这次比赛也有挺多收获,比如认识了好几个新的师傅并加上了微信或者 QQ 的好友 :)