前两篇文章介绍了栈帧和函数调用约定,这篇文章通过一个简单的程序来具体说明一下调用函数时都经历了哪些步骤。(建议结合着栈帧的介绍和调用约定的介绍这两篇来理解这篇文章,然后通过一个buffer overflow的实例进行深入的理解)。
首先看一下源码,非常简单的一个例子,定义了一个foo()
函数,将传入的参数x
和y
的和赋值给z
,然后返回z
。main()
函数中调用了foo()
函数,将返回结果赋值给a
并打印。
#include <stdio.h>
int foo(int x, int y)
{
int z;
z = x + y;
return z;
}
int main(int argc, char* argv[])
{
int a;
a = foo(5, 6);
printf("a = %d", a);
}
用pwndbg调试一下,在main函数下断点,下面是反汇编代码。
首先通过源码可以知道,main
函数调用了foo
函数,所以main
函数又称调用函数(caller),foo
函数又被称为被调用函数(callee)。然后通过反汇编代码可以看到,main
函数在调用foo
函数之前先进行了传参,C语言默认的调用惯例是cdecl
(不清楚的可以看一下之前的介绍调用惯例的文章),所以是通过栈进行传参
的,并且是从右至左
。
单步运行到call foo
处,查看一下栈的状态,可以看到,参数已经被逆序的压入栈中。
然后call foo
,这个指令可以拆分为两步,第一步为push eip
(保存返回地址),第二步跳转到foo
函数处。
可以看到,进入foo
函数后栈顶多了一个值,存入的是0x56555563这个地址,也就是在main
函数中,foo
函数下一条指令的地址,称之为返回地址,可以自行对比一下这个地址和第二个图中call foo
这条指令的下一指令地址是不是一样的。
接下来再看一下foo
函数的反汇编代码。
首先是保存调用函数
的ebp
(也就是caller
的基地址),然后将ebp
寄存器的值更新为当前的栈顶的地址(也就是ebp
被更新为callee
的基地址)。接下来通过sub
指令来抬高栈顶,为了保存寄存器和局部变量等。
然后通过ebp+偏移
的方式将参数保存到寄存器中,然后进行相加,将结果保存到eax
寄存器中,在32位程序中,返回值是放在eax
寄存器里的。
最后是一个ret
指令,这个指令和call
指令是一对儿,他也可以理解为两步,第一步pop 返回地址
,第二步jmp 返回地址
,返回到call foo
的下一条指令继续执行。call
指令是相当于保存下一条指令的地址,然后调用callee
,然后在callee
的最后呢又通过ret
指令,返回到之前保存的地址,继续执行。通俗的来讲就是,你在玩游戏的过程中,突然要处理一些事情,你就将游戏存档了,然后去办事去了,等事情办完了,通过那个存档点又继续了你的游戏。
至于栈溢出的漏洞,是由于一些函数没有对输入进行限制,你就可以去构造一个函数,通过覆盖掉返回地址,控制函数跳转。可以参考pwnable.kr的bof这道题.