那些年病毒用過的損招——攻擊反彙編

這次的標題真tm的繞口,反反彙編,還是叫攻擊反彙編好聽。攻擊反彙編其實就是在程序中使用一段特殊構造的代碼或者數據,讓反彙編分析工具產生錯誤的程序代碼列表,這種代碼一般都是手寫的,在病毒變異和部署階段使用一個單獨的混淆工具,或者是直接在源碼裡面插入混淆代碼。n

其實病毒設計都是帶有很強的目的性的,比如說記錄你在鍵盤上的敲擊記錄、通過建立後門反向鏈接控制電腦、再或者就是用你的電腦發送大量郵件讓伺服器癱瘓。但是這些病毒開發者也是知道肯定會有病毒分析師去分析他們寫的東西,所以也會衍生出一些對抗性的安全技術,比如說對用戶或者管理員隱藏資深的存在、使用RootKit、進程注入或和其他對抗分析探測的技術。n

病毒開發者會利用今天說的攻擊反彙編技術來延緩分析師對自己作品的分析。需要聲明的一點是:任何可執行的病毒都可以被逆向分析,但是是用了攻擊反彙編或者是之前說的反調試技術的病毒實際上是可以刷掉一大片不合格的分析師的。由於這個世界是一個窮人靠變異的社會,病毒也是經過一段時間之後就會變種,換句話說,如果能在變種前盡量拖延病毒分析師去把病毒的特徵和目的分析出來,對於病毒開發者而言是非常有價值的,換句話說,分析人員如果不能提取病毒的主機與網路特徵,沒法開發出解密演算法,就會導致分析被延誤,只能去找大牛去分析這些病毒。n

除了延緩和防止人工分析之外,攻擊反彙編技術也能一定程度上去對抗自動化分析技術,許多惡意代碼相似性檢測演算法和啟發式病毒引擎都採用反彙編分析技術對惡意代碼進行歸類,利用帶有特殊指令進行標記,比如說Yara這種。n

0x00 什麼是攻擊反彙編技術:n

說這個之前得說明白一件事情,可執行代碼的序列可以有多種反彙編代碼的表達,有些可能無效,目的僅僅是掩蓋代碼的真實意圖。實現攻擊反彙編技術的時候,病毒開發者會寫一段代碼序列去欺騙反彙編器,讓反彙編器去輸出與實際情況不符的指令列表。這個也就是反彙編技術的局限性。n

利用這種局限性,就是攻擊反彙編技術的實現原理。舉個例子,反彙編器在某個時間點智能將程序的每一個位元組作為指令組成部分。如果欺騙反彙編器在一個錯誤的偏移量處開發反彙編,那麼無效的指令就可能在視圖中看不到了。上代碼:n

jmp short near ptr loc_2017+1nloc_2017: ;CODE XERF: seg000:00000000jn call near ptr 15DCA112hn or [ecx], dln inc eaxn;———————————————————————————n db 0n

其實這段代碼就是根據線性反彙編技術去反彙編產生的,但是實際上這個結果返回的是錯誤的。如果你熟悉彙編語言的話,就會發現我們漏掉了病毒開發者試圖隱藏的信息,我們看到call near ptr 15DCA112h這句似乎是一條call指令,但是跳轉的目標有點奇怪。在這一段反彙編代碼裡面,第一條指令是jmp指令,這個跳轉的目標是無效的,因為跳轉地址位於他和下一條指令之間。再來看一個例子。n

jmp short loc_2017n;———————————————————————————n db 0EAhn;——————————————————————————nloc_2017: ;CODE XREF: seg000:0000000n push 2Ahn call Sleepn

這段代碼表示了一個不同的彙編代碼序列,看上去似乎給的是一個正確的信息,最後一行我們看到了一個sleep函數,第一條jmp指令的跳轉目標現在還沒被正確表示,我們看到塔跳轉到sleep函數調用了之前的push指令,第三行是0xEA位元組,但是程序沒有執行它,因為jmp指令跳過了。n

這段代碼其實就是反彙編器生成的,而不是使用前面的線性反彙編器。這個例子裡面,其實面向代碼流的反彙編器其實要比線性反彙編器的結果要更加準確些,因為面向代碼流的反彙編器能從代碼邏輯上反映出真實的程序,並不去反彙編那些不在程序執行流的位元組。n

所以,如果把反彙編想的太簡單了,就是too young too naive了,上面的兩個例子裡面,相同的位元組集合被反彙編出兩個完全不同的指令序列,這也就說明了在給定一段位元組序列前提下,對抗反彙編技術是怎麼工作能讓反彙編器生成錯誤的指令序列。n

0x01 攻擊反彙編演算法:n

其實攻擊反彙編演算法的技術,其實是基於反彙編演算法的漏洞產生的。為了去展示反彙編代碼,反彙編器都會針對情況進行某種特定的假設。一旦假設不成立,病毒開發者就有機會去坑分析人員。n

反彙編演算法有兩種,其實前面提到過了,線性反彙編演算法和面向代碼流的反彙編演算法,其中線性反彙編演算法實現起來是最簡單的,但是也是最容易被利用的。n

(1)線性反彙編演算法:n

線性反彙編演算法的策略其實是去遍歷一個代碼段,一次一條指令的線性反彙編,不會偏離。反彙編器使用的這個基本策略已經被寫入教科書,而且調試器採用這種方法比較多。線性反彙編用已經反彙編的指令大小來決定下一個要反彙編的位元組,從而不考慮代碼流的控制指令,我們可以利用libdisasm實現一個簡單的反彙編器。

char buffer[BUF_SIZE];nint pos = 0;nwhile(pos < BUF_SIZE)nn{n x86_inst_t insn;n int size = x86_disasm(buf, BUF_SIZE, 0, pos, &insn);n if(size != 0)n {n char disasm_line[1024];n x86_format_insn(&insn, disasm_line, 1025, intel_syntax)n printf("%sn", disasm_line);n pos += size;n }n elsen {n pos++;//不能識別的指令n }n}nx86_cleanup();n

在上面的代碼中,我們定一個buffer作為數據緩衝區,裡面用來包含需要反彙編的指令。函數x86_disasm將會用剛剛反彙編過得具體指令填充一個數據結構,然後返回這個指令的大小。如果反彙編的指令是一條合法指令,這個循環會用size值遞增pos變數,否則執行++。n

對於大部分代碼來說,這個演算法沒什麼問題。但是呢,有時候還是會出現一些問題,甚至是在反彙編非二進位文件的時候會出現。這個演算法的缺點是會反彙編太多的代碼,即使控制流只執行程序裡面的很少的一部分代碼,也會很固執的去彙編到程序的末尾。n

其實在PE格式的文件中,可執行代碼通常包含在一個單獨的段中,只對包含代碼的.text段實行線性反彙編演算法的假設雖然合理,但是存在一個很嚴重的問題——絕大多數的二進位的文件的代碼也包含不是指令的數據內容。代碼段中最常見的數據類型是指針值,經常被用在表驅動的開關裡面。下面的一段代碼就描述了一個包含開關指針隨後是函數代碼的函數:n

jmp ds:off_401054[eax*4]; 切換jumpn ;省略的 switch casesn xor eax, eaxn pop esin retnn;————————————————————————————noff_401054 dd offset loc_401020 ;DATA XREF: _main+19r n dd offset loc_401027 ;開關表n dd offset loc_40102En dd offset loc_401035n

函數的最後一條指令時retn,內存中緊跟著retn指令的是401020開頭的指針值,在內存中,它顯示為十六進位的位元組序列20 10 40 00。代碼段裡面這四個指針值組成了這個二進位文件.text段中的一個16位元組的數據。但也存在將其反彙編成一些有效指令的可能性。線性反彙編演算法繼續返回變函數末尾之外的演算法,將會產生以下的反彙編代碼

and [eax], dlninc eaxnadd [edi], ahnadc [eax+0x0], alnadc cs: [eax+0x0], alnxor eax, 0x4010n

這個代碼片段中的很多指令擁有多個位元組,病毒開發者利用線性反彙編演算法的關鍵方法其實就是植入能夠組成多位元組指令機器碼的數據數據位元組。比如說本地的call指令有五個位元組,用0xE8打頭。如果16位元組數據組成一個以值0xE8結尾的開關表,那麼黨反彙編器碰到call指令的機器碼時,會把接下來的4個位元組當做操作數去對待而不是當做下一個函數的開頭。線性反彙編演算法由於不能夠區分代碼和數據,所以很容易就被病毒欺騙了。n

(2)面向代碼流的反彙編n

這個就是很多反彙編器用的反彙編演算法,比如說IDA Pro。面向代碼流的反彙編與之前提到的現行反彙編主要的不同在於他不會盲目的反彙編整個緩衝區,也不會假設代碼段中僅包含指令而不包含數據,他會檢查所有的指令後建立一個需要反彙編地址列表。來一段代碼

test eax, eaxnjz short loc_1Anpush Helloncall printfnjmp short loc_1Dn;————————————————————————————————nHello: ndb 『HelloWorld』, 0n;————————————————————————————————nloc_1A:n xor eax, eaxnloc_1D:n retnn

這個例子裡面,我們看到的是test指令和一個條件跳轉。當面向代碼流的反編譯器掃描到jz short loc_1A的時候會記下將來需要反彙編的一些位置,也就是loc_1A。因為這是唯一的條件分支,但是push Hello也有可能被執行,所以反編譯器也會反編譯它。n

push Hello和call printf處負責在屏幕列印字元串Helloworld,這些代碼之後是一條jmp指令,面向代碼流的反編譯器會將jmp指令的跳轉目標加入列表也就是loc_1D,為了方便將來去反編譯它。因為jmp是無條件跳轉指令,因此面向代碼流的反編譯器並不會自動反彙編內存中緊隨其後的指令,而是退後一步, 檢查此前放入需要反彙編列表的數據,比如說loc_1A,然後從這裡反彙編。n

當線性反彙編器遇到jmp命令的時候,不管代碼的邏輯流是什麼樣子的,都會去按照內存中的序列繼續反彙編指令。上面這段代碼就會把Helloworld當做代碼執行反彙編,這就會一不小心隱藏了代碼的ASCII字元串和後面的兩條指令。實際上線性反彙編演算法針對上面的數據是下面這個樣子的:

test eax, eaxn jz short near ptr loc_15+5n push Hellon call printfn jmp short loc_15+9nHello:n inc esin popanloc_15:n imul ebp,[ebp+64h], 0C3C03100hn

在線性反編譯器中是沒有考慮在給定時間內選擇反彙編那些指令,但是面向代碼流反彙編器會做出一些選擇和假設。雖然有時候這種假設和選擇似乎沒有什麼太大必要,因為要額外考慮這些問題的指針、異常和條件分支等等,回事反編譯那些簡單機器代碼複雜化。然而在人工編寫的彙編代碼和採用攻擊反彙編代碼的時候,同一段代碼的兩個分支會經常產生不同的反彙編結果,當衝突的時候,大部分反彙編器會首先相信給定位置的最開始的解釋。大多數面向代碼流的反彙編器會優先處理條件跳轉中的false分支。n

如果IDA產生了不正確的反彙編代碼,你可把這些指令手動轉換成數據或者反過來。n

0x02 攻擊反彙編技術的簡單實現:n

其實病毒對抗反彙編技術還是利用之前提到的演算法漏洞,讓反彙編分析工具生成錯誤的反彙編代碼。當然更先進的反彙編技術是利用反彙編器通常不能獲取的信息產生一些不可能被傳統反彙編技術所解析的代碼。舉個例子來說:

74 03 jz short near ptr loc_4011C4+1n75 01 jnz short near ptr loc_4011C4+1n;————————————————————————nE8 db 0E8hn;————————————————————————nloc_4011C5 ;CODE XREF: sub_4011C0n ;sub_4011C0+2jn58 pop eaxnC3 retnn

在這一段代碼裡面,左側列顯示組成指令的位元組,打開方式是在Option-》Generals-》Number of OpCode Byte選項中。我們使用一張圖來描述一下這個過程:n

另一種比較常見的攻擊反彙編技術是由跳轉條件總是相同的一條跳轉指令構成的,上一段代碼:

33 C0 xor eax, eaxn74 01 jz short near ptr loc_4011C4+1n ;CODE XREF: 004001C2jn ;DATA XREF : .rdata: 004020AConE9 58 C3 68 94 jmp near ptr 94A8D521hn

這段代碼以xor eax, eax開頭,這條指令的作用是把EAX寄存器置0,同時它也會設置寄存器的zero表示。下一條指令是jz跳轉指令,如果標誌寄存器zero標誌被置位,他就會執行跳轉。但是這根本不是條件跳轉的說,程序的這個位置我們可以保證zero標誌總是被置位的。n

之前說過反彙編器會首先處理false分支,這會和true分支產生衝突代碼,因為優先處理更信任的分支。我們可以利用IDA提供的功能把代碼行轉換成數據或者是把數據轉成代碼,我們將其修改完了之後就變成了:

33 C0 xor eax,eaxn74 01 jz short near ptr loc_4011C5n;—————————————————————————————————————nE9 db 0E9hn;—————————————————————————————————————nloc_4011C5: ;CODE XREF: 004011C2jn ;DATA XREF:.rdata: 004020ACon58 pop eaxnC3 retnn

0xE9和前面的0xE8位元組的用法大同小異,0xE9是5位元組jmp的機器碼,0xE8是5位元組的call機器碼,通過欺騙反調試器反彙編這些位置,能從事途中有效的隱藏這個機器碼隨後的4位元組,畫個圖表示一下:

除了以上的兩種方法,還可以使用一些無效的反彙編指令,前面我們說了,反彙編器第一次試圖反彙編的時候會生成不正確的反彙編代碼。使用IDA這種交互性較強的反編譯器能夠在反彙編代碼上操作來生成正確的反彙編結果。but,在某些特殊的情況下,常規的彙編列表不能表達運行之靈,我們用無效的反彙編指令來表示這種情況。之前討論的一些比較簡單的情況是在條件跳轉指令之後放一個位元組,這種技術的思路是:從這個位元組開始反彙編,阻止其後真正的指令反彙編,因為插入的位元組是一個多位元組指令的機器碼。這種位元組很明顯不屬於程序的一部分,還是用在代碼段來迷惑反編譯器。n上面提到的情況如果插入的惡意位元組是合法指令的一部分,而且在運行的時候能夠正確執行的話這就蛋疼了,所有給定位元組都是多位元組指令的一部分,而且他們都能執行。這就遇到了一個坑:反彙編器不能講單個位元組標示位兩個指令的組成部分,但是處理器卻沒有這種限制。舉個例子來說:

當我們嘗試表達這個反彙編序列的時候,如果把FF作為JMP指令的一部分,那麼就不能作為inc eax指令的開頭來進行顯示。位元組FF同時作為兩條實際運行執行的一部分,而現在的反編譯器並沒有辦法來表達這種情況,這4個位元組首先遞增EAX然後遞減EAX,實際上這是一個複雜的NOP序列,幾乎可以插入到程序的任何位置來破壞反彙編鏈。我們一般會使用IDC或者是IDAPython腳本調用PatchByte函數使用NOP指令來替換這個位元組序列。上面的這個例子就是一種欺騙編譯器的例子,下面我們再來看一個例子。

在上面這段代碼裡面,第一條指令是4位元組的mov指令,最後兩個位元組被高亮顯示的原因是因為它既是mov指令的一部分,又是隨後運行指令的一部分。第一條指令會用數據去填充EAX寄存器,第二條指令xor會歸零這個寄存器,且將標誌寄存器zero置為0,第三條指令是一個跳轉指令,當寄存器置為0的時候進行跳轉。但是你可能看到了,因為他的前一條指令總是設置zero標誌。反編譯器會反彙編緊跟jz的指令,該指令是以0xE8開頭,之前說過0xE8是一個5位元組的call指令,但是呢,這條0xE8開頭的指令實際上是永遠不會執行的。這種情況下,反彙編器不能正確反彙編jz指令的目標,因為這個位元組已經被正確的表達為mov指令的一部分,jz指令指向的代碼總會被執行,因為jz指令跳轉到4位元組mov指令中間,mov指令的最後兩個位元組存放在寄存器中的操作數,當單獨反彙編或者運行這個操作數的時候,他又會組成一個新的jmp指令,這個jmp指令會從指令末尾向前跳轉5個位元組。我們用代碼來實現以下上面的命令:

66 B8 EB 05 mov ax, 5EBhn31 C0 xor eax, eaxn74 FA jz short near ptr sub_4011C0+2nloc_4011C8:nE8 58 C3 90 90 call near ptr 98A8D525hn

因為沒有辦法整理這些代碼,導致所有的可執行指令都會被表達,我們必須選擇取捨哪些指令。這個攻擊反彙編指令序列的實際影響就是eax寄存器會被置為0,如果你用IDA去修改這段代碼讓其只顯示xor指令同時隱藏其他的指令,那麼代碼就會變成這樣的:n

66 byte_4011C0 db 66hnB8 db 0B8hnE8 db 0EBhn05 db 5n;————————————————————————————————n31 C0 xor eax, eaxn;————————————————————————————————n74 db 74hnFA db 0FAhnE8 db 0E8hn;————————————————————————————————n58 pop eaxnC3 retnn

這樣的話就能夠一定程度上去解決這個問題,但是這麼乾的話會干擾分析過程,因為不太好說出xor和pop、retn指令序列到底是怎麼執行的。我們之前說過可以利用PatchByte來修改其餘的位元組讓他們變成NOP指令,我們可以這麼干:把以內存地址0x004011C0開頭的4個位元組和以內存地址0x004011C6開頭的3個位元組全都轉換為NOP指令。用IDAPython構建以下腳本

def NopCreate(start, length):n for i in range(0, length):n PatchByte(start + I, 0x90)n MakeCode(start)nNopCreate(0x004011C0, 4)nNopCreate(0x004011C6, 3)n

使用以上腳本對指令進行處理之後,就變成了如下的結果:

90 nopn90 nopn90 nopn90 nopn31 C0 xor eax, eax n90 nopn90 nopn90 nopn58 pop eaxnC3 retnn

這樣的話就可以完美的執行了。我們也可以寫個腳本來干這件事情:

import idaapinidaapi.CompileLine(』static n_key(){RunPythonStatement(「nopIt()」);}』)nAddHotKey("Alt-N", 「n_key」)ndef nopIt():n start = ScreenEA()n end = NextHead(start)n for ea in range(start, end):n PatchByte(ea, 0x90)n Jump(end)n Refresh()n

這樣的話,下次摁ALT+N就可以全部搞定了。n


推薦閱讀:

利用SAP 0day,四分鐘內黑掉華爾街
手機「成人影院」套路深:假「草榴」 真騙錢

TAG:逆向工程 | 信息安全 | IDA |