C++反汇编与逆向分析技术(三)

记录一下《C++反汇编与逆向分析技术》(三)

流程控制语句的识别

if语句

if 语句只能判断两种情况:“0”为假值,“非0”为真值。如果为真值,则进人语句块内执行语句;如果为假值,则跳过if语句块,继续运行程序的其他语句。要注意的是,if语句转换的条件跳转指令与if语句的判断结果是相反的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//C++源码说明:if语句结构组成
if( argc == 0 ){
printf("%d \r\n", argc);
}
//C++源码对应汇编
//如果argc = 0的情况,为真
if( argc == 0 )
//使用cmp指令,将ebp+8地址的4字节和0相减
//结果argc不改变,淡水会改变CF,ZF,OF,AF和PF
00401028 cmp dword ptr [ebp+8],0
//JNE看ZF,如果等于0跳转,表示现在的argc不等于0
//跳转到0x40103F
//这个地址为if的结束地址,之后跳转出if
0040102C jne main+2F(0040103f)
{
//printf略
}
//函数返回
return 0;
0040103F xor eax,eax

出现这种情况是因为汇编语言的条件跳转是满足某条件则跳转,绕过某些代码块,这一点是与C语言相反的,而C语言编译器不将else语句块提到if语句块前面是因为C语言是根据代码行的位置来决定编译后的二进制代码的地址高低的,也就是说,低行数对应低地址,高行数对应高地址

根据这一特性,如果将if语句中的比较条件 “argc==0” 修改为 “if(argc > 0)”,则其对应的汇编语言所使用的条件跳转指令会是“小于等于0”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//C++源码说明: if语句大于0比较
if( argc > 0 ){
printf("%d \r\n",argc);
}
//C++源码对应汇编代码
//C++源码对比,如果参数argc大于0,结果为真,进入执行语句
if(argc > 0)
//使用cmp,将ebp+8地址的4字节数据和0进行减法
0040103F cmp dword ptr [ebp+8],0
00401043 jle MyIf + 42h (00401052)
{
//printf略
}
00401052 pop edi

在分析过程中,表达式短路(上一篇blog的内容)和if语句这两种分支结构的实现结构都是一样的,蛮难区分的

总结:

1
2
3
;先执行各类影响标志位的指令
;其后是各种条件跳转指令
jxx xxxx

如果遇上以上指令序列,大概率是一个if语句组成的单分支结构

if···else···语句

if和if···else···的流程对比

结构对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//C++源码说明: if  else
if(argc == 0){
printf("argc = 0");
}
else{
printf("argc != 0");
}
//对应汇编
004010B8 cmp dword ptr [ebp+8],0 //相减
004010BC jne IfElse+2Dh (004010cd){
//printf 略
}
004010CB jmp IfElse+3Ah (004010da)
{
//进入else
004010CD push offset string "argc != 0" (00420030)
004010D2 call printf (00401150)
004010D7 add esp,4
}
//结束
004010DA pop edi

将上述代码稍作改动,改为符合条件表达式转换方式1的形式,如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//C++源码说明,if...else..模拟条件表达式转换方式
if(argc == 0){
argc = 5;
}
else{
argc = 6;
}
printf("%d \r\n",argc); //防止argc被优化处理
//对应汇编,debug版,无优化
if(argc == 0){
00401098 cmp dword ptr [ebp+8],0
0040109c jne main+27h(004010a7)
argc = 5;
0040109E mov dword ptr [ebp+8],5
}
else{
004010A5 jmp main+2Eh(004010ae)
argc = 6;
004010A7 mov dword ptr [ebp+8],6
}
printf("%d \r\n",argc);
//略
004010AE ...

还是挺简单的,总结一下

1
2
3
4
5
6
7
8
9
10
总结结构:
//产生修改标志位的指令
jxx else_begin
if begin:
//执行if中的内容
if end:
jmp else end
else begin:
//执行else的内容
else end

这两个跳转指令就是辨识的关键,不过和条件表达式差不多,所以反推的时候看个人习惯

上面那份代码是debug版,下面给个O2优化的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arg_0 = dword ptr 4
//取到的参数数据放在edx中
mov edx,[esp+argc_0]
//将eax清0
xor eax,eax
//对edx和edx进行想与的操作,结果不影响edx
test edx,edx
//检查ZF的标记位,edx不等于0的时候,我们的al = 1,等于0 我们的al = 0
setnz al
add eax,5 //这里可能是5/6,看al了,我们不等于0,那么就是6,我们等于0,那么就是5
push eax
push offset Format ; "%d \r\n"
call _printf
add esp,8
retn

可以看到优化了很多,做了一个流水线优化,还是蛮难还原回去的

用if构成的多分支流程

多分支结构就是if···else if···else if···else···这种结构,直接看代码吧,这是debug版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//C++源码说明,多分支结构
void IfElseIf(int argc){
if (argc > 0){
printf("argc > 0");
}
else if (argc == 0){
printf("argc == 0");
}
else{
printf("argc <= 0");
}
}
//对应汇编
if(argc > 0)
00401108 cmp dword ptr [ebp+8],0
//相减 argc-0 之后使用小于等于跳转的条件,jle
0040110C jle IfElseIf+2Dh(0040111d) //到else位置,目测那边是一个cmp 一个jne{
printf("argc > 0");
//printf 略
0040110E push offset string "argc > 0"(00420f9c)
00401113 call printf(00401150)
00401118 add esp,4
}
else if ( argc == 0 )
0040111B jmp IfElseIf+4Fh(0040113f)
//if比较转换,和我上面目测的差不多一个cmp,一个jne
0040111D cmp dword ptr [ebp+8],0
00401121 jne IfElseIf+42h(00401132) //到下一个else的位置,直接就是执行了
{
printf("argc == 0");
//printf 略
00401123 push offset string "argc == 0"(0042003c)
00401128 call printf(00401150)
0040112D add esp,4
}
//跳转到多分支结构结束地址
00401130 jmp IfElseIf+4Fh(0040113f)
{
printf("argc < = 0");//上面都不满足,无条件执行
//printf略
00401132 push offset string "argc != 0" (00420030)
00401137 call printf (00401150)
0040113C add esp,4
}
0040113F pop edi

debug版的代码看着还是舒适,就是一个很简单的cmp+跳转指令,来个总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
总结:
//影响标志位
jxx else if begin
if begin:
...
if end:
jmp end
else if begin:
//影响标志位
jxx else begin
...
else if end:
jmp end
else begin
...
end:
...

在编译期间,永远不可抵达的分支将被优化掉而不参与编译处理,在写代码的时候,可以在每个else if的代码块里加个return,这样就可以减少分支比较。

看一下开启O2优化的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.text:00401000 sub_401000	proc near ; CODE XREF: _main+5p
//arg_0为函数参数
.text:00401000 arg_0 = dword ptr 4
//取出参数数据,放入eax,进行第一次if比较
.text:00401000 mov eax,[esp+arg_0]
.text:00401004 test eax,eax
//比较后进行转移,目测jle
.text:00401006 jle short loc_401016
//执行
.text:00401008 push offset Format ; "argc > 0"
.text:0040100D call _printf
.text:00401012 add esp,4
//使用retn指令返回,结束函数调用
.text:00401015 retn

//代表此处代码被符号sub_401000地址加6的地方引用
.text:00401016 loc_401006: ;CODE XREF: sub_401000+6j
//这里目测因为有test的问题 直接jne就可以
.text:00401018 push offset aArgc0_0 ; "argc == 0"
.text:0040101D call _printf
.text:00401022 add esp,4
//返回
.text:00401025 retn

//前两次失败没返回直接执行此处
.text:00401026 loc_401026: ; CODE XREF: sub_401000:loc_401016j
.text:00401026 push offset aArgc0_1; "argc <= 0"
.text:0040102B call _printf
.text:00401030 pop ecx
.text:00401031 retn
.text:00401031 sub_401000 endp

有内味了,看来现在的出题人都喜欢开O2优化

switch的真相

看一下3条case语句的switch跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
switch (nIndex} {				//源码对比
0040DF00 mov ecx, dword ptr [ebp-4]
;取出变量nIndex的值并放到ecx中,再将ecx放入临时变量ebp - 8中
0040DF03 mov dword ptr [ebp-8] , ecx
;将临时变量和1进行比较
0040DF06 cmp dword ptr [ebp-8] ,1
;条件跳转比较,等于1则跳转到地址0x0040DF1A处
0040DF0A je SwitchIf+4Ah (0040df1a)
;将临时变量和3比较
0040DFOC cmp dword ptr [ebp-8] ,3
;条件跳转比较,等于3则跳转到地址0x0040DF29处
0040DF10 je SwitchIf+59h (0040df29)
;将临时变量和100比较
0040DF12 cmp dword ptr [ebp-8] , 64h
;条件跳转比较,等于100则跳转到地址0x0040DF38处
0040DF16 je SwitchIf+68h (0040df38)
0040DF18 jmp SwitchIf+75h (0040df45)

case 1: //源码对比
printf ("nIndex == 1") ; //源码对比
0040DF1A push offset string "nIndex == 1" (00421024)
0040DF1F call printf (004014b0)
0040DF24 add esp,4
break; //源码对比
0040DF27 jmp SwitchIf+75h (0040df45)

case 3: //源码对比
printf ("nIndex == 3") ; //源码对比
0040DF29 push offset string "nIndex == 3" (004210d8)
0040DF2E call printf (004014b0)
0040DF33 add esp,4

case 100: //源码对比
printf ("nIndex == 100"); // 源码对比
0040DF38 push offset string "nIndex == 100" (0042004c)
0040DF3D call printf (004014b0)
0040DF42 add esp,4
break;
}} //源码对比
0040DF45 pop edi

可以看到与if···else差距还是挺大的,if···else是跳转后跟着语句块,而switch结构则将所有的条件跳转都放在了一起。这样就实现了C语法的要求,在case语句块中没有break语句时,可以顺续执行后续case语句块

总结:

1
2
3
4
5
6
7
8
9
10
11
12
13
mov		reg, mem		;取出switch中考察的变量
; 影响标志位的指令
jxx xxxx ; 跳转到对应case语句块的首地址
; 影响标志位的指令
jxx xxxx
; 影响标志位的指令
jxx xxxx
jmp END ; 跳转到switch的结尾地址处
...... ; case语句块的首地址
jmp END ; case语句块结束,有break则产生这个jmp
//下同
END: ; switch结尾
......

这是switch分支数小于4的情况下,VC++ 6.0采用的策略,当分支数大于3时,并且case的判定值存在明显线性关系组合时,switch的优化特性便可以凸显出来,如代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//C++示例代码
int nIndex = 0;
scanf ("%d", & nIndex) ;
switch (nIndex) {
case 1: printf ("nIndex == 1") ;break;
case 2: printf ("nIndex == 2") ;break;
case 3: printf ("nIndex == 3") ;break;
case 5: printf ("nIndex == 5") ;break;
case 6: printf ("nIndex == 6") ;break;
case 7: printf ("nIndex == 7") ;break;
}

//debug版
switch (nIndex){ //源码对比
;将变量nIndex内容放入ecx中
00401110 mov ecx, dword ptr [ebp-4]
;取出ecx的值并放入临时变量ebp-8中
00401113 mov dword ptr [ebp-8] , ecx
;取临时变量的值放入edx中,这几句代码的功能看似没有区别
;只有在Debug版下才会出现
00401116 mov edx, dword ptr [ebp-8]
;对edx减1,进行下标平衡
00401119 sub edx, 1
;将减1后的临时变量放回
0040111C mov dword ptr [ebp-8] , edx
;判断临时变量是否大于6
0040111F cmp dword ptr [ebp-8] ,6
;大于6跳转到0x00401187处
00401123 ja $L556+0Dh (00401187)
;取出临时变量的值放到eax中
00401125 mov eax, dword ptr [ebp-8]
;以eax为下标,0x00401198为基址进行寻址,跳转到该地址处
;注意:地址0x00401198就是case地址数组
00401128 jmp dword ptr [eax*4+401198h]
}

这里将case语句块的首地址保存在一个数组中,并且数组下标以0为起始,在进入switch后会先进行一次比较,检查输入的值是否大于case的最大值。case地址数组如下:

有序线性case地址表

如果每两个case值之间的差值小于等于6,并且case语句数大于等于4,编译器中就会形成这种线性结构。在编写代码的过程中无需有序排列case值,编译器会在编译过程中对case线性地址表进行排序,如case的顺序为3、2、1、 4、5,在case线性地址表中,会将它们的语句块的首地址进行排序,将case 1语句块的首地址放在case线性地址表的第0项上,case 2语句块首地址放在表中第1项,以此类推,将首地址变为一个有序的表格进行存放,如图

模拟图

给出release版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
;取出switch语句的参数值并放入ecx中
00401018 mov ecx, dword ptr [esp+8]
0040101C add esp, 8 ;平衡scanf函数的参数

;将ecx减1后放入eax中,因为最小的case 1存放在case地址表中下标为0处,需要调整对齐到0下标,便于直接查表
0040101F lea eax, [ecx-1]
;与6进行比较,有了这两步操作可以初步假设这里的代码是一个switch结构
;无符号比较,大于6时跳转到地址0x00401086处
00401022 cmp eax,6
00401025 ja 00401086

;下面的指令体现了switch的第二个特性;查表(case地址表)
;可以确认这是一个switch结构
;上一步的跳转地址00401086就是switch结尾或者是default语句块的首地址
;下面指令中的地址0x00401088便是case线性地址表的首地址
00401027 jmp dword ptr [eax* 4+401088h]
;地址0x0040706C为字符串"nIndex == 1"的首地址
;此处为第一个case语句块的地址
0040102E push 40706Ch
//调用printf函数
00401033 call 004012F0
;平衡printf函数破坏的栈空间
00401038 add esp,4
;还原esp
0040103B pop ecx
;返回,在Release版中,煸译器发现switch后什么也没做就直接返回,所以
;将每句break优化为了return
;到此处,第一-个case语句块结束,顺序向下为第二个case语句块
0040103C ret

;第二个case语句块
;地址0x00407060为字符串"nIndex m= 2"的首地址
0040103D push 407060h
00401042 call 004012F0
00401047 add esp,4
0040104A pop ecx
0040104B ret

//下面代码类似,不写了

总结:

1
2
3
4
5
6
7
8
mov		reg,mem			;取变量
;对变量进行运算,对齐case地址表的0下标,非必要
;上例中的eax也可用其他寄存器替换,这里也可以是其他类型的运算
lea eax,[reg+xxxx]

;影响标志位的指令,迸行范围检查
jxx DEFAULT_ADDR
jmp dword ptr [eax*4+xxxx]; 地址xxxx为case地址表的首地址

当遇到这样的代码块时,可获取某一变量的信息并对其进行范围检查,如果超过case的最大值,则跳转条件成立,跳转目标指明了switch语句块的末尾或者是default块的首地址。条件跳转后紧跟jmp指令,并且是相对比例因子寻址方式,且基址为地址表的首地址,说明此处是线性关系的switch分支结构

难以构成跳转表的switch

对于两个case值间隔较大时,编译器用索引表来优化,索引表优化需要两张表:一张为case语句块地址表,另一张为case语句块索引表。

地址表中的每一项保存一个case语句块的首地址,有几个case语句块就有几项。default语句块也在其中,如果没有则保存一个switch结束地址。这个结束地址在地址表中只会保存一份,不会像有序线性地址表那样,重复保存switch的结束地址。

索引表中保存地址表的编号,它的大小等于最大case值和最小case值的差。当差值大于255时,这种优化方案也会浪费空间,可通过树方式优化,这里就只讨论差值小于或等于255的情况。表中的每- -项为- 一个字节大小,保存的数据为case语句块地址表中的索引编号。

首先将所有case语句块的首地址保存在-一个地址表中,地址表中的表项个数会根据程序中case分支来决定。有多少个case分支,地址表就会有多少项。索引表,中保存了地址表中的下标值。索引表中最多可以存储256项,每- -项的大小为 1字节,这决定了case值不可以超过1字节的最大表示范围(0~255), 因此索引表也只能存储256项索引编号。

索引表结构模拟图

具体的参考这个blog吧,写的很好

do/while/for的比较

do循环是向上跳转例如:

1
2
3
4
DO_BEGIN
······ ;循环语句块
cmp xxxx/test xxxx/sub xxxx/·····;影响标记位的指令
jxx DO_BEGIN ; 向上跳转

while循环是先进行判断,向下跳转,到单次循环结束时,向上跳转,例如

1
2
3
4
5
6
WHILE_BEGIN:
; 影响标记位的指令
jxx WHILE_END ; 条件成立跳转到循环语句块结尾处
······ ; 循环语句块
jmp WHILE_BEGIN ; 跳转到取出条件比较数据处
WHILE_END;

for循环由赋初值、设置循环条件、设置循环步长这三条语句组成

for循环比较复杂,看一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//C++源码说明:for循环完成整数累加和
int LoopFor (int nCount} {
int nSum = 0;
//初始计数器变量、设置循环条件、设置循环步长
for (int nIndex = 0; nIndex <= nCount; nIndex++) {
nSum += nIndex;
}
return nSum;
}

// C++源码于对应汇煸代码讲解
int nSum = 0;
0040B818 mov dword ptr [ebp-4] ,0
// C++源码对比,for语句
for (int nIndex = 0; nIndex <= nCount;nIndex++)
; =====================================================
; 初始化计数器变量--nIndex 1.赋初值部分
0040B81F mov dword ptr [ebp-8] ,0
; 跳转到地址0x0040B831处,跳过步长操作
00408826 jmp LoopFor+31h (0040b831)
;=====================================================
; 取出计数器变量,用于循环步长 2.步长计算部分
0040B828 mov eax, dword ptr [ebp-8]
; 对计数器变量执行加1操作,步长值为1
0040B82B add eax,1
;将加1后的步长值放回计数器变量一nIndex
0040B82E mov dword ptr [ebp-8] ,eax
;=====================================================
;取出计数器变量nIndex放入ecx 3.条件比较部分
0040B831 mov ecx, dword ptr [ebp-8]
; ebp+8地址处存放数据为参数nCount, 见C++源码说明
0040B834 cmp ecx, dword ptr [ebp+8]
; 比较nIndex与nCount, 大于则跳转到地址0x0040B844处,结柬循环
0040B837 jd LoopFor+44h (0040b844)
;=====================================================
{
// for循环内执行语句块 4.循环体代码
nSum += nIndex;
mov edx, dword ptr [ebp-4]
0040B83C add edx, dword ptr [ebp-8]
0040B83F mov dword ptr [ebp-4] , edx
}
;跳转到地址0x0040B828处,这是一个向上跳
0040B842 jmp LoopFor+28h (0040b828)
return nSum;
//设置返回值eax为ebp-4, 即nSum
0040B844 mov eax, dword ptr [ebp-4]

以上,可以看到代码分为四个部分,总结一下就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mov		mem/ reg, xxx						;賦初值
jmp FOR_CMP ;跳到循环条件判定部分
FOR_STEP: ;步长计算部分
;修改循环变量Step
mov reg,Step
add reg,xxxx ;修改循环变量的计算过程,在实际分析中,视算法不同而不同
mov Step,eax
FOR_CMP: ;循环条件判定部分
mov ecx, dword ptr Step
;判定循环变量和循环终止条件StepEnd 的关系,满足条件则逞出for循环
cmp ecx,StepEnd
jxx FOR_END ;条件成立则结柬循环
·····
jmp FOR_STEP ;向上跳转,修改流程回到步长计算部分
FOR_END;

这种结构是for循环独有的,在计数器变量被赋初值后,利用jmp跳过第一次步长计算。然后,可以通过三个跳转指令还原for循环的各个组成部分:第一个jmp指令之前的代码为初始化部分;从第一个jmp指令到循环条件比较处( 也就是上面代码中FOR_ _CMP 标号的位置)之间的代码为步长计算部分;在条件跳转指令jxx之后寻找一个jmp指令,这jmp指令必须是向上跳转的,且其目标是到步长计算的位置,在jxx和这个jmp(也就是上面代码中省略号所在的位置)之间的代码即为循环语句块。

编译器对循环结构的优化

while和for循环在O2优化中,都会被优化成do的形式,而结构被优化后,细节上还能被再次优化

  1. 代码外提

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    //C++源码说明:for循环完成整数累加和
    int CodePick (int nCount) {
    int nSum = 0;
    int nIndex = 0;
    do {
    nSum += nIndex;
    nIndex++ ;
    //此处代码每次都要判断nCount一1, nCount并没有自减,仍然为一个固定值
    //可在循环体外先对nCount进行减等于1操作,再进入循环体
    } while (nIndex < nCount - 1) ;
    return nSum;
    }

    //经过优化后的反汇编代码
    . text : 00401000 sub_ 401000 proc near; CODE XREF:_ main+21↑p
    . text :00401000 arg_ 0 = dword ptr 4
    ;荻取参数到edx中
    . text : 00401000 mov edx,[esp+arg_0]
    . text : 00401004 xor eax,eax
    . text : 00401006 xor ecx,ecx
    ;代码外提,对edx执行自减1操作
    . text : 00401008 dec edx
    ;进入循环体,在循环体内直接对保存参数的edx进行比较,没有任何减1操作
    . text : 00401009 loc 401009: : CODE XREF: sub_ 401000+E↑j
    . text : 00401009 add eax, ecx
    . text : 0040100B inc ecx
    . text : 0040100C cmp ecx,edx
    . text : 0040100E jl short loc_401009
    . text : 00401010 retn
    . text : 00401010 sub_401000 endp

    也就是被优化成了
    int CodePick (int nCount) {
    int nSum = 0;
    int nIndex = 0;
    nCount -= 1;
    do {
    nSum += nIndex;
    nIndex++ ;
    //此处代码每次都要判断nCount一1, nCount并没有自减,仍然为一个固定值
    //可在循环体外先对nCount进行减等于1操作,再进入循环体
    } while (nIndex < nCount) ;
    return nSum;
    }

    2.强度削弱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// C++源码说明:强度削弱
int main(int argc) {
int t=0;
int i=0;
while (t < argc) {
t = i * 99; //强度削弱后,这里将不会使用乘法运算
i++; //此处转换后将为 t = i; i += 99
} //利用加法运算替换掉了指令周期长的乘法运算
printf("%d",t);
return 0;
}

;优化后的反汇编代码
. text : 00401020 arg_0 = dword ptr 4
;将参数信息保存到edx中
. text : 00401020 mov edx, [esp+arg_0]
. text : 00401024 xor eax,eax ; 清空eax
. text : 00401026 test edx,edx
. text : 00401028 jle short loc_401035
. text : 0040102A xor ecx,ecx ; 清空ecx
. text : 0040102C
;循环语句玦首地址
. text : 0040102C loc_ 40102C: ; CODE XREF: sub_ 401020+13↑j
. text : 0040102C mov eax,ecx ; 将ecx传入eax中
;ecx自加63h,即十进制99,等价于ecx每次加1乘以99.
. text : 0040102E add ecx,63h
. text : 00401031 cmp eax,edx
. text : 00401033 jl short loc_40102C ; eax小于edx则执行跳转
. text : 00401035
. text : 00401035 loc_401035: ; CODE XREF: sub_ 401020+8↑j
;printf函数调用处略
. text : 00401043 retn
. text :00401043 sub_401020 endp

函数工作原理

栈帧的形成和关闭

当栈顶指针esp小于栈底指针ebp时,就形成了栈帧

栈平衡

​ 不同的两次函数调用,所形成的栈帧也不相同。当由一个函数进入到另一个函数中时,就会针对调用的函数开辟出其所需的栈空间,形成此函数的栈帧。当这个函数结束调用时,需要清除掉它所使用的栈空间,关闭栈帧,我们把这一过程称为栈平衡。

给一个小demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// C++源码说明:一个空函数
int main() {
return 0;
}

//C++源码与对应的汇编代码讲解
int main(){
; 以下是进入函数时的代码
00401010 push ebp ;进入函数后的第一件事,保存栈底指针ebp
00401011 mov ebp,esp ;调整当前栈底指针位置到栈顶
00401013 sub esp,40h ;抬高栈顶esp,此时开辟栈空间0x40,作为局部变量的存储空间
00401016 push ebx ;保存寄存器ebx
00401017 push esi ;保存寄存器esi
00401018 push edi ;保存寄存器edi
00401019 lea edi, [ebp-40h] ;取出此函数可用栈空间首地址
0040101C mov ecx,10h ;设置ecx为0x10
00401021 mov eax, 0CCCCCCCCh ;将局部变量初始化为0CCCCCCCCh

;根据ecx的值,将eax中的内容,以4字节为单位写到edi指向的内存中
00401026 rep stos dword ptr [edi]

;以下是用户编写的函数实现代码
return 0;
0040102A xor eax,eax ;设置返回值为0
}

;以下是函数退出时的代码
0040102C pop edi ;还原寄存器edi
0040102D pop esi ;还原寄存器esi
0040102E pop ebx ;还原寄存器ebx
0040102F add esp,40h ;降低栈顶esp,此时局部变量空间被释放
00401032 cmp ebp,esp ;检测栈平衡,如ebp与esp不等,则不平衡
00401034 call _chkesp (00401050) ;进入栈平衡错误裣测函数
00401039 mov esp,ebp ;还原esp
0040103B pop ebp
0040103C ret

各种调用方式

由于函数自身无法对不定参数执行平衡操作,所以有了函数的调用约定:

  • _cdecl : CIC++默认的调用方式,调用方平衡栈,不定参数的函数可以使用。
  • _stdcall : 被调方平衡栈,不定参数的函数无法使用。
  • _fastcall : 寄存器方式传参,被调方平衡栈,不定参数的函数无法使用。

_cdecl 与 _stdcall 的对比,debug版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// C++源码说明:_cdecl、_stdcall 两种调用方式的区别
void _stdcall Showstd (int nNumber){ // 使用_stdca1l 调用方式,被调方平衡栈
printf("%d \r\n",nNumber);
}
void _cdecl ShowCde (int nNumber){ // 使用_ _cdecl 调用方式,调用方平衡栈
printf("%d \r\n",nNumber);
}

void main(){
ShowStd(5); //不会有平衡栈操作
ShowCde(5); //函数调用结柬后,对esp平衡4
}

// C++源码于对应汇编代码讲解
// C++源码对比,_stdcall调用方式
void _stdcall ShowStd (int nNumber)
;略去部分代码
{
//printf 函数实现略
printf("%d \r\n",nNumber);
}
;略去部分代码
00401059 ret 4 ;结柬后平衡栈顶4,等价esp += 4

// C++源码对比,_cdecl 调用方式
void _cdec1 ShowCde (int nNumber)
;略去部分代码
{
//printf 函数实现略
printf("%d \r\n",nNumber);
}
;略去部分代码
004010A9 ret ;没有平衡操作

// C++源码对比,使用 _stdcall方式调用函数Showstd
ShowStd(5) ;
0040B7C8 push 5 ;函数传参,使用push指令esp-4
0040B7CA call @ILT+10 (show) (0040100f) ;没有对esp操作的指令
// C++源码对比,使用_cdecl方式调用函数ShowCde
ShowCde(5) ;
0040B7CF push 5 ;函数传参,使用push指令esp-4
0040B7D1 call @ILT+15 (ShowCde) (00401014)
0040B7D6 add esp,4 ;esp += 4,平衡栈顶

虽然区别明显,但是不能当作区分依据,因为汇编语言可能会在其他地方平衡栈~

在O2优化下,_cdecl会采取复写传播优化,将每次参数平衡的操作进行归并,一次性平衡栈顶指针esp。

_cdecl 调用方式的函数在同一作用域内多次使用, 会在效率上比 _stdcal l 高一点,这是因为 __cdecl 可以使用复写传播,而 _ stdcall 都在函数内平衡参数,无法使用复写传播这种优化方式。在这三种调用方式中, _ fastcall 调用方式的效率最高,其他两种调用方式都是通过栈传递参数,唯独 _ fastcall 可以利用寄存器传递参数。但由于寄存器数目很少,而参数相比可以很多,只能量力而行,故 _fastcall 调用方式只使用了ecx和edx,分别传递第-一个参数和第二个参数,其余参数传递则转换成栈传参方式。

来份_fastcall代码康康~:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// C++源码说明:_fastcall调用方式
vold _fastcall ShowFast (int nOne,int nTwo,int nThree, int nFour) {
printf("%d%d%d%d \r\n", nOne, nTwo, nThree, nFour) ;
}
void main(){
ShowFast(1,2,3,4);
}

// C++源码与对应汇编代码讲解
// C++ 源码对比, 函数调用
ShowFast (1,2,3,4) ;
004012A8 push 4 ;使用栈方式传递参数
004012AA push 3 ;使用栈方式传递参数
004012AC mov edx,2 ;使用edx传递第二个参数2
004012B1 mov ecx,1 ;使用ecx传递第一个参数1
004012B6 call @ILT+15 (ShowFast) (00401014)

// C++源码对比,函数说明
void fastcall ShowFast(int nOne,int nTwo, int nThree, int nFour) {
004010F0 push ebp
004010F1 mov ebp,esp
004010F3 sub esp,48h
004010F6 push ebx
004010F7 push esi
004010F8 push edi
;由于ecx即将被赋值作为循环计数器使用,在此将ecx原值保存
004010F9 push ecx
004010FA lea edi,[ebp-48h]
004010FD mov ecx,12h
00401102 mov eax, 0CCCCCCCCh
00401107 rep stos dword ptr [edi]
00401109 pop ecx ;还原ecx
;使用临时变量保存edx (参数2)
0040110A mov dword ptr [ebp-8] , edx
;使用临时变量保存ecx (参数1)
0040110D mov dword ptr [ebp-4] , ecx
// C++源码对比,printf 函数调用
printf(" %d%d%d%d \r\n", nOne,nTwo, nThree, nFour) ;
;使用ebp相对寻址取得参数4
00401110 mov eax, dword ptr [ebp+0Ch]
00401113 push eax ;将eax压栈,作为参数
;使用ebp相对寻址取得参数3
00401114 mov ecx, dword ptr [ebp+8]
00401117 push ecx ;将ecx压栈, 作为参数
;在ebp-8中保存edx,即参数2
00401118 mov edx,dword ptr [ebp-8]
0040111B push edx ;将edx压栈, 作为参数
;在ebp-4中保存ecx,即参数1
0040111C mov eax, dword ptr [ebp-4]
0040111F push eax ;将eax压栈, 作为参数
00401120 push offset string "%d %d %d %d \r\n" (00422024)
00401125 call printf(004012e0)
0040112A add esp,14h ;平衡pirntf 使用的5个参数
}
0040113D ret 8 ;此函数有4个参数,ret指令对其平衡

使用ebp或esp寻址

由于局部变量使用栈空间进行存储,因此进入函数后的第一件事就是开辟函数中局部变量所需的栈空间大小,变量随着进入函数体开始生命,函数执行的时候结束

在大多数情况下,使用ebp寻址局部变量只能在非O2选项中产生,这样做是为了方便调试和检测栈平衡,使目标代码可读性更高。而在O2编译选项中,只要栈顶是稳定的,就可以不再使用ebp, 利用esp直接访问局部变量,可以节省一个寄存器资源。来个小demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// C++源码说明:通过esp访问局部变量
void InNumber() {
int nInt = 1;
scanf("%d", &nInt);
char cChar = 2;
scanf("%c", &cChar);
printf("%d %c\r\n",nInt,cChar);
}

//函数在main函数中被调用
void main(){
InNumber();
}
;在Release版下,反汇蝙代码信息
;函数定义,由于在main函数中只有一句函数调用代码,因此IDA为其取名 _main_0, 而不是使用地址做标号
_main_0 proc near
var_5 = byte ptr -5 ; IDA定义的局部变量标号,IDA环境下局部变量用var_ 开头
var_4 = dword ptr -4 ; IDA定义的局部变量标号
;为局部变量开辟8字节栈空间,这里在没有了那些繁琐的操作
sub esp,8
;这句指令等价于; esp+8-4,标号var_4等于-4, IDA自动识别出访问的变量地址,并调整显示方式,省去了计算偏移量这个过程,类似于高级语言中为变量命名,使代码显示起来更具可读性
lea eax,[esp+8+var_4]
mov [esp+8+var_4], 1 ; 初始化var_ 4变量为1
push eax ; eax中保存[esp+8-4]的值,将eax作为参数入栈
push offset aD ; "%d"
call _scanf

;在分析指令的时候,IDA会根据代码上下文归纳出影响栈顶的指令,以确定esp相对寻址所访问的目标。
;于是IDA识别出以下相对寻址指令的目标是该函数中的局部变量var_5, 之前执行了两次push指令,
;所以esp指向的栈顶地址存在-8的差值,而且本函数第一条指令sub esp,8也影响栈顶。综合以
;上信息,IDA为了表达出此时访问的局部变量为var_5, 并且将var_5定义为-5, 需要对esp相对寻址进行调整,先求解[esp+X+var_5]中的X,此处求解的X值为10h, 然后就可以表达为; [esp+10h+var_ 5], 以加强代码的可读性。
lea ecx,[esp+10h+var_5]
mov [esp+10h+var_5] ,2 ; 为var_5处的局部变量赋值2
push ecx ; esp -= 4
push offset aC ; "%c",esp -= 4
call _scanf
;由于又执行了两次push指令,并且没有平衡钱,所以需要再次调整esp的相对偏移值,这里的调整值为18h。注意,在这里的movsx指令处点一下Q键,可以得到movsx edx, byte ptr [esp+13h], 按K键可还原,名称。这里的movsx指令显示var_5 的类型为有符号类型,byte ptr 说明长度为单字节,对应C语言中的定义应该是char.当然读者也可以考察使用变量作参数的函数,如果函数功能是已知的,那么参数类型也就已知了,进而推导出变量的类型。
movsx edx, [esp+18h+var_5]
mov eax, [esp+18h+var_4]
push edx
push eax
push offset Format
call _printf

;经过优化后的代码,一次性平衡了栈顶esp.在此函数中,共执行了7次push操作,而函数scanf和printf函数使用相同的调用方式,即_ cdecl 调用方式,因此函数内没有平衡钱,需要调用者来平衡栈顶指针esp,又因为在退出函数前,还需释放局部变量的日个字节(见函数入口指令)空间,所以esp需要加(7*4+8=)36转换成十六进制后为24h
add esp,24h
retn
_main_0 endp

每次访问变量都需要计算,如果在函数执行过程中esp发生了改变,再次访问变量就需要重新计算偏移。为了省去对偏移量的计算,方便分析,IDA在分析过程中事先将函数中的每个变量的偏移值计算出来,得出了一个固定偏移值,使用标号记录。这里有两个方案,正数标号法和负数标号法

  • 正数标号法:以调整后的esp作为基址来计算局部变量的偏移值

    1
    2
    3
    4
    5
    6
    var_0 = 4 	;定义第一个变量偏移量,所在地址为0x0012FFEC
    var_1 = 0 ;定义第二个变量偏移量,所在地址为0x0012FFE8
    sub esp, 8 ;申请变量栈空间,esp保存地址变为0x0012FFE8
    lea eax, [esp+var_0] ;寻址第一个变量地址为0x0012FFE8+4 = 0x0012FFEC
    push eax ;执行push指令,esp被减4,esp地址变为0x0012FFE4
    lea eax [esp+4+var_1] ;由于esp被减4,需要对基址esp进行加4,调整后再加上标号
  • 负数标号法:以调整前的esp作为基址来计算局部变量的偏移值

    1
    2
    3
    4
    5
    6
    7
    8
    var_0 = -4		;定义第一个变量偏移量,所在地址为0X0012FFEC
    var_1 = -8 ;定义第二个变量偏移量,所在地址为0x0012FFE8
    sub esp,8 ;申请变量栈空间,esp 保存地址变为0x0012FFE8
    ;使用申请变量栈空间前的esp作为基扯,就需要调整esp,将其加8
    lea eax, [esp+8+var_0]
    push eax ;执行push指令,esp被减4,esp地址变为0x0012FFE4
    ;由于esp被减4,需要对基址esp进行二次调整,加8后再加4,因此得到数值0x0C
    lea eax [esp+0Ch+var_1]

显然ida选择了后者,下一节分析

函数的参数

参数也是函数中的一个变量,采用正数标号法来表示局部变量偏移标号时,函数的参数标号和局部变量的标号值都是正数,无法区分,不利于分析。如果使用负数标号法表示,则可以将两者区分,正数表示参数,而负数则表示局部变量,0值表示返回地址。

不放代码了,挺简单的

C\C++将不定长参数的函数定义为:

  • 至少要有一个参数
  • 所有不定长的参数类型传入时都是dword类型
  • 需在某一个参数中描述参数总个数或将最后一个参数赋值为结尾标记

根据参数的传递特性,只要确定第一个参数的地址,对其地址值做加法,就可访问到此参数的下一个参数所在的地址。获取参数的类型是为了解释地址中的数据。.上面提到的第三点是为 了获取参数的个数,其目的是正确访问到最后一个参数的地址,以防止访问参数空间越界。

函数的返回值

call指令被执行后,该指令同时还会做另一件事:将下一条指令所在的地址压入栈中

ret指令读取栈中地址传送给EIP寄存器,使程序回到call的下一条指令

函数的返回值是由EAX寄存器来保存,但是只能保存四字节数据,大于四字节的需要使用其他方法保存,来个demo代码(DEBUG版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// C++源码说明
// 函数功能:获取当前函数的返回地址
int GetAddr (int nNumber) {
//获取参数地址,减1后得到返回地址在栈中的地址
int nAddr =*(int*) (&nNumber - 1) ;
return nAddr; //将返回地址作为返回值返回
}

// C++源码与对应汇编代码讲解
int GetAddr (int nNumber) {
; Debug保护环境初始化部分略
int nAddr =*(int*) (&nNumber - 1) ;
; ebp加法与esp加法原理相同,都是取参数,但是这里为什么是加8呢?
;在Debug版下进入函数后,首先保存ebp会执行push ebp的操作,这样esp将执行压栈减4操作,随后执行mov ebp, esp的操作,由于栈顶esp之前被修改,所以ebp需要加4调整到最初的栈底位置,因此ebp+4 可以得到返回地址,ebp+8将会寻址第一个参数
;以下代码将第一个参数的地址传入eax中
0040DB78 lea eax,[ebp+8]
;执行eax自减4操作,执行后eax等价于ebp+4, 得到函数返回地址所在栈中的地址
0040DB7B sub eax,4
;取出函数返回地址传入ecx中
0040DB7E mov ecx, dword ptr [eax]
;使用ecx赋值局部变量
0040DB80 mov dword ptr [ebp-4] , ecx
return nAddr;
;取出局部变量数据传入eax中,用做函数返回值
0040DB83 mov eax, dword ptr [ebp-4]
}
; Debug恢复环境,平衡栈、栈平衡检测部分略
0040DB8C ret

//函数调用处
int nAddr = Getaddr (1) ;
0040DAF8 push 1 ;压栈传参,传入参数1
0040DAFA call @ILT+30(ss) (00401023) ;函数调用
0040DAFF add esp,4 ;_cdec1调用方式,平衡栈
0040DB02 mov dword ptr [ebp-4] , eax ;取得返回值

再来个结构体的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// C++ 源码说明:结构体类型作为返回值
struct tagTEST { //结构体定义
int m_nOne;
int m_nTwo;
};

//返回值为结构体类型的函数
tagTEST RetStruct () {
tagTEST testRet ;
testRet.m_nOne = 1;
testRet.m_nTwo = 2;
return testRet ;
}

//调用函数,并将返回值赋值到结构体实例test中
void main(){
tagTEST test;
test = RetStruct();
}

// C++源码与对应汇蝙代码讲解
tagTEST RetStruct() {
; Debug保存环境、初始化部分略
tagTEST testRet;
testRet.m_nOne = 1;
004012A8 mov dword ptr [ebp-8] ,1 ;对结构体成员变量赋值
testRet.m_nTwo = 2;
004012AF mov dword ptr [ebp-4] ,2 ;对结构体成员变量赋值
return testRet ;
004012B6 mov eax, dword ptr [ebp-8] ;取结构体成员变量数据传入eax中
004012B9 mov eax, dword ptr [ebp-4] ;取结构体成员变量数据传入eax中
}
;Debug恢复环境略
004012C2 ret ;执行ret指令结束函数调用

//函数调用处
tagTEST test;
test = RetStruct () ;
0040DC38 call @ILT+35 (RetStruct) (00401028) ;调用函数RetStruct
; eax中保存函数Retstruct中结构体testRet成员m_nOne的数据
0040DC3D mov dword ptr [ebp-10h] ,eax ; ebp-10h 为临时变量
; edx中保存函数Retstruct中结构体testRet成员m_nTwo的数据
0040DC3D mov dword ptr [ebp-0ch] ,edx ; ebp-0ch 为临时变量

;经过几次数据传递,最终将返回结果存入结构体实例test的两个成员所在地址处
0040DC43 mov eax, dword ptr [ebp-10h]
0040DC46 mov dword ptr [ebp-8] ,eax
0040DC49 mov ecx, dword ptr [ebp-0Ch]
0040DC4C mov dword ptr [ebp-4] ,ecx

结构体只有两个成员,所以用了eax和edx来传递返回值