代碼逆向(三)——循環分支的識別技巧

“嘿!爲什麼要先學這裏,不應該是for循環嗎?”
    相信很多讀者都會產生以上疑問,要的就是這種效果!就讓我們帶着這個疑問開始這一節的學習,先看源碼:

int _tmain(int argc, _TCHAR* argv[])
{
    int nNum = 26;
    printf("Mom! I can sing my ABC!/r/n");

    // 聽!小Baby開始唱ABC了……
    do 
    {
        printf("%c ",0x41+(26-nNum) );
        nNum--;
    } while (nNum>0);
    return 0;
}

    現在讓我們從反彙編的角度看看小Baby是怎麼唱歌的:

004117DE  MOV DWORD PTR SS:[EBP-8], 1A             ; 16進制的0x1A等於10進制的26,不要在這裏犯暈
004117E7  PUSH Test_0.00415C18                     ; /format = "Mom! I can sing my ABC!
004117EC  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
004117F2  ADD ESP, 4
004117FC  /MOV EAX, 1A                             ; <--!!!!!
00411801  |SUB EAX, DWORD PTR SS:[EBP-8]
00411804  |ADD EAX, 41                             ; EAX = 41+(1A-[EBP-8]) = 0x41+(26-nNum)
00411809  |PUSH EAX                                ; /<%c>
0041180A  |PUSH Test_0.0041573C                    ; |format = "%c "
0041180F  |CALL DWORD PTR DS:[<&MSVCR90D.printf>]  ; /printf
00411815  |ADD ESP, 8
0041181F  |MOV EAX, DWORD PTR SS:[EBP-8]
00411822  |SUB EAX, 1
00411825  |MOV DWORD PTR SS:[EBP-8], EAX           ; [EBP-8]-- = nNum--
00411828  |CMP DWORD PTR SS:[EBP-8], 0             ; 看[EBP-8]是否仍大於0,是的話則跳轉到標記處繼續
0041182C  /JG SHORT Test_0.004117FC

    是不是感覺很容易理解?這似乎與我們前面所講的內容差距不大,那麼就讓我們在看看Release版的:

00401000  PUSH ESI
00401001  PUSH EDI
00401002  MOV EDI, DWORD PTR DS:[<&MSVCR90.printf>>;  MSVCR90.printf
00401008  PUSH Test_0.004020F4                     ; /format = "Mom! I can sing my ABC!
0040100D  CALL EDI                                 ; /printf
0040100F  ADD ESP, 4
00401012  MOV ESI, 41                              ; 將ESI加0x41後準備
00401017  /PUSH ESI                                ; <--!!!!!
00401018  |PUSH Test_0.00402110                    ;  ASCII "%c "
0040101D  |CALL EDI                                ; 又見此優化
0040101F  |INC ESI                                 ; 直接將ESI加1
00401020  |ADD ESP, 8                              ; 爲什麼要將平衡堆棧操作放到這裏?
          |                                        ; 這與CPU的流水線有關係,我們目前先不深究。
          |                                        ;
00401023  |CMP ESI, 5B                             ; 快看看,直接與0x5B相比較了(Z的ASCII碼是0x5A)
00401026  /JL SHORT Test_0.00401017                ; 如果小於此值則繼續
00401028  POP EDI
00401029  XOR EAX, EAX
0040102B  POP ESI
0040102C  RETN

    通過以上代碼我們不難看出,編譯器直接將我們的代碼優化爲以下模式了:

int _tmain(int argc, _TCHAR* argv[])
{
    int nNum = 0x41;
    printf("Mom! I can sing my ABC!/r/n");

    // 聽!小Baby開始唱ABC了……
    do 
    {
        printf("%c ",nNum++ );
    } while (nNum<0x5B);
    return 0;
}

    看看,多麼聰明的編譯器!直接看透了我們代碼的本質!在感嘆之餘,不要忘記總結反彙編代碼的特點,我們現在可以看到的最大的特點就是一個有條件判斷的向上跳轉,因此可以這樣理解“如果我們看到了一個判斷分支的跳轉是向上的,那麼這必然就是一個循環”。

特點總結:
DO_TAG:
  ......
  ......
  CMP XXX,XXX
  JXX DO_TAG


1.4.2、while循環的識別技巧

    先看源碼:

int _tmain(int argc, _TCHAR* argv[])
{
    int nNum = 26;
    printf("Mom! I can sing my ABC!/r/n");

    // 聽!小Baby開始唱ABC了……
    while(nNum>0)
    {
        printf("%c ",0x41+(26-nNum) );
        nNum--;
    }
    return 0;
}

    再看Debug版反彙編代碼:

004117DE  MOV DWORD PTR SS:[EBP-8], 1A
004117E7  PUSH Test_0.00415C18                     ; /format = "Mom! I can sing my ABC!
004117EC  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
004117F2  ADD ESP, 4
004117FC  /CMP DWORD PTR SS:[EBP-8], 0
00411800  |JLE SHORT Test_0.00411830               ; 多了個判斷,如果其小於等於0則跳出循環
00411802  |MOV EAX, 1A
00411807  |SUB EAX, DWORD PTR SS:[EBP-8]
0041180A  |ADD EAX, 41
0041180F  |PUSH EAX                                ; /<%c>
00411810  |PUSH Test_0.0041573C                    ; |format = "%c "
00411815  |CALL DWORD PTR DS:[<&MSVCR90D.printf>]  ; /printf
0041181B  |ADD ESP, 8
00411825  |MOV EAX, DWORD PTR SS:[EBP-8]
00411828  |SUB EAX, 1
0041182B  |MOV DWORD PTR SS:[EBP-8], EAX
0041182E  /JMP SHORT Test_0.004117FC

    細心的讀者可能發現了,這與do-while循環幾乎如出一轍,僅僅在循環頭部多了兩條用於判斷是否跳出循環的指令,那Release版的又會是怎樣的呢?

00401000  PUSH ESI
00401001  PUSH EDI
00401002  MOV EDI, DWORD PTR DS:[<&MSVCR90.printf>>;  MSVCR90.printf
00401008  PUSH Test_0.004020F4                     ; /format = "Mom! I can sing my ABC!
0040100D  CALL EDI                                 ; /printf
0040100F  ADD ESP, 4
00401012  MOV ESI, 41
00401017  /PUSH ESI
00401018  |PUSH Test_0.00402110                    ;  ASCII "%c "
0040101D  |CALL EDI
0040101F  |INC ESI
00401020  |ADD ESP, 8
00401023  |CMP ESI, 5B
00401026  /JL SHORT Test_0.00401017
00401028  POP EDI
00401029  XOR EAX, EAX
0040102B  POP ESI
0040102C  RETN

    請不要懷疑我複製錯了代碼,事實就是這樣!do-while與while生成的Release版在這裏看就是100%完全相同的。
    編譯器很明顯的已經探測出了我們的循環判斷用的是一個常量,因此就不存在首次執行條件不匹配的情況。既然如此,它爲什麼還要在循環前面加上那個判斷分支來浪費我們的空間與時間呢?
    當然,如果我們將它的判斷條件改爲一個變量,那麼就是另外一番景象了:

00401000  PUSH EBX
00401001  MOV EBX, DWORD PTR DS:[<&MSVCR90.printf>>;  MSVCR90.printf
00401007  PUSH EDI
00401008  PUSH Test_0.004020F4                     ; /format = "Mom! I can sing my ABC!
0040100D  CALL EBX                                 ; /printf
0040100F  MOV EDI, DWORD PTR SS:[ESP+10]           ; 取得參數(其實就是main函數的argc)
00401013  ADD ESP, 4
00401016  TEST EDI, EDI                            ; 測試參數是否爲0
00401018  JLE SHORT Test_0.00401034                ; 如果爲0則跳出循環
0040101A  PUSH ESI
0040101B  MOV ESI, 5B                              ; 0x5B = 0x41+26 
00401020  SUB ESI, EDI                             ; 用0x5B減去參數
00401022  /PUSH ESI
00401023  |PUSH Test_0.00402110                    ;  ASCII "%c "
00401028  |CALL EBX
0040102A  |DEC EDI                                 ; 參數減1
0040102B  |ADD ESP, 8
0040102E  |INC ESI                                 ; ESI加1
0040102F  |TEST EDI, EDI
00401031  /JG SHORT Test_0.00401022                ; 如果參數大於ESI則結束循環
00401033  POP ESI
00401034  POP EDI
00401035  XOR EAX, EAX
00401037  POP EBX
00401038  RETN

    我們可以看出用變量做判斷條件很明顯與常量不一樣,而關於優化,很顯然他只是單純的將我們的“0x41+(26-argc)”優化成“0x5B-argc”。

特點總結:
WHILE_TAG:
  CMP XXX,XXX
  JXX WHILE_END_TAG
  ......
  ......
  CMP XXX,XXX
  JXX WHILE_TAG
WHILE_END_TAG:


1.4.3、for循環的識別技巧

    for循環與while循環本質上都是一樣的,唯一的不同在於for循環在循環體內多了一個步長部分,接下來我們一起看看for循環的樣子,先看源碼:

int _tmain(int argc, _TCHAR* argv[])
{
    printf("Mom! I can sing my ABC!/r/n");

    // 聽!小Baby開始唱ABC了……
    for (int nNum = 26;nNum>0;nNum--)
    {
        printf("%c ",0x41+(26-nNum) );
    }
    return 0;
}

    接下來我們再看看Debug版的反彙編代碼:

004117E0  PUSH Test_0.00415C18                     ; /format = "Mom! I can sing my ABC!
004117E5  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
004117EB  ADD ESP, 4
004117F5  MOV DWORD PTR SS:[EBP-8], 1A
004117FC  JMP SHORT Test_0.00411807
004117FE  /MOV EAX, DWORD PTR SS:[EBP-8]           ; / 步長控制部分開始
00411801  |SUB EAX, 1                              ; | 步長爲1
00411804  |MOV DWORD PTR SS:[EBP-8], EAX           ; / 將操作後的結果傳回給局部變量,步長操作結束
00411807  |CMP DWORD PTR SS:[EBP-8], 0
0041180B  |JLE SHORT Test_0.00411832               ; 如果此變量小於等於0則結束循環
0041180D  |MOV EAX, 1A
00411812  |SUB EAX, DWORD PTR SS:[EBP-8]
00411815  |ADD EAX, 41                             ; 0x41+(0x1A-[EBP-8])
0041181A  |PUSH EAX                                ; /<%c>
0041181B  |PUSH Test_0.0041573C                    ; |format = "%c "
00411820  |CALL DWORD PTR DS:[<&MSVCR90D.printf>]  ; /printf
00411826  |ADD ESP, 8
00411830  /JMP SHORT Test_0.004117FE               ; 跳到循環頭部

    看到這裏不知道各位讀者們是否發現了什麼,記得我當時學到這裏時,直覺上認識到了以下兩點:
(1、)顯然循環語句是do-while先誕生的,而後是while,最後纔是for,這從側面上講for應該是最“高級”的了。
(2、)從執行效率上看,代碼最短且判斷最少的就是do-while循環了。

    Release版反彙編:

00401000  PUSH ESI
00401001  PUSH EDI
00401002  MOV EDI, DWORD PTR DS:[<&MSVCR90.printf>>;  MSVCR90.printf
00401008  PUSH Test_0.004020F4                     ; /format = "Mom! I can sing my ABC!
0040100D  CALL EDI                                 ; /printf
0040100F  ADD ESP, 4
00401012  MOV ESI, 41
00401017  /PUSH ESI
00401018  |PUSH Test_0.00402110                    ;  ASCII "%c "
0040101D  |CALL EDI
0040101F  |INC ESI
00401020  |ADD ESP, 8
00401023  |CMP ESI, 5B
00401026  /JL SHORT Test_0.00401017
00401028  POP EDI
00401029  XOR EAX, EAX
0040102B  POP ESI
0040102C  RETN

    又是常量惹的禍,這段代碼與do-while、while一模一樣,有疑問的讀者可以返回上面仔細觀察一下,連地址都是一樣的。
    現在正好印證了我開篇時講的一句話“其本質上只有一種”,很明顯的,我們的while與for都是以do-while爲基礎框架的,只不過是在裏面加了一些小判斷。爲了讓各位讀者更清晰的看到它們之間的異同,我再爲各位獻上一個變量版的:

00401000  PUSH EBX
00401001  MOV EBX, DWORD PTR DS:[<&MSVCR90.printf>>;  MSVCR90.printf
00401007  PUSH EDI
00401008  PUSH Test_0.004020F4                     ; /format = "Mom! I can sing my ABC!
0040100D  CALL EBX                                 ; /printf
0040100F  MOV EDI, DWORD PTR SS:[ESP+10]
00401013  ADD ESP, 4
00401016  TEST EDI, EDI
00401018  JLE SHORT Test_0.00401034
0040101A  PUSH ESI
0040101B  MOV ESI, 5B
00401020  SUB ESI, EDI
00401022  /PUSH ESI
00401023  |PUSH Test_0.00402110                    ;  ASCII "%c "
00401028  |CALL EBX
0040102A  |DEC EDI
0040102B  |ADD ESP, 8
0040102E  |INC ESI
0040102F  |TEST EDI, EDI
00401031  /JG SHORT Test_0.00401022
00401033  POP ESI
00401034  POP EDI
00401035  XOR EAX, EAX
00401037  POP EBX
00401038  RETN

    再重申一下,筆者並沒有搞錯,以變量爲判斷條件的for循環與while循環所生成的代碼是完全相同的,連地址都一樣……

特點總結:
FOR_START_TAG:
    初始化塊
    JMP CMP_TAG
STEP_TAG:
    步長塊
CMP_TAG:
    反條件判斷
    JXX  FOR_END_TAG
    ……
    ……
JXX STEP_TAG

    到此,我們應該可以做一個總結了,Debug版下三種循環各不相同,Release版下可總結如下:
(1、)當循環採用常量爲判斷條件時,相同邏輯的三種循環生成的代碼完全相同。
(2、)當循環採用變量爲判斷條件時,相同邏輯的while與for生成的代碼完全相同,而do-while則自成一格。


小插曲:循環體的語句外提優化

    我們看看下面這段代碼:

int _tmain(int argc, _TCHAR* argv[])
{
  printf("Mom! I can sing my ABC!/r/n");

  // 聽!小Baby開始唱ABC了……
  for (int nNum = 24;nNum>0;nNum--)
  {
    argc = (int)argv;
    printf("%c ",0x41+(26-nNum) );
  }
  printf("%p",argc);
  return 0;
}

   通過這段代碼我們可以發現一處可以優化的地方,就是“argc = (int)argv”這條語句,很明顯

00401000  PUSH ESI
00401001  PUSH EDI
00401002  MOV EDI, DWORD PTR DS:[<&MSVCR90.printf>>;  MSVCR90.printf
00401008  PUSH Test_0.004020F4                     ; /format = "Mom! I can sing my ABC!
0040100D  CALL EDI                                 ; /printf
0040100F  ADD ESP, 4
00401012  MOV ESI, 43
00401017  /PUSH ESI
00401018  |PUSH Test_0.00402110                    ;  ASCII "%c "
0040101D  |CALL EDI
0040101F  |INC ESI
00401020  |ADD ESP, 8
00401023  |CMP ESI, 5B
00401026  /JL SHORT Test_0.00401017
00401028  MOV EAX, DWORD PTR SS:[ESP+10]           ; EAX = (int)argv; <--注意這裏
0040102C  PUSH EAX
0040102D  PUSH Test_0.00402114                     ;  ASCII "%p"
00401032  CALL EDI
00401034  ADD ESP, 8
00401037  POP EDI
00401038  XOR EAX, EAX
0040103A  POP ESI
0040103B  RETN

    由上面代碼可知,循環體內很明顯沒有我們“argc = (int)argv;”的代碼,再向下看一行才知道,這段代碼被提到了外面,這就是編譯進行的代碼外提優化。
    本小節到此就結束了,筆者在本小節向大家詳細的介紹了三種循環結構在逆向時的一些需要注意的特點,當你怎能快速的花柱這些特點之後,剩下的就是反覆的練習了。

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