空函数、裸函数与函数传参和堆栈平衡

今天我们来看看在VC++6.0中,C语言的空函数在反汇编中是怎样的结构,然后来学习C语言和汇编的混合编程中裸函数的使用、学习函数传参的方式与传参的三个调用约定,以及不同约定在于堆栈平衡中的处理方式。这些概念不仅是汇编的学习的进一步深入,是逆向的基础,对于正向来说也是了解汇编层的原理至关重要的一步。

1、C空函数的反汇编结构分析:

以下面的代码为例,进行反汇编:

#include "stdafx.h"
//C空函数
void empty(){}
int main(int argc,char *argv[])
{
    empty();
    return 0;
}

反汇编的部分代码分析如下:
空函数、裸函数与函数传参和堆栈平衡_第1张图片

其main函数中call 0X0040101E指令前的一部分堆栈如下所示:

空函数、裸函数与函数传参和堆栈平衡_第2张图片

我们具体来分析一下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则需要进一步错误处理(这里不重点分析,主要是这块儿包含多重调用,而这里我们主要分析一个函数的调用过程,不去主要研究错误是如何处理的。另外这里错误处理主要是输出一些错误信息,并结束返回主调函数)。

2、C求和函数的反汇编分析:

完成了对一个空函数的简单分析,我们再来看看一个求和函数的反汇编代码分析(这次我们就主要分析函数传参、返回值传递以及函数栈的使用):

#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;
}

汇编代码如下(与堆栈图结合来看):
空函数、裸函数与函数传参和堆栈平衡_第3张图片

部分堆栈图如下:
空函数、裸函数与函数传参和堆栈平衡_第4张图片

我们来根据汇编代码和堆栈图具体分析:
大部分代码都是结构式的重复代码,我们只看每个函数中间的一部分代码即可,首先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(),此时栈中的基本内存分布如下:
空函数、裸函数与函数传参和堆栈平衡_第5张图片
再次执行add_1()的指令完毕后,eax中存放的便是x+y+z的返回值。再返回后再执行add esp,8将压入的eax以及第一次调用前压入的‘3’所在内存均回收。

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

如果不加任何语句,则在设置断点运行时会提示出错:
空函数、裸函数与函数传参和堆栈平衡_第6张图片

那么我们可以使用这种裸函数的方式进行比较自由的函数操作,而不必让编译器替我们做主,当然我们也可以在__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{}中写的完全不用编译器翻译成汇编,而是直接作为编译后的一部分复制到的汇编文件中去。

4、函数传参的调用约定与堆栈平衡:

上面我们已经提到了函数堆栈平衡,如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的效率就不那么明显了。

你可能感兴趣的:(C/C++)