从一段 C 程序开始

软件安全中的许多问题,最终都要落到两个问题上:

  1. 程序把数据放在了哪里;
  2. 程序是否按照对象的边界和生命周期访问这些数据。

下面这段程序同时包含全局变量、函数、局部变量和动态内存分配。它适合用来观察一个 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;
}

这段程序可以编译为可执行文件:

bash
gcc -O0 -Wall -Wextra -o memory-demo memory-demo.c

这里使用 -O0 是为了让编译器尽量保留容易观察的函数调用结构。即使如此,实际机器代码、寄存器使用方式和栈帧大小仍然由编译器、目标架构和 ABI 决定。

源代码如何进入进程

编译器和链接器会把源代码转换成可装载文件。程序启动时,操作系统为它创建进程,并把可执行文件中的代码和静态数据映射到进程地址空间。动态分配的内存和函数调用产生的栈帧,则在运行期间逐步出现。

flowchart LR source["C 源代码"] --> compile["编译与汇编"] compile --> link["链接目标文件与库"] link --> executable["可执行文件"] executable --> load["操作系统装载"] load --> process["进程地址空间"] process --> execute["CPU 取指、执行并更新状态"]

磁盘上的可执行文件是静态文件;进程则包含代码、数据、寄存器状态、虚拟地址空间以及操作系统维护的运行时信息。二者不能混为一谈。

进程内存布局

以下是常见的概念模型。它描述不同类型对象的典型位置,不代表不同操作系统都使用完全相同的地址排列。

flowchart TB high["较高地址"] env["环境变量"] stack["栈
函数参数、局部变量、返回所需状态"] gap["空闲区域"] heap["堆
动态分配的对象"] bss["未初始化数据
通常由系统置零"] data["已初始化数据
全局变量和静态变量"] code["代码段
函数机器指令"] low["较低地址"] high --> env --> stack --> gap --> heap --> bss --> data --> code --> low stack -->|栈通常向较低地址扩展| gap heap -->|堆通常向较高地址扩展| gap

代码和数据分别在哪里

把上面的程序逐项对应到内存区域,可以得到下面的结果。

源代码对象典型区域原因
foo、bar、main 的函数体代码段函数体会被编译成处理器要执行的机器指令
hello已初始化数据全局数组具有静态存储期,并且有明确初始值
dummy未初始化数据全局变量具有静态存储期,未显式给出初值时通常被初始化为零
numfoo 的参数区域或寄存器参数传递方式由调用约定决定
resultfoo 的栈帧或寄存器它是自动存储期的局部变量
bufferbar 的栈帧或寄存器变量本身是一个局部指针
malloc(1024) 返回的对象堆对象在运行期间动态申请
42指令立即数或只读数据区域具体表示方式由编译器决定

这里最容易混淆的是 buffer 和 malloc(1024) 返回的对象。buffer 只是一个指针变量;它保存一个地址,指向堆中的另一块内存。指针变量和指针指向的对象不在同一个概念层次上。

flowchart TB high["较高地址"] stack["bar 栈帧
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,因此会造成内存泄漏。一个完整的版本应当明确处理分配失败并释放对象:

c
void bar(void) {
    char *buffer = (char *)malloc(1024);

    if (buffer == NULL) {
        return;
    }

    buffer[0] = '\0';
    free(buffer);
}

函数调用如何建立栈帧

调用栈不是把局部变量简单堆在一起。每次函数调用都会产生一个与该次调用对应的栈帧。栈帧通常与以下信息有关:

  • 函数参数;
  • 局部变量;
  • 保存的寄存器状态;
  • 返回地址或其他控制信息;
  • 栈展开和调试所需的附加信息。

具体字段的排列不是 C 语言标准规定的固定格式。编译器可能把参数或局部变量放入寄存器,也可能省略帧指针。因此,下面的图是解释调用关系的模型,不是某一台机器上的字节级内存转储。

main 调用 bar

程序进入 main 后,调用栈中至少有 main 对应的活动记录。执行到 bar() 后,操作系统和编译器生成的调用序列会为 bar 建立新的栈帧。

flowchart TB high["较高地址"] env["环境变量"] main_frame["main 栈帧
返回状态与 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 保存中间计算结果:

c
int foo(int num) {
    int result = 0;
    result = num * 2;
    return result;
}

在未优化或较少优化的概念模型中,栈帧可以表示为:

flowchart TB high["较高地址"] caller["main 栈帧
调用 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 的调用关系

如果把函数调用关系画出来,可以看到调用栈遵循后进先出原则。

sequenceDiagram participant M as main participant F as foo participant B as bar M->>F: 传入 42,建立 foo 栈帧 F-->>M: 返回 84,撤销 foo 栈帧 M->>B: 建立 bar 栈帧 B->>B: 申请堆对象并保存 buffer B-->>M: 释放资源并返回

栈帧的生命周期只覆盖对应函数处于活动状态的时间。函数返回后,不能继续使用其中的局部变量地址;而堆对象是否仍然有效,则由 malloc 和 free 的配对关系决定。

两种不同的栈问题

调用栈耗尽

下面的程序来自本讲的递归调用示例。foo 和 bar 互相调用,两个函数都没有返回到 main 的路径。

c
#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;
}

调用过程的前几步是:

text
main()
  -> foo()
       -> bar()
            -> foo()
                 -> bar()
                      -> ...

每进入一个尚未返回的函数,就要保留一个新的栈帧。栈空间是有限的,因此调用链持续增长后,最终无法再建立新的栈帧。

flowchart TB start["main 栈帧"] --> foo1["foo 栈帧"] foo1 --> bar1["bar 栈帧"] bar1 --> foo2["新的 foo 栈帧"] foo2 --> bar2["新的 bar 栈帧"] bar2 --> more["继续建立栈帧"] more --> check{"仍有可用栈空间?"} check -->|是| more check -->|否| stop["调用栈耗尽
进程异常终止"]

这属于调用栈耗尽:栈帧数量不断增加,原因是函数调用没有结束。

栈缓冲区越界

另一类问题发生在单个栈帧内部。假设函数有一个容量为 8 字节的数组:

c
#include <string.h>

void copy_name(const char *input) {
    char name[8];

    /* 这里省略边界检查,仅用于说明越界风险。 */
    memcpy(name, input, strlen(input) + 1);
}

如果输入长度至少为 8,memcpy 就可能写出 name 的边界。下面的版本先检查长度,再执行复制:

c
#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:限制间接调用和间接跳转的目标;
  • 编译器诊断和运行时检查:在开发阶段发现越界访问、未定义行为和不安全接口。

这些机制不能替代边界检查。正确的对象生命周期和长度验证仍然是第一道防线。

小结

从上面的程序可以得到一条具体的分析路径:

  1. 函数体会被编译成代码段中的机器指令;
  2. 全局对象根据是否有初始值进入已初始化数据或未初始化数据区域;
  3. 局部变量属于某次函数调用的栈帧,但编译器也可能把它们放入寄存器;
  4. malloc 返回的对象位于堆,保存对象地址的指针变量可能位于栈;
  5. 函数调用返回后,栈帧失去活动状态,堆对象仍需单独释放;
  6. 无限递归会不断增加栈帧,缓冲区越界则可能破坏一个栈帧及其相邻数据;
  7. 软件安全分析必须同时检查代码、对象边界、生命周期、调用关系和运行时防护。

返回目录