一、裸函数
1.引出裸函数
-
我们现在已经知道,我们用C语言定义好一个函数,就算这个函数中什么都不写,只要符合函数的定义规范,那么在调用此函数时,编译器和连接器会自动帮我们生成一些汇编指令,用来执行函数,即C语言会转变成汇编语言然后执行。如下图:
-
那么如果我们把函数定义成如下模样:
void __declspec(naked) 函数名(){}
-
现在看看编译器和连接器会做些什么?当我们在此函数调用前设置断点,然后让CPU跳转到此函数所在地址时,发现什么都没有
-
2.裸函数定义与格式
-
裸函数:裸函数定义好调用时,编译器和链接器不会帮我们做任何事情,想做什么由我们自己决定。格式如下:
返回类型 __declspec(naked) 函数名(参数类型,参数名/无){}
-
裸函数调用时编译器和链接器会在main中生成一个call指令,所以裸函数调用还是会将下一行指令地址压入堆栈,将EIP的值改为call后面跟的地址;然后再执行会跳到一个jmp指令(中转、跳板);再执行程序会跳到jmp后面的地址开始执行,但是由于是裸函数,会发现这些地址后面跟的都是int 3,表示断点的意思。所以程序运行到这里时一定会停下来,而且裸函数不像正常的空函数一样有retn返回指令,即裸函数执行后无法结束,即回不去了。那么此时就会报错
----》
----》
-
为解决上述问题:如果此时在裸函数中自己手动添加一段返回的汇编指令,那么会发现此时在裸函数中就已经有一条retn返回的汇编指令了,此时裸函数执行完后会回到call指令的下一跳指令继续执行,即不会报错了。(所以定义裸函数最简单也一定要加一句retn指令,不然就会报错)
void __declspec(naked) Test(){ __asm{ //__asm{}是在c语言中添加汇编指令的固定格式 retn } }
-
那么我们就可以自己设计裸函数,但是要注意几点规则:
- 函数的参数,在函数调用前(call之前)就存到堆栈中了
- 传参根据函数使用的调用约定来决定参数入栈的顺序
- 函数的局部变量,是存在缓冲区中的,所以要先提升堆栈及填充后才能存放局部变量
- 函数的返回值,是存在eax寄存器中或者内存中的
- 一定不能少平衡堆栈
二、函数的调用约定
1.常见的几种调用约定
-
不同语言、环境、系统等的函数使用的调用约定是不尽相同的,常见的有三种函数的调用约定
调用约定 参数压栈顺序 平衡堆栈 __cdecl 从右至左入栈 外平栈 __stdcall 从右至左入栈 内平栈 __fastcall 前两个存入ECX/EDX,剩下:从右至左入栈 不用平衡堆栈/内平栈
2.__cdecl
调用约定
-
C和C++默认使用
__cdecl
这种调用约定,传参时使用堆栈(内存)来传递,且是从最后一个参数往前依次push入栈;且堆栈平衡是在调用函数(call)的外面平衡,即在call指令后面有一条add esp,x
指令来平衡堆栈,又称为外平栈 -
举例:
//__cdecl int __cdecl Plus1(int x,int y){ return x + y; } void main(int argc, char* argv[]){ Plus1(); }
Plus1函数使用的是
__cdecl
调用约定,可以看到参数使用堆栈传递,且入栈顺序是从后往前入栈;call指令下一条指令平衡堆栈,所以是外平栈
3.__stdcall
调用约定
-
win32操作系统的API函数使用的是
__stdcall
这种调用约定,传参的规则和__cdecl
一样,但是平衡堆栈不一样:你如果去分析操作系统自带的API函数,那么在函数调用call指令的下面一行见不到add esp,x
这条平衡堆栈的指令,因为使用__stdcall
这种调用约定的函数,都用的是内平栈,即函数返回时有retn x
指令,retn后面跟一个立即数,表示修改eip的值为返回地址并且将esp加立即数,所以此时才函数调用的内部就平衡堆栈了 -
举例:
//__stdcall int __stdcall Plus2(int x,int y){ return x + y; } void main(int argc, char* argv[]){ Plus2(); }
Plus2函数使用的是
__stdcall
调用约定,可以看到参数也是通过堆栈传递,且入栈顺序是从后往前;进入到call中可以看到Plus2函数最后的返回指令retn后面跟了一个数,因为push了两个参数,所以这里改变eip的同时,还将esp的值+0x8,所以是内平栈
4.__fastcall
调用预定
-
__fastcall
调用约定,如果参数数量少时,传递参数使用的是寄存器(快!);而且使用寄存器存储参数,调用完后是不需要平衡堆栈的! -
优势:如果某一个函数需要不停地调用且参数数量少,那么此函数使用
__fastcall
效率会高很多,因为使用寄存器存参数且不需要平衡堆栈。如果参数数量大于2,那么还是按照从后往前存参数,后面的参数还会会用push存入堆栈,只是最前面的两个参数使用mov 寄存器来存储;那么最后还是要平衡堆栈,只是平衡add esp,x中的x不需要那么大了。 -
举例一:如果参数数量为2
//__fastcall int __fastcall Plus3(int x,int y){ return x + y; } void main(int argc, char* argv[]){ Plus3(1,2); }
Plus3函数使用的是
__fastcall
调用约定,可以看到参数少时,参数是使用寄存器传递的,且参数存入的顺序依然是从后往前,且使用edx和ecx两个寄存器存储;由于参数使用寄存器传递,并没有影响内存,所以不需要平衡堆栈 -
举例二:如果参数数量大于2
//__fastcall int __fastcall Plus4(int x,int y,int a,int b){ return x + y + a + b; } void main(int argc, char* argv[]){ Plus4(1,2,3,4); }
Plus4函数使用的是
__fastcall
调用约定,可以看到当传入参数多于两个时,除了前面的两个参数使用edx、ecx寄存器存储外,其他的参数都使用堆栈存储,但是参数存储的顺序依然是从后往前由于传参影响了堆栈,所以此时采用内平栈的方式来平衡使用内存传递参数的堆栈,因为使用了两块堆栈(8字节),所以最后esp要+8来平衡堆栈
-
==注意一个细节:==如果采用fastcall调用约定,传参的个数大于2,虽然存的时候前两个参数使用寄存器存储,但是我们可以看到在做运算的时候编译器会自动把寄存器中的两个参数值存到内存中再做运算!!!所以以后逆向的时候见到它不要觉得这是函数的局部变量!
三、判断一个函数参数个数方法
1.错误方法
- 所以此时就可以说明一个判断函数参数个数方法的误区:
- 如果函数使用的是内平栈,不能根据retn后面的数来判断函数到底有几个参数!如果是外平栈也不能根据add esp后面跟的数来判断!因为参数可能用了push存入堆栈,还可能用了mov存入寄存区。最后只需要根据存入内存中的数据占得内存宽度来平衡堆栈,而用mov存入寄存器的参数那一部分不需要平衡(因为没有影响内存)
- 而且也不能根据call指令前的几条push和mov指令来判断一个函数有多少参数!因为可能这里push进去的参数,下面相邻的call指令调用的函数是没有使用到的,这是push进去的参数可能是给下下个call函数使用的
2.正确方法
-
一般情况:把push入栈指令或者mov入寄存器指令,和平衡堆栈的add esp,x指令或者retn x结合起来,百分之八十就可以确定函数的参数
步骤一:观察调用处的代码 push 3 push 2 push 1 call 0040100f 步骤二:找到平衡堆栈的代码继续论证 call 0040100f add esp,0Ch 或者函数内部 ret 4/8/0xC/0x10 最后,两者一综合,函数的参数个数基本确定.
-
最稳妥的办法:还是进入到call指令中去读指令,去分析函数中到底使用了哪些参数,因为参数传递未必都是通过堆栈,还可能通过使用寄存器,并且结合最后平衡堆栈retn指令后面的数或者call指令下面的add esp指令后面的数来判断
比如: push ebx push eax mov ecx,dword ptr ds:[esi] mov edx,dword ptr ds:[edi] push 45 push 33 call 函数地址
一个call语句上面有很多入栈或者存入寄存器的指令,那么怎么分析哪些是函数的参数呢?不知道就要跟进去
观察步骤:
- 不考虑ebp、esp
- 只找给其他存储容器赋值的寄存器(eax/ecx/edx/ebx/esi/edi);以及[ebp+8](不同的编译器可能对参数的寻址方式不一样,比如有的编译器是用esp+x来寻址,或者其他的方式,所以要对症下药)
- 找到以后追查其来源,如果该寄存器中的值不是在函数内存赋值的,那一定是传进来的参数
- 再结合内平栈的指令后面的数字判断
- 公式一:寄存器 + ret 4 = 参数个数
- 公式二:寄存器 + [ebp+0x8] +[ebp+0x…] = 参数个数
00401050 push ebp //1.只找给别人赋值的寄存器,即寄存器做为mov后面的参数 00401051 mov ebp,esp 00401053 sub esp,48h 00401056 push ebx 00401057 push esi 00401058 push edi 00401059 push ecx 0040105A lea edi,[ebp-48h] //这里有一个,但是ebp中存的值是在函数内赋的值 0040105D mov ecx,12h 00401062 mov eax,0CCCCCCCCh 00401067 rep stos dword ptr [edi] 00401069 pop ecx 0040106A mov dword ptr [ebp-8],edx //这里有一个,而且发现函数中没有给edx赋值的语句 0040106D mov dword ptr [ebp-4],ecx //这里有一个,而且发现函数中没有给ecx赋值的语句 00401070 mov eax,dword ptr [ebp-4] 00401073 add eax,dword ptr [ebp-8] 00401076 add eax,dword ptr [ebp+8] //这里有一个[ebp+8]有些可疑,结合下面的ret指令判断 00401079 mov [g_x (00427958)],eax //修改全局变量 0040107E pop edi 0040107F pop esi 00401080 pop ebx 00401081 mov esp,ebp 00401083 pop ebp 00401084 ret 4 //上面两个寄存器,再结合这里的4,可以基本确定函数有3个参数
四、作业
-
用__declspec(naked)裸函数实现下面的功能
int Plus(int x,int y,int z) { int a = 2; int b = 3; int c = 4; return x+y+z+a+b+c; } int main(int argc, char* argv[]){ Plus(5,6,7); //参数入栈是调用函数前编译器就自动帮我们做了,根本不用在裸函数中定义 return 0; }
-
裸函数定义如下:
#include "stdafx.h" int __declspec(naked) Plus(int x,int y,int z){ __asm{ //保留栈底 push ebp //提升堆栈 mov ebp,esp sub esp,0x40 //这里假设提升0x40空间,足够用即可 //保留现场 push ebx push esi push edi //填充缓冲区 lea edi,dword ptr es:[ebp-0x40] mov ecx,0x10 mov eax,0xcccccccc rep stosd //rep stos dword ptr ds:[edi] //局部变量存入缓冲区(挨着顺序) mov dword ptr es:[ebp-0x4],0x2 mov dword ptr es:[ebp-0x8],0x3 mov dword ptr es:[ebp-0xC],0x4 //实现函数功能 mov eax,dword ptr es:[ebp+0x8] add eax,dword ptr es:[ebp+0xC] add eax,dword ptr es:[ebp+0x10] add eax,dword ptr es:[ebp-0x4] add eax,dword ptr es:[ebp-0x8] add eax,dword ptr es:[ebp-0xC] //恢复现场 pop edi pop esi pop ebx //降低堆栈 mov esp,ebp pop ebp //函数返回(由于c语言默认使用cdecl调用约定,所以采用外平栈,此处不需要我们去平衡) retn } } int main(int argc, char* argv[]) { Plus(5,6,7); return 0; }