-
-
[原创]从反汇编的角度学C/C++之函数
-
2021-9-29 21:29 8585
-
一.总体流程
函数作为编程中重要的组成部分,我们需要频繁使用函数来完成编码。而在程序运行过程中,为了保存我们的在函数中使用的临时数据,计算机使用了后进先出被称为栈的数据结构来保存这些临时数据。为了维持栈的平衡以及使用这些临时数据,我们需要用到两个非常重要的寄存器,分别是esp和ebp。它们分别指向了栈的顶部和栈的底部,由于栈的增长是从高地址到低地址增长的,所以栈顶的地址小于栈底的地址,也就是说esp<=ebp。而对于入栈和出栈的操作分别是push和pop,指向push就会把数据压入栈中,pop就会把数据从栈中取出来。
那么它在内存中的表现形式究竟如何?下面先通过一个简单的函数实例来看看在内存中函数的运行机制是如何的。
#include <cstdio> int add(int x, int y) { int z = 0; z = x + y; return z; } int main() { int x = 3, y = 4, z = 0; z = add(x, y); return 0; }
这段代码非常简单,函数add只是实现了一个加法操作,最后将返回值给了z。我们从调用函数,也就是z=add(x,y)开始看看函数是如何使用栈帧。
int x = 3, y = 4, z = 0; 00C01088 mov dword ptr [x],3 00C0108F mov dword ptr [y],4 00C01096 mov dword ptr [z],0 //为x, y, z分别赋值 z = add(x, y); 00C0109D mov eax,dword ptr [y] //取出y的值赋给eax 00C010A0 push eax //将eax压入栈中 00C010A1 mov ecx,dword ptr [x] //取出x的值赋给ecx 00C010A4 push ecx //将ecx的值压入栈中 00C010A5 call add (0C01000h) //调用函数 00C010AA add esp,8 //esp + 8 00C010AD mov dword ptr [z],eax //此时eax保存着返回值,将返回值赋给z
在函数开始调用之前也就是运行到0x00C0109D之前,我们的esp等于0x006FF788,ebp等于0x006FF878。
调用函数的时候,首先会从右往左依次将所需要用到的参数push到栈里面,当程序运行到0x00C010A5的时候,也就是调用函数之前,我的电脑中esp等于0x006FF780,ebp等于0x006FF878此时栈中的内容如下图所示。
可以看出此时参数已经被压入栈中,进一步跟进函数,我们就来到了函数的首地址,0x00C01000,而在运行第一条指令之前,此时我们的esp再次改变,变成0x006FF77C,地址减少了4,我们再次查看栈中数据,如下图
可以看出此时栈中多了一个数据,值为0x00C010AA,而这个数据其实就是我们调用函数的地址的下一条指令的地址,也就是上面call add的后面一条指令add esp, 8的地址,由此可以得出结论,函数调用的时候先是从右往左压入需要用到的参数,然后调用call指令的时候,先是把下一条指令的地址压入栈中,在跳转到需要运行的函数地址。此时我们函数的反汇编结果如下:
int add(int x, int y) { 00C01000 push ebp //压入ebp 00C01001 mov ebp,esp //把esp的值赋给ebp 00C01003 sub esp,0CCh //将esp减少0x0CC,这就扩大了栈,此时的esp和ebp都指向了新得地方,而这块地方我们就称为这个函数的栈空间 00C01009 push ebx 00C0100A push esi 00C0100B push edi //保存寄存器得值 00C0100C lea edi,[ebp-0CCh] //将ebp-0x0CC的地址赋给edi,此时edi的值就是栈顶地址 00C01012 mov ecx,33h 00C01017 mov eax,0CCCCCCCCh 00C0101C rep stos dword ptr es:[edi] //这三次汇编就是在将扩展开了这段栈空间初始化为0xCC 00C0101E mov ecx,offset _D3472036_test@cpp (0C92004h) 00C01023 call __CheckForDebuggerJustMyCode (0C010D0h) //这两句汇编作用是vs2017生成得,不需要理会 int z = 0; 00C01028 mov dword ptr [ebp-8],0 //将0赋值给ebp-8地址中, 此时得ebp指向我们函数栈得底部,而这个地址也就作为z这个变量存储数据得内存地址 z = x + y; 00C0102F mov eax,dword ptr [ebp+8] //由于压入了返回地址和esp,所以此时ebp+8得位置才能拿到我们得第一个参数也就是x得值 00C01032 add eax,dword ptr [ebp+0Ch] //ebp+0xC就是我们第二个参数得值两者相加 00C01035 mov dword ptr [ebp-8],eax //在赋值到ebp-8得地址也就是赋值给z return z; 00C01038 mov eax,dword ptr [ebp-8] //将z得值赋值给eax,作为返回值返回给调用函数使用 00C0103B pop edi 00C0103C pop esi 00C0103D pop ebx //弹出寄存器得值,这三个pop和上面得3个push配合就可以保证在调用函数前后这三个寄存器得值不改变 00C0103E add esp,0CCh //增加esp得值,这和上面sub得值一样都是0xCC 00C01044 cmp ebp,esp 00C01046 call _RTC_CheckEsp (0C012A0h) //这两句是编译器检查栈帧是否平衡,可先不看 00C0104B mov esp,ebp //将ebp得值赋给esp 00C0104D pop ebp //弹出ebp得值 00C0104E ret //函数返回到被调函数 }
可以看到,当进入一个函数得时候,首先是指向push ebp操作将原来得ebp压入栈中,然后在把esp赋值给ebp,也就是把ebp移上来。当程序运行到sub esp,0xCC得时候,此时得esp==ebp==0x006FF778。此时在从内存中查看这个地址中得内容如下:
也就是说这个时候ebp地址中保存得数据是上一次得ebp得地址,而ebp+4地址中保存得就是返回地址,ebp+8保存的就是第一个参数,依次往后类推。
随后在减少esp的值,此时esp到ebp之间这段内存就是这个函数的栈空间,随后程序保存了三个寄存器的值为了之后恢复并且对这段栈空间初始化为0xCC,当程序运行到0x00C0101E的时候,此时esp==0x006FF6A0,ebp==0x006FF778。此时在查看栈中内容如下:
此时这些0xcc就是我们这个函数的栈空间,用来存储我们的局部变量。
随后我们对ebp-8这个地址赋值为0,也就是局部变量z保存在这个地址,由于此时ebp==0x006FF778,所以为了拿到传入的参数,我们需要从ebp+8和ebp+0xC这两个地址中拿。当程序运行到0x00C01038的时候,查看ebp-8的内容,也就是z的值,可以看到此时等于7,在把这个值赋值给eax作为返回地址。
随后执行了3个pop操作把栈顶部保存的三个寄存器的值恢复回去,在对esp增加0x0CC大小的值,在把ebp的值赋给esp,此时esp与ebp的值再次相等,等于0x006FF778,也就是开辟栈空间之前的值。此时的栈空间再次如图
ebp中保存的就是上一个ebp的地址,为了成功恢复之前函数的栈空间,我们首先需要恢复ebp,所以我们执行pop ebp,把保存的ebp的值恢复到ebp中。执行完pop ebp之后,就恢复到了esp等于0x006FF77C,ebp就等于0x006FF878的状态,也就是说此时栈中的数据再一次变成这样
此时esp地址中保存的数据就是我们前面调用函数的地址的下一条指令的地址,随后执行ret,就会把0x00C010AA放入eip中,程序就会转到0x00C010AA执行,如下图
此时esp的值等于0x006FF780,栈中数据重新恢复为
这是保存的两个值就是传入的两个参数,而此时这两个参数已经不需要使用了,所以随后就执行add esp,8。此时esp与ebp的值真正恢复到了函数调用前的状态。而此时eax保存着函数的返回值,根据程序的代码,我们需要把eax赋值给z。
至此,整个函数从调用到返回的分析结束,整个调用过程可用下面的图来表示。
随后我们将执行push ebp和mov ebp, esp操作来进行移动
接下来我们sub esp, 0xCC来开辟新函数的栈空间并保存了三个寄存器的值,随后在这个栈空间中进行操作。
执行完函数功能后,程序开始恢复栈,先pop掉三个寄存器,在执行完add esp,0xCC,mov esp, ebp操作后栈空间重新恢复如图:
接下来就是函数开始调用的逆向过程,让栈恢复到最开始的状态,过程如下:
以上就是全部内容,当然这里还需要说明的是由于对栈的操作push或者pop一次都是四个字节,所以我们传递参数的时候,即使你传递的参数的类型是char或者short这种只占一个或者两个字节的参数,它也是按照4个字节进行。
二.浮点数和函数
浮点数在计算机中的运算法则和普通的数据类型不太一样,这个在之前的文章中已经说明了,那么在函数调用过程中是否有异同呢?下面这段代码实现的功能和上面的一样,只不过这次是对浮点数进行操作。
#include <cstdio> float add(float x, float y) { float z = 0; z = x + y; return z; } int main() { float x = 3, y = 4, z = 0; z = add(x, y); return 0; }
首先看main函数中的反汇编代码
14: float x = 3, y = 4, z = 0; 00401078 mov dword ptr [ebp-4],40400000h 0040107F mov dword ptr [ebp-8],40800000h 00401086 mov dword ptr [ebp-0Ch],0 //对三个浮点数进行初始化 15: z = add(x, y); 0040108D mov eax,dword ptr [ebp-8] 00401090 push eax 00401091 mov ecx,dword ptr [ebp-4] 00401094 push ecx //入栈操作没区别 00401095 call @ILT+0(add) (00401005) 0040109A add esp,8 0040109D fst dword ptr [ebp-0Ch] //从ST(0)中取出返回值放到z
由此可以看出入栈操作并没区别,只是返回值是从ST(0)中取出,说明函数返回值应当是被放到ST(0)中。继续看add函数的反汇编
3: float add(float x, float y) 4: { 00401020 push ebp 00401021 mov ebp,esp 00401023 sub esp,44h 00401026 push ebx 00401027 push esi 00401028 push edi 00401029 lea edi,[ebp-44h] 0040102C mov ecx,11h 00401031 mov eax,0CCCCCCCCh 00401036 rep stos dword ptr [edi] //上述操作无区别 5: float z = 0; 00401038 mov dword ptr [ebp-4],0 //将z赋值为0 6: 7: z = x + y; 0040103F fld dword ptr [ebp+8] //将第一个参数放入ST(0)中 00401042 fadd dword ptr [ebp+0Ch] //将第一个参数与第二个参数相加在把结果放入ST(0)中 00401045 fst dword ptr [ebp-4] //将ST(0)的数据放到z中 8: 9: return z; 10: } 00401048 pop edi //此时ST(0)中数据就是返回值,所以并没继续操作 00401049 pop esi 0040104A pop ebx 0040104B mov esp,ebp 0040104D pop ebp 0040104E ret
三.数组,指针和函数
如果理解了上面的内容,那应该对局部变量,局部变量作用域等等有了不一样的认识。以及我们知道在函数中传递参数的时候,如果在函数内部对这些参数进行更改是不会影响到原来的数值的,比如上面的x,y即使在add函数里面进行修改,在main函数中也不会发生改变,这是因为函数传参过程中对其进行了备份。那么对于数组和指针是不是也是这样的呢,以及将他们作为返回值有什么不同呢?请看下面的实例。
#include <cstdio> char* test(int arr[], int *pInt) { char arrRet[] = { "Hello 1900" }; for (int i = 0; i < 10; i++) { arr[i] = 0; *(pInt + i) = 0; } return arrRet; } int main() { int arrInt[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; int *pInt = arrInt; char *pChar = NULL; pChar = test(arrInt, pInt); return 0; }
代码内容比较简单,主要是参数和返回值都是数值或指针。首先我们依然先对main函数进行反汇编
int arrInt[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; 00B01108 mov dword ptr [arrInt],0 00B0110F mov dword ptr [ebp-28h],1 00B01116 mov dword ptr [ebp-24h],2 00B0111D mov dword ptr [ebp-20h],3 00B01124 mov dword ptr [ebp-1Ch],4 00B0112B mov dword ptr [ebp-18h],5 00B01132 mov dword ptr [ebp-14h],6 00B01139 mov dword ptr [ebp-10h],7 00B01140 mov dword ptr [ebp-0Ch],8 00B01147 mov dword ptr [ebp-8],9 //对数组进行初始化 int *pInt = arrInt; 00B0114E lea eax,[arrInt] 00B01151 mov dword ptr [pInt],eax //将数组的首地址赋给pInt char *pChar = NULL; 00B01154 mov dword ptr [pChar],0 //初始化pChar pChar = test(arrInt, pInt); 00B0115B mov eax,dword ptr [pInt] 00B0115E push eax //取出pInt保存的地址并压栈 00B0115F lea ecx,[arrInt] 00B01162 push ecx //取出数组首地址并压栈 00B01163 call test (0B01000h) 00B01168 add esp,8 00B0116B mov dword ptr [pChar],eax //将eax的值赋给pChar
从上面可以看出,将数组作为参数传递的时候所入栈的元素其实是数组的首地址,而指针则是本身的数值,根据对pChar的赋值可以想到,函数最后的返回值是放到eax中。继续看test反汇编的结果
char* test(int arr[], int *pInt) { 00B01000 push ebp 00B01001 mov ebp,esp 00B01003 sub esp,0E0h 00B01009 push ebx 00B0100A push esi 00B0100B push edi 00B0100C lea edi,[ebp-0E0h] 00B01012 mov ecx,38h 00B01017 mov eax,0CCCCCCCCh 00B0101C rep stos dword ptr es:[edi] 00B0101E mov ecx,offset _D3472036_test@cpp (0B92004h) 00B01023 call __CheckForDebuggerJustMyCode (0B01390h) char arrRet[] = { "Hello 1900" }; 00B01028 mov eax,dword ptr [string "Hello 1900" (0B731B0h)] 00B0102D mov dword ptr [arrRet],eax 00B01030 mov ecx,dword ptr ds:[0B731B4h] 00B01036 mov dword ptr [ebp-0Ch],ecx 00B01039 mov dx,word ptr ds:[0B731B8h] 00B01040 mov word ptr [ebp-8],dx 00B01044 mov al,byte ptr ds:[00B731BAh] 00B01049 mov byte ptr [ebp-6],al //对arrRet数组进行赋值 for (int i = 0; i < 10; i++) 00B0104C mov dword ptr [ebp-1Ch],0 00B01053 jmp 00B0105E 00B01055 mov eax,dword ptr [ebp-1Ch] 00B01058 add eax,1 00B0105B mov dword ptr [ebp-1Ch],eax 00B0105E cmp dword ptr [ebp-1Ch],0Ah 00B01062 jge 00B01080 { arr[i] = 0; 00B01064 mov eax,dword ptr [ebp-1Ch] //取出i的值 00B01067 mov ecx,dword ptr [ebp+8] //取出数组首地址 00B0106A mov dword ptr [ecx+eax*4],0 //计算地址 *(pInt + i) = 0; 00B01071 mov eax,dword ptr [ebp-1Ch] //取出i的值 00B01074 mov ecx,dword ptr [ebp+0Ch] //取出保存的指针的值 00B01077 mov dword ptr [ecx+eax*4],0 //计算偏移 } 00B0107E jmp 00B01055 return arrRet; 00B01080 lea eax,[arrRet] //将arrRet的地址赋给eax作为返回值 } 00B01083 push edx 00B01084 mov ecx,ebp 00B01086 push eax 00B01087 lea edx,ds:[00B010A8h] 00B0108D call 00B012E0 00B01092 pop eax 00B01093 pop edx 00B01094 pop edi 00B01095 pop esi 00B01096 pop ebx 00B01097 add esp,0E0h 00B0109D cmp ebp,esp 00B0109F call 00B01350 00B010A4 mov esp,ebp 00B010A6 pop ebp 00B010A7 ret
由上可以看出,数组作为参数传递的时候传递的是原数组的首地址,所以在函数内对数组进行更改会影响到原数组。以及,指针作为返回值的时候,最终返回的是一个地址,而这个地址是新函数内开辟的地址空间所用的数组变量。如果函数退出栈关闭,我们在函数外使用这个指针的话就很有可能导致程序错误。
四.结构体和函数
上面的实例参数和返回值都是4个字节,可是我们知道自定义的结构体是可以超过4个字节,那如果超过四个字节的时候,传参和返回值有什么不同呢?请看如下代码:
#include <cstdio> #include <cstring> #pragma pack(1) typedef struct _Test { char cTest; int iTest; char arrTest[11]; } Test; Test structTest(Test test) { Test test_ret = { 0 }; test_ret.cTest = test.cTest; test_ret.iTest = test.iTest; strcpy(test_ret.arrTest, test.arrTest); return test_ret; } int main() { Test test1 = { 0 }, test2 = { 0 }; test1.cTest = 'a'; test1.iTest = 1900; strcpy(test1.arrTest, "Hello 1900"); test2 = structTest(test1); return 0; }
首先依然先对main函数进行反汇编
Test test1 = { 0 }, test2 = { 0 }; 003310F8 xor eax,eax 003310FA mov dword ptr [test1],eax 003310FD mov dword ptr [ebp-10h],eax 00331100 mov dword ptr [ebp-0Ch],eax 00331103 mov dword ptr [ebp-8],eax 00331106 xor eax,eax 00331108 mov dword ptr [test2],eax 0033110B mov dword ptr [ebp-28h],eax 0033110E mov dword ptr [ebp-24h],eax 00331111 mov dword ptr [ebp-20h],eax //对两个结构体进行初始化 test1.cTest = 'a'; 00331114 mov byte ptr [test1],61h test1.iTest = 1900; 00331118 mov dword ptr [ebp-13h],76Ch strcpy(test1.arrTest, "Hello 1900"); 0033111F push offset string "Hello 1900" (03A31B0h) 00331124 lea eax,[ebp-0Fh] 00331127 push eax 00331128 call strcpy (03439E0h) //对test1进行赋值 0033112D add esp,8 test2 = structTest(test1); 00331130 sub esp,10h //提高栈空间,这个提高的空间为0x10,刚好容纳我们的结构体 00331133 mov eax,esp //将提升到的栈地址赋值给eax 00331135 mov ecx,dword ptr [test1] 00331138 mov dword ptr [eax],ecx 0033113A mov edx,dword ptr [ebp-10h] 0033113D mov dword ptr [eax+4],edx 00331140 mov ecx,dword ptr [ebp-0Ch] 00331143 mov dword ptr [eax+8],ecx 00331146 mov edx,dword ptr [ebp-8] 00331149 mov dword ptr [eax+0Ch],edx //这里对新提升的这片栈空间赋值给test1中的内容 0033114C lea eax,[ebp-11Ch] //这里将ebp-0x11C的地址压入栈中 00331152 push eax 00331153 call structTest (0331000h) 00331158 add esp,14h //平横掉压入栈中的4字节和sub掉的0x10 0033115B mov ecx,dword ptr [eax] //可以看到eax依然是返回值,只不过此时eax存的是地址 0033115D mov dword ptr [ebp-104h],ecx 00331163 mov edx,dword ptr [eax+4] 00331166 mov dword ptr [ebp-100h],edx 0033116C mov ecx,dword ptr [eax+8] 0033116F mov dword ptr [ebp-0FCh],ecx 00331175 mov edx,dword ptr [eax+0Ch] 00331178 mov dword ptr [ebp-0F8h],edx //这段代码是从eax中的地址值开始取出内容,而这些内容就是结构体的值,赋值到ebp-0x104 0033117E mov eax,dword ptr [ebp-104h] 00331184 mov dword ptr [test2],eax 00331187 mov ecx,dword ptr [ebp-100h] 0033118D mov dword ptr [ebp-28h],ecx 00331190 mov edx,dword ptr [ebp-0FCh] 00331196 mov dword ptr [ebp-24h],edx 00331199 mov eax,dword ptr [ebp-0F8h] 0033119F mov dword ptr [ebp-20h],eax //这段代码就是从ebp-0x104从开始取出结构体内容放到test2中
由上可以看出,结构体作为参数传递的时候,程序会开辟出一段空间来存储这个变量并且会压入一个地址作为参数,那么此时进入函数后,ebp+8中的内容就是这个地址,而从ebp+0xC开始则是我们新开辟的这段空间,里面保存了我们的参数内容。那么这个被压入的地址有什么作用呢?
接下来看看函数的反汇编
Test structTest(Test test) { 00331000 push ebp 00331001 mov ebp,esp 00331003 sub esp,0D8h 00331009 push ebx 0033100A push esi 0033100B push edi 0033100C lea edi,[ebp+FFFFFF28h] 00331012 mov ecx,36h 00331017 mov eax,0CCCCCCCCh 0033101C rep stos dword ptr es:[edi] 0033101E mov ecx,3C2008h 00331023 call 003313D0 Test test_ret = { 0 }; 00331028 xor eax,eax 0033102A mov dword ptr [ebp-14h],eax 0033102D mov dword ptr [ebp-10h],eax 00331030 mov dword ptr [ebp-0Ch],eax 00331033 mov dword ptr [ebp-8],eax //ebp-0x14就是test_ret的首地址 test_ret.cTest = test.cTest; 00331036 mov al,byte ptr [ebp+0Ch] //由于我们传参时候压入了一个地址所以ebp+0xC开始才是我们test1的参数地址 00331039 mov byte ptr [ebp-14h],al test_ret.iTest = test.iTest; 0033103C mov eax,dword ptr [ebp+0Dh] 0033103F mov dword ptr [ebp-13h],eax strcpy(test_ret.arrTest, test.arrTest); 00331042 lea eax,[ebp+11h] 00331045 push eax 00331046 lea ecx,[ebp-0Fh] 00331049 push ecx 0033104A call 003439E0 0033104F add esp,8 //这段代码就是将test1中的内容赋值给test return test_ret; 00331052 mov eax,dword ptr [ebp+8] //取出第一个参数就是我们压入的一个地址 00331055 mov ecx,dword ptr [ebp-14h] 00331058 mov dword ptr [eax],ecx 0033105A mov edx,dword ptr [ebp-10h] 0033105D mov dword ptr [eax+4],edx 00331060 mov ecx,dword ptr [ebp-0Ch] 00331063 mov dword ptr [eax+8],ecx 00331066 mov edx,dword ptr [ebp-8] 00331069 mov dword ptr [eax+0Ch],edx //这段代码就是在把test_ret的内容赋值到第一个参数也就是我们压入的地址 0033106C mov eax,dword ptr [ebp+8] //将这个压入的地址作为返回值给eax } 0033106F push edx 00331070 mov ecx,ebp 00331072 push eax 00331073 lea edx,ds:[00331094h] 00331079 call 00331320 0033107E pop eax 0033107F pop edx } 00331080 pop edi 00331081 pop esi 00331082 pop ebx 00331083 add esp,0D8h 00331089 cmp ebp,esp 0033108B call 00331390 00331090 mov esp,ebp 00331092 pop ebp 00331093 ret
由上可以看出这个新压入的地址被用来保存程序需要返回的结构体的内容,而后程序将这个地址作为返回值放回给主函数。最终这个主函数根据这个地址将结构体的内容备份到一个新的内存地址中,在从这个备份的内存地址中将内容赋值到我们需要的变量中。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工 作,每周日13:00-18:00直播授课