常见平台的函数栈帧及调用过程 – 作者:ATL安全团队

前言

从一个函数的调用说起

在逆向分析往往要从庞大的汇编代码中分析出核心代码与数据结构,那么函数作为一个程序的重要组成部分,对函数的识别肯定避免不了,既然是了解一个函数的结构,那么首先我们概览一下函数是怎样调用的。

首先我们知道函数有三个基本组成部分:函数返回值、函数名、函数参数,在函数中又涉及到变量。那么我们通过一个例子,来了解一个函数所涉及到的内容。

这里我们借助微软的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的下一条指令的地址。

图片[1]-常见平台的函数栈帧及调用过程 – 作者:ATL安全团队-安全小百科

上边从实战的角度解释了一个函数调用的基本过程,可能一些基础薄弱的同学有很多疑问,为什么栈不是正向增长的?什么是栈?什么又是栈帧?抱着负责的态度,那么我继续解释解释。

在计算机科学理论中,栈一种数据结构;从技术上来说,栈是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安全团队

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论