前言
从一个函数的调用说起
在逆向分析往往要从庞大的汇编代码中分析出核心代码与数据结构,那么函数作为一个程序的重要组成部分,对函数的识别肯定避免不了,既然是了解一个函数的结构,那么首先我们概览一下函数是怎样调用的。
首先我们知道函数有三个基本组成部分:函数返回值、函数名、函数参数,在函数中又涉及到变量。那么我们通过一个例子,来了解一个函数所涉及到的内容。
这里我们借助微软的VC编译器来进行学习。首先我们来看下整个程序源码,如下:
// test.c
#include <stdio.h>
int calc(int x, int y) {
int ret;
ret = x + 4 * y; //简单的计算
return ret;
}
int main() {
int result = 0;
result = calc(1, 2); //函数
printf("%d", result);
return 0;
}
如下代码所示,我们来看他调用calc()函数的反汇编,这一句C代码就反汇编出5句汇编代码,我们可以看到函数的参数传递是从右往左依次PUSH进栈中,然后再跳转到函数中去,在0x00B51A4E处,平衡了前边PUSH的栈。
result = calc(1, 2);
00B51A45 push 2 ;首先将第二个参数“2”,push到了栈中
00B51A47 push 1 ;又将第一个参数“1”,push到了栈中
00B51A49 call 00B511DB ; 调用了calc()函数
00B51A4E add esp,8 ; ESP栈顶指针下降8个字节,平衡调用calc使用的栈帧
00B51A51 mov dword ptr [ebp-8],eax ; 将返回值存储到result
如下代码,然后我们再进去calc这个函数,分析下函数的结构及准备
int calc(int x, int y)
{
//我们一般把下面三句汇编语句叫做函数序言(function prologue),是一个函数开始准备的部分
00F413C0 push ebp ; 保存栈底指针EBP
00F413C1 mov ebp,esp ; 抬高栈底指针到栈顶
00F413C3 sub esp,0CCh ; 抬高栈顶指针,开辟栈帧大小为0xCC字节,用于存储局部变量
00F413C9 push ebx ; 保存EBX
00F413CA push esi ; 保存ESI
00F413CB push edi ; 保存EDI
00F413CC lea edi,[ebp+FFFFFF34h] ; EBP-0xCC,即当前栈帧首地址
00F413D2 mov ecx,33h ; 重复次数为0x33
00F413D7 mov eax,0CCCCCCCCh ; 每次填充4字节的0xCC
00F413DC rep stos dword ptr es:[edi] ; EAX内容填充到edi所指向内存空间,重复0x33次
int ret;
ret = x + 4 * y;
00F413DE mov eax,dword ptr [ebp+0Ch] ; 参考下面栈图,参数“2”给EAX
00F413E1 mov ecx,dword ptr [ebp+8] ; 参数“1”给ECX
00F413E4 lea edx,[ecx+eax*4] ; 计算1+4*2给EDX
00F413E7 mov dword ptr [ebp-8],edx ; 将计算结果存储到变量ret
return ret;
00F413EA mov eax,dword ptr [ebp-8] ; 将ret存储到EAX作为函数返回值
}
00F413ED pop edi ; 恢复EDI
00F413EE pop esi ; 恢复ESI
00F413EF pop ebx ; 恢复EBX
//一般把下面三句汇编语句叫做函数尾声(function epilogue)
00F413F0 mov esp,ebp ; 降低栈顶指针
00F413F2 pop ebp ; 恢复EBP,如图所示EBP=0x2af818
00F413F3 ret ; 返回(读取寄存器EBP所指的线程栈之处保存的函数返回地址并加载到IP寄存器)
下边就是栈帧的部分图,细心的同学可以看到EBP+4的地址就是CALL的下一条指令的地址。
上边从实战的角度解释了一个函数调用的基本过程,可能一些基础薄弱的同学有很多疑问,为什么栈不是正向增长的?什么是栈?什么又是栈帧?抱着负责的态度,那么我继续解释解释。
栈
在计算机科学理论中,栈一种数据结构;从技术上来说,栈是ESP/RSP/SP所指向的一段内存空间,其中ESP/RSP是x86/x64平台,SP是ARM平台的寄存器。
但是为什么栈是逆增长的呢?其实每一个我们当今看似“理所当然”的技术,背后都有许多有趣的故事或者理论来支持,简单说,早先时候计算机的内存是非常宝贵的,为了使用计算机内存的,他们将计算机内存分为两个部分“堆”和“栈”,那么由于两者都是动态使用的,又不能界定他们俩使用的范围,只好一个从前一个从后,就类似于我们写笔记,如果要记两类东西的话,常常喜欢从前、后两个方向来写。
栈帧
当栈顶指针ESP小于栈低指针EBP时,从ESP到EBP的区域,就叫做栈帧,栈帧中可寻址的数据有局部变量、函数返回地址、函数参数。
x86平台
三种调用约定
前边刚开始讲了函数传参的过程,但实际上,在32位体系结构中,有3种调用约定,分别是cdecl,stdcall,fastcall。GCC的默认调用约定为cdecl。参数从右向左压栈,调用者负责在调用后清理堆栈,返回值保存在eax寄存器中,非易失寄存器为ebp,esp,ebx,esi,edi。
前边的第一个例子默认使用的是cdecl,所以我们接下来关注下stdcall和fastcall,依旧使用微软的VC编译器:
stdcall
修改前边的示例代码如下:
int __stdcall calc(int x, int y) //calc前增加了__stdcall
我们可以看到,对calc()函数的反汇编,与前面的cdecl对比,少了 add esp,8
result = calc(1, 2);
00B01A45 push 2
00B01A47 push 1
00B01A49 call 00B011E0
00B01A4E mov dword ptr [ebp-8],eax
如下,进入calc函数,我们可以看到,ret后边多了一个8,类似于 add esp,8
的意思,只不过是被调用的函数做了平衡栈的操作。
... ; 省略前边相同的反汇编代码
00B013F0 mov esp,ebp
00B013F2 pop ebp
00B013F3 ret 8 ; ret后边多了一个8
fastcall
修改前边的示例代码如下:
int __fastcall calc(int x, int y, int z) //calc前增加了__fastcall,增加一个参数
我们看到,对calc()函数的反汇编,与前面的cdecl对比,前两个参数分别被放进了ecx和edx,第三个参数才用到了栈。
result = calc(1, 2, 3);
00FB1A45 push 3
00FB1A47 mov edx,2
00FB1A4C mov ecx,1
00FB1A51 call 00FB11EA
00FB1A56 mov dword ptr [ebp-8],eax
进入了calc函数内部,我们看到 ret 4
平衡了栈:
00FB13FB mov esp,ebp
00FB13FD pop ebp
00FB13FE ret 4
x86_64平台
重新贴下源码:
#include <stdio.h>
int calc(int x, int y, int z, int z2, int z3) {
int ret;
ret = x + 4*y + z*z2+z3;
return ret;
}
int main() {
int result = 0;
result = calc(1, 2, 3, 4, 5);
printf("%d", result);
return 0;
}
Windows下使用VC编译器
还是使用微软的VC编译器,首先我们分析下函数调用过程,我们看到了这里的传参是直接将1、2、3、4传入到了四个寄存器中,但是将参数5传入了rsp+20中,虽然这里用到了栈,但是没有使用PUSH和POP:
result = calc(1, 2, 3, 4, 5);
000000013FAC109D mov dword ptr [rsp+20h],5
000000013FAC10A5 mov r9d,4
000000013FAC10AB mov r8d,3
000000013FAC10B1 mov edx,2
000000013FAC10B6 mov ecx,1
000000013FAC10BB call 000000013FAC1014
000000013FAC10C0 mov dword ptr [rsp+30h],eax
跟进去函数看一下,我们发现,前边传入的4个参数又在栈中保存了一遍;与x86不同,我们发现针对栈的操作,只用到了一个RSP寄存器。
int calc(int x, int y, int z, int z2, int z3)
{
000000013FAC1020 mov dword ptr [rsp+20h],r9d ; 保存了参数4
000000013FAC1025 mov dword ptr [rsp+18h],r8d ; 保存了参数3
000000013FAC102A mov dword ptr [rsp+10h],edx ; 保存了参数2
000000013FAC102E mov dword ptr [rsp+8],ecx ; 保存了参数1
000000013FAC1032 push rdi ; 将rdi保存
000000013FAC1033 sub rsp,10h ; 抬高rsp 0x10个字节
000000013FAC1037 mov rdi,rsp ; 栈帧首地址给rdi
000000013FAC103A mov ecx,4 ; 重复填充次数为4
000000013FAC103F mov eax,0CCCCCCCCh ; 每次填充4字节的0xCC
000000013FAC1044 rep stos dword ptr [rdi] ; 重复填充4次,总共0x10字节
000000013FAC1046 mov ecx,dword ptr [rsp+20h] ; 将参数1给ecx
int ret;
ret = x + 4*y + z*z2+z3;
000000013FAC104A mov eax,dword ptr [rsp+20h] ; 将参数1(x)给eax
000000013FAC104E mov ecx,dword ptr [rsp+28h] ; 将参数2(y)给ecx
000000013FAC1052 lea eax,[rax+rcx*4]
000000013FAC1055 mov ecx,dword ptr [rsp+30h]
000000013FAC1059 imul ecx,dword ptr [rsp+38h]
000000013FAC105E mov edx,dword ptr [rsp+40h]
000000013FAC1062 add edx,eax
000000013FAC1064 mov eax,edx
000000013FAC1066 add ecx,eax
000000013FAC1068 mov eax,ecx ; 得出结果存储到 eax
000000013FAC106A mov dword ptr [rsp],eax ; 将结果存储到变量ret中
return ret;
000000013FAC106D mov eax,dword ptr [rsp] ; 将ret存储到eax,作为返回参数
}
000000013FAC1070 add rsp,10h ; 降低栈
000000013FAC1074 pop rdi ; 恢复rdi
000000013FAC1075 ret ; 返回
小结:Windows x86_64的ABI,前四个参数使用rcx, rdx, r8, r9传递,其余是通过栈传递,但在栈上依然会预留0x20字节保存前4个参数,rax是返回寄存器
rax function(rcx, rdx, r8, r9, [rsp+0x20], [rsp+0x28], ...)
Linux下使用GCC编译器
我们这里使用的环境是Ubuntu-server-20.04.1,编译器是gcc 9.3.0,首先我们用IDA反编译下,查看反汇编代码我们看到传入参数的顺序是
0000000000001199 mov r8d, 5 ; 传入参数5
000000000000119F mov ecx, 4 ; 传入参数4
00000000000011A4 mov edx, 3 ; 传入参数3
00000000000011A9 mov esi, 2 ; 传入参数2
00000000000011AE mov edi, 1 ; 传入参数1
00000000000011B3 call _Z4calciiiii ; 调用函数
00000000000011B8 mov [rbp-4], eax ; 返回值给result
跟进calc函数我们看看
0000000000001149 endbr64 ;
000000000000114D push rbp ; 保存rbp寄存器
000000000000114E mov rbp, rsp ; 抬高rbp
0000000000001151 mov [rbp-14h], edi ; 将参数1这4个字节存储到本地
0000000000001154 mov [rbp-18h], esi ; 2
0000000000001157 mov [rbp-1Ch], edx ; 3
000000000000115A mov [rbp-20h], ecx ; 4
000000000000115D mov [rbp-24h], r8d ; 5
0000000000001161 mov eax, [rbp-18h] ; eax=2
0000000000001164 lea edx, ds:0[rax*4] ; edx = 2*4
000000000000116B mov eax, [rbp-14h] ; eax = 1
000000000000116E add edx, eax ; edx = 2*4+1
0000000000001170 mov eax, [rbp-1Ch] ; eax = 3
0000000000001173 imul eax, [rbp-20h] ; eax = 3*4
0000000000001177 add edx, eax ; edx = 2*4+1 + 3*4
0000000000001179 mov eax, [rbp-24h] ; eax = 5
000000000000117C add eax, edx ; eax = 2*4+1 + 3*4+5
000000000000117E mov [rbp-4], eax ; 将结果存储到ret这个局部变量
0000000000001181 mov eax, [rbp-4] ; 作为函数返回值传给eax
0000000000001184 pop rbp ; 恢复栈低
0000000000001185 retn ; 返回
参考
Calling Convention: https://gcc.gnu.org/onlinedocs/gcc-6.2.0/gnat_ugn/C-Calling-Convention.html
x64 calling convention: https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention?view=vs-2019
System V ABI:https://wiki.osdev.org/System_V_ABI
小结
逆向分析所涉及到的知识非常之广,非常考验个人对操作系统底层原理的理解、逆向分析的经验和耐力。所以当我们熟悉了这样的结构之后才能够做到“庖丁解牛”的程度。
来源:freebuf.com 2020-10-30 09:29:47 by: ATL安全团队
请登录后发表评论
注册