今天我们来看看在VC++6.0中,C语言的空函数在反汇编中是怎样的结构,然后来学习C语言和汇编的混合编程中裸函数的使用、学习函数传参的方式与传参的三个调用约定,以及不同约定在于堆栈平衡中的处理方式。这些概念不仅是汇编的学习的进一步深入,是逆向的基础,对于正向来说也是了解汇编层的原理至关重要的一步。
以下面的代码为例,进行反汇编:
#include "stdafx.h"
//C空函数
void empty(){}
int main(int argc,char *argv[])
{
empty();
return 0;
}
其main函数中call 0X0040101E指令前的一部分堆栈如下所示:
我们具体来分析一下main函数的反汇编代码:首先将主调函数的栈底(esp_1)压入栈中保存(VC++6.0中main作为被调函数,其主调函数是mainCTRStartup()函数,该函数主要进行一些初始化工作:如堆内存初始化、编译器版本获取、命令行参数获取等一系列工作),然后提升栈底ebp_2 = esp_1,该栈底为被调函数main的栈底,提升栈顶esp_2=esp_1-10H,将三个寄存器压入栈中保存主调函数现场。esp变化而分配的中间这一部分内存作为函数栈,用于存储局部变量,并且初始化为0XCCCCCCCC(CCH是int 3软中断的机器码,也是我们通常在VC++中看到的“烫烫烫烫烫烫烫烫…”对应的值)。由于main函数中没有定义任何变量,所以VC++6.0的编译器默认给定函数栈的大小为40H即64字节(mian函数调用的empty函数,其函数栈空间大小也默认是40H)。在call指令调用时,将eip置为0X0040101E以实现CPU下次执行时的跳转,并将call的下一条指令的地址(本条指令地址+指令长度)压入栈中,以便empty函数返回时,从栈顶取出改地址将eip置为其值,则CPU就可以直接接着call后面的指令执行了。
在empty中我们发现,由于该函数没有做任何操作,所以在提升栈空间、保存现场、初始化函数栈以后就直接开始恢复现场、降低栈空间销毁函数栈。如果存在一些操作,则在初始化函数栈与恢复现场之间还有一部分操作的指令,关于这点我们在下面求和函数中进行分析。这里我们要强调一下,几个关键点:
①修改后的ebp(被调函数栈底)所指向空间dword ptr ss:[ebp]存储的是ebp修改前的ebp,即主调函数的栈底;
②dword ptr ss:[ebp+0X4]存储的是主调函数call命令的下一条指令地址,即返回地址;
③dword ptr ss:[ebp+0X8]、dword ptr ss:[ebp+0XC]…是主调函数的传递参数;
④dword ptr ss:[ebp-0X4]、dword ptr ss:[ebp-0X8]、dword ptr ss:[ebp-0XC]…是被调函数的局部变量;
还有一点需要注意:在main函数中我们看到两条指令:
cmp esp,ebp
call 0X004010E0
而0X004010E0出的指令为:
0X004010E0 jne 0X004010E3
0X004010E2 ret
0X004010E3 ……
这里主要是进行堆栈平衡的判断,一个函数在执行完毕并销毁函数栈之后,即恢复主调函数的现场,并执行add esp,40H后则需要满足:栈空间的栈顶与栈底在同一内存处,这样才能在pop ebp时获取到正确的主调函数栈底地址,恢复主调函数堆栈状态。在错误判断中,如果esp == ebp则直接结束判断,无错误,如果esp ≠ ebp则需要进一步错误处理(这里不重点分析,主要是这块儿包含多重调用,而这里我们主要分析一个函数的调用过程,不去主要研究错误是如何处理的。另外这里错误处理主要是输出一些错误信息,并结束返回主调函数)。
完成了对一个空函数的简单分析,我们再来看看一个求和函数的反汇编代码分析(这次我们就主要分析函数传参、返回值传递以及函数栈的使用):
#include "stdafx.h"
int add_1(int x, int y)
{
return (x+y);
}
int add_2(int x, int y, int z)
{
return (add_1(add_1(x,y), z));
}
int main(int argc, char* argv[])
{
add_2(1,2,3);
return 0;
}
我们来根据汇编代码和堆栈图具体分析:
大部分代码都是结构式的重复代码,我们只看每个函数中间的一部分代码即可,首先push 3、push 2、push 1将三个参数依次压入栈中,然后call调用add_2(),在add_2中,先依次将main传递的三个参数取出来并压入add_2的栈顶,然后调用一次add_1(),也就是C代码中return (add_1(add_1(x,y), z)); 的add_1(x,y)一部分,由于return后面z、y、x还是三个相对从右往左的传递关系,所以取出的是三个一并压栈,而不是先压y、x调用完毕返回后将z和add_1(x,y)的返回值再压入。在第一次add_1()调用完毕后,x+y的返回值被放在eax中传递回来,返回后先将ebp+4、ebp+8两个4字节的内存回收,即add esp,8(这一步是堆栈平衡,但是没有add esp,0C,因为3还没有使用,即使回收了还得重新压入)。然后再将eax压入栈中,再调用add_1(),此时栈中的基本内存分布如下:
再次执行add_1()的指令完毕后,eax中存放的便是x+y+z的返回值。再返回后再执行add esp,8将压入的eax以及第一次调用前压入的‘3’所在内存均回收。
我们发现,在以上测试中,虽然我们并没有用到函数栈,但是编译器都默认分配了64字节的内存,并初始化为0XCCCCCCCC,并且有传参、保存现场、堆栈提升、恢复现场、堆栈恢复等操作。这一切都是编译器做的。而有时我们需要自己按照自己的意愿设计函数结构,而不是让编译器把一个空函数都转成十几行代码。那我们就需要自己实现了。比如,empty空函数在调用后由于是空函数,在jmp到的地址只要一行ret就可以达到相同的功能。如下所示:
#include "stdafx.h"
//裸函数,编译器不自动构建函数框架
//裸函数不经过编写无法直接调用,至少需要ret
void __declspec(naked) empty()
{
__asm{
ret
}
}
int main(int argc, char* argv[])
{
//float i = 65536.123456f;
empty();
//plus(1,2);
return 0;
}
__declspec(naked)即用来修饰函数,被修饰的函数即称为裸函数,裸函数中如果不进行任何操作,则不能被直接调用,因为裸函数是不被编译器处理的,即不会生成基本的结构,而call调用时修改了eip,跳转过去后只有int 3中断指令,是不能跳转回来的,所以我们应该用__asm{}格式来编写汇编代码,在其中只写一条ret即可实现最简单的函数调用,反汇编部分代码如下:
0040100F jmp main (00401030)
00401014 jmp empty (00401060)
00401019 int 3
0040101A int 3
...
0040102E int 3
0040102F int 3
main:
00401030 push ebp
00401031 mov ebp,esp
00401033 sub esp,40h
00401036 push ebx
00401037 push esi
00401038 push edi
00401039 lea edi,[ebp-40h]
0040103C mov ecx,10h
00401041 mov eax,0CCCCCCCCh
00401046 rep stos dword ptr [edi]
00401048 call @ILT+15(empty) (00401014)
0040104D xor eax,eax
0040104F pop edi
00401050 pop esi
00401051 pop ebx
00401052 add esp,40h
00401055 cmp ebp,esp
00401057 call __chkesp (00401170)
0040105C mov esp,ebp
0040105E pop ebp
0040105F ret
empty:
;这里只有一个ret,如果__asm{}中不加ret,则这个地址内容为int 3中断
;jmp跳转过来以后就无法返回,程序也无法正常结束
00401060 ret
00401061 int 3
00401062 int 3
00401063 int 3
00401064 int 3
那么我们可以使用这种裸函数的方式进行比较自由的函数操作,而不必让编译器替我们做主,当然我们也可以在__asm{}中模拟编译器的做法,如下所示:
#include "stdafx.h"
int __declspec(naked) plus(int a,int b,int c)
{
__asm{
//保存栈底
push ebp
//提升栈底
mov ebp,esp
//提升栈顶
sub esp,0X40
//保存现场
push ebx
push edi
push esi
//初始化填充栈
lea edi,word ptr ss:[ebp-0X40]
mov eax,0XCCCCCCCC
mov ecx,0X10
rep stosd
//具体操作
//取出主调函数传递的参数
mov eax,dword ptr ss:[ebp+0X8]//a
add eax,dword ptr ss:[ebp+0XC]//b
add eax,dword ptr ss:[ebp+0X10]//c
//加上局部变量,最终结果存放到eax中返回
add eax,dword ptr ss:[ebp-0X0X4]//x
add eax,dword ptr ss:[ebp-0X0X8]//y
add eax,dword ptr ss:[ebp-0X0XC]//z
//恢复现场
pop esi
pop edi
pop ebx
//降低栈顶
mov esp,ebp
//恢复栈底
pop ebp
ret
}
}
int main(int argc, char* argv[])
{
plus(1,2,3);
return 0;
}
int __declspec(naked) plus(int a,int b,int c)的__asm{}中的代码效果完全等价于:
int plus(int a,int b,int c)
{
int x = 1;
int y = 2;
int z = 3;
return (x+y+z+a+b+c);
}
我们在__asm{}中写的完全不用编译器翻译成汇编,而是直接作为编译后的一部分复制到的汇编文件中去。
上面我们已经提到了函数堆栈平衡,如add esp,8的做法 ,并且接触到了汇编层面的函数传参形式(VC++6.0中以从右向左的方式传递参数,并且参数被依次压入栈中)。而接下来我们具体分析这种传参方式与堆栈平衡处理方式(我们称作调用约定)。首先,调用约定共有以下几种:
__cdecl:从右至左入栈;调用者做堆栈平衡:外平栈(ret回去后add esp,x)
__stdcall: 从右至左入栈;被调者做堆栈平衡:内平栈(ret x)
**__fastcall:**ECX/EDX接收最左边两个,其它参数同上;被调者做堆栈平衡:内平栈(ret x)
通常我们在C语言汇中不用这几个约定修饰函数,那么编译器默认的是__cdecl:
void __cdecl func(int x,int y){} <==> void func(int x,int y){}
即从右往左入栈传参且保持堆栈平衡的方式为外平栈(即在主调函数中恢复平衡),而内平栈是在被调函数中恢复堆栈平衡。我们上面见到的均是外平栈,对于内平栈我们具体来看C代码和对应的反汇编代码:
#include "stdafx.h"
int func(int x,int y){
return x+y;
}
int __cdecl func_cde(int x,int y){
return x+y;
}
int __stdcall func_std(int x,int y){
return x+y;
}
int __fastcall func_fast(int x,int y,int z){
return x+y+z;
}
int main(int argc, char* argv[])
{
func(1,2);
func_cde(1,2);
func_std(1,2);
func_fast(1,2,3);
return 0;
}
;__fastcall
func_fast:
004106F0 push ebp
004106F1 mov ebp,esp
004106F3 sub esp,48h
004106F6 push ebx
004106F7 push esi
004106F8 push edi
;ecx有传递参数,这里循环用到时要先保存原有参数值
004106F9 push ecx
004106FA lea edi,[ebp-48h]
004106FD mov ecx,12h
00410702 mov eax,0CCCCCCCCh
00410707 rep stos dword ptr [edi]
;ecx使用完毕恢复ecx的参数值
00410709 pop ecx
;这里用到了分配的函数栈,其实不用也是可以的:直接add eax,edx、add eax,ecx
0041070A mov dword ptr [ebp-8],edx
0041070D mov dword ptr [ebp-4],ecx
00410710 mov eax,dword ptr [ebp-4]
00410713 add eax,dword ptr [ebp-8]
00410716 add eax,dword ptr [ebp+8]
00410719 pop edi
0041071A pop esi
0041071B pop ebx
0041071C mov esp,ebp
0041071E pop ebp
;内平栈,虽然有三个参数,但是两个从寄存器传参,只有一个入栈传参,所以ret 4
;ret 4等价于ret返回后add esp,4
0041071F ret 4
main:
00410730 push ebp
00410731 mov ebp,esp
00410733 sub esp,40h
00410736 push ebx
00410737 push esi
00410738 push edi
00410739 lea edi,[ebp-40h]
0041073C mov ecx,10h
00410741 mov eax,0CCCCCCCCh
00410746 rep stos dword ptr [edi]
;默认外平栈,参数均以入栈方式传递:右边的先入栈
00410748 push 2
0041074A push 1
0041074C call @ILT+25(func) (0040101e)
00410751 add esp,8
;__cdcel外平栈,参数均以入栈方式传递:右边的先入栈
00410754 push 2
00410756 push 1
00410758 call @ILT+35(func_cde) (00401028)
0041075D add esp,8
;__stdcall内平栈,参数均以入栈方式传递:右边的先入栈
00410760 push 2
00410762 push 1
00410764 call @ILT+20(func_std) (00401019)
;__fastcall:最右边的一个入栈方式传递
00410769 push 3
;左边的两个依次存放到ecx和edx中传递
0041076B mov edx,2
00410770 mov ecx,1
00410775 call @ILT+30(func_fast) (00401023)
;call结束后也没有add esp,4的做法,因为返回前已经使用ret 4做了内平栈了
0041077A xor eax,eax
0041077C pop edi
0041077D pop esi
0041077E pop ebx
0041077F add esp,40h
00410782 cmp ebp,esp
00410784 call __chkesp (00401170)
00410789 mov esp,ebp
0041078B pop ebp
0041078C ret
;中转表
00401019 jmp func_std (00401090)
0040101E jmp func (00401060)
00401023 jmp func_fast (004106f0)
00401028 jmp func_cde (00401030)
func_cde:
00401030 push ebp
00401031 mov ebp,esp
00401033 sub esp,40h
00401036 push ebx
00401037 push esi
00401038 push edi
00401039 lea edi,[ebp-40h]
0040103C mov ecx,10h
00401041 mov eax,0CCCCCCCCh
00401046 rep stos dword ptr [edi]
00401048 mov eax,dword ptr [ebp+8]
0040104B add eax,dword ptr [ebp+0Ch]
0040104E pop edi
0040104F pop esi
00401050 pop ebx
00401051 mov esp,ebp
00401053 pop ebp
;__cdecl属于外平栈
00401054 ret
func:
00401060 push ebp
00401061 mov ebp,esp
00401063 sub esp,40h
00401066 push ebx
00401067 push esi
00401068 push edi
00401069 lea edi,[ebp-40h]
0040106C mov ecx,10h
00401071 mov eax,0CCCCCCCCh
00401076 rep stos dword ptr [edi]
00401078 mov eax,dword ptr [ebp+8]
0040107B add eax,dword ptr [ebp+0Ch]
0040107E pop edi
0040107F pop esi
00401080 pop ebx
00401081 mov esp,ebp
00401083 pop ebp
;默认的便是__cdecl属于外平栈
00401084 ret
func_std:
00401090 push ebp
00401091 mov ebp,esp
00401093 sub esp,40h
00401096 push ebx
00401097 push esi
00401098 push edi
00401099 lea edi,[ebp-40h]
0040109C mov ecx,10h
004010A1 mov eax,0CCCCCCCCh
004010A6 rep stos dword ptr [edi]
004010A8 mov eax,dword ptr [ebp+8]
004010AB add eax,dword ptr [ebp+0Ch]
004010AE pop edi
004010AF pop esi
004010B0 pop ebx
004010B1 mov esp,ebp
004010B3 pop ebp
;内平栈,并且ret 8,两个参数均是栈传参
004010B4 ret 8
关于__fastcall有一点需要注意:由于ecx/edx属于寄存器传参,效率较快;而用 __fastcall修饰的目的就是为了快,那一般情况就最多两个参数;否则多余的参数要入栈, __fastcall的效率就不那么明显了。