好久沒有到博客寫文章了,9月份開學有點忙,參加了一個上海的一個CHINA SIG信息比賽,前幾天又無錫南京來回跑了幾趟,簽了阿里巴巴的安全工程師,准備11月以后過去實習,這之前就好好待在學校學習了。
這段時間斷斷續續把《加密與解碼 第三版》給看完了,雖然對逆向還是一知半解,不過對VMP虛擬機加殼這塊有了一點新的認識。這里分享一些筆記和想法,沒有新的東西,都是書上還KSSD里看來的,權當筆記和分享,大神勿噴。
准備分成3部分講一下
1. VM虛擬機簡介
2. VM虛擬指令和x86匯編的映射原理
3. 用VM虛擬指令構造一段小程序
1. VM虛擬機簡介
虛擬機保護技術就是將基於x86匯編系統的可執行代碼轉換為字節碼指令系統的代碼,以達到保護原有指令不被輕易逆向和修改的目的,這種指令也可以叫偽指令,和VB的pcode有點類似。
從本質上講,虛擬指令系統就是對原本的x86匯編指令系統進行一次封裝,將原本的匯編指令轉換為另一種表現形式。
例如:
push uType
push lpCaption
push lpText
push hWnd,
call MessageBox
這是一段x86的匯編指令,編譯器在翻譯的時候會產生一個固定模式的匯編代碼(在同一個CPU指令集下)。
但如果我們對原本的C代碼使用VMP SDK進行虛擬化,那么在編譯這段代碼的時候就會使用等效的VM虛擬指令來達到同樣的功能。
vPushMem uType
vPushMem lpCaption
vPushMem lpText
vPushMem hWnd,
vCall vCode
注意,虛擬指令的也有自己的機器碼,但和原本的x86匯編機器碼完全不一樣,而且常常是一堆無意義的代碼,它們只能由VM虛擬解釋器(Dispatcher)來解釋並執行(關於虛擬解釋器接下來會詳細解釋),
所以,我們在用OD等工具進行反匯編分析的時候,看到的就是一大堆的無意義的代碼,甚至還有大量的junk code,jmp code等,導致我們無法從反編譯層面分析出原本的代碼流向,自然也就無法輕易的進行
算法逆向分析了。
我們在逆向虛擬機加殼后的程序中看到的匯編代碼其實不是x86匯編代碼,而是字節碼(偽指令)。它是由指令執行系統定義的一套指令和數據組成的一串數據流。
java的JVM、.NET或其他動態語言的虛擬機都是靠解釋字節碼來執行的,但它們的字節碼(中間語言IL)之間不通用,因為每個系統設計的字節碼都是為自己使用的,並不兼容其他的系統。
所以虛擬機的脫殼很難寫出一個通用的脫殼機,原則上只要虛擬指令集一變動,那原本的偽指令的解釋就會發生變化。
我個人理解要逆向被VM SDK保護起來的原始代碼,只有手工分析這段虛擬指令,找到虛擬指令和原始匯編的對應關系,然后重寫出原始程序的代碼,完成算法的逆向和分析。
這張圖是一個虛擬機執行時的整圖概述,VStartVM部分初始化虛擬機,VMDispatcher負責調度這些Handler,Handler可以理解為一個個的子函數(功能代碼),它是每一個偽指令
對應的執行功能代碼,為什么要出現一條偽指令對應着一個Handler執行模塊呢?這和虛擬機加殼的指令膨脹有關,被虛擬機加殼后,同樣一條指令被翻譯成了虛擬偽指令,
一條虛擬偽指令往往對應着好幾倍的等效代碼,當中可能還加入了花指令,整個Handler加起來可能就等效為原本的一條x86匯編指令。
Bytecode就是虛擬偽指令,在程序中,VMDispatcher往往是一個類while結構,不斷的循環讀取偽指令,然后執行。
Virtual Machine Loop Start
...
...
...
--> Decode VM Instruction's
...
--> Execute the Decoded VM Instruction's
...
...
--> Check Special Conditions
...
...
...
Virtual Machine Loop End
在理解Handler(VM虛擬指令和x86匯編的映射)之前,有必要先理解一下VM的啟動框架和調用約定,每種代碼執行模式都需要有啟動框架和調用約定。
在C語言中,在進入main函數之前,會有一些C庫添加的啟動函數,它們負責對棧區,變量進行初始化,在main函數執行完畢之后再收回控制權,這就叫做啟動框架。而
C CALL,StdCall等約定就是調用約定,它們規定了傳參方式和堆棧平衡方式。
同樣,對與VM虛擬機,我們也需要有一種啟動框架和調用約定,來保證虛擬指令的正確執行以及虛擬和現實代碼之間的切換。
1. 調度器VStartVM
VStartVM過程將真實環境壓入后有一個VMDispatcher標簽,當handler執行完畢之后會跳回到這里形成了一個循環,所以VStartVM過程也可以叫做Dispatcher(調度器)
VStartVM首先將所有的寄存器的符號壓入堆棧,然后esi指向字節碼的起始地址,ebp指向真實的堆棧,edi指向VMContext,esp再減去40h(這個值可以變化)就是VM用的堆棧地址了。
換句話說,這里將VM的環境結構和堆棧都放在了當前堆棧之下200h處的位置上了。
因為堆棧是變化的,在執行完跟堆棧有關的指令時總應該檢查一下真實的堆棧是否已經接近自己存放的數據了,如果是,那么再將自己的結構往更地下移動。
然后從 movzx eax, byte ptr [esi]這句開始,讀字節碼,然后在jump表中尋找相應的handler,並跳轉過去繼續執行。
VStartVM
push eax
push ebx
push ecx
push edx
push esi
push edi
push ebp
pushfd
mov esi, [esp+0x20] ;字節碼開始的地址(寄存器占了32byte,從32byte開始就是剛才push的字節碼的位置)
mov ebp, esp ;ebp就是堆棧了
sub esp, 0x200
mov edi, esp ;edi就是VMContext
sub esp, 0x40 ;這時的esp就是VM用的堆棧了
VMDispatcher
movzx eax, byte ptr [esi] ;獲得bytecode
lea esi, [esi+1] ;跳過這個字節
jmp dword ptr [eax*4 + JUMPADDR] ;跳到handler執行處
調用方法
push 指向字節碼的起始地址
jmp VStartVM
這里有幾個約定
edi = VMContext
esi = 當前字節碼地址
ebp = 真實堆棧
在整個虛擬機代碼執行過程中,必須要遵守一個事實。
1. 不能將edi,esi,ebp寄存器另做他用
2. edi指向的VMContext存放在棧中而沒有存放在其他固定地址或者申請的堆空間中,是因為考慮到多線程程序的兼容
2. 虛擬環境 VMContext
VMContext即虛擬環境結構,存放了一些需要用到的值
struct VMContext
{
DWORD v_eax;
DWORD v_ebx;
DWORD v_ecx;
DWORD v_edx;
DWORD v_esi;
DWORD v_edi;
DWORD v_ebp;
DWORD v_efl; 符號寄存器
}
3. 平衡堆棧 VBegin和VCheckEsp
VMStartVM將所有的寄存器都壓入了堆棧,所以,首先應該使堆棧平衡才能開始執行真正的代碼(這是書上寫的)。我的理解是這里要先將現實的代碼中的寄存器復制切換到虛擬代碼中
Vebing:
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi+0x1C], eax ;edi指向VMContext
add esp, 4
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi+0x18], eax ;v_ebp
add esp, 4
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi+0x14], eax ;v_edi
add esp, 4
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi+0x10], eax ;v_esi
add esp, 4
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi+0x0C], eax ;v_edx
add esp, 4
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi+0x08], eax ;v_ecx
add esp,
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi+0x04], eax ;v_ebx
add esp, 4
mov eax, dword ptr [ebp] ;ebp指向真實的堆棧
mov [edi], eax ;v_eax
add esp, 4
add esp, 4 ;釋放參數
jmp VMDispatcher
執行這個"Handler"之后,堆棧就平衡了,就可以開始"繼續"執行真正的偽代碼了。
還有一個問題,因為將VMContext結構存放在當前使用的堆棧(EBP)更低地址的區域(初始時VMContext距離棧頂EBP有0x200的空間),當堆棧被push壓入數據時,
總會在某條指令之后改寫VMContext的內容,為了避免這種情況,就需要對VMContext進行動態移位。
VCheckEsp:
lea eax, dword ptr [edi+0x100] ;edi指向VMContext
cmp eax, ebp ;小於則繼續正常運行
jl VMDispatcher
mov edx, edi ;否則,則進行移位
mov ecx, esp
sub ecx, edx ;計算一下需要搬移的字節空間,作為循環復制的次數
push esi ;保存ip指針
mov esi, esp
sub esp, 0x60
mov edi, esp
push edi ;保存新的edi VMContext基址
sub esp, 0x40
cld
rep movsb ;復制
pop edi
pop esi
jmp VMDispatcher
一些設計到堆棧的Handler在執行后跳到VCheckEsp判斷esp是否接近VMContext所在的位置,如果是就將VMContext結構復制到更低的地方去
2. VM虛擬指令和x86匯編的映射原理
這是VM虛擬機的核心部分,即把每條x86指令或每一類x86匯編指令轉換為等效的偽指令Handler,可以說,不同的虛擬機都有自己的一套為指令集,不同的為指令集對原始的
x86匯編指令的翻譯都是不同的
handler分兩大類:
1. 輔助handler,指一些更重要的、更基本的指令,如堆棧指令
2. 普通handler,指用來執行普通的x86指令的指令,如運算指令
1. 輔助handler
輔助handler除了VBegin這些維護虛擬機不會導致崩潰的handler之外,就是專門用來處理堆棧的handler了。
vPushReg32:
mov eax, dword ptr [esi] ;從字節碼中得到VMContext中的寄存器偏移
add esi, 4
mov eax, dword ptr [edi+eax] ;得到寄存器的值
push eax ;壓入寄存器
jmp VMDispatcher
vPushImm32:
mov eax, dword ptr [esi]
add esi, 4
push eax
jmp VMDispatcher
vPushMem32:
mov eax, 0
mov ecx, 0
mov eax. dword ptr [esp] ;第一個寄存器偏移
test eax, eax
cmovge edx, dword ptr [edi+eax] ;如果不是負數則賦值
mov eax, dword ptr [esp+4] ;第二個寄存器偏移
test eax, eax
cmovge ecx, dword ptr [edi+eax] ;如果不是負數則賦值
imul ecx, dword ptr [esp+8] ;第二個寄存器的乘積
add ecx, dword ptr [esp+0x0C] ;第三個為內存地址常量
add edx, ecx
add esp, 0x10 ;釋放參數
push edx ;插入參數
jmp VMDispatcher
vPopReg32:
mov eax, dword ptr [esi] ;得到reg偏移
add esi, 4
pop dword ptr [edi+eax] ;彈回寄存器
jmp VMDispatcher
vFree:
add esp, 4
jmp VMDispatcher
2. 普通Handler
add指令的形式通常有
add reg,imm
add reg,reg
add reg,mem
add mem,reg
等寫法....
如果將操作數都先交給堆棧handler處理,那么執行到vadd handler時,已經是一個立即數存在堆棧中了,vadd handler不必去管它從哪里來,只需要用這個立即數做加法操作即可。
也就是說,將輔助指令和普通指令配合起來來一起完成x86指令到偽指令的轉換
vadd:
mov eax, [esp+4] ;取源操作數
mov ebx, [esp] ;取目的操作數
add ebx, eax
add esp, 8 ;平衡堆棧
push ebx ;壓入堆棧
下面的指令轉換為偽代碼:
add esi, eax
轉換為
vPushReg32 eax_index ;eax在VMContext下的偏移
vPushReg32 esi_index
vadd
vPopReg32 esi_index
add esi, 1234
轉換為
vPushImm32 1234
vPushReg32 esi_index
vadd
vPopReg32 esi_index
add esi, dword ptr [401000]
轉換為
vPushImm32 401000
vPushImm32 -1 ;scale
vPushImm32 -1 ;reg_index
vPushImm32 -1 ;reg2_index
vPushMem32 ;壓入內存地址的值
vPushReg32 esi_index
vadd
vPopReg32 esi_index
這就是add指令的多種實現,我們可以發現無論是哪一種形式,都可以使用vadd來執行,只是使用了不同的堆棧handler,這就是Stack_Based虛擬機的方便之處。
標志位的問題:
在相關的handler執行前恢復標志位,執行后保存標志位。以stc命令來說,stc指令是將標志的CF位置為1
VStc:
push [edi+0x1C]
popfd
stc
pushfd
pop [edi+0x1C]
jmp VMDispatcher
這樣操作之后就能保證代碼中的標志不會被虛擬機引擎所執行的代碼所改變
轉移指令:
轉移指令有條件轉移、無條件轉移、call和retn
實現時可以將esi指向當前字節碼的地址,esi指針就好比真實CPU中的eip寄存器,可以通過改寫esi寄存器的值來更改流程。無條件跳轉jmp的handler比較簡單
vJmp:
mov esi, dword ptr [esp] ;[esp]指向要跳轉到的地址
add esp, 4
jmp VMDispatcher
條件轉移jcc指令稍微有一點麻煩,因為它要通過測試標志位來判斷視為需要更改流程
基本上所有條件跳轉指令都有相應的CMOV指令來匹配。
vJne:
cmovne esi, [esp]
add esp, 4
jmp VMDispatcher
vJa:
cmova esi, [esp]
add esp, 4
jmp VMDispatcher
vJae:
cmovae esi, [esp]
add esp, 4
jmp VMDispatcher
vJb:
cmovb esi, [esp]
add esp, 4
jmp VMDispatcher
vJbe:
cmovbe esi, [esp]
add esp, 4
jmp VMDispatcher
je:
cmove esi, [esp]
add esp, 4
jmp VMDispatcher
jg:
cmovg esi, [esp]
add esp, 4
jmp VMDispatcher
call指令:
call和retn指令雖然也是轉移指令,但是它們的功能不太一樣。
首先,虛擬機設計為只在一個堆棧層次上運行
mov eax, 1234
push 1234
call anotherfunc
theNext:
add esp, 4
其中第1、2、4條指令都是在當前堆棧層次上執行的,而call anotherfunc是調用子函數,會將控制權移交給另外的代碼,這些代碼是不受虛擬機控制的。所以碰到call指令,必須退出虛擬機,讓子函數在真實CPU中執行完畢后再交回給虛擬機執行下一條指令。
vcall:
push theNext
jmp anotherfunc
如果想在推出虛擬機后讓anotherfunc這個函數返回后再次拿回控制權,可以更改返回地址,來達到繼續接管代碼的操作。在一個地址上寫上這樣的代碼:
theNextVM:
push theNextByteCode
jmp VStartVM
這是一個重新進入虛擬機的代碼,theNextByteCode代表了theNext之后的代碼字節碼。只需將theNext的地址改為theNextVM的地址,即可完美地模擬call指令了。當虛擬機外部的代碼執行完畢后ret回來的時候就會執行theNextVM的代碼,從而使虛擬機繼續接管控制權。
vcall:
push all vreg ;所有虛擬寄存器
poo all reg ;彈出到真實寄存器中
push 返回地址 ;
push 要調用的函數的地址
retn
ret指令:
retn指令和其他普通指令不太一樣,retn在這里被虛擬機認為是一個推出函數。retn有兩種寫法:一種是不帶操作數的,另一種是帶操作數的。
retn
retn 4
第一種retn形式先得到當前esp中存放的返回地址,然后再釋放返回地址的堆棧並跳轉到返回地址
第二種在釋放返回地址的堆棧時再釋放操作數的空間
vRetn:
xor eax, eax
mov ax, word ptr [esi] ;retn的操作數是word型的,所以最大只有0xFFFF
add esi, 2
mov ebx, dword ptr [ebp] ;得到要返回的地址
add esp, 4 ;釋放空間
add esp, eax ;如果有操作數,同樣釋放
push ebx ;壓入返回地址
push ebp ;壓入堆棧指針
push [edi+0x1C]
push [edi+0x18]
push [edi+0x14]
push [edi+0x10]
push [edi+0x0C]
push [edi+0x08]
push [edi+0x04]
push [edi]
pop eax
pop ebx
pop ecx
pop edx
pop esi
pop edi
pop ebp
popfd
pop esp ;還原堆棧指針到esp中,而VM_CONTEXT也算是自動銷毀了
retn
不可識別指令:
在這里任何不能識別的指令都可將其划分為不可模擬指令,碰到這類指令時,只能與vcall使用相同的方法,即先退出虛擬機,執行這個指令,
然后再壓入下一字節碼(虛擬指令)的地址,重新進入虛擬機。
3. 用VM虛擬指令構造一段小程序
上面闡述了一些關於虛擬機運行和虛擬指令的一些原理和理解,接下來從實踐的調度來實現一個最簡單的基於虛擬指令的小程序。
即我們的執行代碼是我們自定義的一些偽指令,調度器在執行的時候不斷的從偽指令字節碼中取指令,然后到Handler地址表中尋址對應的執行體Handler,進行執行,執行完畢后
再跳回到調度器回收控制權,依次循環,知道執行完所有的虛擬指令。
------------------------------
code:
#include "stdafx.h" #include "windows.h" /* 下面是虛擬指令 我們只模擬了2條指令 */ //push 0x12345678 push一個4字節的數 #define vPushData 0x10 //call 0x12345678 call一個4字節的地址 #define vCall 0x12 //結束符 #define vEnd 0xff //一個字符串 char *str = "Hello World"; /* 這是我們構造的虛擬指令, 數據還不 在mian里面我們進行了修改 push 0 push offset str push offset str ;把字符串的地址入棧 push 0 call MessageBoxA ; */ BYTE bVmData[] = { vPushData, 0x00, 0x00, 0x00,0x00, vPushData, 0x00, 0x00, 0x00,0x00, vPushData, 0x00, 0x00, 0x00, 0x00, vPushData, 0x00, 0x00, 0x00,0x00, vCall, 0x00, 0x00, 0x00, 0x00, vEnd}; //這就是簡單的虛擬引擎了 _declspec(naked) void VM(PVOID pvmData) { __asm { //取vCode地址放入ecx mov ecx, dword ptr ss:[esp+4] __vstart: //取第一個字節到al中 mov al, byte ptr ds:[ecx] cmp al, vPushData je __vPushData cmp al, vCall je __vCall cmp al, vEnd je __vEnd int 3 __vPushData: inc ecx mov edx, dword ptr ds:[ecx] push edx add ecx, 4 jmp __vstart __vCall: inc ecx mov edx, dword ptr ds:[ecx] call edx add ecx, 4 jmp __vstart __vEnd: ret } } int main(int argc, char* argv[]) { //修改虛擬指令的數據
*(DWORD *)(bVmData+5 + 1) = (DWORD)str; *(DWORD *)(bVmData+10 + 1) = (DWORD)str; *(DWORD *)(bVmData+20 + 1) = (DWORD)MessageBoxA; //執行虛擬指令 VM(bVmData); return 0; }
這個程序中的__vstart就相當於前面說的VMDispatcher,對偽指令進行識別判斷,采取調度策略使程序流跳轉到相應的Handler里去。
每個Handler在執行完之后都有一句相同的代碼:jmp __vstart 用於回收控制權到VMDispatcher中。以便下一次調度。
最后,當所有的偽指令都執行完之后,程序識別到vEnd,就ret退出程序。
基本上,這個虛擬機的原理就是這樣了。
4. 總結
這里有一點關鍵點其實沒有弄清楚,就是x86匯編指令到虛擬機偽指令的轉換問題,上面的小程序我們是直接自定義了一套偽指令和映射規則,實際情況中如VMProtect加殼軟件要
解決的是根據原本的匯編指令動態的轉換為偽指令再回寫到原程序的二進制文件中。
還有一個問題沒搞明白的就是,如果要對虛擬機的程序進行逆向分析或脫殼。我的理解就只能是想辦法找到目標程序偽指令和x86匯編之間的映射關系,然后手工分析這段代碼
(因為考慮到效率問題,一般的程序都是只對一些核心算法進行了虛擬化),將這段代碼重寫出來,以達到逆向分析或脫殼的目的。
不知道理解的對不對,逆向這塊感覺算法分析和虛擬機是個難點,以后可以針對這個問題進行一些更深入的研究