PWN基础-栈帧、C语言函数调用
在x86的计算机系统中,内存空间中的栈主要用于保存函数的参数,返回值,返回地址,本地变量等。一切的函数调用都要将不同的数据、地址压入或者弹出栈。因此,为了更好地理解函数的调用,我们需要先来看看栈是怎么工作的。
栈
栈是一种计算机系统中的数据结构,它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。
栈的操作常用操作:
- 进栈(PUSH):将一个数据放入栈里叫进栈(PUSH)
- 出栈(POP):将一个数据从栈里取出叫出栈(POP)
- 栈顶:常用寄存器ESP,ESP是栈指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
- 栈底:常用寄存器EBP,EBP是基址指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

上面例子中栈的生长方向是从高地址到低地址的,这是因为在下文讲的栈帧中,栈就是向下生长的,因此这里也用这种形式的栈。pop操作后,栈中的数据并没有被清空,只是该数据我们无法直接访问。
有了这些栈的基本知识,我们现在可以来看看在x86-32bit系统下,C语言函数是如何调用的了。
栈帧
栈帧,也就是stack frame,其本质就是一种栈,只是这种栈专门用于保存函数调用过程中的各种信息(参数,返回地址,本地变量等)。栈帧有栈顶和栈底之分,其中栈顶的地址最低,栈底的地址最高,SP(栈指针)就是一直指向栈顶的。
在x86-32bit中,我们用 ebp 指向栈底,也就是基址指针;用 esp 指向栈顶,也就是栈指针。下面是一个栈帧的示意图:
一般来说,我们将 ebp 到 esp 之间区域当做栈帧。并不是整个栈空间只有一个栈帧,每调用一个函数,就会生成一个新的栈。在函数调用过程中,我们将调用函数的函数称为“调用者(caller)”,将被调用的函数称为“被调用者(callee)”。在这个过程中,
- “调用者”需要知道在哪里获取“被调用者”返回的值;
- “被调用者”需要知道传入的参数在哪里,
- 返回的地址在哪里。同时,我们需要保证在“被调用者”返回后,
ebp,esp等寄存器的值应该和调用前一致。因此,我们需要使用栈来保存这些数据。
https://gcc.godbolt.org函数调用
我们直接通过实例来看函数是如何调用的。
这是一个有参数但没有调用任何函数的简单函数,它被其他函数调用。
int MyFunction(int x, int y, int z) |
对于这段代码,汇编代码大致如下:MyFunction:
push ebp
mov ebp, esp
sub esp, 0xc
mov DWORD PTR [ebp-0x4], 0x72
mov DWORD PTR [ebp-0x8], 0x202
mov DWORD PTR [ebp-0xc], 0x13
leave
ret
main:
push ebp
mov ebp, esp
sub esp, 0xc
mov DWORD PTR [ebp-0x4], 0x1
mov DWORD PTR [ebp-0x8], 0x2
mov DWORD PTR [ebp-0xc], 0x3
push 0x3
push 0x2
push 0x1
call MyFunction
xor eax, eax
leave
ret
调用函数时,用 call 指令:call MyFunction
等价于:
- push eip_next (把“call 下一条指令的地址”压栈,就是下图中的
返回地址,还记得吗,eip/rip保存下一条执行的指令的地址) - jmp MyFunction (跳转到函数入口)
所以栈顶保存了“函数执行完后该回到哪里”的地址。
接着是MyFunction开头的push ebp;mov ebp, esp
光看代码可能还是不太明白,我们先来看看此时的栈是什么样的:
此时调用者做了两件事情: - 将被调用函数的参数按照从右到左的顺序压入栈中。
- 将返回地址压入栈中。这两件事都是调用者负责的,因此压入的栈应该属于调用者的栈帧。
我们再来看看被调用者,它也做了两件事情: - 将老的(调用者的)
ebp压入栈,此时esp指向它。 - 将
esp的值赋给ebp,ebp就有了新的值,它也指向存放老ebp的栈空间。这时,它成了是函数MyFunction()栈帧的栈底。这样,我们就保存了“调用者”函数的ebp,并且建立了一个新的栈帧。
只要这步弄明白了,下面的操作就好理解了。在ebp更新后,我们先分配一块0xc字节的空间用于存放本地变量,这步用sub指令实现。通过使用mov转移指令配合字节数 ptr [offset]我们便可以给a,b和c赋值了。
函数返回
上面讲的都是函数的调用过程,我们现在来看看函数是如何返回的。从下面这个例子我们可以看出,和调用函数时正好相反。当函数完成自己的任务后,它会将esp移到ebp处,然后再弹出旧的ebp的值到ebp。这样,ebp就恢复到了函数调用前的状态了。leave ;//等同于mov ebp, esp ; pop ebp
ret ;//等同于pop eip;
最后一步ret ;//等同于pop eip;使eip的值为返回地址
call call back:
eip/rip保存下一条执行的指令的地址返回地址存的是是调用者call被调用者的指令的下一条
于是程序回到上一个函数接着运行下去,直至整个程序运行完。
宏观一例


