本题为KCTF 2025第八题, 做这题的时候上网查找VMPWN相关资料发现基本都是一些把vm布置在栈上或明显的越界漏洞, 本题的漏洞点很少而且做完之后确实学到了新东西, 故作此记录
初步分析
程序实现了一个虚拟机, 有自己的栈和内存区, 虚拟机的内存段被固定映射到0x200000, 代码区被随机映射:
1 | vm *do_malloc() |
相关结构体:
1 | struct vm |
写个脚本导出函数让AI分析各个操作码对应的操作(我到底什么时候能用上MCP啊):
1 | import ida_funcs, ida_hexrays |
Opcode | 原函数 | 作用 | 建议命名 |
---|---|---|---|
0x00 | sub_1314 | 从寄存器 chunk4[rN] 压栈 | op_push_reg |
0x01 | sub_13AA | 压常数到栈 | op_push_imm |
0x02 | sub_141B | 压常数地址解引用的值到栈 | op_push_mem |
0x03 | sub_1494 | 栈顶弹出 → 存到寄存器 rN | op_pop_reg |
0x04 | sub_1532 | r[dst] = r[src](寄存器间拷贝) | op_mov_reg_reg |
0x05 | sub_15AC | r[dst] = imm(立即数写寄存器) | op_mov_reg_imm |
0x06 | sub_1600 | r[dst] = r[dst] + r[src](带溢出检查) | op_add_reg_reg |
0x07 | sub_1787 | r[dst] += imm | op_add_reg_imm |
0x08 | sub_1864 | r[dst] = r[dst] - r[src] | op_sub_reg_reg |
0x09 | sub_19EB | r[dst] -= imm | op_sub_reg_imm |
0x0A | sub_1AC8 | r[dst] *= r[src] | op_mul_reg_reg |
0x0B | sub_1C53 | r[dst] *= imm | op_mul_reg_imm |
0x0C | sub_1D34 | r[dst] /= r[src] | op_div_reg_reg |
0x0D | sub_1EC0 | r[dst] /= imm | op_div_reg_imm |
0x0E | sub_1FA2 | r[dst] &= r[src] | op_and_reg_reg |
0x0F | sub_2129 | r[dst] &= imm | op_and_reg_imm |
0x10 | sub_2206 | r[dst] |= r[src] | op_or_reg_reg |
0x11 | sub_238D | r[dst] |= imm | op_or_reg_imm |
0x12 | sub_246A | r[dst] ^= r[src] | op_xor_reg_reg |
0x13 | sub_25F1 | r[dst] ^= imm | op_xor_reg_imm |
0x14 | sub_26CE | r[dst] = *r[src](寄存器作为指针解引用 load) | op_load_mem |
0x15 | sub_2782 | *r[addr] = r[value](store) | op_store_mem |
0x16 | sub_2831 | if (r[a] > r[b]) pc += imm(条件跳转) | op_jmp_gt |
0x17 | sub_28ED | if (r[a] < r[b]) pc += imm | op_jmp_lt |
0x18 | sub_29A9 | if (r[a] == r[b]) pc += imm | op_jmp_eq |
0x19 | - | 结束程序 | op_halt |
同时拷打AI写出汇编工具:
1 | # asmbuilder.py |
程序中所有涉及到数组的操作都对下标进行了检验, 有关下标唯一的漏洞我只在push_imm
里找到一个, 在栈指针自增后是先进行赋值再检验下标的, 可以越界访问1个qword, 但是我不知道有什么用.
另外程序还对地址值有检验, 要通过vm来执行类似mov [r0], r1
的操作时会对r0
检验其最高位是否置1, 这是vm对有效地址打的tag, 同时通过运算得出的地址(通过是否带tag来判断)会检测其是否在vm的栈和内存段的合法区域, 不在的话会强制转化为内存段的初始位置.
程序漏洞
而在限制运算得到地址这一点上程序对寄存器之间的运算和寄存器与立即数之间的运算是不同的, 以乘法为例:
1 | unsigned __int64 __fastcall mul_reg_reg(__int64 a1) |
寄存器之间的运算只会在运算前寄存器本身就是带tag的地址值才会进行检测, 而和立即数的运算会在运算完后检测算出的结果是否为地址值并检测合法性.
另外一点就出在check_addr
上:
1 | unsigned __int64 __fastcall check_addr(__int64 a1) |
本来只需要检验地址是否落在固定映射的mem
上就好了, stack
本身是随机映射的, 用户不应该能得到其地址值.
获得vm栈段被映射到的地址
综合以上两点可以通过爆破的方式得到stack
被映射到的地址, 伪代码如下:
1 | mov r1, start |
调试可以发现vm栈段被映射到的地址低12位是320h:
最高1字节就选定为0x55, 只需要爆破中间56位即可, bytecode如下:
1 | from pwn import * |
上面说过寄存器和立即数的运算会导致检测地址, 所以这里存起来的实际上是(TAG | addr) >> 1
, 在要算出地址时与存放了2
的r4
做乘法即可得到带tag地址且不会被检测, 这一步在本机上的爆破时间不超过5秒, 但是靶机很可能程序给的100秒alarm都不够用, 要多尝试几次, 至此我们获得了任意已知地址读写的能力.
泄露libc地址
在得到了一个堆地址后就好办很多了, 注意到开头push了两次吗? 现在只需要将堆上vm代码段的地址放入vm的栈基址中, 再进行一次pop操作就能将一个mmap出的地址送入寄存器中, 而mmap出的地址与ld间有固定偏移:
而ld中存放了大量libc中的地址:
这里就选择这个_dl_catch_exception
, 它与mem段的偏移为0x3018, 这一步的bytecode:
1 | bc += mov_reg_reg(1, 0) |
泄露原生栈地址
程序开启了got表保护, 不用思考改got来getshell了, 我的想法是通过libc中的environ
泄露栈地址来修改函数返回地址写入ROP链, 这一步的bytecode:
1 | bc += mov_reg_reg(2, 1) |
写入ROP
接下来就没什么好说的了, 写入返回到system(‘/bin/sh’)的ROP链, 这里直接返回上去栈会不对齐, 要多放一个ret
完整exp
1 | from pwn import * |
总结
做出这题的关键在于意识到扫描整个堆地址空间的可能性, 因为粗略计算一下需要爆破的56位大概是亿级水平, 以往的需要爆破的pwn都是本机发指令爆破, 而这里我们需要自己构造一个虚拟机来在远端直接爆破, 实际上自己写一个C程序验证一下, 循环一亿次的时间其实很短, 爆破的可能性是完全存在的.