代碼逆向(四)——switch-case識別技巧初探


    我們先看一段代碼:

int _tmain(int argc, _TCHAR* argv[])
{
    int nNum = 2;
    switch (nNum)
    {
    case 0:
        printf("nNum=0");
        break;
    case 1:
        printf("nNum=1");
        break;
    case 2:
        printf("nNum=2");
        break;
    default:
        printf("nNum=%d,error!",nNum);
    }
    return 0;
}

    看完這段代碼,在看完本小節的標題,有些讀者可能會產生一些疑問,例如switch-case的不可達分支會被剪掉嗎、switch-case分支以常量爲判斷條件的優化效果與if-else有多大區別、以變量爲判斷條件的switch-case分支優化效果與if-else分支有多大區別等等問題。
    現在就讓我們帶着這些問題繼續,讓我們一一爲其找到答案。
    先看Debug版的反彙編代碼:

004133AE  MOV DWORD PTR SS:[EBP-8], 2              ; 給局部變量賦值
004133B5  MOV EAX, DWORD PTR SS:[EBP-8]
004133B8  MOV DWORD PTR SS:[EBP-D0], EAX
004133BE  CMP DWORD PTR SS:[EBP-D0], 0             ; 比較是否等於0
004133C5  JE SHORT Test_0.004133DB                 ; 如果等於0則跳到相應分支,否則繼續
004133C7  CMP DWORD PTR SS:[EBP-D0], 1
004133CE  JE SHORT Test_0.004133F4                 ; 同上
004133D0  CMP DWORD PTR SS:[EBP-D0], 2
004133D7  JE SHORT Test_0.0041340D                 ; 同上
004133D9  JMP SHORT Test_0.00413426                ; 都不符合則直接跳轉到最後一個分支處
004133DD  PUSH Test_0.00415808                     ; /format = "nNum=0"
004133E2  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
004133E8  ADD ESP, 4
004133F2  JMP SHORT Test_0.00413441
004133F6  PUSH Test_0.004157B0                     ; /format = "nNum=1"
004133FB  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
00413401  ADD ESP, 4
0041340B  JMP SHORT Test_0.00413441
0041340F  PUSH Test_0.00415C18                     ; /format = "nNum=2"
00413414  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
0041341A  ADD ESP, 4
00413424  JMP SHORT Test_0.00413441
00413428  MOV EAX, DWORD PTR SS:[EBP-8]
0041342B  PUSH EAX                                 ; /<%d>
0041342C  PUSH Test_0.004157A0                     ; |format = "nNum=%d,error!"
00413431  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
00413437  ADD ESP, 8

    通過以上反彙編代碼我們可以總結出以下特點:

cmp XXX,XXX
jXX CASE1_TAG
cmp XXX,XXX
jXX CASE2_TAG
cmp XXX,XXX
jXX CASE3_TAG
......
CMP XXX,XXX
JXX CASEN_TAG
......
JMP DEFAULT
CASE1_TAG:
......
CASE2_TAG:
......
CASE3_TAG:
......
......
CASEN_TAG:
......
......
DEFAULT:
......
SWITCH_END_TAG:

    我們可以看到Debug版的反彙編指令與我們的源代碼的相似度還是非常高的,都是通過開始的一連串判斷,然後確定接下來走哪個Case分支。下面我們再看看Release版的反彙編代碼:

00401000  PUSH Test_0.004020F4                     ; /format = "nNum=2"
00401005  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
0040100B  ADD ESP, 4
0040100E  XOR EAX, EAX
00401010  RETN

    瞧瞧,簡單至極呀!由此可見switch-case語句與我們之前接觸的if-else語句一樣,不可達分支都會被編譯器優化掉,那麼如果如果其部分分支相同,是否仍會像if-else分之一樣呢?先看源碼:

int _tmain(int argc, _TCHAR* argv[])
{
    switch (argc)
    {
    case 0:
        printf("argc=0",argc);
        break;
    case 1:
        printf("argc=%d",argc);
        break;
    case 2:
        printf("argc=%d",argc);
        break;
    default:
        printf("argc=%d,error!",argc);
    }
    return 0;
}

    按照if-esle的優化邏輯,case 1 與 csae 2 會指向同一處,真的是這樣嗎?我們直接看Release版反彙編代碼:

00401000  /$>MOV ECX, DWORD PTR SS:[ESP+4]
00401004  |.>MOV EAX, ECX
00401006  |.>SUB EAX, 0                               ;  Switch (cases 0..2) 
00401009  |.>JE SHORT Test_0.0040104D
0040100B  |.>SUB EAX, 1                               ; 注意這裏用的是減法
0040100E  |.>JE SHORT Test_0.0040103A
00401010  |.>SUB EAX, 1
00401013  |.>JE SHORT Test_0.00401027
00401015  |.>PUSH ECX                                 ; /<%d>; Default case of switch 00401006
00401016  |.>PUSH Test_0.00402104                     ; |format = "argc=%d,error!"
0040101B  |.>CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401021  |.>ADD ESP, 8
00401024  |.>XOR EAX, EAX
00401026  |.>RETN                                     ; 執行完某一個分支後會直接返回
00401027  |>>PUSH 2                                   ; /<%d> = 2; Case 2 of switch 00401006
00401029  |.>PUSH Test_0.004020FC                     ; |format = "argc=%d"
0040102E  |.>CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401034  |.>ADD ESP, 8
00401037  |.>XOR EAX, EAX
00401039  |.>RETN
0040103A  |>>PUSH 1                                   ; /<%d> = 1; Case 1 of switch 00401006
0040103C  |.>PUSH Test_0.004020FC                     ; |format = "argc=%d"
00401041  |.>CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401047  |.>ADD ESP, 8
0040104A  |.>XOR EAX, EAX
0040104C  |.>RETN
0040104D  |>>PUSH 0                                   ;  Case 0 of switch 00401006
0040104F  |.>PUSH Test_0.004020F4                     ; /format = "argc=0"
00401054  |.>CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
0040105A  |.>ADD ESP, 8
0040105D  |.>XOR EAX, EAX
0040105F  /.>RETN

    看來switch-case並沒有將相同的分支合併,我們可以很清楚的看到它的4個分支仍都存在。
    既然它的分支並沒有合併,那麼我們就討論點其他的,請各位讀者回頭仔細觀察反彙編代碼的第1-9行,我們可以發現Release在條件跳轉前用的不再是cmp,而是sub,很顯然編譯器這樣優化是有其理由的,但是這個理由究竟是什麼?
    我們通過閱讀這塊代碼可知程序先將main函數的參數1傳遞給EAX,然後減0,這有點讓人迷糊,我們接着看下面的那個跳轉:

00401009  |.>JE SHORT Test_0.0040104D
 
    讓我們回顧一下彙編語言,我們應該都記得JE的跳轉條件是ZF=1,因此當我們的EAX爲0時,那麼將其減0肯定會使ZF位置1,因此其實這就是一個變形的CMP指令,只不過這麼做程程的代碼體積更小、效率更高。
    知道這些後,後面的優化自然就肯好理解了,現在假設我們的EAX等於2,因此按照上面代碼的流程走會先將其減0,此時ZF位不變,接着下面又對其減1,此時ZF位仍然沒變化,而當走到第三步時,此時EAX的值爲1,又將其減1後肯定就等於0了,ZF位置爲1,後面的JZ跳轉生效……
    我們可以看到其實就是做了一連串的減法,到哪等於0後,就證明這個值原先爲多少,由此可見微軟的編譯器還是很聰明的。不過這些代碼從本質上來說還是屬於if-esle範疇內的。

    
1.5.2、多分支的switch-case識別

    我們平時在寫程序時都會遇到一些問題,而這些問題肯定必須要用多分支的switch-case才能解決,但是你知道這種情況在反彙編狀態下應該怎麼去識別嗎?下面我們就一起看看switch-case的另外一種體現方式,我們先看代碼:

int _tmain(int argc, _TCHAR* argv[])
{
    switch (argc)
    {
    case 0:
        printf("argc=0",argc);
        break;
    case 1:
        printf("argc=%d",argc);
        break;
    case 6:
        printf("argc=%d",argc);
        break;
    case 7:
        printf("argc=%d",argc);
        break;
    case 9:
        printf("argc=%d",argc);
        break;
    default:
        printf("argc=%d,error!",argc);
    }
    return 0;
}

    注意上面的case條件並不是有規律的,我們直接看它的Release版反彙編代碼:

00401000  MOV EAX, DWORD PTR SS:[ESP+4]
00401004  CMP EAX, 9                               ;  Switch (cases 0..9)
00401007  JA SHORT Test_0.0040106F                 ;  如果大於最大值9則直接跳到Default處
00401009  JMP DWORD PTR DS:[EAX*4+401084]          ;  注意這裏!!
00401010  PUSH 0                                   ;  Case 0 of switch 00401004
00401012  PUSH Test_0.004020F4                     ; /format = "argc=0"
00401017  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
0040101D  ADD ESP, 8
00401020  XOR EAX, EAX
00401022  RETN
00401023  PUSH 1                                   ; /<%d> = 1; Case 1 of switch 00401004
00401025  PUSH Test_0.004020FC                     ; |format = "argc=%d"
0040102A  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401030  ADD ESP, 8
00401033  XOR EAX, EAX
00401035  RETN
00401036  PUSH 6                                   ; /<%d> = 6; Case 6 of switch 00401004
00401038  PUSH Test_0.004020FC                     ; |format = "argc=%d"
0040103D  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401043  ADD ESP, 8
00401046  XOR EAX, EAX
00401048  RETN
00401049  PUSH 7                                   ; /<%d> = 7; Case 7 of switch 00401004
0040104B  PUSH Test_0.004020FC                     ; |format = "argc=%d"
00401050  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401056  ADD ESP, 8
00401059  XOR EAX, EAX
0040105B  RETN
0040105C  PUSH 9                                   ; /<%d> = 9; Case 9 of switch 00401004
0040105E  PUSH Test_0.004020FC                     ; |format = "argc=%d"
00401063  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401069  ADD ESP, 8
0040106C  XOR EAX, EAX
0040106E  RETN
0040106F  PUSH EAX                                 ; /<%d>; Default case of switch 00401004
00401070  PUSH Test_0.00402104                     ; |format = "argc=%d,error!"
00401075  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
0040107B  ADD ESP, 8
0040107E  XOR EAX, EAX
00401080  RETN

    看完上段反彙編代碼後有些讀者可能感覺很奇怪,難道那個位於第4行的JMP指令就能解決這些問題嗎?當然不會這麼簡單……
    我們現在就仔細分析一下頭幾條反彙編指令:

00401000  MOV EAX, DWORD PTR SS:[ESP+4]            ; 取得局部變量後傳遞給EAX
00401004  CMP EAX, 9                               ; 與9作比較,我們通過源代碼可知這個switch-case分支的最大值“case 9”,因
                                                   ; 此如果傳入的值大於9就肯定會執行Default處代碼了。
00401007  JA SHORT Test_0.0040106F
00401009  JMP DWORD PTR DS:[EAX*4+401084]          ; EAX此時保存的是輸入的值,將其乘以4後再加上一個地址,這其實就是一個典型
                                                   ; 的數組尋址,由於我們還沒學數組尋址,所以這塊先放一放。我們現在只需要要
                                                   ; 知道這是一個讀取int型的數組,裏面保存的是地址指針。

    那麼目標地址裏究竟保存了什麼數據?這個機制的原理又是怎麼回事呢?我們看看如下內容便可以猜出一二了……

Address   Data      Tag
00401084  00401010  Test_0.00401010   ; Case0
00401088  00401023  Test_0.00401023   ; Case1
0040108C  0040106F  Test_0.0040106F   ; Case2
00401090  0040106F  Test_0.0040106F   ; Case3
00401094  0040106F  Test_0.0040106F   ; Case4
00401098  0040106F  Test_0.0040106F   ; Case5
0040109C  00401036  Test_0.00401036   ; Case6
004010A0  00401049  Test_0.00401049   ; Case7
004010A4  0040106F  Test_0.0040106F   ; Case8
004010A8  0040105C  Test_0.0040105C   ; Case9

    通過上面表格的地址可以知道這就是我們上面提到的“數組指針”了,裏面保存的內容是各個Case塊的地址,我們將之稱爲“跳轉表”。跳轉表的作用就是通過數組的尋址運算代替複雜的if-else分支,這樣可以大大提供程序的執行效率。
    它的基本原理是建立一張表格,裏面保存着從case1到caseN的所有分支應該到達的地址。以上面程序的情況爲例子,我們可以看出從case2至case5裏保存的地址都是跳向Default分支的地址,這就證明這幾個case在程序的源代碼中是屬於未處理(或稱爲非正常)的狀態。
    但是什麼時候編譯器纔會決定用跳轉表的方式來優化程序呢?這顯然不是我等簡單的用個什麼公式就能計算出來的,這需要綜合各種條件、因素來決定是否使用跳轉表。在這裏我可以給出一個反例,既如果我們的最大case塊爲999999的話,難道程序還會用這個方法解決嗎?肯定不會!

發佈了41 篇原創文章 · 獲贊 4 · 訪問量 14萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章