栈溢出基础¶
栈溢出是“写入超过栈上对象边界”造成的一类内存安全错误。它的根因是程序违反对象边界,而不是“输入太长”这一个表面现象。本文以 Linux x86-64 System V ABI 为主线,并只在本地最小程序上做无害崩溃验证。
仅限授权学习
所有示例都应在自己编写的程序、课程靶场或明确授权的 CTF 附件中运行。本文不提供真实目标、远程攻击流程、shellcode、可直接复用的控制流 payload 或保护绕过链。
学习本篇前,建议先阅读栈的概念。
先区分缺陷、破坏和利用¶
这三个概念不是同义词:
| 层次 | 含义 | 示例结论 |
|---|---|---|
| 缺陷 | 程序执行了越界访问 | 写入第 17 字节时目标数组容量只有 16 |
| 破坏 | 越界影响了某些相邻数据 | 相邻标志或 Canary 被改变 |
| 利用 | 攻击者把破坏转化为预期安全影响 | 需要额外满足可控性与防护条件 |
一次崩溃能证明程序出现异常,通常不能单独证明可稳定控制执行。防御性分析应先找到**第一条错误访问**,再讨论受影响对象和缓解机制。
一个准确的边界模型¶
对 char buf[16],合法范围是 buf[0] 到 buf[15]。若保存 C 字符串,终止空字节 \0 也占容量,所以最多保存 15 个普通字符和一个终止符。边界安全的核心不变量是“实际写入字节数不大于目标对象可用容量”;如果还要追加分隔符或终止符,必须提前计入总量。
缺陷根因一:危险输入接口¶
最典型的历史接口是 gets。它无法接收目标缓冲区容量,因此无法安全限制写入。现代标准已移除它,编译器或链接器通常会明确警告。
另一些接口本身可安全使用,但 read(fd, buf, requested)、recv、memcpy、strcpy 或无限宽 %s 的容量、长度或终止条件错误时仍会越界。
问题分别可能是:
要点:requested 大于 sizeof buf;count 来自不可信长度且未经验证;源字符串长度超过目标容量;%s 没有限定最大字段宽度;返回值没有用于判断实际读取长度。
“换成另一个函数名”不是完整修复。正确做法是让接口获得真实容量,并检查所有长度运算和返回值。
缺陷根因二:边界条件错误¶
常见 off-by-one:
若capacity 是元素数量,最后一次访问 dst[capacity] 已越界。通常应是 < capacity,但是否还要保留终止符位置取决于具体语义。
另一个错误是只检查输入长度,不检查目标容量:
若 local_buf 小于 MAX_PACKET,检查仍然无效。边界必须以**实际目标对象**为准。
缺陷根因三:整数与类型转换¶
长度往往不是字面量,而是计算结果:
固定宽度无符号整数会按模回绕。乘法回绕后可能分配较小空间,却按原count 写入更多元素。
安全检查通常应在乘法前完成:
还要警惕:
要点:负的 int 转成很大的 size_t;64 位长度截断为 32 位整数;比较使用有符号类型,复制使用无符号类型;len + 1 自身发生溢出;字节数与元素数混用。
编译器警告能发现部分问题,但不能替代运行时范围验证。
缺陷根因四:终止符理解错误¶
C 字符串以 \0 结束,但二进制输入不保证包含终止符。
例如:
read 正好返回 sizeof buf,缓冲区内可能没有 \0。 printf("%s") 会继续读取对象之外的内存,形成越界读。
安全模式之一:
这仍要求:
要点:buf 容量至少为 1;正确处理短读、信号中断和协议分帧;若数据允许内嵌空字节,不把它误当纯文本处理。
栈上可能被覆盖的对象¶
一个简化栈帧从低地址到高地址可能出现 char buf[N]、普通局部数据、Stack Canary、保存的旧 rbp 和返回地址。连续写出 buf 边界时可能依次影响这些对象,但真实顺序由编译器、优化、变量重排、对齐和保护选项决定,绝不能只根据源码声明顺序计算覆盖距离。
覆盖普通数据¶
普通数据包括长度、布尔标志、计数器、指针和业务状态。 概念示例:
如果错误写入越过name,可能改变 authenticated。这属于数据完整性破坏,即使返回地址完全未受影响,也可能产生安全后果。
编译器不保证两个独立局部变量按源码顺序相邻。使用结构体只是为了明确展示对象内字段顺序,仍不应把它当作利用模板。
覆盖 Stack Canary¶
启用栈保护时,编译器可能在栈上保存一个哨兵值。函数返回前会把它与线程相关参考值比较。
覆盖 Canary 与覆盖返回地址是不同事件: 要点:Canary 改变说明连续写入到达其位置;程序可能在执行ret 前主动终止;Canary 本身不能防止所有普通数据覆盖;未被保护的函数、非连续写和其他内存错误可能不触发它。
看到 *** stack smashing detected *** 时,应回到首次越界写,而不是只分析终止位置。
覆盖保存的帧信息¶
若函数使用 rbp 作为帧指针,栈上可能保存调用者的旧 rbp。它被破坏后可能导致:
要点:leave 恢复错误栈状态;调试器回溯出现错误帧;后续基于帧指针的访问指向错误位置。
保存的 rbp 不是返回地址。二者通常相邻但语义不同:前者用于恢复帧状态,后者直接决定普通 ret 的下一执行位置。
优化构建可能省略帧指针,因此不能假设每个函数都有保存的旧 rbp。
覆盖返回地址¶
call 压入返回地址,ret 从当前 [rsp] 取回它。若越界写到达并改变该位置,函数返回时可能出现控制流完整性破坏。
必须区分:
| 观察结果 | 能说明什么 | 不能单独说明什么 |
|---|---|---|
| 返回地址字节改变 | 控制数据受到破坏 | 可预测地控制完整地址 |
ret 时崩溃 |
返回路径异常 | 已绕过所有缓解机制 |
rip 位于无效地址 |
错误值被当作指令地址 | 可执行任意代码 |
| Canary 先失败 | 栈保护检测到破坏 | 所有相邻数据都得到保护 |
教学中只需用无害崩溃确认边界错误。不要把“成功造成崩溃”误写成“成功利用”。
本地最小示例¶
下面的程序故意把标准输入最多 64 字节写入 16 字节数组。它仅用于本地诊断,不能用于生产代码。
#include <stdio.h>
#include <unistd.h>
__attribute__((noinline))
static void copy_input(void) {
char buf[16];
ssize_t n = read(STDIN_FILENO, buf, 64); /* intentional bug */
if (n < 0) {
perror("read");
return;
}
printf("read %zd bytes\n", n);
}
int main(void) {
copy_input();
return 0;
}
overflow_demo.c,仅在隔离的本地目录中使用。
先看编译器警告¶
使用常见警告选项编译:
现代 GCC 通常会指出read 的写入大小超过目标对象大小。警告文字和触发能力随编译器版本、优化和内联信息变化。
建议把高质量项目中的警告升级为错误:
但 -Werror 只是构建策略。静态不可见的运行时长度仍需要代码检查和动态测试。
用 AddressSanitizer 定位第一条错误写入¶
构建 Sanitizer 版本:
gcc -g -O1 -Wall -Wextra \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
overflow_demo.c -o overflow_demo_asan
python3 -c "import sys; sys.stdout.buffer.write(b'A' * 40)" > input.bin
./overflow_demo_asan < input.bin
read 调用期间。
- 目标对象是 copy_input 中的 buf。
- 报告给出对象范围和越界方向。
- 调用栈指向本地示例源码行。
ASan 使用影子内存和插桩改变程序布局。它非常适合找根因,但其地址和帧布局不应直接套用到非 ASan 构建。
用安全版本验证修复¶
修复读取上限:
如果后续要把数据作为 C 字符串,应保留终止符空间: 重新运行相同的 ASan 输入。修复验证必须使用原始触发输入,并加入边界值:| 输入长度 | 目的 |
|---|---|
| 0 | 空输入路径 |
| 1 | 最小非空输入 |
capacity - 1 |
文本最大内容长度 |
capacity |
精确容量边界 |
capacity + 1 |
拒绝、截断或分帧策略 |
| 很大长度 | 检查循环、算术和资源限制 |
用 GDB 做无害崩溃验证¶
为了观察保护机制,保留编译器默认的栈保护策略或显式启用:
gcc -g -O0 -Wall -Wextra -fno-omit-frame-pointer \
-fstack-protector-strong \
overflow_demo.c -o overflow_demo_canary
set disassembly-flavor intel
break copy_input
run < input.bin
disassemble /m copy_input
info registers rsp rbp rip
info frame
x/24gx $rsp
continue
bt
__stack_chk_fail 附近终止,记录:
1. 输入长度和原始输入文件哈希。
2. buf 的地址与编译器报告的容量。
3. read 的第三参数是否确实大于容量。
4. 函数尾声是否执行 Canary 校验。
5. 最终信号或终止路径。
详细 GDB 工作流见GDB 在 Pwn 中的使用。
cyclic 的用途与边界¶
循环模式 cyclic pattern 由可识别的非重复子串组成。它可在本地崩溃实验中回答:某个被覆盖值来自输入的哪个偏移? 使用 Pwntools 生成本地测试输入:
也可以保留纯二进制输出,避免自动换行: 在 GDB 中只对本地示例运行: 若得到某个确实来自模式的 4 字节序列,可在终端查询: 注意: - 必须使用崩溃现场实际出现的字节顺序。 - 默认模式查询常按 4 字节子序列工作。 - 64 位寄存器值不代表必须把完整 8 字节都交给默认查询。 - 终端换行会改变输入长度,二进制输出更可控。 - 偏移只描述这次构建和输入路径,不能跨版本盲用。 - cyclic 定位偏移,不证明控制流可利用。 完整工具说明见Pwntools 使用指南。32 位与 64 位差异¶
初学资料常混用 i386 与 amd64,需要先确认目标:
| 项目 | x86 32 位常见情况 | x86-64 System V 常见情况 | | ------------ | ----------------- | --------------------------- | | 指针宽度 | 4 字节 | 8 字节 | | 返回地址宽度 | 4 字节 | 8 字节 | | 栈指针 |esp | rsp |
| 帧指针 | ebp | rbp |
| 普通参数 | 多数经栈传递 | 前六个整数/指针参数经寄存器 |
| 对齐规则 | 依 ABI 与编译器 | 调用边界强调 16 字节对齐 |
| red zone | 无对应规则 | System V 用户态有 128 字节 |
因此 32 位教程中的固定图、偏移和寄存器名不能直接套到 64 位程序。即使源码相同,不同编译器与选项也会生成不同布局。
小端序如何影响观察¶
x86 和 x86-64 通常采用小端序:低有效字节放在低地址。 数值:
在连续内存中显示为: GDB 中应同时用字节和机器字查看: 字符输入按地址顺序写入;调试器把同一批字节解释为整数时,会按小端序显示数值。这就是 cyclic 查询必须关注“内存中的字节顺序”而不是肉眼抄一个整数外观的原因。缓解机制的边界¶
NX 通常让栈、堆等可写页面不可执行,主要阻止把输入字节直接当作新指令运行。它不阻止普通数据破坏、返回地址或函数指针改写、跳转到已有代码、越界读和逻辑错误,所以它是缓解层,不是边界检查。
PIE 与 ASLR 随机化栈、堆、共享库以及启用 PIE 的主程序基址,提高地址预测难度,但不能防止越界写本身;信息泄露、低熵或配置错误会削弱效果。调试时应记录“模块基址 + 符号偏移”和相对栈位置,不把一次运行的绝对地址当常量。
Canary 主要检测某些从局部缓冲区连续写向函数尾部控制数据的破坏。它不能保证 Canary 之前的普通数据未被覆盖、所有函数都被插桩,也不覆盖非连续任意写、堆错误或逻辑错误;合理启用 -fstack-protector-strong 后仍须修复根因。
RELRO 保护 ELF 动态重定位相关区域。Partial RELRO 提供部分保护,Full RELRO 通常配合立即绑定使相关 GOT 区域初始化后只读;它不检测栈越界,也不保护所有函数指针或普通数据。
FORTIFY 在启用优化且编译器能推断对象大小时强化部分 libc 调用:
__memcpy_chk 等检查版本,但不能覆盖自定义循环、未知运行时对象和所有算术错误。
Intel CET 常讨论两类能力:
| 能力 | 目标 |
|---|---|
| Shadow Stack | 用受保护副本校验返回地址 |
| IBT | 限制间接 call/jmp 的合法落点 |
CET 需要 CPU、内核、加载器、编译器和二进制属性共同支持并实际启用。二进制中出现 endbr64 不自动证明保护已在运行时生效;CET 主要增强控制流完整性,不能阻止普通数据覆盖、越界读或业务逻辑错误。
保护机制对照表¶
| 机制 | 主要降低的风险 | 不负责解决 |
|---|---|---|
| NX | 数据页直接执行 | 越界写、代码复用 |
| PIE + ASLR | 固定地址可预测性 | 边界错误、信息泄露 |
| Canary | 某些栈控制数据连续覆盖 | 所有数据破坏 |
| RELRO | 特定重定位区域被改写 | 栈越界本身 |
| FORTIFY | 部分已知大小库调用错误 | 自定义逻辑和未知大小 |
| CET | 部分返回与间接分支劫持 | 普通数据与内存边界 |
| ASan | 测试时发现内存错误 | 生产环境完整缓解 |
分层防护的原则是:一层失效时,其他层继续增加失败概率或提供检测。但没有任何组合能替代正确的容量计算。
ret2win 的概念边界¶
CTF 教学中的 ret2win 通常指:题目二进制内已有一个预设“成功函数”,练习者在授权环境中研究控制数据破坏是否可能让程序转向该函数。 它用于建立三个概念:
- 返回地址是控制数据。
- NX 不阻止执行二进制中已有代码。
- 可达性、地址稳定性和栈状态都影响结果。
本文不提供覆盖字节串、目标地址拼接或远程脚本。练习时应使用专门设计的本地 CTF 题,并把成果限定为预设无害输出或正常退出。
代码复用的概念边界¶
代码复用泛指不注入新指令,而是组合程序或库中已有代码完成控制流效果。ROP 是其中一种常见教学概念。 理解它的防御价值在于:
- NX 不能独立解决控制流破坏。
- 返回地址、间接调用目标和函数指针都需要保护。
- ASLR/PIE、Canary、CET 和 CFI 等机制需要分层部署。
- 最优先的工作仍是消除越界写。
本篇不构造 gadget 链,不讨论真实目标绕过,也不提供可迁移 payload。相关汇编背景可阅读面向 Reverse 与 Pwn 的汇编。
正确修复危险读取¶
原始错误是 char buf[16]; ssize_t n = read(fd, buf, 64);,读取上限明显超过对象容量。
若数据按二进制块处理:
char buf[16];
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0) {
return -1;
}
process_bytes(buf, (size_t)n);
若数据按 C 字符串处理:
char buf[16];
ssize_t n = read(fd, buf, sizeof buf - 1);
if (n < 0) {
return -1;
}
buf[n] = '\0';
process_text(buf);
这两个版本语义不同。二进制接口应显式传递长度;文本接口才依赖终止符。
更系统的修复原则¶
- 让目标容量与指针一起传递,避免接口丢失边界信息。
- 使用
sizeof object,而不是复制粘贴的魔数。 - 对指针参数使用显式容量参数,不能用
sizeof pointer代替对象大小。 - 使用
size_t表示对象大小,并谨慎处理有符号 I/O 返回值。 - 在加法、乘法和窄化转换前验证范围。
- 区分字节长度、字符数、元素数和编码后的最大长度。
- 检查短读、短写、错误码和重试语义。
- 在协议层定义超长输入是拒绝、截断还是分帧。
- 默认启用编译器警告和平台强化选项。
- 在 CI 中运行 ASan、UBSan、单元测试和模糊测试。
strncpy 不是万能修复:它可能不写终止符,也可能填充整个目标缓冲区。任何 API 都要按其精确定义使用。
建议的安全构建基线¶
开发和测试阶段可考虑:
gcc -O1 -g3 -Wall -Wextra -Wpedantic -Wconversion \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
source.c -o program_test
发布构建可根据发行环境考虑:
gcc -O2 -D_FORTIFY_SOURCE=3 \
-fstack-protector-strong -fPIE \
source.c -Wl,-z,relro,-z,now -pie -o program
实际选项需与工具链、性能要求和部署平台兼容。不要为了让练习“更容易”而把关闭保护当成默认工程配置。
防御性分析流程¶
步骤:用 file 和 readelf 确认架构、位数与 ELF 类型;识别所有输入入口和长度来源;找到目标对象的真实容量;审计加法、乘法、转换和终止符空间;用编译器警告寻找静态可见问题;用 ASan 定位第一条错误访问;用 GDB 验证参数、栈帧、终止路径和保护机制;必要时用 cyclic 仅定位本地崩溃偏移;区分普通数据、Canary、保存帧和返回地址破坏;修复源码并用原始输入与边界值回归;检查发布二进制是否保留预期强化;记录哪些结论已验证,哪些仍是假设。
常见误区¶
- 输入长就一定是栈溢出:只有写入超过栈上对象边界才成立;长输入也可能被安全截断、动态分配或造成堆错误。
- 程序崩溃就等于可利用:崩溃只证明路径异常,稳定控制、地址知识、防护和程序状态仍须分别证明。
- Canary 保护整个栈帧:Canary 之前的普通数据可能已经被破坏。
- NX 开启就没有控制流风险:NX 阻止数据页直接执行,但不阻止跳转到已有代码。
- 固定偏移适用于所有构建:架构、优化、编译器、Canary、变量重排和源码变化都会改变布局。
sizeof(buf)总是容量:数组对象成立,参数退化为指针后得到的是指针大小。- 字符串长度等于复制字节数:终止符、编码、内嵌空字节和附加分隔符都会改变语义。
- 关闭 ASLR 才能调试:多数根因分析可在保护开启时完成,应理解真实条件而不是先移除缓解机制。
自检清单¶
完成一个本地栈溢出实验后,应能回答:
- 目标对象的起始地址和容量是多少?
- 实际请求写入和实际完成写入分别是多少?
- 第一条越界访问发生在哪里?
- 长度是否经过有符号转换、截断或算术计算?
- 是否为终止符保留空间?
- 首先受影响的是普通数据、Canary、保存帧还是返回地址?
- 编译器给出了哪些警告?
- ASan 报告的对象范围和越界方向是什么?
- GDB 中
rsp/rbp/rip与反汇编是否一致? - cyclic 偏移是否来自同一构建和同一输入路径?
- NX、PIE、ASLR、Canary、RELRO、FORTIFY、CET 各自实际是否启用?
- 每个缓解机制阻止什么,又不阻止什么?
- 修复后原始输入和边界值是否全部通过?
- 发布构建是否保留警告与强化策略?
练习路线¶
第一阶段:边界与诊断¶
手工列出合法索引与终止符位置,编译本地错误示例并阅读警告,用 ASan 找到第一条越界写,修复后覆盖 0、边界前、边界和边界后输入。
第二阶段:栈帧与调试¶
对比 -O0、-O2 和有无帧指针的反汇编;在 GDB 中观察 call、序言、Canary 与 ret;用 x/bx、x/gx 和 cyclic 理解端序及本地偏移。
第三阶段:保护机制¶
用 readelf、checksec 和运行时映射核对保护状态,写出各机制的能力边界,比较 ASan 与普通构建,并在不关闭保护的前提下完成根因定位和修复。
第四阶段:授权 CTF 概念题¶
选择明确用于教学的本地 ret2win 题,只以预设成功函数或无害输出为目标;记录控制数据可达性与保护机制,不迁移到真实服务,并继续学习调用约定、ELF、动态链接和代码复用的防御含义。
建议配套阅读函数与调用约定、GDB 在 Pwn 中的使用与Pwntools 使用指南。