逆向与Pwn基础:C/C++虚拟内存布局与函数调用约定
逆向与Pwn基础:C/C++虚拟内存布局与函数调用约定
在完成了网络协议与操作系统安全机制的探索后,我们将视线转移到一切安全漏洞的最终源头——代码的底层运行机制。
无论你在上层使用多么高级的框架,一旦用 C/C++ 编译成二进制可执行文件(Windows 的 .exe 或 Linux 的 ELF),它们最终都会被操作系统加载到内存中,变成一行行机器码与数据的集合。
在安全领域,尤其是**二进制漏洞利用(Pwn)和逆向工程(Reverse Engineering)**中,如果你不理解进程的虚拟内存布局,不清楚函数在汇编层面是如何传递参数的,你将永远无法理解“栈溢出”、“堆风水”、“ROP 链”等高级攻击手法的原理。
本文将剥开 C/C++ 的高级语法外衣,直击进程在内存中的真实模样。
1. 欺骗进程的艺术:虚拟内存 (Virtual Memory)
在现代操作系统中,无论你的电脑物理内存(RAM)是 8GB 还是 64GB,当你运行一个 32 位的 C 语言程序时,这个程序都会“自以为”它独占了整整 4GB 的连续内存空间。
这就是虚拟内存技术。操作系统和 CPU 的 MMU(内存管理单元)共同撒了一个弥天大谎:
- 进程眼中的 4GB 空间是虚拟的。
- 操作系统负责在后台将虚拟内存的“页(Page)”映射到真实的物理内存条上。
- 安全意义:正因为有了虚拟内存,进程 A 绝对无法直接通过指针读写进程 B 的内存(实现了进程间的沙盒隔离)。如果黑客想读取其他进程的内存,必须调用操作系统提供的特定 API(如 Windows 的
ReadProcessMemory,且需要极高的特权)。
在 32 位系统中,这 4GB 通常被切分为:
- 用户空间 (0x00000000 ~ 0x7FFFFFFF, 2GB):存放你写的代码和数据。
- 内核空间 (0x80000000 ~ 0xFFFFFFFF, 2GB):存放操作系统的核心代码。普通进程如果尝试读取这个区域,会直接触发段错误(Segfault)并崩溃。
2. 进程内存五大分区 (Memory Segments)
当一个程序被加载到用户空间时,操作系统会将其整齐地切分为五个具有不同读写执行权限(R/W/X)的区域。理解它们是挖掘内存破坏漏洞的基础。
💻 日常接触:使用
size和/proc/pid/maps查看内存段 在 Linux 下,我们可以用size命令查看二进制文件的静态段大小:运行起来后,可以通过
cat /proc/<PID>/maps查看真实的动态虚拟内存布局。
2.1 代码段 (.text)
- 存放内容:编译后的机器指令(汇编代码)。
- 权限:只读、可执行 (R-X)。
- 安全特性:为了防止黑客在运行时篡改代码,该段不可写。如果程序尝试写入
.text段,会直接崩溃。
2.2 数据段 (.data)
- 存放内容:已初始化的全局变量和静态变量。(例如:
int global_var = 100;) - 权限:可读、可写 (RW-)。
2.3 BSS 段 (.bss)
- 存放内容:未初始化的全局变量和静态变量。(例如:
int global_var;) - 底层机制:为了节省磁盘空间,可执行文件中并不会实际存储这些全是 0 的变量。只有当程序加载到内存时,操作系统才会为它们分配空间并全部清零。
2.4 堆区 (Heap)
- 存放内容:由程序员动态分配的内存(如 C 的
malloc()/free(),C++ 的new/delete)。 - 生长方向:从低地址向高地址生长(往上长)。
- 安全关联:如果程序员忘记
free会导致内存泄漏;如果free了之后还在使用该指针,就会产生极度危险的 Use-After-Free (UAF) 漏洞;如果堆溢出了,覆盖了堆块的管理元数据(如malloc_chunk),黑客就可以实现任意地址写。
2.5 栈区 (Stack)
- 存放内容:局部变量、函数参数、函数返回地址。
- 生长方向:从高地址向低地址生长(往下长)。这是理解栈溢出极其关键的一点!
- 底层机制:由操作系统和编译器自动分配与释放,速度极快,但容量有限(Linux 默认通常为 8MB)。
3. 函数的灵魂:栈帧 (Stack Frame) 与核心寄存器
在汇编的世界里,没有 if、while 或 { } 这种高级语法块,只有内存地址的跳转。
当主函数 main() 调用子函数 func() 时,CPU 是如何记住执行完 func() 之后该回到哪里的?靠的就是栈帧和三大核心寄存器。
3.1 三大指针寄存器 (32位 x86 架构)
- EIP (Instruction Pointer,指令指针):永远指向 CPU 下一条要执行的指令地址。黑客 Pwn 掉一个程序的核心目标,就是劫持 EIP,让它指向恶意代码的地址!
- ESP (Stack Pointer,栈顶指针):永远指向当前栈的顶部。随着
push(压栈,ESP 减小)和pop(出栈,ESP 增大),ESP 会像弹簧一样上下跳动。 - EBP (Base Pointer,栈底/基址指针):作为一个“锚点”,指向当前函数栈帧的底部。函数内部找局部变量和参数,都是通过
EBP - 偏移量或EBP + 偏移量来定位的。
3.2 栈帧的构建过程 (Prologue)
当发生函数调用时,内存中会瞬间搭起一个脚手架:
- 压入参数:主函数将子函数需要的参数压入栈中。
- 压入返回地址 (Saved EIP):将调用完子函数后,主函数的下一条指令地址压入栈中。(这是栈溢出攻击最想覆盖的目标!)
- 压入旧的 EBP (Saved EBP):把主函数的 EBP 压栈保存,以便一会儿能恢复主函数的现场。
- 提升栈顶分配空间:将当前的 ESP 赋值给 EBP(形成新的锚点),然后把 ESP 往低地址方向拉(例如
sub esp, 0x40),为子函数的局部变量腾出空间。
此时的内存切面图如下(高地址在上,低地址在下):
4. 函数调用约定 (Calling Conventions)
不同的编译器、不同的操作系统,在“谁负责把参数压栈”、“谁负责清理栈空间”这个问题上,有着不同的约定。
| 调用约定 | 参数传递方式 | 清理栈空间的责任方 | 应用场景 |
|---|---|---|---|
| cdecl (C Declaration) | 从右向左压栈 | 调用者 (Caller) 清理 | C/C++ 程序的默认约定。支持可变参数(如 printf)。 |
| stdcall | 从右向左压栈 | 被调用者 (Callee) 清理 | Windows Win32 API 的标准调用约定。 |
| fastcall | 前两个参数通过寄存器 (ECX, EDX) 传,剩下的压栈 | 被调用者 (Callee) 清理 | 追求极致速度的底层代码。 |
💻 现代 64 位系统的剧变 (x64 ABI) 在 64 位系统(x86_64)中,由于 CPU 寄存器变得极度丰富,栈传参被认为是“慢动作”。
- Linux/Unix (System V ABI):前 6 个参数统统不走栈!而是直接塞进寄存器
RDI, RSI, RDX, RCX, R8, R9。只有第 7 个参数开始才会压栈。- 安全意义:在 64 位系统上写 Pwn 漏洞利用(如 ROP 链)时,黑客不能再简单地往栈上塞参数了,必须去二进制文件里寻找名为
pop rdi; ret的 Gadget(代码片段),先把参数弹出到寄存器里,再去调用函数。这极大地增加了漏洞利用的门槛。
5. 总结
- 进程的五大分区:隔离了代码、数据、动态内存与函数控制流。
- 栈区与 EIP/EBP 寄存器:构成了函数调用的基石。
- 内存的物理排布:栈从高向低长,而向局部变量写入数据(如向数组拷贝字符串)是从低向高写的。
下一篇预告: 当你深刻理解了上述最后一句话(“栈往低处长,写数据往高处写”),你就已经触碰到了计算机安全史上最古老、最著名的漏洞门槛。
只要在写数据时多写了几十个字节,就会越过局部变量的边界,撞上那个致命的 “返回地址 (Saved EIP)”。 下一篇,我们将亲手撕开 C 语言指针的伪装,深入推演 栈溢出 (Stack Overflow) 漏洞的底层原理与利用艺术!