反彙編演算法介紹和應用——遞迴下降演算法分析
上一篇博文我介紹了Windbg使用的線性掃描(linear sweep)反彙編演算法。本文我將介紹IDA使用的遞迴下降(recursive descent)反彙編演算法。(轉載請指明來源於breaksoftware的csdn部落格)
遞迴(recursive)可能大家都很清楚,說白了就是自己呼叫自己。那麼什麼是recursive descent呢?似乎很難理解。recursive還是有迴圈和迴歸的意思,那麼recursive descent就可以理解為“不停減少的迴圈”和“不停減少的迴歸”。或許這麼說還是不是很好理解,那我們來研究下這個演算法的思路的來源,這樣可以容易理解這個演算法的精髓。
回顧《反彙編演算法介紹和應用——線性掃描演算法分析》,我們知道線性掃描一個很大的缺點是:因為其不知道程式執行流而導致將資料識別為程式碼。我們可能會罵這個演算法不智慧,那麼如何才能智慧起來呢?想想我們的二進位制檔案在系統中正常執行時是不會出錯的,因為CPU總是可以找到真正的指令起始地址,那麼我們反彙編演算法只要能模擬CPU執行指令就可以得到正確的反彙編結果了。OK!沒錯,遞迴下降演算法一個主要的思路就是源於這樣的思考結果。但是我們反彙編是靜態的,而CPU執行指令是動態的,靜態分析無法得知動態執行的結果,這個嚴重的缺陷會導致我們想完全模擬CPU執行去反彙編的思路變得不現實。但是不要退卻,沒有完美的方案,只有最可以接受的方案。那我們開始研究下怎麼修改我們的思路,讓我們的演算法變得“最令人可以接受”。
研究修改的方法之前我們要了解CPU執行指令“順序”的一些基礎知識,知己知彼百戰百勝。
A 順序流指令
熟悉彙編的朋友,應該對add、sub、mov、push和pop等指令很熟悉,這類指令執行後,會執行與其地址緊接著的下一條指令。CPU識別這類指令如線性掃描一般簡單,那麼我們的遞迴下降演算法也就如線性掃描方式去識別這樣的指令就行了。
B 無條件跳轉指令
jmp是無條件跳轉指令。CPU執行這條指令後會跳轉到jmp指令引數所指向的地址。這個操作對CPU來說,和順序流指令沒什麼區別,只是將EIP改成要跳轉的地址。但是這個動態的過程卻害慘了靜態分析的線性掃描演算法,那我們遞迴下降演算法要吸取教訓:我們從jmp到的地址開始分析下一條指令。貌似這個想法天衣無縫,但是現實往往是殘酷的。請問你一定能得到jmp的地址麼?對於jmp 00401010這類的指令我們當然可以得到下條指令的地址,即0x00401010。那麼jmp eax呢?eax是多少?CPU知道,我們不知道。這個缺陷我們Mark下。
C 有條件跳轉指令
ja和je等是有條件跳轉指令,即符合某些要求後才執行跳轉,不符合要求則執行其緊接著的那條指令。這些指令的執行順序如同A、B兩種指令的靈魂附體。即條件為真,則走A流程分支;條件為假,則走B流程分支。這麼一拆解,我想遞迴下降演算法怎麼去分析有條件跳轉指令就清楚了。
但是有個問題需要說下,CPU執行這類指令時是知道要走A流程分支還是要走B流程分支的,它不會同時一起走這兩條流程。而且可能整個程式執行完了,這個指令的一個分支還沒走過(比如if(1){}else{},else永遠進不去的)。而我們的遞迴下降演算法是要分析出所有分支的!
那怎麼辦呢?那我們就將A和B分支的地址中的某一個優先分析,另一個延後分析。可是手心手背都是肉,我們如何取捨?這個時候,我們就要學習國羽和國乒的做法——不惜“讓球”,也要選擇出最有利於目前流程順利進行的方法。那麼A、B這兩個孩子誰有缺陷呢?如上所述,A流程分支沒缺陷,而B流程分支存在一定的隱患。那我們就將要執行跳轉的B流程分支儲存到一個延後分析的列表中。
最後說一句:C有B的靈魂,C有B的缺陷。
D 函式呼叫指令
call指令是函式呼叫指令,但是目前,我們可以將其看成B流程。或許有人會說call指令怎麼會和jmp混為一談呢?我們看一個call例子
[plain] view plain copy
- 0x0040177f call 0040209C
- 0x00401785 mov ecx,eax
- push 00401785 // call指令結束的位置,注意該位置不一定是call完後下條指令開始的位置
- jmp 0040177F // 跳轉到函式地址
- pop eax
- jmp 00401788
是不是可以將call簡單的看成jmp呢?是吧。
最後說一句:D也有B的缺陷。
E 函式返回指令
ret和retn等是函式返回指令,同call一樣,我們可以將其看成是B流程分支。為什麼這麼說呢?我們接著以D中的例子為例。假如0x0040209C的程式碼最後是ret,則該ret等效於
[plain] view plain copy- pop EIP
- jmp EIP // 當然不能這麼寫,這兒只是為了說明這是個跳轉的過程
是否還記得我們在B中說的那個場景?如果我們jmp eax了而不知eax是啥時,或者call、ret不知跳轉地址時,本次遞迴下降都會結束,並在延時反彙編列表中尋找新的起始反彙編地址。
貌似我們的遞迴反彙編思路都講完了。但是還存在很大的缺陷!為什麼?還記得我在《反彙編演算法介紹和應用——線性掃描演算法分析》所說的遞迴下降演算法缺陷麼?它可能無法覆蓋全部程式碼。我們舉個例子
[plain] view plain copy- 0x0040177f call 0040209C
- 0x00401785 mov ecx,eax
- .
- .
- .
- 0x0040209C ret
D 函式呼叫指令(修正後)
我們將call看成C流程,即有條件跳轉。那麼如上那段彙編,我們將產生兩個分支:一個是00401785,一個是0040209C。雖然我們將00401785看成一個分支是非常不嚴謹的(因為下條指令完全由0040209C裡的邏輯決定的),但是為了能儘量多的反彙編出程式碼,我們還是要做這個妥協!因為這個妥協,也將導致遞迴下降演算法產生一個致命的缺陷——將call指令後資料當成指令去反彙編。
這兒有個小細節需要注意,對於Call指令,我們會將跳轉分支地址優先分析,緊跟著call指令的分支延遲分析。因為存在一種可能,即跳轉分支中或許可以確定返回的地址。如果返回地址和緊跟著call指令的分支地址相同,則照舊進行;如果不相同,則以返回地址為準。舉個例子
[cpp] view plain copy- void TestFun(void* lpfun)
- {
- __asm{
- mov esp,ebp
- pop ebp
- pop eax
- ret
- }
- }
- int _tmain(int argc, _TCHAR* argv[])
- {
- __asm{
- push xxx
- call TestFun
- _emit 0xE8
- xxx:
- }
- return 0;
- }
想想,如果我們將緊跟call指令的分支優先分析,將會出現將0xE8當成call來解析的情況。那麼或許之後得靠跳轉分支的分析結果再來糾正,這樣還不如優先反彙編跳轉分支。
說了這麼多,再說說上面所說的如何利用call指令分析的缺陷。通過以上例子,我們發現,如果讓遞迴下降演算法不知道其call後跳轉分支的返回地址,然後在緊跟call指令的位置插入一些廢資訊,那就造成IDA分析失敗了。看例子
[cpp] view plain copy- void Fun( void* p )
- {
- __asm
- {
- add p,3
- push p
- pop eax
- mov esp,ebp
- pop ebp
- push eax
- ret 4
- }
- }
- int _tmain(int argc, _TCHAR* argv[])
- {
- int i = 0;
- __asm
- {
- push yyy
- call Fun
- _emit 0xE8
- yyy:
- _emit 0xE8
- mov eax,ebp
- }
- return 0;
- }
看!已經錯了,當然windbg也是分析錯的。
到此,關於反彙編演算法的兩篇博文寫完了。僅供大家參考。