代碼逆向(二)——if-else分支的識別技巧

if-else分支幾乎是所有人學習C語言後第一個接觸的知識點,那麼我們學習逆向理所當然也應該從這裏開始了。其實關於if-else分支我們在上一節已經接觸過了,這一節我們將詳細的探討有關於if-else分支的識別與編譯器可能使用的優化方案。

     在學習逆向的時候,我們要始終記住我們是在與編譯器打交道,其次也要注重總結前輩們的經驗,我個人大致將if-else分支的逆向分爲4種狀態,下面我將爲大家一一講解。

1.3.1、以常量爲判斷條件的簡單if-else分支

     我們的代碼如下:

int _tmain(int argc, _TCHAR* argv[])
{
    int nTest = 1;

    if (nTest>0)
    {
        printf("Hello world!/r/n");
    }
    else
    {
        printf("Hello everybody!/r/n");
    }
  return 0;
}

    先以Debug方式生成,用OllyDbg打開後找到main函數,我們看到如下彙編代碼:

00411A20  PUSH EBP                                 ; 先將EBP保存保存
00411A21  MOV EBP, ESP                             ; 然後將堆棧指針ESP的值傳遞給EBP,想想這樣做是爲了什麼?沒錯!這樣在
00411A21                                           ; 這個函數內只需要使用EBP就可對棧進行操作了。這樣做的好處是不要在對
00411A21                                           ; ESP做過多的操作,從而更好的保證了程序的健壯性(也增加了易讀性)。
00411A21
00411A23  SUB ESP, 0CC                             ; 將ESP減0x0CC,也就是將棧頂擡高0x0CC的意思,這裏有一個專業名詞叫做
00411A23                                           ; 打開棧幀。但是通過源程序我們知道根本用不了這麼大的空間,這是因爲
00411A23                                           ; 編譯器在編譯Debug版本時爲了增強程序的健壯性與可調式性而做的一件事。
00411A23
00411A29  PUSH EBX
00411A2A  PUSH ESI 
00411A2B  PUSH EDI                                 ; 保存EBX、ESI、EDI(這往往證明後面會用到這些寄存器,但也並不絕對)
00411A2C  LEA EDI, DWORD PTR SS:[EBP-CC]
00411A32  MOV ECX, 33
00411A37  MOV EAX, CCCCCCCC
00411A3C  REP STOSD                                ; 向EDI指向的地址處依次填入EAX裏的內容,循環ECX次(也就是填CC操作)。
00411A3C                                           ; 以上代碼是典型的Debug輔助代碼,只有在以Debug方式編譯時纔會生成上述
00411A3C                                           ; 代碼,這些代碼根據編譯器的不同或編譯器版本的不同而稍有變化。由於以
00411A3C                                           ; 上代碼的高度重複性,筆者在以後的文章中會忽略以上代碼,請各位讀者注
00411A3C                                           ; 意。
00411A3C                                           ; ==================================================================
00411A3C
00411A3E  MOV DWORD PTR SS:[EBP-8], 1              ; 給變量1賦值(局部變量由8開始是因爲EBP-4處被環境佔用,後面會講解)
00411A45  CMP DWORD PTR SS:[EBP-8], 0              ; 拿變量1與0比較
00411A49  JLE SHORT Test_0.00411A64                ; 小於等於0則跳走
00411A4B  MOV ESI, ESP                             ; 編譯器生成的檢查代碼,不必關心(後面的文章筆者會清除這類代碼)
00411A4D  PUSH Test_0.004157B0                     ; /format = "Hello world!
00411A52  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
00411A58  ADD ESP, 4                               ; 平衡堆棧(因爲庫函數使用的是__cdecl調用方式,後面會講,這裏不必深究)
00411A5B  CMP ESI, ESP
00411A5D  CALL Test_0.00411145                     ; 棧平衡檢查函數,調試版纔會有的東西(後面的文章會清除這類代碼)
00411A62  JMP SHORT Test_0.00411A7B                ; 跳過分支二
00411A64  MOV ESI, ESP
00411A66  PUSH Test_0.0041573C                     ; /format = "Hello everybody!
00411A6B  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
00411A71  ADD ESP, 4                               ; 平衡堆棧
00411A71  
00411A71                                           ; ==================================================================
00411A74  CMP ESI, ESP
00411A76  CALL Test_0.00411145
00411A7B  XOR EAX, EAX                             ; EAX清零(彙編裏EAX會被作爲返回值,希望各位讀者沒忘)
00411A7D  POP EDI
00411A7E  POP ESI
00411A7F  POP EBX                                  ; 彈出(恢復)EBX、ESI、EDI
00411A80  ADD ESP, 0CC                             ; 銷燬局部變量,平衡堆棧
00411A86  CMP EBP, ESP
00411A88  CALL Test_0.00411145
00411A8D  MOV ESP, EBP                             ; 恢復ESP
00411A8F  POP EBP                                  ; 彈出(恢復)EBP
00411A90  RETN                                     ; 此CALL(main函數)執行完畢,返回

    通過以上代碼可知,if-else分支用的都是反比(00411A49  JLE 00411A64),按照我們的代碼邏輯應該是用JAE(大於等於0)纔對。其實編譯器這麼做是有其道理的,因爲我們以Debug方式生成代碼肯定是要注重可讀性、強壯性的。但是除此之外還有一點也非常重要,那就是彙編代碼與高級語言代碼的對應性。我們想想,按照我們C/C++的語言描述來看,肯定是顯示“Hello world!”的這個分支在上面的,但是如果按照我們的邏輯使用JAE指令的話,請問這個分支會到那裏去?明白了這一點後相信各位讀者應該理解編譯器作者的苦衷了。

    通過總結我們可以知道,if-else分支的特點如下:

CMP ????,????   ; 比較數值
JXX AAAAAAAA    ; 比較方式
......          ; 分支一
JMP BBBBBBBB
......          ; 分支二

    下面我們看看Release版:

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

    請注意,筆者沒有刪減任何代碼,這正是Release版的強大之處,更確切的說應該是“這正是編譯優化的強大之處”。由於編譯器在編譯前掃描時檢測到了if語句後面的判斷條件是一個常量,因此這個if-else分支的執行結果很定是不會發生變化的,所以編譯器在編譯時就剪掉了那個永遠不可達的分支,並且去掉了判斷。
    看到這裏也許有的讀者會感到迷惑,我們如何才能將原來的代碼還原出來呢?我的答案是還原不出來,恐怕你問其他人得到的答案也是一樣的。因此對於Release版編譯出來的程序,我們只能還原出功能相同的代碼,但是這就足夠了。


1.3.2、以變量爲判斷條件的簡單if-else分支

    下面我們將判斷條件改爲變量,看看會有什麼不同,先看C++代碼:

int _tmain(int argc, _TCHAR* argv[])
{
    if (argc>0)
    {
        printf("Hello world!/r/n");
    }
    else
    {
        printf("Hello everybody!/r/n");
    }
  return 0;
}

    Debug方式編譯後彙編代碼如下:

00411A3E  CMP DWORD PTR SS:[EBP+8], 0
00411A42  JLE SHORT Test_0.00411A5D
00411A46  PUSH Test_0.004157B0                     ; /format = "Hello world!
00411A4B  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
00411A51  ADD ESP, 4
00411A5B  JMP SHORT Test_0.00411A74
00411A5F  PUSH Test_0.0041573C                     ; /format = "Hello everybody!
00411A64  CALL DWORD PTR DS:[<&MSVCR90D.printf>]   ; /printf
00411A6A  ADD ESP, 4

    基本與上一個版本無異,現在我們再看看Release版:

00401000  CMP DWORD PTR SS:[ESP+4], 0              ; 我們可以發現Release版是直接使用ESP尋址
00401005  JLE SHORT Test_0.00401018
00401007  PUSH Test_0.004020F4                     ; /format = "Hello world!
0040100C  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401012  ADD ESP, 4
00401015  XOR EAX, EAX
00401017  RETN
00401018  PUSH Test_0.00402104                     ; /format = "Hello everybody!
0040101D  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401023  ADD ESP, 4
00401026  XOR EAX, EAX
00401028  RETN

    請注意,筆者沒有刪減任何代碼(本系列教程中的Release版放上來的都是全部代碼,作者不會對其做任何修改,以後不再提示此信息,請各位讀者注意)。
    細心的讀者可能會發現一個問題“爲什麼Release版會爲每一個分支後面都添加上了結束代碼了呢?這會使程序的體積增加呀!”首先請各位讀者仔細觀察,編譯器這樣做其實僅增加了一個字節的體積,其次這些細節行爲都是由編譯選項決定的,Release版的默認選項採取的是速度優先,因此編譯器在權衡之後,認爲犧牲一個字節的體積換取減少一個跳轉指令還是非常划算的。

    由以上特徵我們可以總結Release版的簡單if-else分支特徵如下:

CMP ????,????   ; 比較數值
JXX AAAAAAAA    ; 比較方式
......          ; 分支一
ret
AAAAAAAA        ; JXX後面的地址
......          ; 分支二
ret


1.3.3、以常量爲判斷條件的複雜if-else分支

    簡單的玩完了該弄點複雜的了,看看下面的代碼:

int _tmain(int argc, _TCHAR* argv[])
{
    int nTest = 1;
    if (nTest>0)  // 第一個if-else
    {
        printf("Hello!/r/n");
    }
    else
    {
        printf("Hello everybody!/r/n");
    }

    if (nTest>0)  // 第二個if-else
    {
        printf("World!/r/n");
    } 
    else
    {
        printf("Hello everybody!/r/n");
    }

    printf("End!/r/n");
    return 0;
}

   這裏我們直接看Release版的彙編代碼:

00401000  PUSH ESI
00401001  MOV ESI, DWORD PTR DS:[<&MSVCR90.printf>>;  MSVCR90.printf
00401001  ; 這裏涉及到一個較難理解的優化,其實原理很簡單。
00401001  ; 我們發現編譯器將庫函數printf的地址傳遞給了ESI,但是爲什麼要這樣做呢?我們可以大致往後看看,會發現此程序中
00401001  ; 會用三次prinft,因此在初期將其放在一個寄存器裏,可以減少代碼體積,而且CALL寄存器也要比CALL地址快一些。
00401001
00401007  PUSH Test_0.004020F4                     ; /format = "Hello!
0040100C  CALL ESI                                 ; /printf
0040100E  PUSH Test_0.00402100                     ;  ASCII "World!
00401013  CALL ESI
00401015  PUSH Test_0.0040210C                     ;  ASCII "End!
0040101A  CALL ESI
0040101C  ADD ESP, 0C                              ; 這裏其實也是一個優化,我們在以後的章節中會講。
0040101F  XOR EAX, EAX
00401021  POP ESI
00401022  RETN

    從Release版生成的代碼上看來,與上一個例子沒有太大區別,不可達的分支都讓編譯器在編譯初期給剪掉了。


1.3.4、以變量爲判斷條件的複雜if-else分支

    通過上面的例子一些敏感的讀者應該會大致猜得到結果,沒錯,如果if-else分支的條件判斷不是常量,那麼編譯器就無法對某些分值進行裁減了,真的是這樣嗎?先看源碼:

int _tmain(int argc, _TCHAR* argv[])
{
    if (argc>0)
    {
        if (argc == 1)
        {
            printf("Hello!/r/n");
        } 
        else
        {
            printf("Hello everybody!/r/n");
        }
    }
    else
    {
        if (argc == 1)
        {
            printf("World!/r/n");
        } 
        else
        {
            printf("Hello everybody!/r/n");
        }
    }

    return 0;
}

    再看反彙編代碼:

00401000  MOV EAX, DWORD PTR SS:[ESP+4]
00401004  TEST EAX, EAX
00401006  JLE SHORT Test_0.0040101E                ; 最外層的if-else分支
00401008  CMP EAX, 1
0040100B  JNZ SHORT Test_0.00401034                ; 內層第一個if-else分支
0040100D  PUSH Test_0.004020F4                     ; /format = "Hello!
00401012  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
00401018  ADD ESP, 4
0040101B  XOR EAX, EAX
0040101D  RETN
0040101E  CMP EAX, 1
00401021  JNZ SHORT Test_0.00401034                ; 內層第二個if-else分支
00401023  PUSH Test_0.00402114                     ; /format = "World!
00401028  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
0040102E  ADD ESP, 4
00401031  XOR EAX, EAX
00401033  RETN
00401034  PUSH Test_0.00402100                     ; /format = "Hello everybody!
00401039  CALL DWORD PTR DS:[<&MSVCR90.printf>]    ; /printf
0040103F  ADD ESP, 4
00401042  XOR EAX, EAX
00401044  RETN

    通過上面的反彙編代碼我們不難發現,編譯器將重複的分支合併了,下面將以一個比較簡單的圖例說明這種優化:

 Start    Start
   |        |
   A        A
  / /      / /
  B C      B C
  | |  =>  / /
  D D       D
  / /       |
  End      End

    編譯器很聰明地將兩個相同的“D流程”合併爲一個了,這樣做無疑大大減少了編譯器生成代碼的體積。但是同時這也是初學逆向的讀者最難理解的地方,有的讀者可能還未看出它的難點在哪裏,因此我在這裏提醒一下,如果你不知道編譯器有這種優化方案,那麼此時讓擬將此段代碼轉爲C代碼,你會怎麼做呢?用goto嗎?
    我們仔細想想,雖然用goto可以達到還原出“等價高級語言”的效果,但是這並不是我們想要的,最起碼的,goto轉爲彙編指令毫無疑問就是jmp了,但是上述反彙編代碼中並沒有jmp。所以本着認真求實的態度,我們一定要記住,這是編譯器的一種優化結果。

    通過上面的講解,我們在本節中對於if-else分支做了一個詳細的瞭解,讀完本文後,你應該知道一下幾個問題的答案了:

(1、)爲什麼if-else的彙編代碼會用反比的方式來判斷分支?如果不這樣做會導致什麼情況?
(2、)默認配置的Release版本中以常量爲判斷條件的if-else會產生什麼樣的反彙編代碼?分支判斷語句是否仍會存在?
(3、)對於某些所屬分支不同,代碼相同的if-else分支,在默認配置的Release版本中會產生怎樣的變化?


    最後爲各位做一個總結:

(1、)以常量爲判斷條件的簡單if-else分支:Release版的不可達分支會被剪掉
    結構如下(DeBug版的結構,意義不大,但是很經典,所以總結了出來):
    CMP ????,????   ; 比較數值
    JXX AAAAAAAA    ; 比較方式
    ......          ; 分支一
    JMP BBBBBBBB
    ......          ; 分支二
 
(2、)以變量爲判斷條件的簡單if-else分支:Release版的不可達分支會被剪掉
    結構如下:
    CMP ????,????   ; 比較數值
    JXX AAAAAAAA    ; 比較方式
    ......          ; 分支一
    ret
    AAAAAAAA        ; JXX後面的地址
    ......          ; 分支二
    ret

(3、)以常量爲判斷條件的複雜if-else分支:Release版的不可達分支會被剪掉
    無法總結出有意義的結構。

(4、)以變量爲判斷條件的複雜if-else分支:相同功能的分支會被歸併
    無法總結出有意義的結構。

小插曲:怎樣識別三目運算符

    是否還記得初學C語言時接觸三目運算符的那種感覺?相信每個人在學習三目運算符時或多或少都會有一些奇妙的或不尋常的感覺的。記得我當時在學完三目運算符時感覺很有魅力,它與衆不同、特立獨行,可謂俠者也。因此在學完後就開始不停的幻想各種三目運算符的應用場景……

    我們都知道三目運算符其本質就是if-else,但是真是如此嗎?下面讓我們一探究竟……

    源碼:

int _tmain(int argc, _TCHAR* argv[])
{
    return argc==1 ? 2:3;
}

    下面是它的反彙編代碼:

0041138E  XOR EAX, EAX                  ; 將EAX清零
00411390  CMP DWORD PTR SS:[EBP+8], 1   ; 比較
00411394  SETNE AL                      ; 如等於則將AL置爲0,否則置爲1
00411397  ADD EAX, 2                    ; 將EAX加2

    這看起來似乎有些繞,首先對於SETNE這個指令有可能會讓一部分新手犯暈,其次那個“ADD EAX, 2”也顯得神乎其神。那麼就讓我們攻克它吧!
    通過閱讀源碼可知,程序最後的返回結果只可能有兩種,既2與3,而SETNE則會根據ZF位的影響來決定是給AL(EAX)賦1還是0,當然,這要取決於上面的比較結果。
    其實分析到到這裏已經很明瞭了,如果比較相等,則EAX的值會被置爲0,加上2之後正好返回2,而如果不等的話自然就會返回3了,怎麼樣?是不是感覺編譯器很聰明?但是這似乎並不算什麼,爲了更好的證明,讓我們再看一個例子:

int _tmain(int argc, _TCHAR* argv[])
{
    return argc==1 ? 6:8;
}

    下面是它的反彙編代碼:

0041138E  XOR EAX, EAX
00411390  CMP DWORD PTR SS:[EBP+8], 1
00411394  SETNE AL
00411397  LEA EAX, DWORD PTR DS:[EAX+EAX+6]

補充:
    由於這種三目運算並不常見,因此筆者本沒想多講,後來看完讀者的回帖感覺還是有部分讀者對此比較感興趣,所以在這裏將其補齊。
    我們先看一段返回值與判斷值都爲無序長量的三目運算的例子:

int _tmain(int argc, _TCHAR* argv[])
{
    return argc==1?6:18;
}

   他的反彙編如下所示:

0041138E  mov         eax,dword ptr [EBP+8] ; 將參數傳遞給EAX
00411391  sub         eax,1                
00411394  neg         eax                   ; 這兩句指令的實際意思就是測試EAX是否爲1,如果EAX爲1則減1
                                            ; 再求補之後會將CF位置1
                                            ;
00411396  sbb         eax,eax               ; 代位減法,如果CF位此時爲1,那麼得到的結果將是0xFFFFFFFF
                                            ; 否則得到的結果則爲0x00000000
                                            ;
00411398  and         eax,0Ch               ; 根據EAX的值來決定, 做完與運算之後或爲0,或爲0x0C
0041139B  add         eax,6                 ; 將EAX加6

    這是一段比較繞的代碼,而且是一環套一環的,最終的結果爲幾取決於與0x0C做與運算的是什麼值,而這個值又取決於CF位是否爲1,而其參數是否相等則影響着CF位的狀態爲幾。

    但是這看似複雜的流程其實是有規律可循的,以下就是筆者總結出來的。

邏輯公式:
if(試圖將參數平衡爲0)
{
    and 0xFFFFFFFF,0Ch  ; EAX = 12
    add eax,6           ; EAX = 18
}
else
{
    and 0x00000000,0Ch  ; EAX = 0
    add eax,6           ; EAX = 6
}

數學公式:
最終返回值 = ( and ( neg(變量-判斷值) - neg(變量-判斷值) ),(分支2的值 - 分支1的值) ) + 分支1的值
    return = ( and ( neg(argc-1)      - neg(argc-1) )     ,(18 - 6) )                + 6


    現在我們再來看一種返回值爲變量的三目運算的例子:

int _tmain(int argc, _TCHAR* argv[])
{
    return argc==1?6:(int)argv;
}

反彙編:

00401000  CMP DWORD PTR SS:[ESP+4], 1
00401005  MOV EAX, 6
0040100A  JE SHORT Test_0.00401010
0040100C  MOV EAX, DWORD PTR SS:[ESP+8]
00401010  RETN

    無需多說,典型的if-else分支……

    由此可知三目運算符總共可以分爲三種情況,既有返回值爲序可循常量的、返回值無序可循常量的與返回值爲變量的三種情況。

    怎麼樣?是不是突然感覺到編譯器優化算法的強大之處了?試着想一下,如果我們有能力用算法解決這類複雜的問題,那麼所謂的虛擬機與混淆器還有什麼呢?因此各位學完逆向一定要明白一件事,真正的牛人與前輩其實是在幕後默默的寫編譯器的那幫傢伙……

    本小節到此就結束了,希望各位讀者學完後自己下去總結一下,多多實踐,要記住逆向技巧是在大堆的彙編代碼中沐浴出來的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章