站在巨人的肩膀上学习ctf vm

0x00 前言

本文提到的vm是ctf里的vm,最近vm还是很热门的,最近的虎符,de1ctf,再到网鼎杯都有vm的身影,但是vm的知识在网上挺散的(我只找到了绿盟那篇比较系统),vm的wp也相对比较简单,所以身为小白的我就在这里归纳一下网上大佬们的文章,和我的思考和学习总结,希望可以给各位看官一些帮助。

参考文章:

虚拟机保护逆向入门

虚拟机保护技术浅谈

《加密与解密》第四版 P592 ~P593

0x01 简单结构

《加密与解密》“中说ctf_vm不是VMware,,它类似于 P-CODE ,将一系列指令解释成bytecode 后放到一个解释引擎中执行,从而对软件进行保护。”简单来说,就是先设立一个一个不重复的标志(一个字节就是一个标志),然后每个标志通过分发器(dispatcher),寻找到对应的解释(handler),之后把这些标志通过不同的排列组合,并把组合后的标志存到一个固定的地方(这个大数组就是opcode),而组合后的逻辑与将保护程序逻辑相同,实现替换,从而进行保护。

一下是图解:

说明:在ctf_vm中 分发器(dispatcher) 与解释器 (handler)通常是用switch和case实现的,通常有如下结构

switch(opcode)
{
    case 1: // handler1
        ......
    break;

    case 2: // handler2
        ......
    break;
 
    case 3: // handler3
        ......
    break;
}

为了更好的理解,我们来看一道类似vm思想的题(伪虚拟机)南邮的一道ctf

本文着重看vm保护,其他本文不再赘述。进入vm_protect。

一般分为两个部分,在分发器之前(红框),会先对一些辅助的值进行赋值(这个下文具体讲解),第二部分(紫框)就是具体的分发,和每一个handler,

在这道伪虚拟机

1)handler的含义非常好理解,所以也没有什么必要去理解红框里辅助的值,

2)本道题所保护的flag算法只是最简单的+ - * /,所以没必要通过opcode的顺序自己来还原(15000个也不可能自己还原)

小结:本题并没有逆向虚拟机最重要的东西(翻译handler含义,翻译所保护代码的含义),放在这是为了初步认识ctf_vm的结构。

放上师傅写的脚本(拿idapython跑一下):

adr1 = 0x6010C0
adr2 = 0x601060
flag =[]

for i in range(0,24*4,4):
    print "%x"%Dword(adr2+i)
    flag.append(Dword(adr2+i))

for i in range(14997,-1,-3):
    a = Byte(adr1+i)
    b = Byte(adr1+i+1)
    c = Byte(adr1+i+2)
    if(a == 1):
        flag[b] -= c
        flag[b] = flag[b]&0xff
    if(a == 2):
        flag[b] += c
        flag[b] = flag[b] & 0xff
    if(a == 3):
        flag[b] ^= c
        flag[b] = flag[b] & 0xff
    if(a == 4):
        flag[b] /= c
        flag[b] = flag[b] & 0xff
    if(a == 5):
        flag[b] ^= flag[c]
        flag[b] = flag[b] & 0xff
print(''.join(map(chr,flag)))

flag: nctf{Embr4ce_Vm_j0in_R3}  嘿嘿嘿,很简单吧,我们来进入下一个阶段。

0x02 网鼎_2020

具体的wp可以看这位师傅写的:网鼎杯2020 伪虚拟机逆向 wp

咱们直接看vm_protect的部分:

 v10 = 0;
  v9 = 0;
  v8 = 0;
  v7 = 0;
  v6 = 0;
  while ( 1 )
  {
    result = v10;
    if ( v10 >= size )
      return result;
    switch ( opcode[v10] )
    {
      case 1:
        v4[v7] = v5;
        ++v10;
        ++v7;
        ++v9;
        break;
      case 2:
        v5 = opcode[v10 + 1] + flag[v9];
        v10 += 2;
        break;
      case 3:
        v5 = flag[v9] - LOBYTE(opcode[v10 + 1]);
        v10 += 2;
        break;
      case 4:
        v5 = opcode[v10 + 1] ^ flag[v9];
        v10 += 2;
        break;
      case 5:
        v5 = opcode[v10 + 1] * flag[v9];
        v10 += 2;
        break;
      case 6:
        ++v10;
        break;
      case 7:
        if ( v4[v8] != opcode[v10 + 1] )
        {
          printf("what a shame...");
          exit(0);
        }
        ++v8;
        v10 += 2;
        break;
      case 8:
        flag[v6] = v5;
        ++v10;
        ++v6;
        break;
      case 10:
        read(flag);
        ++v10;
        break;
      case 11:
        v5 = flag[v9] - 1;
        ++v10;
        break;
      case 12:
        v5 = flag[v9] + 1;
        ++v10;
        break;
      default:
        continue;
    }
  }
}

这是道比上一到正经一些的伪虚拟机的题:(为啥说是“伪”,它没有栈,没有调度)

1)博主一般拿到vm的题,通常会大胆猜一下辅助参数(博主喜欢这么叫,hhh)的含义(这题没啥说的,下文具体讲述)

      

先瞄一眼是参数还是数组,再通过每一个每一个case和调试大致推出其中的意思(真正的栈虚拟机会复杂的多)
 

v10 --- opcode的index

v9 v6----- flag 的 index

v8  v7 -----v4 的index

v4 估计是最后比较的数组或是参与算法的数组

2)推测出辅助参数的意义后,我们就可以去分析每一个case的含义,此题,非常明显直接告诉咱了(栈虚拟机可就要肝一会儿了)

3)之后就需要在拿到的一大堆opcode里区分出,哪些是一个handler(case组成),哪些是真实操作的数据,哪些是调度(这道题里貌似没有这个)。这就需要IDA动态调试(https://bbs.pediy.com/thread-247830-1.htm)。

真实的数据:case里没有的 ; 在调试时,dispatcher每次分配时跳过的,或者一堆循环,一大片相同的操作,估计就是对数组的操作,那肯定就有真实数据嘛。

区分每一个handler:这个如果题目没有特别的暗示,就需要动调,找出一些case组合起来的一些结构,会有参考意义。

例如这题:4, 0x10, 3, 5, 1, 这样类似的一套就是一个handler

调度:,多数就是顺着来的,顶多是跳转,也几乎是顺着来的。(也有可能是我菜,没见过(手动狗头))

4)之后确定这些handler,数据,(调度)之后,就要结合动调,分析,整理出所保护的程序(有的题就开始读汇编了)

(此题具体的wp就去看那位师傅写的吧.)

这道题也可以用angr一把梭出来,会在别的博客里写道。

哒哒!现在已经对ctf_vm的基础框架有了一些了解,下面我们来见识真正的栈虚拟机逆向。

IDA动调连接不上问题:

有时候,在ida动调的时候会出现:参数,ip啥的,都对但就是 长时间响应不了连接不上的问题,如下解决:

果断把虚拟机关上然后

等它还原完就可以连接了。

0x03 栈虚拟机

这就是真正ctf里有难度的虚拟机了。整体的架构和上文的题相差不多,但因为有了栈的加入,就会使题目多了“亿”点点细节。

在看具体ctf题之前,我们不妨正向来了解一下虚拟机保护技术https://www.cnblogs.com/findumars/p/5636492.html

虚拟机(CPU)的体系架构可分为3种,基于堆栈的(Stack based),基于寄存器的(Register based)和3地址机器。我们只讲述基于堆栈的虚拟机架构(Stack based);这种架构的虚拟机需要频繁操作堆栈,其使用的虚拟寄存器(虚拟的eax、ebx等等)保存在堆栈中;每个原子指令的handler都需要push、pop。

ctf通常的.exe程序,是基于寄存器,堆栈一般只是在函数传递参数时使用,而在vm保护中用来替代所保护程序的程序,则是基于堆栈的虚拟机架构,所以,在我们还原case,还原handler或者在还原程序逻辑的时候,要始终贯彻用栈操作的思想

既然栈操作,就少不了汇编的一些常用指令:push pop add sub jmp(jcc合适一些)  cmp

我们可以先参考一下上述文章中指令的实现,再接下来的逆向中会大有好处。

Vadd:                            ;virtual add
        Mov eax,[esp+4]          ;取源操作数
        Mov ebx,[esp]            ;取目的操作数
        Add ebx,eax              ;
        Add esp,8                ;把参数从堆栈中删掉,平衡堆栈
        Push ebx                 ;把结果压入堆栈
      而原有的add命令的参数,我们需要翻译为 push 命令 。根据push 的对象不同,需要不同的实现:
vPushReg32:                      ;寄存器入栈 。esi指向字节码的内存地址
      Mov eax,dword ptr[esi]     ;从伪代码(字节码)中获得寄存器在VMcontext结构中的偏移地址
      Add esi,4                  ;VMcontext结构保存了各个寄存器的值。该结构保存在堆栈内。
      Mov eax,dowrd ptr [edi +eax]   ;得到寄存器的值。edi指向VMcontext结构的基址
      Push eax                    ;压入堆栈
      Jmp VMDispatcher            ;任务完成,跳回任务分派点
vPushImm32:                       ;立即数入栈
      Mov eax,dword ptr[esi]      ;字节码,不用翻译就是了
      Add esi,4
      Push eax                    ;立即数入栈
      Jmp VMDispatcher
     有Push指令了,也得有Pop指令:
vPopReg32:
      Mov eax,dword,ptr[esi]     ;从伪代码(字节码)中获得寄存器在VMcontext结构中的偏移地址
      Add esi,4
      Pop dword ptr [edi+eax]    ;弹回寄存器
      Jmp VMDispatcher
Add esi,eax
  转换为虚拟机的指令如下:
     vPushReg32 eax_index
     vPushReg32 esi_index
     Vadd
     vPopReg32 esi_index      ;不弹eax_index,它作为返回结果保存在堆栈里
Vjmp:
     Mov esi,dword ptr [esp]   ;jmp的参数在堆栈中
     Add esp,4                 ;平衡堆栈
     Jmp VMDispatcher
Vcall:
     Push all vreg            ;所有的虚拟的寄存器 (维护在堆栈中)
     Pop  all reg             ;弹出到真实的寄存器中(保存虚拟机的运行结果)
     Push 返回地址            ;可以让Call调用完成后,把控制权归还给虚拟机。详细见原书第480页。
     Push 要调用的函数地址
     Retn

下面我们来看 hgamectf_第四周_happyVM(一点也不happy)

载到ida里:

根据上述的经验,迅速的找到,opcode, vm保护的函数,和辅助参数位置

这里的辅助参数要特别说明一下,栈虚拟机有大量栈的操作,所以辅助参数并不像上一题一样只是简单的index,辅助参数,多数变成了 vm_stack(操作的栈),vm_eip(偏移),vm_esp(栈的esp),vm_input(输入),vm_code(opcode),vm_block(计数器),和一些其他参数(有可能是参数,或者数组),而这些参数通常存放在__bss段(因为bss存放程序中未初始化的或者初始化为0全局变量和静态变量),我们可以从此入手,大致猜一波 ,进入sub_4007B5:

交叉引用一下(按x):都在bss段

结合整个程序综合分析:(这些判断也需要根据下文每一个case里的调试,整理判断出来的)

602070是申请堆,八九不离十就是vm_stack(操作的栈)

602084:通过简单分析,很明显这就是 vm_eip

602078: 就告诉你了 它是 vm_input

result = *(vm_eip + vm_code) = opcode(这个结构非常常见)

602083:

和栈搞位置,试着调试,确定83为vm_esp。

602085:根据调试分析,这是一个寄存器(负责储存cmp留下的结果)

602080 81 82 ,为三个暂时存放的内存位置 vm_var

 

之后我们按顺序来分析每一个case:进入 vm_protect

判断操作数是否为两个(算他是个人,这样判断opcode中的常数就有依据了)

之后分析每一个case

case的含义不再白给,就拿case 0举例;

进入sub_400A4F

可以拿上文正向虚拟机保护技术的代码做参考,综合上文bss段推断出的辅助参数,分析出这是push操作,

所以case 0为   push opcode

同理,你会分析出 push pop call  add jmp je xor,exit, ret等基础操作,再通过调试分析出每个case的含义:(以下的opcode1,2是一个意思)(这是指纹师傅的分析整理)

Case 0: push opcode2

Case 1: push reg80

Case 2: push reg81

Case 3: push reg82

Case 4: pop reg80

Case 5: pop reg81

Case 6: pop reg82

Case 7: add reg80, reg81

Case 8: add reg80, opcode2

Case 9: add reg81, opcode2

Case A: add reg82, opcode2

Case B: sub reg80, reg81

Case C: sub reg80, opcode2

Case D: sub reg81, opcode2

Case E: sub reg82, opcode2

Case F: xor reg80, reg81

Case 0x10: cmp reg80, reg81

Case 0x11: call opcde2

Case 0x12: ret

Case 0x13: jmp opcode2

Case 0x14: je opcode2

Case 0x15: push input[reg82]

Case 0x16: pop input[reg82]

Case 0x17:exit

 

 

 

你会发现,现在已经不是上一题,每个case还是伪c语言,而是伪汇编了。(哎~~,挺秃然的)

好了咱们接着上题的套路走:分析opcode (找常数,找handler, 找调度)

1) 因为这题第一个switch已经告诉你了哪些是两个操作数,所以常数很好找.

2) 找handler && 找调度,每一个case就是一个,但是要注意,此段程序里有exit (0x17),ret(0x12),所以会几句case形成一段,之后jmp到别的段,形成调度,但即使是调度,通常也是按循序排的,比如我这一段jmp到9,之后exit了,9的位置就紧挨着exit。(经验是这样的)。

附上对opcode的分析(依然是指纹师傅的):

start
11 2D
0 22
05
10
14 09
17

addr9
0 32
05
03
11 16
06
00 16
5
11 16
17

fun16
e 1
15
4
f
1
16
2
0 0
4
3
5
10
14 2B
5
9 3
13 16

addr2B:
5
12

fun2D:
15
04
10
14 36
A 1
13 2d

addr36:
03
04
12

 

紧接着翻译程序逻辑(我上文说的读汇编就是这个意思)

start:
call 2D    //2D函数返回输入flag的长度
push 22
pop reg81
cmp reg80 reg81  //判断flag长度是否为0x22
je 9
exit


addr9:
push 32
pop reg81  
push reg82
call 16      //对输入进行第一次异或运算,传入的参数为0x32
pop reg82
push 16
pop reg81
call 16       //对输入进行第二次异或运算,传入的参数为0x16
exit


fun 16:
sub reg82, 1   //reg82最初的值为0x22,然后一直递减
push input[reg82]
pop reg80
xor reg80, reg81
push reg80
pop input[reg82]
push reg81
push 0
pop reg80  
push reg82
pop reg81
cmp reg80, reg81 //当reg82值递减到0的时候,跳出函数
je 2B
pop reg81
add reg81, 3
jmp fun16

addr2B:
pop reg81
ret


fun 2D:   //函数返回输入flag的长度
push input[reg82]
pop reg80
cmp reg80, reg81
je 36
add reg82, 1
jmp 2D

addr 36:
push reg82
pop reg80
ret

逻辑是首先判断输入的长度是否为0x22,然后两次调用异或函数对输入进行处理。(就这么个玩意儿!!!)

最后脚本:

arr = [132, 131, 157, 145, 129, 151, 215, 190, 67, 114, 97, 115, 115, 12, 106, 112, 115, 17, 72, 44, 52, 51, 49, 54, 35, 52, 62, 92, 35, 78, 23, 17, 25, 89]
flag = ""

reg81 = 0x16
for i in range(len(arr))[::-1]:
    arr[i] = arr[i] ^ reg81
    reg81 += 3

reg81 = 0x32
for i in range(len(arr))[::-1]:
    flag += chr(arr[i] ^ reg81)
    reg81 += 3

print flag[::-1]

flag: hgame{3Z_VM_W0NT_5T0P_UR_PR0GR355}

虎符 vm

ok,最近的虎符的vm就是相似的难度:

如果各位看官没玩够,可以再看一下虎符的题,

wp:虎符ctf -- vm虚拟机逆向 (令则写的这篇很仔细了)

 

0x04 总结

 

从入门到入土,

希望我的总结可以帮助到想学习ctf vm的看官,如果有错误也希望路过的大佬斧正。

谢谢指纹师傅的素材。

(本文的几道题目稍后加在附件里)

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章