一次越界写入会改变什么

程序为数组、字符串或网络数据分配一段内存后,这段内存能够容纳的字节数是有限的。写入操作超过对象边界,就形成越界写入(Out-of-Bounds Write)。通用缺陷枚举(Common Weakness Enumeration,CWE)将这类问题记作 CWE-787,它也是缓冲区溢出(Buffer Overflow)最常见的表现之一。

越界写入首先破坏相邻内存。普通运行条件下,程序可能崩溃、输出错误结果,或者留下难以稳定复现的数据损坏。攻击者能够控制写入内容和长度时,后果还可能扩展到身份标志、函数指针和返回地址,进而改变程序的执行路径。

1988 年的 Morris Worm 已经展示了这类缺陷的破坏力。它利用了多条传播路径,其中一条来自 fingerd 服务中的栈缓冲区溢出。此后多次大规模蠕虫传播也与内存越界有关。软件和系统已经增加许多防护机制,边界错误本身仍然没有消失。

要理解越界写入为什么能够影响控制流,需要先看处理器怎样执行函数调用。

32 位 x86 中的关键寄存器

寄存器位于 CPU 内部,访问速度远高于主存。32 位 x86 有几组与函数调用密切相关的寄存器。

寄存器常用名称典型作用
EIP指令指针(Instruction Pointer)保存下一条将要执行的指令地址
ESP栈指针(Stack Pointer)指向当前栈顶
EBP帧指针(Frame Pointer)在传统栈帧中作为当前函数的稳定基准位置
EAX通用寄存器在常见调用约定中保存整数或指针返回值

这些描述针对 32 位 x86 的常见实现。64 位 x86 使用 RIP、RSP、RBP 和 RAX 等寄存器,参数通常优先通过寄存器传递。编译器还可能省略帧指针,把 EBP 留作通用寄存器。因此,下面的栈帧应当理解为便于分析的典型模型。具体行为由应用程序二进制接口(Application Binary Interface,ABI)规定。

flowchart TB cpu["CPU"] eip["EIP
下一条指令地址"] ebp["EBP
当前栈帧的基准"] esp["ESP
当前栈顶"] eax["EAX
常见返回值位置"] stack["进程栈"] code["代码区域"] cpu --> eip cpu --> ebp cpu --> esp cpu --> eax eip -->|选择下一条指令| code ebp -->|定位参数和局部状态| stack esp -->|跟随压栈与出栈移动| stack

函数调用怎样建立栈帧

以 32 位 x86 上常见的 cdecl 调用约定为例,调用者通常从右向左压入参数。call 指令把下一条指令的地址压入栈,再把控制流交给目标函数。被调用函数可以保存旧 EBP,令 EBP 指向当前栈帧,并移动 ESP 为局部变量预留空间。

sequenceDiagram participant C as 调用者 participant S as 进程栈 participant F as 被调用函数 C->>S: 从右向左压入参数 C->>S: call 保存返回地址 C->>F: EIP 跳到函数入口 F->>S: 保存旧 EBP F->>S: 移动 ESP 分配局部空间 F->>F: 执行函数体 F->>F: 把返回值放入 EAX F->>S: 恢复 ESP 和旧 EBP F->>C: ret 取出返回地址并继续执行 C->>S: cdecl 调用者回收参数空间

相应的机器指令可能接近下面的形式。它只表示一种常见结果,具体指令和空间大小由编译器、优化选项及 ABI 决定。

asm
; 调用者
push argument
call target_function
add  esp, 4

; 被调用函数
target_function:
    push ebp
    mov  ebp, esp
    sub  esp, 32
    ; 函数体
    leave
    ret

在这种模型中,栈通常向较低地址增长。函数的局部缓冲区位于保存的 EBP 和返回地址附近。编译器可能插入对齐空间,也可能调整变量顺序,所以源代码中相邻的变量不保证在机器内存中紧邻。

flowchart TB high["较高地址"] args["调用参数"] ret["返回地址"] oldebp["保存的 EBP"] padding["可能存在的对齐空间"] locals["其他局部状态"] buffer["局部字符数组"] low["较低地址"] high --> args --> ret --> oldebp --> padding --> locals --> buffer --> low buffer -. "连续越界写入可能到达" .-> oldebp oldebp -. "继续写入可能到达" .-> ret

strcpy 为什么容易造成越界

下面的函数为用户名保留了 24 字节,却使用 strcpy 复制外部字符串。

c
#include <stdio.h>
#include <string.h>

static void overflow_me(const char *input) {
    char user_name[24];

    strcpy(user_name, input);
    printf("Hello, %s\n", user_name);
}

strcpy 会一直复制到源字符串的空字符 \0,它没有参数可以接收目标缓冲区容量。输入最多只能包含 23 个普通字符,最后一个字节必须留给 \0。输入更长时,复制不会在 user_name 的边界自动停止。

此时至少要区分三个长度。

长度含义
数组容量user_name 实际可用的总字节数,这里是 24
字符串长度第一个 \0 以前的字符数
写入长度字符串长度再加结尾的 \0

只检查可见字符而忘记空字符,会留下一个字节的越界写入。整数溢出也可能让长度计算失真,因此长度和容量应使用合适的无符号类型,并在加法、乘法和类型转换前检查范围。

栈缓冲区溢出怎样影响控制流

从数组起始位置连续写入时,地址逐字节增加。超出数组末尾的内容会落入更高地址处的相邻区域。在典型栈帧中,它可能先破坏编译器插入的空间和保存的 EBP,继续写入后才到达返回地址。

flowchart TB input["攻击者可控输入"] copy["无容量参数的字符串复制"] array["局部数组内的合法字节"] adjacent["相邻局部状态或对齐空间"] frame["保存的 EBP"] return["返回地址"] effect["函数返回时控制流可能改变"] input --> copy --> array array -->|超过数组边界| adjacent adjacent -->|继续覆盖| frame frame -->|继续覆盖| return return --> effect

函数执行 ret 时,处理器从栈中取出返回地址并装入 EIP。返回地址如果已经被改写,处理器就会尝试从新的地址继续执行。攻击者能否预测目标地址,取决于可执行文件布局、位置无关可执行文件(Position-Independent Executable,PIE)、地址空间布局随机化(Address Space Layout Randomization,ASLR)和信息泄露等条件。跳转能否产生预期效果,还会受到栈保护值、页面执行权限和控制流完整性等机制的限制。

32 位 x86 通常使用小端序(Little Endian)保存多字节整数,最低有效字节放在最低地址。例如数值 0x5655626c 在连续四个字节中通常写成下面的顺序。

text
地址由低到高    6c 62 55 56
对应字符        l  b  U  V

这个顺序解释了调试输出中的字节为什么常与十六进制数的书写方向相反。实际函数地址会随程序版本、PIE、ASLR 和装载环境变化,示例数值不能当作固定地址使用。

崩溃发生的位置也未必是最初越界的位置。被调用函数可能在返回时只恢复了一个错误的 EBP,直到调用者稍后使用该值或继续返回,程序才触发非法访问。调试时需要沿调用链向前追踪内存第一次被破坏的位置。

被覆盖的不只有返回地址

控制数据(Control Data)决定程序下一步执行哪里。返回地址和函数指针都属于这一类。函数指针保存可调用代码的地址,它被覆盖后,后续间接调用可能跳到错误目标。

c
struct request_handler {
    char name[32];
    void (*handle)(const char *request);
};

如果程序没有限制对 name 的写入长度,越界内容可能影响同一对象中的 handle。结构体成员顺序、填充字节和实际地址仍由 ABI 与编译器共同决定,不能只看源码就断定精确偏移。

非控制数据同样可能决定安全结果。配置开关、用户身份、权限位和认证状态被改写后,程序可能继续沿着原有控制流运行,却作出错误的授权决定。这类数据导向攻击不依赖返回地址覆盖,也不一定触发控制流完整性防护。

flowchart LR overflow["越界写入"] control["控制数据
返回地址、函数指针"] noncontrol["非控制数据
身份、权限、配置、长度"] redirect["执行路径被改变"] decision["程序作出错误决定"] overflow --> control --> redirect overflow --> noncontrol --> decision

堆缓冲区溢出

堆保存运行期间动态分配的对象。分配顺序不保证对象在地址上连续,分配器也可能在对象附近保存块大小、使用状态或链表指针等管理信息。具体格式属于分配器实现细节,不同操作系统和运行库之间差异很大。

堆对象发生越界写入后,可能破坏另一个对象的数据、对象内的函数指针,或者分配器用于管理内存的元数据。现代分配器会检查部分结构并加入随机化、隔离和完整性保护,越界写入仍可能先造成数据损坏或进程终止。

flowchart TB subgraph heap["堆区域示意"] objectA["对象 A
含可写缓冲区"] boundary["边界信息或分配器元数据
具体布局因实现而异"] objectB["对象 B
普通数据或指针"] end write["超过对象 A 容量的写入"] --> objectA objectA -->|可能继续破坏| boundary boundary -->|可能继续破坏| objectB

栈溢出与堆溢出的共同根因都是空间内存安全(Spatial Memory Safety)被破坏,也就是程序访问了对象边界之外的地址。释放后使用(Use After Free)和悬空指针属于时间内存安全(Temporal Memory Safety)问题,它们关注对象生命周期结束后仍被访问的情况。

从源代码阻止越界写入

修复的第一步是让每次写入都知道目标容量,并明确处理过长输入。下面的版本使用 snprintf,调用者可以根据返回值区分完整复制和截断。

c
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>

static bool copy_user_name(
    char *destination,
    size_t capacity,
    const char *input
) {
    if (destination == NULL || input == NULL || capacity == 0) {
        return false;
    }

    int written = snprintf(destination, capacity, "%s", input);

    if (written < 0) {
        return false;
    }

    return (size_t)written < capacity;
}

static void greet(const char *input) {
    char user_name[24];

    if (!copy_user_name(user_name, sizeof user_name, input)) {
        fputs("User name is too long.\n", stderr);
        return;
    }

    printf("Hello, %s\n", user_name);
}

snprintf 会在容量大于零时写入结尾空字符,但内容过长时会截断。这里把截断视为错误,避免不同输入被悄悄压成同一用户名。实际系统还应根据业务规则限制字符集、编码和规范化方式。

编译器和操作系统可以提供额外防线。

机制作用主要限制
编译器警告与静态分析在构建阶段发现危险接口、错误长度和部分数据流问题覆盖范围取决于分析规则、程序模型和可用上下文
AddressSanitizer在测试期间检测多类越界访问和释放后使用有运行时开销,通常不直接用于正式环境
栈保护值(Stack Canary)函数返回前检查局部缓冲区附近的校验值不能覆盖所有数据破坏方式
NX(No-eXecute)或 W^X(Write XOR Execute)限制数据页直接作为代码执行不能阻止已有代码被重新组合利用
ASLR 与 PIE随机化代码、库、堆和栈的位置信息泄露或熵不足会削弱效果
控制流完整性(CFI)限制间接调用和跳转的目标集合主要保护控制流,不能阻止数据导向攻击

这些机制构成纵深防御。源代码仍要维护正确的对象边界、长度关系和生命周期。新项目还可以优先选择 Rust 等提供更强内存安全保证的语言,把大量边界和生命周期检查交给类型系统与运行时机制处理。涉及 unsafe、外部函数接口或不安全依赖时,仍需单独审计边界。

小结

内存安全问题可以沿着一条完整路径理解。输入进入程序后,复制操作先写满目标缓冲区,随后越过对象边界并破坏相邻内存。相邻内容如果包含保存的帧指针、返回地址或函数指针,控制流可能被改变;如果包含身份和权限数据,程序可能在原有控制流上作出错误决定。相同的越界发生在堆上时,受影响对象和管理信息由分配器布局决定。

32 位 x86 的栈帧模型解释了经典栈溢出的工作方式,也说明了分析时必须保留的边界。寄存器用途、参数传递、变量排列和精确偏移都依赖目标 ABI、编译器与构建选项。可靠的结论应当来自源代码、反汇编和运行时内存布局的共同证据。

参考资料

返回目录