堆栈图、缓冲区溢出与代码数据混淆

一、堆栈图——函数调用的完整过程

说明:Debug 版本编译的程序未做优化,保留了完整的堆栈帧和填充指令(如 0xCC),更适合学习分析。Release 版会优化掉很多步骤,但原理相通,只要把 Debug 版本的分析思路迁移过去即可。

1.1 准备工作

  • 将被分析的 EXE 拖入调试器,输入函数调用前的地址(例如 401078),下断点,运行至断点处。
  • 记录初始的 ESPEBP 值(例如 ESP = 12FF30EBP = 12FF80)。
  • 从参数压栈开始,逐步跟踪整个函数调用过程。

1.2 堆栈变化详细步骤

第1步:参数压栈

1
2
PUSH 4
PUSH 3
  • PUSH 将立即数压入堆栈,ESP 每次减 4(栈向低地址增长)。
  • 第一次 PUSH 4 后,ESP 变为 12FF2C,栈顶存放 00000004
  • 第二次 PUSH 3 后,ESP 变为 12FF28,栈顶存放 00000003
  • EBP 不变,EIP 随指令执行而变化。

第2步:CALL 指令

1
CALL 00401005 
  • CALL 完成两件事:
    1. 返回地址(当前指令的下一条指令地址,例如 401081)压入堆栈,ESP 再次减 4,变为 12FF24
    2. 修改 EIP 为被调用函数的首地址(本例中会经过一个 JMP 跳板,最终到达真正的函数体 401020)。
  • 此时栈顶存储的就是函数返回后应该继续执行的地址。

第3步:保存原 EBP

1
PUSH EBP
  • 将上层函数的 EBP12FF80)压栈,ESP 变为 12FF20
  • 目的:保留现场,以便函数返回时能恢复原来的栈帧基址。

第4步:建立新的栈帧基址

1
MOV EBP, ESP
  • 将当前 ESP 赋值给 EBP。此时 EBP = ESP = 12FF20
  • 之后在整个函数体内,EBP 不再变化(除非有特殊操作),作为访问参数和局部变量的基准。

第5步:开辟局部变量空间

1
SUB ESP, 44h
  • ESP 减去 0x44,即向低地址扩展 68 字节的栈空间。
  • 12FEDC12FF20 之间的区域就是本函数的 缓冲区(存放局部变量)。

第6步:保存其它寄存器

1
2
3
PUSH EBX
PUSH ESI
PUSH EDI
  • 继续将 EBXESIEDI 的值压栈,ESP 依次变为 12FED812FED412FED0
  • 目的依然是保留现场,避免子函数破坏上层寄存器的值。

第7步:用 0xCC 填充缓冲区(Debug 特性)

1
2
3
4
LEA EDI, DWORD PTR SS:[EBP-44h]
MOV ECX, 11h
MOV EAX, 0CCCCCCCCh
REP STOS DWORD PTR ES:[EDI]
  • LEAEBP-44h 的地址(即缓冲区起点 12FEDC)加载到 EDI
  • ECX 赋值 0x11(17次),EAX 赋值 0xCCCCCCCC
  • REP STOSEAX 的值重复写入 EDI 指向的内存,每次写入一个双字(4字节)后 EDI 自动加 4。
  • 执行结束后,从 12FEDC12FF1C 的整个缓冲区都被填充为 0xCC
  • ESP 仍为 12FED0

第8步:操作局部变量与参数

1
2
3
4
MOV DWORD PTR SS:[EBP-4], 2          ; 局部变量 int i = 2
MOV EAX, DWORD PTR SS:[EBP+8] ; 获取第一个参数(值为3)
ADD EAX, DWORD PTR SS:[EBP+0Ch] ; 加上第二个参数(值为4)→ EAX=7
ADD EAX, DWORD PTR SS:[EBP-4] ; 加上局部变量 i (2) → EAX=9

重要规则

  • [EBP-xxx] 通常访问的是 局部变量
  • [EBP+8] 开始是 参数[EBP+4]返回地址
  • 函数返回值一般通过 EAX 传递。

第9步:恢复现场(反向 POP)

1
2
3
POP EDI
POP ESI
POP EBX
  • 按入栈相反的顺序弹出,将值恢复给各个寄存器。
  • 每次 POPESP 加 4。执行后 ESP 变为 12FEDCEBP 依然为 12FF20
  • 原来的局部变量数据仍残留在栈中,但已不再属于有效栈帧。

第10步:恢复 ESP 和 EBP

1
2
MOV ESP, EBP
POP EBP
  • MOV ESP, EBPESP 重置为 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 之前压入了两个参数(43),现在通过 ADD ESP, 8ESP 调整为 12FF30,恢复到调用前的值,实现 堆栈平衡
  • 最终 EBP = 12FF80ESP = 12FF30,与初始状态完全一致。

堆栈图交互演示(以下为基于 JS 加载的示意图。如禁用了 JS 加载,可忽略该区块):


二、缓冲区溢出基本原理

掌握了函数调用时堆栈的布局之后,就很容易理解缓冲区溢出攻击。

关键点

  • [EBP+4] 存放的是函数的返回地址,函数执行 RET 时会将其弹出到 EIP
  • 如果在局部变量区(缓冲区)写入的数据超过了分配的空间,就可能覆盖到 [EBP+4],从而篡改返回地址,让程序跳转到任意代码去执行。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

void Attack() {
while(1) {
printf("攻击程序\n");
}
getchar();
}

int main(int argc, char* argv[]) {
int arr[5] = {0};
arr[6] = (int)Attack; // 越界写入,覆盖返回地址
return 0;
}

原理分析

  • 数组 arr 有 5 个元素,位于栈上。arr[6] 正好对应到 [EBP+4] 的位置(取决于编译器生成的堆栈布局)。
  • Attack 函数的地址写入 arr[6],当 main 函数返回时,RET 指令会将该地址弹出到 EIP,从而开始执行 Attack,形成死循环。

作业:请分析以下代码为何会进入死循环,并用调试器验证堆栈变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void HelloWorld() {
int i = 0;
int a[] = {1,2,3,4,5,6,7,8,9,10};
for(i = 0; i <= 10; i++) {
a[i] = 0; // 当 i=10 时越界,覆盖了返回地址
printf("Hello World!\n");
}
}

int main(int argc, char* argv[]) {
HelloWorld();
getchar();
return 0;
}

三、代码还是数据——反调试的艺术

正向开发中,编译器会区分代码段和数据段。但在逆向工程师眼里,内存中的二进制没有绝对的代码与数据之分,我们让它是什么,它就是什么

3.1 全局变量与局部变量

1
2
3
4
5
6
int x = 100;    // 全局变量,位于 .data 段,程序整个生命周期存在

int Plus() {
int y = 2; // 局部变量,位于栈中,函数返回后即失效
return x + y;
}
  • 局部变量在堆栈中,通过 [EBP-xxx] 访问;全局变量在文件中固定的数据段。
  • 理解这一点是理解“把代码藏到数据区”的基础。

3.2 数组的本质

1
2
int nBuffer[] = {1,2,3,4,5,6,0};
char szBuffer[] = {0x41, 0x61, 0x12, 'A', 0};
  • 数组里装的都是二进制数据,无论元素是整数还是字符,最终在内存中都是连续的字节序列。
  • 代码编译后也是一串二进制机器码,因此完全可以“伪装”成数组。

3.3 函数指针调用

1
2
3
4
5
6
7
8
9
10
11
12
int Plus(int x, int y) {
return x + y;
}

typedef int (*pFunction)(int x, int y);

int main() {
int z = Plus(1,2); // 直接调用
pFunction p = (pFunction)Plus;
int m = p(4,5); // 通过函数指针调用
return 0;
}
  • 在反汇编窗口中观察,直接调用和函数指针调用最终都会跳转到同一段机器码。
  • 函数名在编译后只是一个地址,与数组名没有本质区别。

3.4 将代码藏在数据区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned char loc[] = {
0x55,0x8B,0xEC,0x83,0xEC,0x40,0x53,0x56,0x57,0x8D,0x7D,
0xC0,0xB9,0x10,0x00,0x00,0x88,0xB8,0xCC,0xCC,0xCC,0xCC,
0xF3,0xAB,0x8B,0x45,0x08,0x03,0x45,0x0C,0x5F,0x5E,0x5B,
0x8B,0xE5,0x5D,0xC3
}; // 这就是一个加法函数的机器码,但被声明为全局数组,存在 .data 段

typedef int (*fun)(int x, int y);

int main() {
fun f = (fun)&loc;
printf("%x", f(4,5)); // 调用了数据区里的“函数”
return 0;
}
  • 这段代码将加法函数的机器码硬编码在全局数组中。
  • 当通过函数指针调用 f 时,CPU 会跳转到数据段执行这些二进制指令,功能完全正确。
  • 这种技术常用于加壳反调试,让代码隐藏在非预期的区域,增加分析难度。

实践要点

  • 在 OD 等调试器中,若在数据区下断点,调试器会发出警告“代码在数据区”,甚至可能导致错误。这正是对抗逆向的一种手段。
  • 如果将来你在分析时发现代码段也全是 0x00,很可能是遇到了强力保护,应谨慎处理。

总结

  • 画透堆栈图是后续所有分析(函数调用约定、缓冲区溢出、漏洞利用、脱壳)的绝对基础。
  • 理解 EBPESP 的作用,以及返回地址的位置,才能理解“溢出”的原理。
  • 二进制无性,数据可为代码,代码可为数据——这是逆向分析与正向开发在思维上的根本区别。
  • 课后务必动手完成死循环程序的分析,用调试器验证堆栈变化,加深理解。

堆栈图、缓冲区溢出与代码数据混淆

https://blog.c8h10n4o2.asia/6dd28e9b/

作者

萌新知

发布于

2026-06-22

更新于

2026-07-20

许可协议