1. 栈结构
1.1 基本知识
栈是遵循后进先出(Last in First Out)原则的一种数据结构、元素的集合。
重要概念:
- 栈顶:栈的顶部,压栈和出栈的操作都发生在栈顶;
- 栈底: 栈中第一个被压入的元素所在的位置,基本上是固定的;
- 栈帧:程序每次调用一个函数时,在内存中分配一块独立的空间用于存放栈的信息;换句话说,栈帧是整个函数调用所需的内存区域;
- 栈指针:通常是一个专用的栈指针寄存器(SP),用于存储当前函数调用栈的顶部;换句话说,栈指针始终指向当前栈顶的位置,栈指针寄存器的值始终是最新的、有效栈顶元素的内存地址;
- 堆栈平衡:当一个函数执行完成并返回到调用它的位置时,栈指针必须恢复到调用该函数之前的状态;换句话说,CALL指令执行之前和执行之后栈指针的值要保持一致;
栈的主要特点:
- 栈结构遵循“后进先出”的原则,即最后入栈的元素,将被第一个取出;
- 栈结构的所有操作都只在栈顶一端进行;
- 程序中使用的栈是从进程地址空间的高地址向低地址增长的;

栈的操作
栈的基本操作有两种:压栈push和出栈pop,还有其他的操作:
- 压栈push:栈顶添加一个新的元素;
- 出栈pop:移除(弹出)并返回栈顶的元素;
- 窥视/取顶peek/top:返回栈顶元素的值,但不会移除元素;
- 大小size:返回栈中当前元素的数量;
- 判空isEmpty:检查栈当前是否包含任何元素;
1.2 栈帧
栈帧,官方术语称为“过程活动记录”,是指程序为支持函数调用在内存中分配的一块空间,专门用于保存函数调用过程中的各种信息,比如局部变量、返回地址、参数等等。
栈帧的起点是栈底,通常使用ebp寄存器指向,也就是基址指针;栈帧的终点是栈顶,通常使用esp寄存器指向,也就是栈指针。栈通常是由高地址向低地址增长的,所以栈底的地址最高,栈顶的地址最低。

栈帧就是ebp到esp之间的区域,每个函数调用都会产生一个新的栈帧,所以栈空间可以有很多个栈帧。
2. 函数调用栈
函数调用就是一种很典型的栈应用的实例。
通常我们使用高级语言编写程序,很难接触到函数调用的过程,那是因为高级语言通过编译转换成汇编语言替我们做了,如果使用汇编语言那就需要充分使用栈结构了。
我们使用C语言写一个简单的程序,打开调试观察程序的反汇编代码以及寄存器状态。
//func_stack.c
#include <stdio.h>
int func(int a,int b);
int main()
{
func(1,2);
return 0;
}
int func(int a, int b) {
return a + b;
}
打开VS2017,int func函数前添加一个断点,以X86架构为例,按F5开始调试,打开调试-窗口-反汇编即可看到具体的汇编代码和函数调用栈细节:
10: func(1,2);
004B1788 push 2
004B178A push 1
004B178C call _func (04B10DCh)
004B1791 add esp,8
//CALL内部:
14: int func(int a, int b) {
004B1700 push ebp
004B1701 mov ebp,esp
004B1703 sub esp,0C0h
004B1709 push ebx
004B170A push esi
004B170B push edi
004B170C lea edi,[ebp-0C0h]
004B1712 mov ecx,30h
004B1717 mov eax,0CCCCCCCCh
004B171C rep stos dword ptr es:[edi]
004B171E mov ecx,offset _E9A710A1_func_stack@c (04BC003h)
004B1723 call @__CheckForDebuggerJustMyCode@4 (04B1208h)
15: return a + b;
004B1728 mov eax,dword ptr [a]
004B172B add eax,dword ptr [b]
16: }
004B172E pop edi
004B172F pop esi
004B1730 pop ebx
004B1731 add esp,0C0h
004B1737 cmp ebp,esp
004B1739 call __RTC_CheckEsp (04B1212h)
004B173E mov esp,ebp
004B1740 pop ebp
004B1741 ret
接着打开调试-窗口-寄存器窗口,观察寄存器的动态:

打开调试-窗口-调用堆栈,在地址栏输入esp,查看当前栈帧的栈顶,输入ebp,查看当前栈帧的栈底:

过程分析,当前的栈结构图:

第一步:将两个变量(实参)压入到栈中的图解:

第二步,执行CALL指令,将返回地址(CALL指令的下一条指令)压栈,用于保存现场:

第三步,进入了函数的内部,将ebp寄存器压栈保存旧的基地址:

当前所调试的内存、反汇编、寄存器窗口的信息:

第四步,开辟一个新的栈帧(func函数的栈帧),即mov ebp,esp,将esp的值赋给ebp:

第五步,提升堆栈,即sub esp,0C0h,将esp的值减去192:

第六步:将ebx、esi、edi三个寄存器压栈,保存寄存器的状态,确保CALL指令之前和之后的环境一致,这正是堆栈平衡的结果:

第七步:执行lea edi,[ebp-0C0h]指令,将ebp-0C0h所指向的内存地址赋值给edi寄存器,这一步的栈并没有什么变化,只是寄存器发生了变化;mov ecx,30h指令和mov eax,0CCCCCCCCh指令也是一样;
第八步:提升堆栈的内容初始化为0CCCCCCCCh,rep的作用是重复执行stos dword ptr es:[edi]指令,将eax的值赋值给edi所指向的内存地址里的值,并且每执行一次edi都会增加4;

第九步:执行表达式运算:mov eax,dword ptr [a]指令将实参1赋值给eax寄存器,add eax,dword ptr [b]指令将实参2与1相加,计算结果存放于eax寄存器中:


第十一步:从这一步开始还原现场了,首先将edi,esi,ebx这三个寄存器弹出:

第十二步:执行add esp,0C0h指令,栈指针又回到了当前栈帧的起点,即和ebp相同:

第十三步:执行mov esp,ebp指令和add esp,0C0h指令的作用是相同的,所以栈帧没有什么变化;
第十四步:将ebp弹出,还原ebp的值,这个图和返回地址压栈的图是一样的:

第十五步:ret返回,相当于pop eip指令,执行完后esp指向了当时传入的实参,这个图和实参压栈的图是一样的:

注意这里执行完ret指令后,直接返回到了CALL指令的下一条指令的地址:

第十六步:此时还没有结束,因为此时的栈状态和压栈之前不一样,我们需要手动调成一样的,然后发现ret返回的下一条指令add esp,8,用于调节esp指针,保持堆栈平衡:


函数调用栈的三个基本流程:调用CALL之前、调用CALL、调用CALL之后:
- 调用CALL之前:将函数运行所需要的实参压入栈中(从右往左);
- 调用CALL:提升堆栈、保护现场、初始化堆栈、执行函数体、恢复现场、返回;
- 调用CALL之后:通过调整栈指针esp的值,保持堆栈平衡;
3. 栈溢出原理
栈溢出是指程序运行过程中使用的内存量超过了操作系统预先分配给该线程的调用栈容量。当向栈上的缓冲区写入超过其容量的数据时,数据会覆盖相邻的栈内存,甚至修改“返回地址”。

3. Windows平台栈溢出
首先用C语言写一个带有危险函数的程序:
#include <stdio.h>
#include <string.h>
#include <windows.h>
#include <stdlib.h>
#include <stdint.h>
#define PTR_SIZE 4 // 指针的大小
#define EBP_SIZE 4 // ebp寄存器的大小
void msgBoxA() //0x00411940
{
printf("msgBoxA func called...\n");
MessageBoxA(0, "msg", "box", 0);
}
void copy_func(char *addr, int len) //0x00411830
{
char buffer[16] = { 0xff };
memcpy(buffer+ 20 + EBP_SIZE, addr, len);
printf("copy_func called...\n");
}
int main(int argc, char** argv)
{
uint32_t msgBoxAPtr = (uint32_t)&msgBoxA;
copy_func(&msgBoxAPtr, PTR_SIZE);//返回地址0x004119EF
printf("main exited...\n");
return 0;
}
打开VS新建一个C/C++项目,输入上面的代码,注意在调试之前需要关闭栈保护机制和随机地址(ASLR)选项,否则会执行失败;右键项目-属性-C/C++-所有选项-禁用安全检查(GS):

右键项目-属性-链接器-高级-随机地址选择否、数据执行保护(DEP)也选择否:

设置好了以后开始调试代码,在memcpy函数调用处下一个断点,选择Debug-X86模式启动本地Windows调试器,打开内存、寄存器、反汇编三个窗口,观察调用栈的变化,初始的栈是这样的:

进入memcpy函数,内存窗口输入esp,即可看到返回地址压栈:

下一步一直走,内存输入框输入ebp查看当前堆栈的起始处,执行完mov edx,dword ptr [esi]和mov dword ptr [edi],edx指令后,返回地址就被msgBoxA函数的地址覆盖掉了:

memcpy函数返回后(执行完ret指令),EIP将变成msgBoxA函数的地址,跳到msgBoxA函数执行:

根据栈帧结构图,返回地址在ebp的前面压入的,所以这里正好覆盖掉了copy_func函数的返回地址,跳出程序弹窗就执行了:


总结:这段代码就是调用memcpy函数复制数据时将msgBoxA函数的地址覆盖了copy_func函数的返回地址,导致copy_func函数执行完返回时跳转到了msgBoxA函数处执行;
这就是本段代码栈溢出的主要原因,copy_func函数调用memcpy等危险函数时,没有对数据的长度做出健壮性的验证,从而导致了栈溢出的发生。
3.2 Linux平台栈溢出
Linux平台和Windows的溢出原理基本一致,还是那段代码:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdint.h>
#define PTR_SIZE 4 // 指针的大小
#define EBP_SIZE 4 // ebp寄存器的大小
void inject()
{
printf("inject func called...\n");
}
void copy_func(char *addr, int len)
{
char buffer[16] = { 0xff };
memcpy(buffer+ 20 + EBP_SIZE, addr, len);
printf("copy_func called...\n");
}
int main(int argc, char** argv)
{
uint32_t injectPtr = (uint32_t)&inject;
copy_func(&injectPtr, PTR_SIZE);
printf("main exited...\n");
return 0;
}
打开kali系统VS Code编辑器输入上面的代码,使用gcc编译器进行编译,需要加上-fno-stack-protector参数关闭栈保护,否则执行失败:
gcc stack_demo.c -fno-stack-protector -o stack_demo
生成一个名为stack_demo的中间文件,然后直接运行:
./stack_demo
执行完后发现inject函数被调用了,证明copy_func函数的返回地址被覆盖,存在栈溢出漏洞:

1. 一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。