1. 程式人生 > >反彙編演算法介紹和應用——遞迴下降演算法分析

反彙編演算法介紹和應用——遞迴下降演算法分析

上一篇博文我介紹了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
  1. 0x0040177f call 0040209C  
  2. 0x00401785 mov ecx,eax  
       其執行等效於 [plain] view plain copy
  1. push 00401785 // call指令結束的位置,注意該位置不一定是call完後下條指令開始的位置  
  2. jmp 0040177F // 跳轉到函式地址  
        可能有人疑惑為什麼push進入堆疊的00401785不一定是call完後下條指令的位置?比如 我們在0040209C的程式碼如下 [plain] view plain copy
  1. pop eax  
  2. jmp 00401788  
        那麼,我們程式執行完將會進入00401788,從而過掉了00401785開始的指令。

        是不是可以將call簡單的看成jmp呢?是吧。

        最後說一句:D也有B的缺陷。

        E 函式返回指令

        ret和retn等是函式返回指令,同call一樣,我們可以將其看成是B流程分支。為什麼這麼說呢?我們接著以D中的例子為例。假如0x0040209C的程式碼最後是ret,則該ret等效於

[plain] view plain copy
  1. pop EIP   
         因為EIP是下條指令的起始地址,則這步操作可以看成 [plain] view plain copy
  1. jmp EIP // 當然不能這麼寫,這兒只是為了說明這是個跳轉的過程  
         這是動態執行的流程,但是我們是靜態分析,怎麼知道EIP是啥呢?是的,一般情況下,我們無法知道。那麼這個時候,該次遞迴流程就走完了,我們將會去C流程中產生的延時反彙編佇列中取出地址來開始再次的遞迴操作……這就是遞迴下降演算法名稱的由來。

        是否還記得我們在B中說的那個場景?如果我們jmp eax了而不知eax是啥時,或者call、ret不知跳轉地址時,本次遞迴下降都會結束,並在延時反彙編列表中尋找新的起始反彙編地址。

        貌似我們的遞迴反彙編思路都講完了。但是還存在很大的缺陷!為什麼?還記得我在《反彙編演算法介紹和應用——線性掃描演算法分析》所說的遞迴下降演算法缺陷麼?它可能無法覆蓋全部程式碼。我們舉個例子

[plain] view plain copy
  1. 0x0040177f call 0040209C  
  2. 0x00401785 mov ecx,eax  
  3. .  
  4. .  
  5. .  
  6. 0x0040209C ret  
        如果依我們之上的流程,那麼0x00401785將可能分析不到,因為我們將call看成了jmp,我們該分支分析將在0x0040209C處結束,而0x00401785沒出現在延遲反彙編佇列中。想想,這是多麼可怕!於是比較嚴謹的將call看成jmp將要做必要修改。

        D 函式呼叫指令(修正後)

        我們將call看成C流程,即有條件跳轉。那麼如上那段彙編,我們將產生兩個分支:一個是00401785,一個是0040209C。雖然我們將00401785看成一個分支是非常不嚴謹的(因為下條指令完全由0040209C裡的邏輯決定的),但是為了能儘量多的反彙編出程式碼,我們還是要做這個妥協!因為這個妥協,也將導致遞迴下降演算法產生一個致命的缺陷——將call指令後資料當成指令去反彙編。

        這兒有個小細節需要注意,對於Call指令,我們會將跳轉分支地址優先分析,緊跟著call指令的分支延遲分析。因為存在一種可能,即跳轉分支中或許可以確定返回的地址。如果返回地址和緊跟著call指令的分支地址相同,則照舊進行;如果不相同,則以返回地址為準。舉個例子

[cpp] view plain copy
  1. void TestFun(void* lpfun)  
  2. {  
  3.     __asm{  
  4.         mov esp,ebp  
  5.         pop ebp  
  6.         pop eax  
  7.         ret  
  8.     }  
  9. }  
  10.   
  11. int _tmain(int argc, _TCHAR* argv[])  
  12. {  
  13.     __asm{  
  14.         push xxx  
  15.         call TestFun  
  16.         _emit 0xE8  
  17. xxx:  
  18.     }  
  19.     return 0;  
  20. }  
        在TestFun中,我最後丟擲返回地址到eax中。這樣堆疊頂部就是lpfun,ret後,EIP變成xxx處地址,並將執行到xxx處,而不是緊跟在call後面的0xE8。我們遞迴下降演算法,優先分析TestFun地址的指令,然後可以通過一些判斷,判斷出最後返回的地址是我們傳入的資料,那麼我們傳入的資料就是正確的下條指令地址,而0xE8處只是個數據。IDA的反彙編結果是


        想想,如果我們將緊跟call指令的分支優先分析,將會出現將0xE8當成call來解析的情況。那麼或許之後得靠跳轉分支的分析結果再來糾正,這樣還不如優先反彙編跳轉分支。

        說了這麼多,再說說上面所說的如何利用call指令分析的缺陷。通過以上例子,我們發現,如果讓遞迴下降演算法不知道其call後跳轉分支的返回地址,然後在緊跟call指令的位置插入一些廢資訊,那就造成IDA分析失敗了。看例子

[cpp] view plain copy
  1. void Fun( void* p )  
  2. {  
  3.     __asm  
  4.     {  
  5.         add p,3  
  6.         push p  
  7.         pop eax  
  8.         mov esp,ebp  
  9.         pop ebp  
  10.         push eax  
  11.         ret 4  
  12.     }  
  13. }  
  14.   
  15. int _tmain(int argc, _TCHAR* argv[])  
  16. {  
  17.     int i = 0;  
  18.     __asm   
  19.     {  
  20.         push yyy  
  21.         call Fun  
  22.         _emit 0xE8  
  23.     yyy:  
  24.         _emit 0xE8  
  25.         mov eax,ebp  
  26.     }  
  27.     return 0;  
  28. }  
        我在Fun函式一開始處將地址指向了return 0,然後將這個指標通過push和pop放入eax,讓push eax到棧頂,從而在ret時讓程式從return 0;開始執行。那麼IDA反彙編結果呢?

        看!已經錯了,當然windbg也是分析錯的。

        到此,關於反彙編演算法的兩篇博文寫完了。僅供大家參考。

附上測試的工程