对于二进制安全来说,栈是最最基本的知识。这篇文章介绍了x86下栈、栈帧与函数调用的详情,解释这些概念之间的关系。

栈是一个先入后出队列。关于算法栈,请自行搜索或参考网络。

在操作系统运行程序时,一般用栈来保存函数的状态和局部变量。对于Linux x86,栈位于程序内存空间的末端,从高地址向低地址增长。

在x86架构中,使用esp寄存器指向栈顶的内存地址;在x86_64架构中,使用rsp寄存器指向栈顶。一般可以简称为sp。

对于想做二进制安全(包括逆向工程、漏洞利用等等)的同学来说,栈是最最基本的知识。正是因此才会有这篇文章。

栈帧

栈帧是指函数在被调用时,该函数私有的一块用于存放函数所使用的状态和局部变量的栈空间。

每个函数都对应有一个栈帧。同一个函数多次进入,每次可能会分配到不同的栈帧。整个栈在同一个时刻可以看作是由许多栈帧依序“堆叠”组成的。

对于一个运行中的函数,其使用的栈帧区域为sp和bp寄存器所限定(对于x86,sp是esp,bp是ebp;对于x64,sp是rsp,bp是rbp)。bp指向栈帧的底部,sp指向栈帧的顶部。

Image.png

(注:图片来源http://witmax.cn/c-function-heap-stack.html

在函数中使用的所有变量(本地变量、实参),一般使用bp定位,即通过bp+x的形式来在栈帧中寻找变量。设N为一个整型(int)的字节数,那么bp+2N是第一个实参的地址,bp-N是第一个本地变量的地址。

函数调用

一个函数调用,一般需要以下步骤:

  1. 保存函数的实参
  2. 保存子函数结束后,需要返回的地址(返回到哪里)
  3. 保存父函数的栈帧信息
  4. 在栈上开辟空间供局部变量使用
  5. 执行函数实现的功能
  6. 释放局部变量使用的空间
  7. 根据保存的父函数栈帧信息,恢复父函数栈帧
  8. 根据保存的返回地址,恢复父函数执行流,一般是函数调用指令后的下一条指令

共有多种函数调用方式,这里介绍一种:stdcall。stdcall是标准调用约定,还有其他调用约定:cdecl、PASCAL、fastcall、thiscall,可以自行搜索。

在stdcall中,调用一个函数func(a,b,c)会有一些比较固定的代码(x86架构)。

父函数调用时,会把参数从右至左入栈,实现保存函数实参的功能:

push c
push b
push a

然后执行call指令:

call func

这里call指令内部实际上做了两个工作,一个是将这个call指令的下一条语句入栈,实现返回地址的保存。然后把执行流跳转到函数里。所以一个call指令从功能上可以拆分为以下两个指令:

push 本call指令下一条指令的地址
jmp func

执行流到了func函数内部,此时esp和ebp依然维持父函数的栈帧,程序会先进行父函数栈帧信息的保存。因为调用函数之后要回到父函数继续执行,所以要在执行流返回父函数之前恢复父函数的栈帧,即恢复原先的esp和ebp。

当前子函数所有的栈中变量被释放后,esp会回到函数调用前的状态,因此无需保存esp,只要保存当前的ebp信息即可

push ebp

此时,子函数的栈帧底部变到esp处:

mov ebp, esp

栈帧底部设置完毕后,可以为局部变量开辟空间,这里开辟了一个32(0x20)字节的栈空间

sub esp, 20h

注意栈是从高地址到低地址增长的,故这里做减法。

然后就可以开始进行函数的操作,通过ebp定位函数的参数和局部变量空间,并将函数返回值放在eax寄存器中。

在函数的功能代码全部执行完毕后,释放之前开辟的栈空间:

add esp, 20h

此时esp恢复到压入ebp之后的状态,这时栈顶为父函数的ebp值,可以依据这个信息恢复父函数的ebp,进而恢复父函数的栈帧:

pop ebp

当前栈顶为返回地址,这时父函数的栈信息已经恢复,只要根据这个返回地址更改执行流,回到父函数call func指令的下一条指令即可。

retn

至此,一个函数的调用流程结束,栈的状态和调用前完全一致,子函数的返回值被存在eax寄存器中。

标签: 二进制, 逆向工程,

添加新评论