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分支……
由此可知三目运算符总共可以分为三种情况,既有返回值为序可循常量的、返回值无序可循常量的与返回值为变量的三种情况。
怎么样?是不是突然感觉到编译器优化算法的强大之处了?试着想一下,如果我们有能力用算法解决这类复杂的问题,那么所谓的虚拟机与混淆器还有什么呢?因此各位学完逆向一定要明白一件事,真正的牛人与前辈其实是在幕后默默的写编译器的那帮家伙……
本小节到此就结束了,希望各位读者学完后自己下去总结一下,多多实践,要记住逆向技巧是在大堆的汇编代码中沐浴出来的。
【返回到目录】:http://bbs.pediy.com/showthread.php?t=113689
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课