逆向与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 命令查看二进制文件的静态段大小:

$ size /bin/ls
   text	   data	    bss	    dec	    hex	filename
 139175	   4312	   4504	 147991	  24217	/bin/ls

运行起来后,可以通过 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) 与核心寄存器

在汇编的世界里,没有 ifwhile{ } 这种高级语法块,只有内存地址的跳转。 当主函数 main() 调用子函数 func() 时,CPU 是如何记住执行完 func() 之后该回到哪里的?靠的就是栈帧和三大核心寄存器。

3.1 三大指针寄存器 (32位 x86 架构)

  1. EIP (Instruction Pointer,指令指针):永远指向 CPU 下一条要执行的指令地址。黑客 Pwn 掉一个程序的核心目标,就是劫持 EIP,让它指向恶意代码的地址!
  2. ESP (Stack Pointer,栈顶指针):永远指向当前栈的顶部。随着 push(压栈,ESP 减小)和 pop(出栈,ESP 增大),ESP 会像弹簧一样上下跳动。
  3. EBP (Base Pointer,栈底/基址指针):作为一个“锚点”,指向当前函数栈帧的底部。函数内部找局部变量和参数,都是通过 EBP - 偏移量EBP + 偏移量 来定位的。

3.2 栈帧的构建过程 (Prologue)

当发生函数调用时,内存中会瞬间搭起一个脚手架:

  1. 压入参数:主函数将子函数需要的参数压入栈中。
  2. 压入返回地址 (Saved EIP):将调用完子函数后,主函数的下一条指令地址压入栈中。(这是栈溢出攻击最想覆盖的目标!
  3. 压入旧的 EBP (Saved EBP):把主函数的 EBP 压栈保存,以便一会儿能恢复主函数的现场。
  4. 提升栈顶分配空间:将当前的 ESP 赋值给 EBP(形成新的锚点),然后把 ESP 往低地址方向拉(例如 sub esp, 0x40),为子函数的局部变量腾出空间。

此时的内存切面图如下(高地址在上,低地址在下):

| 高地址 |
|--------|
| 参数 2 |
| 参数 1 |
| 返回地址 (Saved EIP) |  <--- 黑客日思夜想的目标
| 旧的EBP (Saved EBP) |  <--- 当前 EBP 指向这里
| 局部变量 1 |
| 局部变量 2 |  <--- 当前 ESP 指向这里 (栈顶)
|--------|
| 低地址 |

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) 漏洞的底层原理与利用艺术!