要问接触C++逆向之前最有趣的知识点是什么?笔者认为非switch-case结构莫属了……
    但是不得不说的是,有关于switch-case逆向真的可谓博大精深了(相对于基础薄弱者),因此笔者在提笔之前做了很多假设,并且自己也推翻了几种方案。最后得出的结论是此知识点一定要深入讲解,特别是到后面的东西更是要涉及到一些数据结构相关的知识,笔者会尽力用最简洁、最简单的语言为大家阐述清楚,我想既然我都有信心使各位读者能看懂本小节,那么各位读者就更因该努力了。
    下面我们进入主题,我们都知道switch-case的诞生其实就是为了避免出现大量的、高重复if-else语句,换句话说,switch-case语句其实就是if-else语句的另一种体现形式,但是事实真的是这样吗?让我们一起为这个观点求证……

1.5.1、简单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的话,难道程序还会用这个方法解决吗?肯定不会!
    下一节我将阐述编译器是怎样解决这类复杂的Switch-case组合的。

【返回到目录】:http://bbs.pediy.com/showthread.php?t=113689