PWN入门之四:C语言调用栈
PWN入门之四:C语言调用栈
参考文章:
PWN入门(1-1-1)-C函数调用过程原理及函数栈帧分析(Intel)
C语言函数调用栈(一)
C语言函数调用栈(二)
Hello算法
0x00 内存简述
内存,更专业点应该叫主存。由一组动态随机存取器(DRAM)芯片组成。逻辑上,存储器是一个线性的字节数组,每个字节都有其唯一的地址(数组索引)。而我们平常操控以及看到的,是操作系统抽象出的虚拟内存,而非物理上的芯片。
0X01 什么是栈
栈,是一种特殊的线性结构,其特殊在于其对于数据的操作受限,是一种LIFO(Last In, First out)形式的数据结构,所有数据都是后进者先出。栈的组成包括数据储存区域、栈顶指针、栈底指针。对栈内数据的操作有两种:push(入栈\压栈)和pop(出栈\弹栈),push将数据压入栈中,pop将数据弹出栈。

这幅图很好的展示了栈这种数据结构的工作模式,但是这样还是太干涩了,我们换一种更加简单的理解方式。栈其实是程序向内存申请的一片连续的[1],线性的内存空间。我们可以把它类比成一个放书箱子,但是这个箱子的大小是专门定制的,只能允许书躺着往里面放。上文数据存储区域就是箱子里用来存放书的空间,栈顶指针可以想象成你的手,因为你的手永远只能访问且操作到箱子中最顶上的那本书,栈底指针就是箱子的底板。由于箱子本身的限制,你的书也只能一本一本的放,那也只能一本一本的拿,当你想拿到箱子中最底下的那本书的时候,你就必须把上面的书一层层拿掉,才能拿到底下的书。并且,由于放书的顺序,越晚放的书,越在箱子中书的上方,所以拿书的的时候,它越早的出去,LIFO(Last In, First out)形式。
什么是栈帧
栈帧(stack frame),其本质也是栈,只不过是这种栈专门用来保存函数调用过程中的各种信息,比如参数,返回地址,临时变量等等。栈帧也有栈顶栈底之分,但是较为反直觉的是,栈底在高地址,而栈顶在低地址,所以说不要被“底”和“顶”这两个字迷惑了。在x86-32bit下,我们使用ebp寄存器储存栈底的地址,esp寄存器储存栈顶的地址(x86-64bit就是rbp和rsp,下文中统一使用x86-32bit形式的寄存器),但是为了方便阐述与理解,你可以理解为,ebp是指向栈底的指针,esp是指向栈顶的指针,同时,也能看得出,栈帧的边界由esp和ebp界定。下面是一个栈帧的示意图。
上文说到,栈帧是用来保存函数调用过程中的各种信息的,所以在栈空间内不可能就一个栈帧,每调用一个函数,就会生成一个新的栈帧,调用函数的函数称为“调用者(caller)”,被调用的函数称为“被调用者(callee)”。
同时,栈的操作,需要一来上一篇文章中提到的PUSH和POP指令,需要注意的是,“栈”本身是一种数据面结构,对起内数据有push和pop两种操作方法,但是,不要把数据结构“栈”以及其操作方法与函数栈帧中的汇编指令PUSH和POP搞混,这是两个不同的概念。
函数调用的过程
下面,我们就从一个简单的程序,来学习函数调用的底层逻辑以及栈帧是如何工作的。
1 | |
很简单的代码,很好理解,但是要理解栈帧,我们需要从汇编来看。下面这是简化的main函数。
1 | |
可以看到, main函数调用了FunA函数,下面我将用<--这个小箭头表示程序该执行那个步骤了,因为在gdb动态调试中也是这样的显示的。
1 | |
我们直接来到call FunA附近,此时内存中的main函数的栈帧如下图:
这里再次强调,ebp和esp是寄存器,这里为了方便理解,将它们类比为指针。
这里的push 1的意思是将1这个立即数压入栈中,其中push指令等效于下面两句汇编:
1 | |
先将esp寄存器中的储存的数(main栈顶的地址)减去4。对应到栈上就是栈又向下“生长”了4个字节。
然后向esp指向的那块内存中,写入立即数1

但是要注意!我上面说的是“等效”,而不是“等于”。push指令是一个单独指令,只是执行后的效果与上文中提到的等效汇编效果一致,不要搞混!
下面,将要执行的指令是call FunA
1 | |
call在x86架构下是一个单一的命令,但是其实际执行时会分解成几个微操作。等效于下面两句汇编:
1 | |
push eip + 5的作用是将call FunA的下一条指令压入栈中保存。这样做的原因也不难理解,CPU中只有一个eip寄存器,也就是说,CPU只能跟踪并顺序执行一段在内存中连续指令,但是程序需要执行另一个函数FunA,所以需要让eip指向FunA函数中指令的地址。但是,在FunA执行完之后,我们仍想回到main函数中,执行call FunA之后的命令。由于寄存器中的值有易失性,变了就回不去了,所以才需要将call FunA之后的那条指令的地址保存在栈中,然后在FunA执行完后,再从栈中将这个地址重新赋值给eip,让CPU继续执行main函数的指令。
至于为什么下一条命令地址值是当前的eip中的地址加5个字节的偏移,这是因为,在计算机底层,call指令也只不过是一个十六进制的数字而已,占一个字节,而在x86_32bit架构下,一个地址的字长是4个字节,所以call FunA在内存中一共占用5个字节,所以+5才是下一个指令的地址。
jmp FunA很好理解,无条件跳转到FunA所在的地址上。
所以,call就干两件事,将call指令的下一条指令的地址压入栈保存,其中这个地址,也叫做返回地址,然后无条件跳转到调用函数的地址上,继续执行指令。
当执行完call FunA,内存中栈如下图所示

此时,我们仍在main函数的栈帧中。
下面,该执行FunA函数了。
1 | |
push ebp,将ebp寄存器的值,也就是main函数栈帧的栈底地址保存在栈中。这样做的原因也很简单,和保存eip中的值一样,都是为了保存现场。在FunA执行的时候,ebp指向的是FunA的栈底。当FunA(被调用者函数)执行完后,ebp又可以根据栈中保存的值,恢复到原来呃状态,指向main(调用者函数)的栈底继续工作。
总结成一句话:被调用者函数栈帧的栈底存放的是调用者函数栈帧栈底的地址。
该指令执行完之后栈帧示意图如下:

同理,main函数的栈底存放的也是调用main函数的函数的栈底地址。
在大多数的面向初学者的C语言教材中都提到,
main都是程序唯一的入口,这句话是正确的,但是请不理解为,main就是这个程序的起始函数,实际上,从启动程序到进入main函数开始执行,有一段复杂的函数调用链路,这涉及到操作系统内核与系统调用的相关知识,这里就不展开讨论了。

继续执行
1 | |
来到mov ebp, esp,这个指令的意思是将esp的值赋值给ebp。让ebp指向FunA的栈底内存
执行完后栈示意图如下:

继续执行
1 | |
sub esp, 24,意思是将esp内的值减去24(0x18)字节,放在栈上,意思就是开辟0x18字节的栈帧空间,因为栈是由高地址向低地址生长,所以,是sub指令。
执行后栈帧:

继续执行
1 | |
后面的操作就不必多说了,对栈中的内存进行赋值,然后调用其他函数。我们直接来到函数返回的部分。
1 | |
目前的栈帧如下:

leave,是一个专门用于函数栈帧清理的指令,其等效于如下两条汇编:
1 | |
mov esp, ebp让esp指向ebp指向的栈底,然后pop ebp将FunA栈底中保存的main函数的栈底地址,pop给ebp,让ebp重新指向main栈底。
就像其等效的这两条汇编,leave在执行完之后,也会让esp的值加上一个机器字长。
执行后的栈帧如下:

1 | |
retn,其作用等效于pop eip,将“返回地址”这个地址值重新给eip,好让调用者函数继续执行其指令。

如上,就是函数栈帧的工作流程。
函数调用约定
这里我只会简单介绍一下cdecl和System V AMD64 ABI这两种函数调用约定,因为这两种在Linux上是最常见的。
cdecl
函数参数使用栈传递,常见于x86_32架构下的程序,并且按照从右到左的顺序入栈,例如下面这个函数调用
1 | |
FunA有三个参数,按照cdecl调用约定,他们的入栈顺序是:c–>b–>a。
对于C函数,cdecl方式的名字修饰约定是在函数名前添加一个下划线
System V AMD64 ABI
前六个参数使用寄存器传递,额外的参数使用使用栈传递,还是从右向左依次入栈,常见于x86_64架构下的程序
1 | |
- 其实也可以不是连续的,比如链栈,但是这里我们只讨论基于数组的栈 ↩