通過編譯優化進行VMP代碼還原
0x00 寫在前面
VMProtect 虛擬機保護軟體是業界公認的高強度軟體保護工具。VMP除了具有常規的IAT保護、資源保護、反調試、完整性校驗、運行時殼等保護手段以為,其最為人痛恨的是虛擬化保護。通過將原始的二進位彙編代碼轉化成語義等價的虛擬機位元組碼(也常稱為PCODE,偽代碼),並使用自定義虛擬機(或稱位元組碼解釋器)對位元組碼進行解釋執行。想恢復原始的代碼,必須分析虛擬機本身,大大提高了逆向分析的難度。
據筆者目前查到的資料,尚無公開的工具或方法可以進行VMP虛擬機位元組碼到原始二進位代碼的還原。
個人覺得主要原因在於VMP虛擬機的RISC棧機體系結構與x86的CISC體系結構差異巨大。
如x86的一條指令如mov eax, [ebp+0x100],轉化為VMP偽代碼會變成類似如下的代碼:
push ebppush 0x100addpop eflpop tmppush tmppop eax
(實際上ebp, efl, tmp, eax都會對應VMP的虛擬寄存器,這裡為了方便表達暫時這麼寫)
一條CISC指令轉化成RISC棧機指令時會發生巨大的指令膨脹。想將膨脹後的偽代碼還原為一條CISC彙編指令是很困難的。
1. 如果單純依靠模式規則的匹配,這麼必須收集非常多的膨脹規則,需要付出巨大的人力代價,開發難度高,卻不一定有好的效果。
2. 由於VMP的寄存器輪轉問題,確定指令的真實寄存器是很困難的。(2009年中國軟體安全峰值 Bughoho的PPT 《VMProtect的逆向分析與靜態還原》中詳細描述了寄存器輪轉並提出了解決方案,但這種方案比較複雜,開發難度比較高。)
3. VMP版本更新,膨脹規則可能會稍有變化,工具通用性會非常差,必須持續跟蹤不同版本。
對於CISC指令集虛擬機的還原難度會有一定的降低(但也很難),已有的工作可以參考DeathWay的Oreans UnVirtualizer OD插件(http://bbs.pediy.com/thread-192434.htm),對早期ThemidaCode Virtualizer的CISC機可以做到很好的代碼還原,還原出的指令基本和原始指令一致,非常優秀的工作!
對於VMP則還沒有見到有公開的可用的,可以直接還原成原始代碼的工具開放出來。但也有很多前輩的研究成果。部分如下:
FKVMP:nooby&fengyue大佬開發的插件,應該耳熟能詳。nooby是很早研究VMP插件的前輩,FKVMP可以進行handler的識別得到偽指令序列,早期神器。
VMP分析插件v1.4:zdhysd大佬開發的(http://bbs.pediy.com/thread-154621.htm )毫不過分的說這裡目前市面的可以見到的最好用的VMP分析插件,沒有之一。可以自行添加handler識別規則和化簡規則。除了基礎的識別handler之外,最酷的功能是表達式化簡功能,可以將VMP偽代碼轉化成可讀性更好的表達式,並進行了數據流分析進行表達式化簡,內置近100條化簡規則,化簡後的表達式可以看到和原始代碼相當接近的語義。非常強大!小問題是由於不是常見的彙編代碼或C代碼,閱讀起來仍不是特別方便。
ZVM:zvrop 開發的工具,開放了源碼(http://bbs.pediy.com/thread-155215.htm)根據文檔說明該工具是可以直接將早期版本的VMP還原為彙編指令的,工作覆蓋從handler認識到偽代碼收縮轉化,非常全面。系統非常複雜,6萬行C++實現。不過我還沒有編譯運行過,不確定效果如何(有興趣的同學可以試試,回帖說明,多謝)
其他還有Zeus、VMSweeper、OoWoodOne插件等等不多介紹。
國外的一些成果包括:
VMAttack: IDA VM分析插件, 2016 IDA插件第2名(https://github.com/anatolikalysch/VMAttack)
VirtualDeobfuscator:某年blackhat上的議題 (https://github.com/jnraber/VirtualDeobfuscator Blackhat視頻:https://www.youtube.com/watch?v=hoda99l5y_g ) 這個工具是通過化簡trace除去虛擬機的邏輯,然後留下指令的邏輯方便分析。思路很清奇。不過我測試對VMP效果一般。按文章描述對CISC指令集似乎效果會好一些。
0x01 基本思路
鋪墊作完了,這裡才是關鍵部分,介紹一下我想到一個進行代碼還原的思路。
前面已經討論過,將VMP偽代碼直接還原成彙編代碼難度非常大,除了需要人工分析大量的匹配規則,還必須解決寄存器輪轉問題。
這裡我們不考慮完全精準還原,對於逆向分析上,我覺得保證兩點即可:1)邏輯正確,即還原出的代碼與原始代碼語義等價。2)可讀性好、易理解。VMP偽代碼之所以讓人頭疼的原因就在於難以閱讀和理解,單條偽指令語義很簡單,但成百上千條偽指令讓人很難一眼看出其表達的演算法含義,逐條分析會大大消耗分析人員的耐心,必須尋求自動化的方法。
好了,這就確定了我的目標:將VMP偽代碼還原成語義等價易讀的代碼(如彙編或者C代碼)。
同時因為自己代碼能力有限且時間有限,在保證目標的前提下我希望盡量降低開發難度。
VMP分析插件v1.4中生成的表達式其實和我的想法已經十分接近,但由於不是彙編或者C,可讀性還是稍差一點點。另一方面,為了化簡表達式,插件中引入複雜的數據流分析,開發難度很大。
提到化簡,很容易讓人聯想到編譯優化中的化簡。編譯優化的活躍變數分析、控制流分析可以極好的處理常量傳播、死代碼的情況,那麼能否將編譯優化規則加到VMP偽代碼上?
這就需要將VMP偽代碼轉化成常見編譯器(gcc、clang)能處理的形式。首先想到的是LLVM,但開發過程用到LLVM相關的庫,開發和調試都比較困難,後來直接利用C作為中間代碼進行轉化。
為了保證優化順利進行,必須轉化成容易優化的代碼。其中關鍵的規則是,盡量使用局部變數。將VMP虛擬機的虛擬機寄存器變成C語言的局部變數、虛擬棧作為局部變數數組。說到這裡估計會有人糊塗了,舉個例子:
原始C代碼: a = b;
編譯成彙編後可能會變成: mov eax, ebx。
經過VMP之後變成:push R1; pop R0。 (R1是ebx,R0是eax)
用我們的方法變成C語言,偽指令會變成C代碼:
sp -= 4; //分配棧stack[sp] = R1; // push R1R0 = stack[sp]; // pop R0sp += 4; //恢復棧
這段C代碼編譯器在優化(比如gcc O3)過程會將直接化為R0=R1。
驚喜出現了,這和最初始的C代碼a=b豈不是(基本)一樣的么?
這是超級簡化例子,實際C代碼 a = b在編譯的時候會涉及棧變數的讀寫,比如可能變成
mov eax, [ebp + 4]mov [ebp+8], eax。
這個時候變成VMP指令會生成內存讀寫指令。轉化成C代碼之後可以將內存讀寫指令變成指針讀寫,編譯器的別名分析也能優化類似的代碼,細節就不多討論了。
再舉一個VMP特點的例子, 即NOR邏輯,也就是not not and邏輯。VMP中的與、或、非、異或、減法都通過NOR邏輯實現。一條xor eax, ebx指令,會產生大量的VMP偽代碼,通過多次NOR運算,達到XOR的效果。
用C語言的宏表示如下:
#define NOR(a,b) ((~((uint32_t)(a))) & (~((uint32_t)(b))))#define NOT(x) NOR( (x),(x) )#define AND(a,b) NOR( NOT(a), NOT(b))#define OR(a,b) NOR( NOR((a),(b)), NOR((a),(b)) )#define SUB(a,b) NOT( NOT(a) + (b) )#define XOR(a, b) NOR(NOR(a, b), AND(a, b))
這個時候又能展示編譯器的強大優化能力了,如果編譯如下C代碼
uint32_t a,b;scanf("%d %d", &a,&b);printf("%d %d %d %d %d",SUB(a,b), XOR(a,b), NOT(a), OR(a,b), AND(a,b));
使用gcc -O3編譯優化,得到的結果程序拖到IDA中可以得到如下的偽代碼
__isoc99_scanf("%d %d");printf("%d %d %d %d %d", v4 - v5, ~(v4 & v5) & (v4 | v5), ~v4, v4 | v5, v4 & v5);
使用clang -O3編譯優化,效果如下。
__isoc99_scanf("%d %d", &v5, &v4);printf("%d %d %d %d %d", ~(v4 + ~v5), v5 ^ v4, ~v5, v5 | v4, v5 & v4);
Bingo!可以看到複雜的NOR邏輯沒有出現優化後的代碼中, 除了sub和xor有一點小問題以外,not, and, or被化簡處理的非常好。
如果採用傳統的規則匹配來化簡NOR邏輯,則需要加入很多對應的匹配規則。而如果某一天這些規則發生變化,則又需要修改匹配規則。當使用編譯器來化簡,問題就變得簡單了許多。
0x02 實現
基本思路有了,基本實現方法是:
1. 提取VMP保護後代碼中的VMP偽指令
2. 將偽指令轉化成C語言變數操作的語句
3. 轉化後的文件使用gcc或者clang進行編譯
4. 結果文件放到IDA中還原回C代碼。
提取偽代碼這一塊前面的工作已經比較成熟,不再花時間造輪子了。直接使用VMP分析插件v1.4進行提取,偽代碼格式類似如下:
0040751C |. 0C vPushReg4 vR3 DWORD _t17 = EBP0040751D |. 7B vPushVEsp DWORD _t18 = 300040751E |. 9C vPopReg4 vR7 DWORD _t19 = 300040751F |. 24 vPushReg4 vR9 DWORD _t20 = _t1300407520 |. 08 vPushReg4 vR2 DWORD _t21 = ESI00407521 |. 00 vPushReg4 vR0 DWORD _t22 = EDI00407522 |. A6 00 vPushImmSx1 0 DWORD _t23 = 000407524 |. 1C vPushReg4 vR7 DWORD _t24 = 3000407525 |. 3E FC vPushImmSx1 0FC DWORD _t25 = 0FFFFFFFC00407527 |. 39 vAdd4 DWORD _t26 = 2C; DWORD _t27 = AddFlag(_t25, _t24)00407528 |. B8 vPopReg4 vR14 DWORD _t28 = _t2700407529 |. 52 vWriteMemSs4 DWORD _t29 = 00040752A |. A6 FC vPushImmSx1 0FC DWORD _t30 = 0FFFFFFFC0040752C |. 1C vPushReg4 vR7 DWORD _t31 = 300040752D |. 39 vAdd4 DWORD _t32 = 2C; DWORD _t33 = AddFlag(_t31, _t30)0040752E |. 8C vPopReg4 vR3 DWORD _t34 = _t33
中間的vPushReg4 vR3即是插件中定義的偽指令格式,後面是插件生成的表達式。
如前面所述,為了進行優化,將VMP的16個虛擬寄存器作為16個局部變數。虛擬棧作為局部變數數組,vESP作為數組下標指針。將所有指令轉化成C代碼。
如vAdd4轉化成如下代碼
uint32_t op1 = pop4();uint32_t op2 = pop4();uint32_t result = op1 + op2;uint32_t flag = add_flag(op1, op2, result);push4(result);push4(flag);
其中push4和pop4則為向虛擬棧數組中賦值的內聯函數。
將所有虛擬指令進行類似的轉化,即得到轉化後的C文件。使用gcc或者clang進行O3編譯,輸出文件就可以拖到IDA中看還原效果了。
其他具體細節還有很多,這裡不多介紹,有興趣的可以留言聯繫進行深入交流。
0x03 還原效果
完成轉化後將轉化後的C文件使用gcc或者clang進行O3優化,得到輸出文件,即為VMP還原後的代碼。
這裡使用測試程序all_op2.exe,使用一個VMP 1.81 demo版本加虛擬化all_op2.vmp.exe(因為只是測試方法,使用了比較弱的版本)。
使用VMP分析插件v1.4提取偽代碼為all_op2_vmp_1.81.txt。
利用前面的方法轉化成C代碼,再進行使用gcc和clang進行編譯優化,得到all_op2.gcc和all_op2.out.clang文件。效果對比如下(為了顯示方便,手動將幾個默認為int類型的變數修改成了_DWORD*型,不影響邏輯):all_op2.exe在IDA中反編譯結果:
all_op2.gcc在IDA中反編譯的結果:
可以神奇的發現主要代碼邏輯已經被清晰的還原出來了。
要注意到代碼中是包含數組訪問的、同時還有與、或等邏輯運算,都較好的還原了出來。
為了不佔用頁面不貼彙編代碼了,彙編代碼可以自己看附件中的程序。
0x4 方法的不足
這個方法是最近發現並簡單實現的,很多細節都無法照顧,而且方法本身也有很多局限。
1. 最主要的問題是不能處理跳轉。
可以看到前面的示例中是不含條件和循環的。因為VMP處理跳轉和條件跳轉的時候會將這些直接跳轉全部轉化為間接跳轉。以條件跳轉為例,VMP會先將兩個跳轉的地址壓處棧中,再根據比如的flag計算值決定使用哪個地址作為跳轉目標。這一部分很難直接利用編譯器進行優化。 目前還沒有想到太好的解決方法。
當然使用VMP分析插件v1.4中匹配並化簡規則的方法是可以解決問題的,但與本文的方法論不一致。
而在編譯優化角度上,我還沒有想到好的可以將VMP間接跳轉化成直接跳轉的方法。
主要問題在於VMP間接跳轉直接提供了指令地址,跳轉到指令地址上。而轉化成C代碼後,指令地址信息已經丟失了。無法將要跳轉的地址與C代碼中的語句關聯起來。具體來說,對於一條jmp指令如:
彙編上表示如下:
label:.... (代碼)jmp label
VMP表示如下:
0x401000:... (代碼)...0x402000vPush 0x401000vJmp
vJmp會跳轉到棧頂的地址去。即0x401000這個地址。
而C代碼級別沒有地址信息,也就無法確定0x401000個地址究竟是要跳轉到哪裡。
(雖然可以 (*((void*)() 0x401000))()這樣進行跳轉,但沒有任何意義,因為0x401000處並未放著轉換後的代碼。
考慮過用一個跳轉表進行轉化
比如
label1:...(代碼)label2:switch(addr){case 0x401000: goto label1;case 0x402000: goto label2;}
但這種switch結構GCC很難優化。會生成大量未優化的代碼,十分影響可讀性。
2. 減法和異或
減法和異或優化的不好。減法有時候不能直接優化成sub,而是變成 a-b = ~(~a+b)的形式。
最常見分配棧空間的指令,如sub esp, 8,如果優化失敗,接下來的指令都無法優化,會產生大量的可讀性特別差的代碼。
可以看到前面的例子中原始代碼是不含局部變數的,也就是不含sub esp類的指令的。
3. 無法恢復完全相同的彙編、恢復出的代碼不能運行
由於虛擬棧作為了局部變數數組,這種方法編譯生成的彙編碼比較冗長,也難以閱讀,不過不影響IDA的反編譯。
IDA反編譯時似乎也會進行簡單的優化,因此顯示效果還不錯。
受方法本質的限制,是不能恢復出和原始代碼一模一樣的彙編的,也不能運行。但代碼邏輯是基本一致的。
0x5 寫在最後
這裡說一下私心,之所以敢於在方法和工具不成型的時候就提前把方法分享出來。是希望有感興趣的和懂VMP的大牛指點一下。論壇上有很多前輩在這方面研究十分深入,經驗豐富。這個方法算是拍腦袋想到的方法,存在不少問題。希望大牛們對提到的幾個問題有什麼改進的思路,或者發現了其他可能存在的問題,都麻煩留言不吝賜教。(尤其是前面提到間接跳轉問題,讓我十分頭疼。如果大大們有好的想法,請一定留言指教)
或者對VMP、LLVM、編譯原理、數據流分析了解的牛人,覺得此方法有完善成實用工具的可能,願意抽時間與我一起進行工具開發,學習討論,更十分歡迎。
測試程序放在附件里,如果有感興趣的,請務必留言聯繫。
轉化的源碼暫時不放出來了,只有幾百行,如果理解我的思路,自己實現也很簡單。
如果以後做出實用的工具的話肯定會放出來。現在就不獻醜了。
多謝各位。
本文由看雪論壇 穆雷 原創
轉載請註明來自看雪社區
推薦閱讀: