跳转至

EN | ZH

虚拟机分析

有关虚拟机分析部分, 我们以一道简单的crackme来进行讲解.

对应的crackme可以点击此处下载: FuelVM.exe

对应的keygenme可以点击此处下载: fuelvm_keygen.py

对应的IDA数据库可以点击此处下载: FuelVM.idb

本题作者设计了一个具有多种指令的简单虚拟机. 我们使用IDA来进行分析. 并为了方便讲解, 我对反汇编出的一些变量重新进行了命名.

运行程序

我们运行程序 FuelVM.exe. 界面如下所示

start.png

在这个界面中, 我们看到右两个输入框, 一个用于输入用户名Name, 另一个则用于输入密钥Key. 还有两个按钮, Go用于提交输入, 而Exit则用于退出程序.

获取用户输入

那么我们就可以从这里入手. 程序想获取用户输入, 需要调用的一个API是GetDlgItemTextA()

UINT GetDlgItemTextA(
  HWND  hDlg,
  int   nIDDlgItem,
  LPSTR lpString,
  int   cchMax
);

获取的输入字符串会保存在lpString里. 那么我们就可以打开IDA查找有交叉引用GetDlgItemTextA()的地方.

.text:00401142                 push    0Ch             ; cchMax
.text:00401144                 push    offset inputName ; lpString
.text:00401149                 push    3F8h            ; nIDDlgItem
.text:0040114E                 push    [ebp+hWnd]      ; hDlg
.text:00401151                 call    GetDlgItemTextA
.text:00401156                 push    0Ch             ; cchMax
.text:00401158                 push    offset inputKey ; lpString
.text:0040115D                 push    3F9h            ; nIDDlgItem
.text:00401162                 push    [ebp+hWnd]      ; hDlg
.text:00401165                 call    GetDlgItemTextA
.text:0040116A                 mov     var_a, 0
.text:00401171                 call    process_input
.text:00401176                 jmp     short locExit

如上, IDA只有这里调用过GetDlgItemTextA并且调用了两次分别获取inputNameinputKey. 随后初始化了一个变量为0, 因为还不明白这个变量的作用, 因此先重命名为var_a. 之后进行了一次函数调用并jmp跳转. 因为jmp跳转位置的代码是一些退出程序的代码, 因此我们可以断定上面的这个call, 是在调用处理用户输入的函数. 因此将jmp的位置重命名为locExit, 函数则重命名为process_input.

处理用户输入

我们进入process_input函数, 该函数仅仅对输入字符串进行了很简单的处理.

  result = strlength((int)inputName);
  if ( v1 >= 7 )                                // v1 = length of inputName
  {
    *(_DWORD *)&lenOfName = v1;
    result = strlength((int)inputKey);
    if ( v2 >= 7 )                              // v2 = length of inputKey
    {
      i = 0;
      do
      {
        inputName[i] ^= i;
        ++i;
      }
      while ( i <= *(_DWORD *)&lenOfName );
      unk_4031CE = i;
      dword_4031C8 = dword_4035FF;
      initVM();
      initVM();
      __debugbreak();
      JUMPOUT(*(_DWORD *)&word_4012CE);
    }
  }
  return result;

首先是这个strlength()函数. 函数使用cld; repne scasb; not ecx; dec ecx来计算字符串长度并将结果保存在ecx里. 是汇编基础知识就不多介绍. 所以我们将该函数重命名为strlength

.text:004011C2 arg_0           = dword ptr  8
.text:004011C2
.text:004011C2                 push    ebp
.text:004011C3                 mov     ebp, esp
.text:004011C5                 mov     edi, [ebp+arg_0]
.text:004011C8                 sub     ecx, ecx
.text:004011CA                 sub     al, al
.text:004011CC                 not     ecx
.text:004011CE                 cld
.text:004011CF                 repne scasb
.text:004011D1                 not     ecx
.text:004011D3                 dec     ecx
.text:004011D4                 leave
.text:004011D5                 retn    4
.text:004011D5 strlength       endp

而在IDA生成的伪C代码处有v1v2, 我对其进行了注解, 可以看汇编, 里面是使用ecx7进行比较, 而ecx是字符串的长度, 于是我们可以知道, 这里对输入的要求是: inputName 和 inputKey 的长度均不少于 7

inputNameinputKey长度均不少于7时, 那么就可以对输入进行简单的变换. 以下是一个循环

      i = 0;
      do
      {
        inputName[i] ^= i;
        ++i;
      }
      while ( i <= *(_DWORD *)&lenOfName );

对应的python代码即

def obfuscate(username):
    s = ""
    for i in range(len(username)):
        s += chr(ord(username[i]) ^ i)
    return s

函数之后对一些变量进行了赋值(这些并不重要, 就忽略不讲了.)

注册SEH

.text:004012B5                 push    offset seh_handler
.text:004012BA                 push    large dword ptr fs:0
.text:004012C1                 mov     large fs:0, esp
.text:004012C8                 call    initVM
.text:004012CD                 int     3               ; Trap to Debugger

initVM完成的是一些虚拟机启动前的初始化工作(其实就是对一些寄存器和相关的部分赋初值), 我们之后来讨论. 这里我们关注的是SEH部分. 这里注册了一个SEH句柄, 异常处理函数我重命名为seh_handler, 并之后使用int 3手动触发异常. 而在seh_handler位置, IDA并未正确识别出对应的代码

.text:004012D7 seh_handler     db 64h                  ; DATA XREF: process_input+7Do
.text:004012D8                 dd 58Fh, 0C4830000h, 13066804h, 0FF640040h, 35h, 25896400h
.text:004012D8                 dd 0
.text:004012F4                 dd 1B8h, 0F7C93300h, 0F7C033F1h, 0FFC483E1h, 8F64FDEBh
.text:004012F4                 dd 5, 4C48300h, 40133068h, 35FF6400h, 0
.text:0040131C                 dd 258964h, 33000000h, 33198BC9h, 83E1F7C0h, 0FDEBFFC4h
.text:0040131C                 dd 58F64h, 83000000h, 5E6804C4h, 64004013h, 35FFh, 89640000h
.text:0040131C                 dd 25h, 0C033CC00h, 0C483E1F7h, 83FDEBFFh, 4035FF05h, 0D8B0200h
.text:0040131C                 dd 4035FFh, 3000B1FFh, 58F0040h, 4031C8h, 31C83D80h, 750A0040h
.text:0040131C                 dd 0B1FF4176h, 403000h, 31C8058Fh, 3D800040h, 4031C8h

我们可以点击相应位置按下c键, 将这些数据转换成代码进行识别. (我们需要按下多次c键进行转换), 得到如下代码.

如下, 在seh_handler位置, 又用类似的方法注册了一个位于401306h的异常处理函数, 并通过xor ecx,ecx; div ecx手动触发了一个除0异常. 而在loc_401301位置, 这是一个反调试技巧, jmp loc_401301+2会使得EIP转向一条指令中间, 使得无法继续调试. 所以我们可以将00401301~00401306部分的代码nop掉, 然后在00401306位置创建一个新函数seh_handler2

seh_handler:                            ; DATA XREF: process_input+7Do
.text:004012D7                 pop     large dword ptr fs:0
.text:004012DE                 add     esp, 4
.text:004012E1                 push    401306h
.text:004012E6                 push    large dword ptr fs:0
.text:004012ED                 mov     large fs:0, esp
.text:004012F4                 mov     eax, 1
.text:004012F9                 xor     ecx, ecx
.text:004012FB                 div     ecx
.text:004012FD                 xor     eax, eax
.text:004012FF                 mul     ecx
.text:00401301
.text:00401301 loc_401301:                             ; CODE XREF: .text:00401304j
.text:00401301                 add     esp, 0FFFFFFFFh
.text:00401304                 jmp     short near ptr loc_401301+2
.text:00401306 ; ---------------------------------------------------------------------------
.text:00401306                 pop     large dword ptr fs:0
.text:0040130D                 add     esp, 4
.text:00401310                 push    401330h
.text:00401315                 push    large dword ptr fs:0
.text:0040131C                 mov     large fs:0, esp
.text:00401323                 xor     ecx, ecx
.text:00401325                 mov     ebx, [ecx]
.text:00401327                 xor     eax, eax
.text:00401329                 mul     ecx

类似的, 还有401330h重命名为seh_handler3, 而40135Eh是最后一个注册的异常处理函数, 我们可以推测这才是虚拟机真正的main函数, 因此我们将40135Eh重命名为vm_main. (有关SEH和反调试的部分, 可以推荐大家自己去动态调试一番弄清楚)

恢复堆栈平衡

我们创建了一个vm_main函数(重命名后还需要创建函数, IDA才能识别), 然后按下F5提示失败, 失败的原因则是由于堆栈不平衡导致的. 因此我们可以点击IDA菜单项Options->General在右侧勾选stack pointer. 这样就会显示出对应的栈指针.

.text:004017F2 000                 jmp     vm_main
.text:004017F7     ; ---------------------------------------------------------------------------
.text:004017F7 000                 push    0               ; uType
.text:004017F9 004                 push    offset aError   ; "Error"
.text:004017FE 008                 push    offset Text     ; "The key is wrong."
.text:00401803 00C                 push    0               ; hWnd
.text:00401805 010                 call    MessageBoxA
.text:0040180A
.text:0040180A     locret_40180A:                          ; CODE XREF: vm_main+492j
.text:0040180A 000                 leave
.text:0040180B -04                 leave
.text:0040180C -08                 leave
.text:0040180D -0C                 leave
.text:0040180E -10                 leave
.text:0040180F -14                 leave
.text:00401810 -18                 leave
.text:00401811 -1C                 retn
.text:00401811     vm_main         endp ; sp-analysis failed

我们来到最下显示不平衡的位置. 最上的jmp vm_main表明虚拟机内在执行一个循环. 而MessageBoxA的调用则是显示最后弹出的错误信息. 而在locret_40180A位置处, 经过多次leave堆栈严重不平衡, 因此我们需要手动恢复堆栈平衡.

这里也很简单, 在0040180A位置已经堆栈平衡了(000), 因此我们只需要将这一句leave修改为retn就可以了. 如下这样

.text:0040180A     locret_40180A:                          ; CODE XREF: vm_main+492j
.text:0040180A 000                 retn
.text:0040180B     ; ---------------------------------------------------------------------------
.text:0040180B 004                 leave
.text:0040180C 004                 leave
.text:0040180D 004                 leave

然后你就可以发现vm_main可以F5生成伪C代码了.

虚拟机指令分析

说实话, 虚拟机的分析部分是一个比较枯燥的还原过程, 你需要比对各个小部分的操作来判断这是一个怎样的指令, 使用的是哪些寄存器. 像这个crackme中, vm进行的是一个取指-译码-执行的循环. 译码过程可给予我们的信息最多, 不同的指令都会在这里, 根据它们各自的opcode, 使用if-else if-else分支进行区分. 实际的还原过程并不复杂, 但有可能会因为虚拟机实现的指令数量而显得有些乏味.

最后分析出的结果如下:

opcode value
push 0x0a
pop 0x0b
mov 0x0c
cmp 0x0d
inc 0x0e
dec 0x0f
and 0x1b
or 0x1c
xor 0x1d
check 0xff

我们再来看分析后的initVM函数

int initVM()
{
  int result; // eax@1

  r1 = 0;
  r2 = 0;
  r3 = 0;
  result = (unsigned __int8)inputName[(unsigned __int8)cur_index];
  r4 = (unsigned __int8)inputName[(unsigned __int8)cur_index];
  vm_sp = 0x32;
  vm_pc = 0;
  vm_flags_zf = 0;
  vm_flags_sf = 0;
  ++cur_index;
  return result;
}

这里有4个通用寄存器(r1/r2/r3/r4), 1个sp指针和1个pc指针, 标志zfsf. 先前我们不知道的var_a也被重命名为cur_index, 指向的是inputName当前正在处理的字符索引.

对于VM实现的多个指令我们就不再多说, 重点来看下check部分的操作.

int __fastcall check(int a1)
{
  char v1; // al@1
  int result; // eax@4

  v1 = r1;
  if ( (unsigned __int8)r1 < 0x21u )
    v1 = r1 + 0x21;
  LOBYTE(a1) = cur_index;
  if ( v1 == inputKey[a1] )
  {
    if ( (unsigned __int8)cur_index >= (unsigned __int8)lenOfName )
      result = MessageBoxA(0, aGoodJobNowWrit, Caption, 0);
    else
      result = initVM();
  }
  else
  {
    result = MessageBoxA(0, Text, Caption, 0);
  }
  return result;
}

如果r1中的值跟inputKey[cur_index]相等, 那么会继续判断是否已经检查完了整个inputName, 如果没有出错且比对结束, 那么就会弹出Good job! Now write a keygen.的消息框. 否则会继续initVM进入下一轮循环.(出错了当然是弹出消息框提示错误了. )

cur_index会在initVM中自增1, 那么还记得之前在process_input里有执行2次initVM吗. 因为有执行2次initVM, 所以我们的inputKey的前2位可以是任意字符.

      unk_4031CE = i;
      opcode = vm_pc;
      initVM();
      initVM();
      __debugbreak();
      JUMPOUT(*(_DWORD *)&word_4012CE);

故而我们分析完了整个虚拟机, 便可以开始着手编写Keygen.

对应的keygenme可以点击此处下载: fuelvm_keygen.py

$ python2 fuelvm_keygen.py ctf-wiki
[*] Password for user 'ctf-wiki' is: 4mRC*TKJI

对应的IDA数据库可以点击此处下载: FuelVM.idb


评论