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

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

说明: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 的作用,以及返回地址的位置,才能理解“溢出”的原理。
  • 二进制无性,数据可为代码,代码可为数据——这是逆向分析与正向开发在思维上的根本区别。
  • 课后务必动手完成死循环程序的分析,用调试器验证堆栈变化,加深理解。

汇编基础:逻辑运算、寄存器与堆栈

一、逻辑运算(位运算)

引入:计算机只认识二进制(0和1),它是如何进行复杂的加减乘除、加密解密等运算的呢?
答案就是通过逻辑运算(位运算) 来实现。这是CPU运算的基石。

1.1 基本逻辑运算

或运算(OR)

  • 规则:两个操作位中,只要有一个为1,结果就为1。相当于电路中的“并联”开关,任一开关闭合,灯泡就会亮。
  • C语言符号|
  • 汇编指令OR

1
2
3
4
   1011 0001 0101 0101
OR 0101 0101 0101 0101
———————————————————————
1111 0101 0101 0101

与运算(AND)

  • 规则:两个操作位中,都为1时,结果才为1。相当于电路中的“串联”开关,必须两个开关都闭合,灯泡才会亮。
  • C语言符号&
  • 汇编指令AND

1
2
3
4
    1011 0001 0101 0101
AND 0101 0101 0101 0101
———————————————————————
0001 0001 0101 0101

异或运算(XOR)

  • 规则:两个操作位,不同(相异)时为1,相同(都为0或都为1)时为0
  • 核心特性:一个数A与另一个数B异或两次(A XOR B XOR B),结果会还原为A本身。这一特性使其在加密算法中非常常用。
  • C语言符号^
  • 汇编指令XOR
1
2
3
4
    1011 0001 0101 0101
XOR 0101 0101 0101 0101
———————————————————————
1110 0100 0000 0000

非运算(NOT)

  • 规则:单操作数运算,按位取反。1变成0,0变成1
  • 汇编指令NOT

1
2
3
NOT 1011 0001 0101 0101
———————————————————————
0100 1110 1010 1010

左移运算(SHL)

  • 规则:将二进制位整体向左移动指定位数。高位丢弃,低位补0。每左移一位,相当于乘以2。
  • 汇编指令SHL
1
2
3
4
   1011 0001 0101 0101
SHL 1 (左移一位)
———————————————————————
0110 0010 1010 1010

1.2 逻辑运算的应用

1. CPU如何做加法?(以 2+3 为例)

CPU只会做逻辑运算,所有算术运算(加、减、乘、除)都必须分解成逻辑运算和移位来实现。以下是 2+3 的计算过程:

  • 2 的二进制:0010
  • 3 的二进制:0011

第一步:不考虑进位的加法(异或)

1
2
3
4
    0010
XOR 0011
————————
0001 (结果存入R)

第二步:计算进位(与运算 + 左移)

1
2
3
4
5
6
7
    0010
AND 0011
————————
0010 (与运算结果)
SHL 1 (左移一位,得到进位值)
————————
0100 (进位值,存入Y)

第三步:判断运算是否结束
判断第二步得到的进位值(0100)是否为 0。若不为 0,则需将第一步的结果(0001)和这个进位值(0100)作为新的加数,重复上述过程。

新一轮运算:

  • 新加数X:0001
  • 新加数Y:0100

重复第一步:

1
2
3
4
    0001
XOR 0100
————————
0101 (新结果,存入R)

重复第二步:

1
2
3
4
5
6
7
   0001
AND 0100
————————
0000 (与运算结果为0)
SHL 1
————————
0000 (新进位值为0)

此时,新进位值为 0,循环结束。最终结果就是 R 中的值 0101,即十进制的 5

2. 一个简单的加密/解密算法(异或加密)

利用异或的特性,可以设计一个最简单的加密方案。

  • 客户端(发送方)与服务器端(接收方)需要事先约定一个密钥
  • 加密过程密文 = 明文 XOR 密钥
  • 解密过程明文 = 密文 XOR 密钥

示例:加密“20”
假设我们要发送的明文是 20,密钥是 26

  1. 明文 20 的二进制0010 0100
  2. 密钥 26 的二进制0001 1010
  3. 加密(XOR)
1
2
3
4
    0010 0100  (明文: 20)
XOR 0001 1010 (密钥: 26)
——————————————————————————
0000 1110 (密文: 14)

客户端将加密后的 14 发送给服务器。服务器收到后,用同样的密钥 26 进行解密:

  1. 解密(XOR)
1
2
3
4
    0000 1110  (密文: 14)
XOR 0001 1010 (密钥: 26)
————————————————————————————————————
0010 0100 (还原为明文: 20)

二、寄存器与内存

引入:我们知道了CPU怎么算,但计算的数据和结果该保存在哪里呢?
计算机中有两个核心的存储部件:CPU内部的寄存器和主板上的内存。它们本质都是存储0和1的“容器”,没有区别。

  • 寄存器:集成在CPU内部,读写速度极快但容量小、价格昂贵。
  • 内存:独立于CPU,读写速度相对慢但容量大、价格便宜。

2.1 32位通用寄存器

x86架构CPU提供了8个32位的通用寄存器,用于临时存储数据。它们是:

寄存器名 约定用途(了解即可,无需硬背)
EAX 累加器,常用于存放函数返回值
ECX 计数器,常用于循环
EDX 数据寄存器
EBX 基址寄存器
ESP 堆栈指针,始终指向栈顶
EBP 基址指针,常用于访问堆栈内的参数和局部变量
ESI 源变址寄存器
EDI 目的变址寄存器

“32位”意味着每个寄存器都是一个能存放32个二进制位(即4字节)的容器。

2.2 如何使用寄存器(基础汇编指令)

1
2
3
4
5
6
7
8
9
10
11
12
; 1. 将立即数存入寄存器
MOV EAX, 12345678H ; 将十六进制数12345678H 放入 EAX

; 2. 加法指令
ADD EAX, 1 ; EAX = EAX + 1

; 3. 寄存器间运算
MOV ECX, 2
ADD EAX, ECX ; EAX = EAX + ECX

; 4. 减法指令
SUB EAX, 3 ; EAX = EAX - 3

三、内存

当数据量远超8个寄存器的容量时,就必须使用内存。

3.1 什么是32位/64位计算机?

这个位数的核心指的是CPU的寻址宽度,即它能访问的内存地址的最大范围。

  • 32位CPU:地址总线宽度为32位,能表示的地址编号范围是 0x000000000xFFFFFFFF。每个地址指向一个字节,因此最大能直接管理的物理内存是 2^32 字节,即 4GB
  • 64位CPU:同理,能管理的理论内存上限远超4GB。

理解误区:不是因为“寄存器是32位的”才叫32位计算机,因为有些寄存器(如段寄存器)位数远超32位。

3.2 内存读写(寻址公式)

要读写内存,必须知道它的“门牌号”,即内存地址。以下是五种获取内存地址和读写数据的寻址方式。

说明:在编写汇编指令时,段寄存器前缀规则如下:

  • 地址如果是直接的数字(立即数):用 DS
  • 地址中包含了 ESPEBP:用 SS
  • 地址中包含了 EDI:用 ES
    (和16汇编有不同,在32位平坦模式下,段寄存器的作用主要是标识内存属性,与寻址本身关系不大,初学时可先按此规则套用。)

寻址公式一:[立即数]

1
2
3
4
5
6
7
8
9
10
; 读取内存的值(将地址0x213456里的4字节内容放到EAX中)
MOV EAX, DWORD PTR DS:[0x213456]
; 注意:EAX得到的是地址0x213456里存放的数值,而不是地址编号0x213456本身。

; 向内存写入数据
MOV DWORD PTR DS:[0x12321], 12345678H

; 获取内存地址(将地址编号0x123456本身放到ECX中)
LEA ECX, DWORD PTR DS:[0x123456]
; LEA = Load Effective Address

寻址公式二:[reg]

1
2
3
4
5
6
7
8
; reg可以是任意通用寄存器
MOV ECX, 0x13FFD0 ; 先将地址编号存入ECX
MOV EAX, DWORD PTR DS:[ECX] ; 从ECX存的地址里读取值

MOV EDX, 0x13FFD0
MOV DWORD PTR DS:[EDX], 0x12345678 ; 向EDX存的地址里写入值

LEA EAX, DWORD PTR DS:[EDX] ; 获取EDX中的地址编号

寻址公式三:[reg + 立即数]

1
2
3
4
5
6
7
8
9
MOV ECX, 0x13FFD0
; 读取地址 (ECX里的值 + 4) 处的内存数据
MOV EAX, DWORD PTR DS:[ECX + 4]

; 向地址 (ECX里的值 + 8) 处写入数据
MOV DWORD PTR DS:[ECX + 8], 0x12345678

; 获取地址编号 (ECX里的值 + 4) 本身
LEA EAX, DWORD PTR DS:[ECX + 4]

寻址公式四:[reg + reg * {1,2,4,8}]

注意:乘数只能是 1, 2, 4, 8

1
2
3
4
5
6
7
8
9
10
MOV EAX, 0x13FFD0
MOV ECX, 2
; 读取地址 (0x13FFD0 + 2*4) 处的内存数据
MOV EDX, DWORD PTR DS:[EAX + ECX * 4]

; 向该地址写入数据
MOV DWORD PTR DS:[EAX + ECX * 4], 0x12345678

; 获取该地址编号
LEA EAX, DWORD PTR DS:[EAX + ECX * 4]

寻址公式五:[reg + reg * {1,2,4,8} + 立即数]

1
2
3
4
MOV EAX, 0x13FFD0
MOV ECX, 2
; 读取、写入或获取地址,公式同上,再加一个立即数偏移
MOV EDX, DWORD PTR DS:[EAX + ECX * 4 + 10H]

四、堆栈

堆栈的本质就是一块被特殊管理的内存区域,用于存放函数调用过程中的临时变量、参数和返回地址等。

  • 关键特性:堆栈的操作遵循 后进先出 的原则。
  • 核心寄存器
    • ESP(堆栈指针):始终指向栈顶(低地址)。
    • EBP(基址指针):常用来作为基准点,访问当前函数栈帧内的参数和局部变量。
  • 重要备注:对于正向开发,堆栈细节常被编译器“透明化”;但对于逆向分析,堆栈是必须攻克的核心高地

这里放堆栈图

4.1 基本堆栈操作

  • PUSH(压栈/入栈):将数据放入堆栈。
    1. ESP = ESP - 4(栈顶向低地址移动4字节)。
    2. 将数据存入 [ESP] 指向的新位置。
  • POP(弹栈/出栈):从堆栈取出数据。
    1. [ESP] 当前指向的数据取出。
    2. ESP = ESP + 4(栈顶向高地址回退4字节)。
1
2
3
4
5
6
7
8
9
; 观察下面的操作对ESP和EBP的影响
PUSH 0x12345678
PUSH EAX
PUSH EBX

POP ECX ; ECX得到EBX压入的值,ESP+4
POP EDX ; EDX得到EAX压入的值,ESP+4
POP EAX ; EAX得到0x12345678,ESP+4
; 结论:POP操作只影响ESP,EBP保持不变。

这里放执行图

4.2 STOS与REP指令组合

  • STOS指令:将 EAX 的值拷贝到 EDI 寄存器所指向的内存地址,并自动修改 EDI
    • STOS DWORD PTR ES:[EDI] -> [EDI] = EAX; EDI 自动加/减4。
  • REP指令:重复执行紧跟的字符串指令(如STOS),重复次数由 ECX 决定。
1
2
3
4
5
6
7
8
9
; 组合用法示例:将EDI指向的内存区域,连续ECX次填充为EAX的值
MOV EAX, 0x88888888 ; 要填充的值
MOV ECX, 2 ; 重复2次
LEA EDI, DWORD PTR SS:[ESP] ; 从栈顶开始
REP STOS DWORD PTR ES:[EDI]
; 执行效果:从[ESP]开始,连续8个字节(2个双字)都被填充为0x88888888。
; EDI的值会根据EFLAG寄存器中的DF位(方向位)自动变化:
; DF=0(默认):EDI自动递增,向高地址覆盖。
; DF=1:EDI自动递减,向低地址覆盖。

这里放执行图

4.3 变形的艺术

逆向分析者通常会重点监控 PUSHPOP 等标志性指令。指令变形的核心思想是:使用其他功能等效的指令组合来代替这些敏感指令,增加分析难度,这是动态变形壳的基础技术

分析 PUSH EAX 到底做了什么?

  1. ESP 的值减4(栈顶提升)。
  2. EAX 的值存入新的栈顶地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
; 传统的 PUSH EAX
mov eax, 88888888H
push eax

; 等效的“变形”写法,可以达到同样效果:
mov eax, 88888888H
; 方式一:先存数据,再移动栈顶
mov dword ptr ds:[esp-4], eax
lea esp, dword ptr ss:[esp-4] ; LEA ESP,[ESP-4] 等同于 SUB ESP,4

; 方式二:先移动栈顶,再存数据
lea esp, dword ptr ss:[esp-4]
mov dword ptr ds:[esp], eax

同理,推导 POP ECX 的等效变形:

1
2
3
4
5
6
7
8
9
10
11
12
; 传统的 POP ECX
; 1. 从栈顶取出值给ECX
; 2. ESP = ESP + 4

; 等效变形写法:
; 方式一:先取值,再移动栈顶
mov ecx, dword ptr ss:[esp]
lea esp, dword ptr ss:[esp+4] ; LEA ESP,[ESP+4] 等同于 ADD ESP,4

; 方式二:先移动栈顶,再取值
lea esp, dword ptr ss:[esp+4]
mov ecx, dword ptr ss:[esp-4]

挑战:

push esp和pop esp怎么变形

五、课后作业

逐步画出以下代码执行时,每一行指令结束后,堆栈区域的变化图。需清晰标注出 ESPEBP 的位置,以及内存中被修改的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; 假设执行前:ESP = 0x12FFC4, EBP = 0x12FFF0
push ebp ; 1. 压入旧的EBP
mov ebp, esp ; 2. 设置新的栈帧基址
sub esp, 40 ; 3. 为局部变量开辟40字节空间
push ebx ; 4. 保存现场:压入EBX
push esi ; 5. 保存现场:压入ESI
push edi ; 6. 保存现场:压入EDI
lea edi, dword ptr ss:[ebp-40] ; 7. EDI指向局部变量区的起始位置
mov ecx, 10 ; 8. 设置循环次数 (40字节 / 4字节 = 10次)
mov eax, 0CCCCCCCCH ; 9. 设置要填充的值 (0xCC是Debug模式下未初始化的栈空间填充值)
rep stos dword ptr es:[edi] ; 10. 将局部变量区填充为0xCC
mov eax, dword ptr ss:[ebp+8] ; 11. 获取第一个参数
add eax, dword ptr ss:[ebp+C] ; 12. 加上第二个参数(结果是返回值)
pop edi ; 13. 恢复现场
pop esi ; 14. 恢复现场
pop ebx ; 15. 恢复现场
mov esp, ebp ; 16. 恢复ESP,等价于 add esp, 40
pop ebp ; 17. 恢复旧的EBP
retn ; 18. 函数返回(从栈中弹出返回地址,并跳到该地址执行)

提示:这不是一个可直接调试的程序,这是一个独立的手绘练习。请将这段代码“放入”你画的内存图中,一步步模拟执行,这能帮你深入理解函数调用的标准过程(__stdcall)以及栈帧的建立和销毁。这个练习是后续学习缓冲区溢出和函数调用约定的绝对基础。

零基础入门:预备知识

本笔记整理自我所学习的课程。

一、课程体系与目标

  • 教学理念:以“逆向思维”驱动“正向知识”学习。
  • 初级班三大实战项目
    1. 反汇编引擎:将二进制机器码转换为汇编代码,是逆向工具的核心模块。
    2. PE解析器:解析Windows可执行文件的结构,从整体上理解程序的构成。
    3. 二选一项目:游戏数据逆向分析 / 编写一个加密壳(软件保护工具)。
  • 毕业课题:开发一个包含登录界面、进程注入、网络验证等功能的商业软件模板,综合运用所学。

二、核心预备知识:程序是如何储存的?

计算机中任何数据(程序、图片、视频等)最终都以二进制(0和1) 的形式存储。我们使用十六进制编辑器查看,只是为了更简洁地展示这些二进制数据。

程序图

左边是地址编号,右边是注释,中间是十六进制,任何文件存入计算机中都是以二进制存在的。

1. 进制基础

理解进制是分析数据的前提。

进制名称 基本符号 进位规则 备注
十进制 0-9 逢十进一 人类最熟悉
二进制 0, 1 逢二进一 计算机的母语
十六进制 0-9, A-F 逢十六进一 二进制的一种便捷表示法

核心转换关系:

  • 任何一种进制都是完美的是没有必要转换的,转换仅仅是为了方便人类理解。关键是要理解进制背后的计数原理,而非机械记忆转换结果,辅助理解请参考时间hh:mm:ss
  • 1位十六进制数恰好对应4位二进制数。
  • 我们之所以会想要转换是因为我们已经习惯了十进制,如果现在让你去运算M+X+Z=?你也是不会知道等于68的

//不同进制0-15

1
2
3
4
5
6
7
8
十进制	0       1       2       3
二进制 0000 0001 0010 0011
十进制 4 5 6 7
二进制 0100 0101 0110 0111
十进制 8 9 10 11
二进制 1000 1001 1010 1011
十进制 12 13 14 15
二进制 1100 1101 1110 1111
1
2
3
4
5
6
7
8
9

十六进制 0 1 2 3
二进制 0000 0001 0010 0011
十六进制 4 5 6 7
二进制 0100 0101 0110 0111
十六进制 8 9 A B
二进制 1000 1001 1010 1011
十六进制 C D E F
二进制 1100 1101 1110 1111

2. 数据存储格式

  • 为什么是两个十六进制一组?
    为什么我们用的这些软件都是一般都是将两个十六进制放在一起分割一下,却不是1个或者3个这样分割
    • 位 (bit):一个二进制位(0或1)。
    • 字节 (Byte)8个位组成1个字节,是计算机存储管理的最小单元
    • 因为 1位十六进制 = 4位二进制,所以 2位十六进制 正好表示 8位,即
      1个字节。因此编辑器用两个十六进制数为一组来显示数据,直观地反映了字节的边界。
  • 其他单位
    • 字 (Word):2字节,16位。
    • 双字 (Dword):4字节,32位。

3. 思考题:理解数据的宽度

在计算器中,已知:八进制3-2=1,八进制 2 - 3
的结果是多少?(提示:答案不是-1,这引出了数据宽度计算机如何存储数字这两个关键概念。)

八进制3-2计算结果

三、如何从整体上认识程序?—— PE文件结构

分析一个程序,首先要回答三个问题:

  1. 程序从哪里开始执行?(入口点
  2. 代码存在哪里?(代码基址
  3. 数据存在哪里?(数据基址

这些信息都记录在Windows可执行文件(.exe, .dll, .sys)的PE文件结构中。

  • 仅限于windows

概念:windows上运行的任何可执行程序都应该遵循的结构,PE结构适用于windows任何可执行程序

1.什么是数据,什么是代码

不精确但有助于理解的例子:1+2

  • 数据:程序处理的“原材料”,如 1+2 中的 12
  • 代码:指示CPU执行操作的指令,如 1+2 中的 + 号。
  • PE结构的作用,就是在一堆相同的“0和1”中,清晰地标识出哪些是代码、哪些是数据,以及它们各自的位置。

2. 手动解析PE结构(简要步骤)

以标题二中的程序图为例

没错我贴心的粘过来一份

  • 目的:在十六进制数据中,按图索骥,找到程序入口点和基址。
  • 原理:PE结构规定了一个“数据地图”,在文件的特定偏移处,存放着指向关键信息的地址。
  1. 从DOS头找到PE签名32位程序起始位置DWORD(偏移 0x3C
    处)。0x3ch=60,windows是小端序(低位在前,高位在后),所以是00000110
  2. 根据PE签名后的结构,查表“入口点地址”和“代码/数据基址”所在的字段,得到(+0x18h=24 +0x10h=16)=40
    ,入口点0000CB90;代码段+0x14h 00001000,数据段+0x18h 00010000,仅PE32有数据段
  3. 关键点:Windows系统内存取数据时采用小端序(低位在前,高位在后),读取多字节数值时需反向拼接。

当然这只是最最最简单的地方,而且实际程序更不会这么简单,所以只是一个参考

因此我们需要编写一个PE解析器工具,可以自动完成上述查找过程,帮助我们快速概览程序结构,是逆向分析的基础工具。

3. PE结构的应用:软件加壳

  • 原理:打乱、加密原有的PE结构,让分析者无法轻易看懂。
  • 应用:例如,在现有程序中插入一段自己的代码(弹出个对话框、执行特定操作),这需要对PE结构有极精确的理解,错一个字节都会导致程序崩溃。

四、从细节处分析代码:硬编码与反汇编

找到代码区后,如何理解一串二进制指令的含义?

  • 硬编码:代表特定指令的固定二进制数值,如 55 代表 push ebp
  • 反汇编:将“硬编码”翻译成人类可读的汇编语言的过程。
  • 反汇编引擎:这就是我们要做的项目,是一个将二进制自动转为汇编代码的核心模块。

五、逆向分析的完整路径

二进制数据 ➔ 反汇编 ➔ 汇编代码 ➔ 深入分析 ➔ C语言逻辑

这个过程就像把粉碎的纸屑先拼回完整的句子(汇编),再理解句子(汇编)表达的文章大意(C语言逻辑)。

六、总结与建议

  • 基础最重要:进制转换、PE结构、硬编码是逆向上的基本功,根基不牢后续会寸步难行。
  • 实践出真知:不要死记硬背PE结构,学会“查文档”和“用工具”。主动编写PE解析器等工具是最好的学习方式。
  • 正向是逆向上限的天花板:优秀的逆向工程师必须有扎实的正向开发能力(如C语言),否则分析深度将严重受限。
  • 自制工具的优势:学习编写自己的调试和分析工具(如反汇编引擎),可以有效规避游戏方对市面上流行分析工具的针对性检测。