从一段 C 程序开始
软件安全中的许多问题,最终都要落到两个问题上:
- 程序把数据放在了哪里;
- 程序是否按照对象的边界和生命周期访问这些数据。
下面这段程序同时包含全局变量、函数、局部变量和动态内存分配。它适合用来观察一个 C 程序从源代码到运行时内存的对应关系。
#include <stdio.h>
#include <stdlib.h>
char hello[] = "Hello World!";
int dummy;
int foo(int num) {
int result = 0;
result = num * 2;
return result;
}
void bar(void) {
char *buffer = (char *)malloc(1024);
}
int main(void) {
foo(42);
bar();
return 0;
}这段程序可以编译为可执行文件:
gcc -O0 -Wall -Wextra -o memory-demo memory-demo.c这里使用 -O0 是为了让编译器尽量保留容易观察的函数调用结构。即使如此,实际机器代码、寄存器使用方式和栈帧大小仍然由编译器、目标架构和 ABI 决定。
源代码如何进入进程
编译器和链接器会把源代码转换成可装载文件。程序启动时,操作系统为它创建进程,并把可执行文件中的代码和静态数据映射到进程地址空间。动态分配的内存和函数调用产生的栈帧,则在运行期间逐步出现。
磁盘上的可执行文件是静态文件;进程则包含代码、数据、寄存器状态、虚拟地址空间以及操作系统维护的运行时信息。二者不能混为一谈。
进程内存布局
以下是常见的概念模型。它描述不同类型对象的典型位置,不代表不同操作系统都使用完全相同的地址排列。
函数参数、局部变量、返回所需状态"] gap["空闲区域"] heap["堆
动态分配的对象"] bss["未初始化数据
通常由系统置零"] data["已初始化数据
全局变量和静态变量"] code["代码段
函数机器指令"] low["较低地址"] high --> env --> stack --> gap --> heap --> bss --> data --> code --> low stack -->|栈通常向较低地址扩展| gap heap -->|堆通常向较高地址扩展| gap
代码和数据分别在哪里
把上面的程序逐项对应到内存区域,可以得到下面的结果。
| 源代码对象 | 典型区域 | 原因 |
|---|---|---|
foo、bar、main 的函数体 | 代码段 | 函数体会被编译成处理器要执行的机器指令 |
hello | 已初始化数据 | 全局数组具有静态存储期,并且有明确初始值 |
dummy | 未初始化数据 | 全局变量具有静态存储期,未显式给出初值时通常被初始化为零 |
num | foo 的参数区域或寄存器 | 参数传递方式由调用约定决定 |
result | foo 的栈帧或寄存器 | 它是自动存储期的局部变量 |
buffer | bar 的栈帧或寄存器 | 变量本身是一个局部指针 |
malloc(1024) 返回的对象 | 堆 | 对象在运行期间动态申请 |
42 | 指令立即数或只读数据区域 | 具体表示方式由编译器决定 |
这里最容易混淆的是 buffer 和 malloc(1024) 返回的对象。buffer 只是一个指针变量;它保存一个地址,指向堆中的另一块内存。指针变量和指针指向的对象不在同一个概念层次上。
buffer:保存堆对象地址"] heap["堆对象
malloc(1024) 分配的 1024 字节"] data["静态数据
hello 与 dummy"] code["代码段
main、bar、foo 的机器指令"] low["较低地址"] high --> stack --> heap --> data --> code --> low stack -->|buffer 指向| heap
bar 中的示例还有一个生命周期问题:函数返回时,局部指针 buffer 消失,但它指向的堆对象没有调用 free,因此会造成内存泄漏。一个完整的版本应当明确处理分配失败并释放对象:
void bar(void) {
char *buffer = (char *)malloc(1024);
if (buffer == NULL) {
return;
}
buffer[0] = '\0';
free(buffer);
}函数调用如何建立栈帧
调用栈不是把局部变量简单堆在一起。每次函数调用都会产生一个与该次调用对应的栈帧。栈帧通常与以下信息有关:
- 函数参数;
- 局部变量;
- 保存的寄存器状态;
- 返回地址或其他控制信息;
- 栈展开和调试所需的附加信息。
具体字段的排列不是 C 语言标准规定的固定格式。编译器可能把参数或局部变量放入寄存器,也可能省略帧指针。因此,下面的图是解释调用关系的模型,不是某一台机器上的字节级内存转储。
main 调用 bar
程序进入 main 后,调用栈中至少有 main 对应的活动记录。执行到 bar() 后,操作系统和编译器生成的调用序列会为 bar 建立新的栈帧。
返回状态与 main 的局部状态"] bar_frame["bar 栈帧
buffer 等局部状态"] heap["堆对象
malloc(1024)"] low["较低地址"] high --> env --> main_frame --> bar_frame --> heap --> low bar_frame -->|buffer 指向| heap
bar 正在执行时,main 的栈帧不能立即消失,因为 bar 返回后还要回到 main 中的下一条指令。bar 返回后,其栈帧所占的空间可以被栈指针回收,之后可能被新的调用复用。
main 调用 foo
当 main 调用 foo(42) 时,参数 42 被按照调用约定传递给 foo。在 foo 中,num 表示接收到的参数,result 保存中间计算结果:
int foo(int num) {
int result = 0;
result = num * 2;
return result;
}在未优化或较少优化的概念模型中,栈帧可以表示为:
调用 foo 后等待返回"] control["返回所需的控制状态"] parameter["foo 参数
num = 42"] local["foo 局部变量
result = 84"] low["较低地址"] high --> caller --> control --> parameter --> local --> low
当 foo 执行 return result 时,返回值会通过 ABI 规定的位置传给调用者。foo 的栈帧随后失去活动状态,main 恢复执行。
main -> foo -> bar 的调用关系
如果把函数调用关系画出来,可以看到调用栈遵循后进先出原则。
栈帧的生命周期只覆盖对应函数处于活动状态的时间。函数返回后,不能继续使用其中的局部变量地址;而堆对象是否仍然有效,则由 malloc 和 free 的配对关系决定。
两种不同的栈问题
调用栈耗尽
下面的程序来自本讲的递归调用示例。foo 和 bar 互相调用,两个函数都没有返回到 main 的路径。
#include <stdio.h>
void bar(void);
void foo(void);
void foo(void) {
printf("foo\n");
bar();
}
void bar(void) {
printf("bar\n");
foo();
}
int main(void) {
printf("Let us overflow the stack!\n");
foo();
return 0;
}调用过程的前几步是:
main()
-> foo()
-> bar()
-> foo()
-> bar()
-> ...每进入一个尚未返回的函数,就要保留一个新的栈帧。栈空间是有限的,因此调用链持续增长后,最终无法再建立新的栈帧。
进程异常终止"]
这属于调用栈耗尽:栈帧数量不断增加,原因是函数调用没有结束。
栈缓冲区越界
另一类问题发生在单个栈帧内部。假设函数有一个容量为 8 字节的数组:
#include <string.h>
void copy_name(const char *input) {
char name[8];
/* 这里省略边界检查,仅用于说明越界风险。 */
memcpy(name, input, strlen(input) + 1);
}如果输入长度至少为 8,memcpy 就可能写出 name 的边界。下面的版本先检查长度,再执行复制:
#include <string.h>
void copy_name(const char *input) {
char name[8];
size_t length = strlen(input);
if (length >= sizeof(name)) {
return;
}
memcpy(name, input, length + 1);
}栈缓冲区越界与调用栈耗尽的直接原因不同:
| 问题 | 发生位置 | 直接原因 | 可能结果 |
|---|---|---|---|
| 调用栈耗尽 | 多个连续栈帧 | 函数调用链持续增长 | 栈空间不足,进程异常终止 |
| 栈缓冲区越界 | 一个栈帧内部及其相邻区域 | 写入超过数组或缓冲区容量 | 数据破坏、崩溃,或控制状态被覆盖 |
如果越界写入能够覆盖保存的控制状态,程序的完整性和控制流就可能受到影响。是否能够形成实际漏洞,需要结合输入可控性、编译器生成的布局、调用约定和系统防护进行判断,不能仅凭一个数组声明下结论。
防护从哪里开始
源代码层面
- 为写入操作传递明确的目标容量;
- 在复制、拼接和格式化前检查输入长度;
- 申请堆内存后检查返回值,并在最后一次使用后调用
free; - 不返回局部变量的地址,也不使用已经释放的堆对象;
- 为递归设置终止条件,并限制递归深度;
- 将错误处理路径写清楚,避免异常状态继续传播。
编译器和操作系统层面
常见的纵深防御包括:
- 栈保护值(stack canary):在控制状态附近放置校验值,函数返回前检查是否被修改;
- NX 或 W^X:让数据页不可执行,降低把数据直接当作指令运行的风险;
- ASLR:随机化代码、库、堆和栈等区域的位置;
- CFI:限制间接调用和间接跳转的目标;
- 编译器诊断和运行时检查:在开发阶段发现越界访问、未定义行为和不安全接口。
这些机制不能替代边界检查。正确的对象生命周期和长度验证仍然是第一道防线。
小结
从上面的程序可以得到一条具体的分析路径:
- 函数体会被编译成代码段中的机器指令;
- 全局对象根据是否有初始值进入已初始化数据或未初始化数据区域;
- 局部变量属于某次函数调用的栈帧,但编译器也可能把它们放入寄存器;
malloc返回的对象位于堆,保存对象地址的指针变量可能位于栈;- 函数调用返回后,栈帧失去活动状态,堆对象仍需单独释放;
- 无限递归会不断增加栈帧,缓冲区越界则可能破坏一个栈帧及其相邻数据;
- 软件安全分析必须同时检查代码、对象边界、生命周期、调用关系和运行时防护。