跳转至

虛擬機分析

有關虛擬機分析部分, 我們以一道簡單的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