堆栈图、缓冲区溢出与代码数据混淆
一、堆栈图——函数调用的完整过程
说明:Debug 版本编译的程序未做优化,保留了完整的堆栈帧和填充指令(如 0xCC),更适合学习分析。Release 版会优化掉很多步骤,但原理相通,只要把 Debug 版本的分析思路迁移过去即可。
1.1 准备工作
- 将被分析的 EXE 拖入调试器,输入函数调用前的地址(例如
401078),下断点,运行至断点处。 - 记录初始的
ESP和EBP值(例如ESP = 12FF30,EBP = 12FF80)。 - 从参数压栈开始,逐步跟踪整个函数调用过程。
1.2 堆栈变化详细步骤
第1步:参数压栈
1 | PUSH 4 |
PUSH将立即数压入堆栈,ESP每次减 4(栈向低地址增长)。- 第一次
PUSH 4后,ESP变为12FF2C,栈顶存放00000004。 - 第二次
PUSH 3后,ESP变为12FF28,栈顶存放00000003。 EBP不变,EIP随指令执行而变化。
第2步:CALL 指令
1 | CALL 00401005 |
CALL完成两件事:- 将 返回地址(当前指令的下一条指令地址,例如
401081)压入堆栈,ESP再次减 4,变为12FF24。 - 修改
EIP为被调用函数的首地址(本例中会经过一个JMP跳板,最终到达真正的函数体401020)。
- 将 返回地址(当前指令的下一条指令地址,例如
- 此时栈顶存储的就是函数返回后应该继续执行的地址。
第3步:保存原 EBP
1 | PUSH EBP |
- 将上层函数的
EBP(12FF80)压栈,ESP变为12FF20。 - 目的:保留现场,以便函数返回时能恢复原来的栈帧基址。
第4步:建立新的栈帧基址
1 | MOV EBP, ESP |
- 将当前
ESP赋值给EBP。此时EBP = ESP = 12FF20。 - 之后在整个函数体内,
EBP不再变化(除非有特殊操作),作为访问参数和局部变量的基准。
第5步:开辟局部变量空间
1 | SUB ESP, 44h |
- 将
ESP减去0x44,即向低地址扩展 68 字节的栈空间。 - 从
12FEDC到12FF20之间的区域就是本函数的 缓冲区(存放局部变量)。
第6步:保存其它寄存器
1 | PUSH EBX |
- 继续将
EBX、ESI、EDI的值压栈,ESP依次变为12FED8、12FED4、12FED0。 - 目的依然是保留现场,避免子函数破坏上层寄存器的值。
第7步:用 0xCC 填充缓冲区(Debug 特性)
1 | LEA EDI, DWORD PTR SS:[EBP-44h] |
LEA将EBP-44h的地址(即缓冲区起点12FEDC)加载到EDI。ECX赋值0x11(17次),EAX赋值0xCCCCCCCC。REP STOS将EAX的值重复写入EDI指向的内存,每次写入一个双字(4字节)后EDI自动加 4。- 执行结束后,从
12FEDC到12FF1C的整个缓冲区都被填充为0xCC。 ESP仍为12FED0。
第8步:操作局部变量与参数
1 | MOV DWORD PTR SS:[EBP-4], 2 ; 局部变量 int i = 2 |
重要规则:
[EBP-xxx]通常访问的是 局部变量。[EBP+8]开始是 参数,[EBP+4]是 返回地址。- 函数返回值一般通过
EAX传递。
第9步:恢复现场(反向 POP)
1 | POP EDI |
- 按入栈相反的顺序弹出,将值恢复给各个寄存器。
- 每次
POP,ESP加 4。执行后ESP变为12FEDC,EBP依然为12FF20。 - 原来的局部变量数据仍残留在栈中,但已不再属于有效栈帧。
第10步:恢复 ESP 和 EBP
1 | MOV ESP, EBP |
MOV ESP, EBP将ESP重置为12FF20,等价于撤销SUB ESP, 44h开辟的空间。POP EBP从栈顶弹出之前保存的原EBP值(12FF80),并恢复给EBP寄存器。此时ESP变为12FF24。
第11步:RET 指令返回
1 | RETN |
RET将当前栈顶保存的 返回地址(401081)弹出并放入EIP。ESP加 4,变为12FF28。程序跳转到调用函数的下一条指令继续执行。
第12步:调用者清理参数(外平栈)
1 | ADD ESP, 8 |
- 调用者在
CALL之前压入了两个参数(4和3),现在通过ADD ESP, 8将ESP调整为12FF30,恢复到调用前的值,实现 堆栈平衡。 - 最终
EBP = 12FF80,ESP = 12FF30,与初始状态完全一致。
堆栈图交互演示(以下为基于 JS 加载的示意图。如禁用了 JS 加载,可忽略该区块):
二、缓冲区溢出基本原理
掌握了函数调用时堆栈的布局之后,就很容易理解缓冲区溢出攻击。
关键点:
[EBP+4]存放的是函数的返回地址,函数执行RET时会将其弹出到EIP。- 如果在局部变量区(缓冲区)写入的数据超过了分配的空间,就可能覆盖到
[EBP+4],从而篡改返回地址,让程序跳转到任意代码去执行。
示例代码:
1 |
|
原理分析:
- 数组
arr有 5 个元素,位于栈上。arr[6]正好对应到[EBP+4]的位置(取决于编译器生成的堆栈布局)。 - 将
Attack函数的地址写入arr[6],当main函数返回时,RET指令会将该地址弹出到EIP,从而开始执行Attack,形成死循环。
作业:请分析以下代码为何会进入死循环,并用调试器验证堆栈变化。
1 | void HelloWorld() { |
三、代码还是数据——反调试的艺术
正向开发中,编译器会区分代码段和数据段。但在逆向工程师眼里,内存中的二进制没有绝对的代码与数据之分,我们让它是什么,它就是什么。
3.1 全局变量与局部变量
1 | int x = 100; // 全局变量,位于 .data 段,程序整个生命周期存在 |
- 局部变量在堆栈中,通过
[EBP-xxx]访问;全局变量在文件中固定的数据段。 - 理解这一点是理解“把代码藏到数据区”的基础。
3.2 数组的本质
1 | int nBuffer[] = {1,2,3,4,5,6,0}; |
- 数组里装的都是二进制数据,无论元素是整数还是字符,最终在内存中都是连续的字节序列。
- 代码编译后也是一串二进制机器码,因此完全可以“伪装”成数组。
3.3 函数指针调用
1 | int Plus(int x, int y) { |
- 在反汇编窗口中观察,直接调用和函数指针调用最终都会跳转到同一段机器码。
- 函数名在编译后只是一个地址,与数组名没有本质区别。
3.4 将代码藏在数据区
1 | unsigned char loc[] = { |
- 这段代码将加法函数的机器码硬编码在全局数组中。
- 当通过函数指针调用
f时,CPU 会跳转到数据段执行这些二进制指令,功能完全正确。 - 这种技术常用于加壳和反调试,让代码隐藏在非预期的区域,增加分析难度。
实践要点:
- 在 OD 等调试器中,若在数据区下断点,调试器会发出警告“代码在数据区”,甚至可能导致错误。这正是对抗逆向的一种手段。
- 如果将来你在分析时发现代码段也全是
0x00,很可能是遇到了强力保护,应谨慎处理。
总结:
- 画透堆栈图是后续所有分析(函数调用约定、缓冲区溢出、漏洞利用、脱壳)的绝对基础。
- 理解
EBP和ESP的作用,以及返回地址的位置,才能理解“溢出”的原理。 - 二进制无性,数据可为代码,代码可为数据——这是逆向分析与正向开发在思维上的根本区别。
- 课后务必动手完成死循环程序的分析,用调试器验证堆栈变化,加深理解。
堆栈图、缓冲区溢出与代码数据混淆