题目被卡住的那一晚上,我在 GDB 里来回跑了十几遍,每次程序都死在同一句话上:*** buffer overflow detected ***: terminated。栈顶明明已经被我打得歪歪扭扭,可它偏偏不是在函数返回时报错,而是在一次memcpy调用里直接 abort。我翻了半天栈帧,才意识到问题根本不在于 canary,而是题目编译时带了-D_FORTIFY_SOURCE=2。这个参数把很多你以为很熟悉的 glibc 函数偷换成了带_chk后缀的加固版本,你前面做的偏移计算、ROP 布局,全都得跟着变。这篇就围绕当时那道编号 034 的 pwn 前置基础题,把 FORTIFY_SOURCE Level 2 的运作机制、识别方法,以及题目该如何下手讲清楚。适合已经知道栈溢出怎么写,但还没系统性碰过编译期加固参数的同学。
1. 一开始我以为是 canary,实际是 memcpy_chk 在拦路
1.1 一条“buffer overflow detected”的退出信息
那道题的函数逻辑很简单:先接收一段输入,再用memcpy往后端缓冲区里拷东西,最后返回。我用 pwntools 组合 payload 时,第一版直接按常规思路打满了 0x50 字节,结果远程没连,本地先崩了。更奇怪的是崩溃信息不是段错误,而是上面提到的那句buffer overflow detected。
我当时本能的判断是开了栈 canary。但 gdb 里的栈回溯把我拉回现实:
(gdb) bt #0 0x00007ffff7c94f10 in __memcpy_chk () from /lib/x86_64-linux-gnu/libc.so.6 #1 0x0000000000401186 in vulnerable () at pwn034.c:24 #2 0x00000000004011c3 in main () at pwn034.c:31__stack_chk_fail根本不在调用链里。memcpy变成了__memcpy_chk,说明程序开启了 FORTIFY_SOURCE 加固,并且编译器的对象大小分析成功识别出目标缓冲区的大小。平时做栈题只看 canary、NX、PIE 的同学,第一次撞见这种情况确实容易懵,因为这不是“堆栈地址保护”,而是发生在 libc 函数调用边界上的检查。
1.2 checksec 结果里没有专门一列
我习惯拿到二进制先跑一遍 checksec,它的输出长这样:
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: PIE enabled这个结果里没有任何一项叫FORTIFY。所以只想靠 checksec 一眼判断程序开没开 FORTIFY 是不行的。需要直接看符号表,或者反汇编里有没有__memcpy_chk、__sprintf_chk这类带_chk后缀的调用。
很多题目平台不会把编译脚本贴出来,只给你一个黑盒二进制。这时候最快的判断是readelf -s或nm -D,看动态符号表里是否存在__*_chk。我那题里一眼看到了__memcpy_chk、__printf_chk、__strcpy_chk,基本可以确认就是 Level 2。至于它和 canary 的关系,两者独立:canary 管栈返回地址,FORTIFY 管部分 libc 函数的边界检查。这道题恰好 canary 没开,FORTIFY 却开了,于是把很多人带进了“面对新保护机制”的节奏。
1.3 普通编译和加固编译出来的调用关系差异
同样是memcpy(dst, src, len),普通编译到最终动态解析时走的是memcpy@plt;而启用了 FORTIFY_SOURCE 且编译器能算出dst大小之后,调用会变成__memcpy_chk@plt。多出来的函数名后缀,代表它额外接收一个“目标对象大小”参数,并把这个参数和请求拷贝的长度做比较。
如果只是跑测试,这两个函数最终都能完成拷贝。但在反汇编层面,函数签名、参数个数、甚至局部栈布局都会变化。我在打这道 pwn 034 时,第一次 offset 算出来的长度在本地普通编译版上能通,放到加固版上全部作废,就是因为 FORTIFY 给函数参数和栈帧都加了料。后面在第五节会具体讲怎么用 gdb 看这个“加料”的现场。
2. FORTIFY 的编译期改写,到底改了哪几步
2.1 关键是__builtin_object_size这套机制
FORTIFY 的核心动作发生在编译期,不是运行期“凭空冒出来”一个检查。GCC 会尝试用__builtin_object_size(ptr, type)计算某个指针最终指向的对象大小。如果ptr是一个声明成char buf[32]的局部数组,那么编译器几乎可以确定它的对象大小是 32。拿到这个大小之后,标准函数会替换成带_chk的版本。
伪代码逻辑大概是这样:
char buf[32]; char *p = buf; memcpy(p, msg, 0x50);开启 FORTIFY 后,等价于:
if (0x50 > __builtin_object_size(p, 0)) __memcpy_chk(p, msg, 0x50, 32); else memcpy(p, msg, 0x50);glibc 内部__memcpy_chk的签名是:
void *__memcpy_chk(void *dest, const void *src, size_t len, size_t destlen);当destlen < len时,不复制,直接调用__fortify_fail终止进程。这一步很关键:它是在“复制内容”发生之前拦下的。所以你在 gdb 里看到的栈回溯停在__memcpy_chk内部,并且没有进入后续任何逻辑。
2.2 对象大小不是凭肉眼能直接看到的
初学者最容易绕进去的一个误区是,以为 FORTIFY 会检查“缓冲区还剩多少剩余空间”。实际上它检查的是 compile-time 能判断出的完整对象边界。比如:
char buf[32]; memcpy(buf, input, 0x80);它能算出buf的大小是 32。再看这个例子:
char buf[32]; char *p = buf + 16; memcpy(p, input, 0x20);编译器可能算出p指向的对象大小是 16,也可能因为偏移分析不够细而给出 32。不同 GCC 版本的推导能力不同。一般来说,数组声明、结构体字段、全局变量这种边界清晰的场景,FORTIFY 的检测效果最好;而通过堆上指针、函数参数、动态索引访问的指针,编译器经常推导出“未知大小”,返回(size_t)-1。destlen是-1时,len < destlen恒成立,等于没拦。
所以我后来做堆题基本不指望 FORTIFY 能帮上忙,它更多是在栈题和格式化字符串题目里捣乱。
2.3 哪些函数会被加上_chk后缀
常见加固替换大致是:
| 原本调用 | FORTIFY 后可能变成 | 额外传入的参数 |
|---|---|---|
memcpy(d,s,n) | __memcpy_chk(d,s,n,dest_size) | 目标对象大小 |
strcpy(d,s) | __strcpy_chk(d,s,dest_size) | 目标对象大小 |
sprintf(buf,fmt,...) | __sprintf_chk(buf,flag,size,fmt,...) | flag 和目标对象大小 |
printf(fmt,...) | __printf_chk(flag,fmt,...) | flag |
fgets(buf,n,fp) | __fgets_chk(buf,n,size,fp) | 读取大小和目标对象大小 |
read(fd,buf,n) | 多数情况下不变 | 无 |
gets(buf) | 不变,但编译期会有告警 | 无 |
注意最后两类:read和gets并没有对应的一套__read_chk运行时检查。这就是 pwn 034 这类题的突破口。你可以合理推断,出题人选择加固memcpy或者strcpy,但漏掉了程序里真正用于接收外部输入的那次read,或者故意把溢出点放在read上让玩家去发现 FORTIFY 的盲区。
3. Level 2 不是难度等级,但确实比 Level 1 更严格
3.1 题目挂着的“Level 2”指的是编译宏
很多同学会把pwn 034里的 Level 2 理解成“这道题属于第二个难度”。其实题目名称里强调的 FORTIFY_SOURCE 后面那个括号,指的是 GCC 编译参数:
gcc -O2 -D_FORTIFY_SOURCE=2 -fno-stack-protector -no-pie pwn034.c -o pwn034源码里通常不需要写特殊代码,只要定义了_FORTIFY_SOURCE=2,glibc 头文件就会把大量字符串和格式化函数重新内联为__*_chk调用。如果只是_FORTIFY_SOURCE=1,一部分替换也会发生,但范围和严格程度都不同。
3.2 Level 1 和 Level 2 的主要差别
_FORTIFY_SOURCE=1时,GCC 主要对编译器本身能算出大小的缓冲区做检查。_FORTIFY_SOURCE=2会进一步处理通过结构体成员、间接引用等路径访问的对象,并且在格式化字符串上做更严格的动作:比如把printf替换为__printf_chk,传入一个 flag 参数,用于禁止对只读格式串使用%n。
简单来说,Level 1 更像“顺手查一下编译时特别明显的溢出”,Level 2 则是“在所有能推导出对象大小的标准函数调用上都争取做运行前拦截”。实际 CTF 里绝大多数题目用的都是 Level 2,所以这道题才单独拿它出来讲。
3.3__printf_chk的 flag 参数带来的额外影响
格式化字符串漏洞在 FORTIFY Level 2 下会变得更加麻烦。普通printf(user_input)在开启 FORTIFY 后,可能被写成printf_chk(1, user_input)。这里的1就是交给系统判断的 flag。glibc 会检查格式串本身,一旦发现格式串位于只读段却试图使用%n,会终止程序。
这意味着老一套“%n写内存拿 shell”的姿势,在 FORTIFY=2 里直接失效。如果题目里还保留格式化字符串漏洞,你就得考虑%hn是否也会被拦,或者改用信息泄露路线而不是写内存路线。不过我这道题的主线其实是栈溢出,FORTIFY 只是给溢出点换了个位置。
| 配置 | 栈溢出保护 | 格式化保护 | 对象大小检查 |
|---|---|---|---|
| 无 FORTIFY | 不影响 | 不影响 | 不启用 |
| FORTIFY=1 | 不直接判定 | 部分保护 | 范围较窄 |
| FORTIFY=2 | 不直接判定 | %n限制更严 | 范围明显扩大 |
4. pwn 034 里的真正溢出位:避开加固函数的读取路径
4.1 反汇编里的主战场不是 memcpy,而是 read
把这题拖进 IDA 或者直接objdump -d pwn034,看到漏洞函数大概是:
void vulnerable() { char buf[0x40]; size_t n; puts("How many bytes?"); scanf("%zu", &n); printf("Copy %zu bytes\n", n); read(0, buf, n); // 实际漏洞点 memcpy(buf, buf, sizeof(buf)); // 被 FORTIFY 加固的调用 }在这里,memcpy的第三参数是常量0x40,和栈上buf的对象大小完全一样,FORTIFY 根本拦不住什么。真正的问题出在read(0, buf, n)。read没有__read_chk,只要我让n大于0x40,就能越过buf覆盖返回地址。
所以做题时不要一看到_chk函数就觉得“完蛋了,这道题没法溢出”。反汇编后要把所有输入函数列出来,尤其检查read、gets、cin、scanf,这些才是 FORTIFY 很少管到的路径。
4.2 别用源码里的buf偏移奏本地,FORTIFY 会改布局
这是我第一次失败的最大原因。我在本地写了个一模一样的vulnerable(),编译时没开 FORTIFY,得到buf离返回地址 0x48。然后同样的代码打开 FORTIFY=2 再编译一次,read后面的memcpy被替换成__memcpy_chk,调用参数从三个变成四个,GCC 给寄存器重新分配了顺序,局部变量栈帧也跟着动了一点。
实际测量的结果是,加固版比非加固版偏移多了 8 字节左右。如果只按非加固版去打成堆 ROP,链子就会因为返回地址没对准而碎掉。
处理方法很简单:不要迷信源码里的声明顺序,直接在 gdb 里看vulnerable的栈帧,或者用题目给的原样二进制重新算偏移。
payload = b'A' * offset + p64(rop_start)offset必须以题目二进制的实际反汇编为准,不是以本地自由编译的结果为准。
4.3 一次成型的利用思路:分两次输入打 ROP
这种带 FORTIFY 的题,只要溢出点落在read上,基本可以走老套路:第一次read读入一个可控长度,覆盖返回地址,第二次read再往 bss 段读 ROP 链。因为read不会被__read_chk替换,前面 FORTIFY 的所有检查都不会触发。
样例骨架:
from pwn import * p = process('./pwn034') p.recvuntil(b'How many bytes?\n') p.sendline(b'300') payload = b'A' * offset payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(system_addr) p.send(payload) p.interactive()当然这道题的题目环境可能开了 PIE 或者 NX,那上面这段只是示意。实际要先用栈泄露 PIE 基址,或者用固定地址的read做二次注入。核心思路不变:凡是有_chk标记的函数尽量不要作为第一入口,真正打穿栈的路径仍然依赖不带边界检查的 I/O 函数。
5. 用 objdump 和 gdb 识别 FORTIFY 的实操手段
5.1 快速判断一个可执行文件是否开启 FORTIFY
拿到任何 pwn 题目,我会先跑这三条命令:
objdump -d ./pwn034 | grep -E "__.*_chk@plt" objdump -R ./pwn034 | grep chk nm -D ./pwn034 | grep chk只要看到__memcpy_chk或__sprintf_chk这类符号,就说明源码里相关函数确实被 FORTIFY 替换了。但如果objdump -R里只有 GOT 重定位而objdump -d里没见到对应调用,可能是程序引用了 libc 里的 chk 函数但没实际使用,这种情况比较少见。
也可以查看二进制里的编译说明字符串,不过readelf -p .comment只能给出 GCC 版本,不能直接证明 FORTIFY。最靠谱的还是看调用点。
5.2 在__memcpy_chk处下断点检查第四参数
我想看 FORTIFY 算出来的“目标对象大小”是否合理,直接在 gdb 里对__memcpy_chk下断点:
b __memcpy_chk run触发后,按 x86-64 调用约定检查参数:
rdi:目标地址rsi:源地址rdx:要拷贝的字节数rcx:FORTIFY 计算的目标对象大小
p $rdx p $rcx如果rdx为 0x30,rcx为 0x40,那说明请求拷贝长度小于对象大小,不会拦。如果rdx为 0x100,rcx为 0x40,那必然触发 abort。这种对比方式很快就能看出一个溢出点究竟是真的能走,还是会在拷贝前被掐死。
5.3 本地复现题目编译配置的正确姿势
遇到这种前置基础题,想主动练 FORTIFY,可以这样编:
gcc -O2 -fno-stack-protector -D_FORTIFY_SOURCE=2 -no-pie pwn034.c -o pwn034-no-pie是为了方便调试,固定二进制地址;-D_FORTIFY_SOURCE=2是开启第二档加固。同时建议用-gdwarf-2或-g带上调试信息,方便 gdb 看源码对应关系。
要对比 Level 1 时,把宏改成 1 再编一版;要对比完全无 FORTIFY,就直接不加这个宏。三个二进制放在一起,用同一套 payload 跑一遍,是最直观的学习路径。
6. 我反复踩过的 FORTIFY 相关坑
6.1-O0下定义 FORTIFY 基本等于空气
我最早试过用gcc -O0 -D_FORTIFY_SOURCE=2编译,结果发现根本替换不出__memcpy_chk。原因是对象大小的推导依赖 GCC 在优化阶段对指针的分析。-O0时编译器保留了大量直接的寄存器赋值,很多情况下无法确定目标对象大小。FORTIFY 的作用自然会打折扣。因此以后只要涉及加固参数,我会默认配合-O1或-O2。
另外还要小心编译代码时宏被头文件重新定义。有些环境可能默认定义了_FORTIFY_SOURCE=1,你再手动-D_FORTIFY_SOURCE=2会收到警告,必须在命令行前面加-U_FORTIFY_SOURCE再定义 2,避免编译脚本覆盖。
6.2__fortify_fail是即时死亡,不是返回后报错
canary 被破坏时,报错通常发生在函数准备返回的阶段,那个时候栈上的局部变量已经执行完;FORTIFY 则是在调用中途、写入发生前就 abort。所以别指望在崩溃点之后还能抢救,也不会像 canary 那样泄露什么 flag。它就是一个硬性的进程终止。
这一点直接影响调试思路:如果 gdb 说死在__memcpy_chk,那就说明当前这条 copy 路径不能走,必须换输入接口,或者构造能绕过它的大小。反复在同一个函数里塞小 payload 没用,问题不是栈上偏移错了,是拷贝本身的边界就不合法。
6.3 同一份源码,开启 FORTIFY 后栈偏移会变
这个坑我已经踩过一次。FORTIFY 把函数签名改了,比如sprintf变成__sprintf_chk,参数数量从可变参数变成带 flag 和 size 的固定头,导致调用方的寄存器分配、栈变量位置都可能变化。即便是在同一个函数里,局部变量与返回地址之间也会差出几个字节。
解决方式很直白:不要在脑海里脑补源码布局,而是用 gdb 的info frame或观察sub rsp, 0xNN指令,直接确认目标函数开头分配的栈大小。pwntools 的cyclic也是好工具,先发一堆cyclic(1024),再通过报错地址反推 offset,这样就不会被 FORTIFY 的编译结果误导。
6.4 别把 libc 里的一堆_chk符号当成题目专属函数
很多时候用nm看动态符号表,发现 libc.so.6 里有大量__memcpy_chk、__strcpy_chk,误以为是题目二进制调用了它们。实际上 libc 本身就会导出这些符号作为公共 API,即使你编译的程序一次都不用,它们依然会在 libc 的符号表里出现。
判断题目二进制是否真的用了它们,要看反汇编中的 PLT 调用,比如call 0x401300 <__memcpy_chk@plt>,或者看重定位表里有没有相关条目。只看 libc 本身的符号列表会让你误判,白白浪费分析时间。
最后再补一个习惯
我现在拿到一道 pwn 题,第一步永远是先看 PLT 和 GOT 重定位,把__*_chk的调用全部标出来,再列表里把read、gets这样的非加固输入函数单独挑出来。FORTIFY 的本质不是阻止所有溢出,而是把一部分“编译器看得懂的溢出”提前拦截掉。只要你能找到一条不带_chk的读写路径,或者构造出编译器无法准确计算出对象大小的场景,绕开它并没有想象中那么玄。pwn 034 这题教给我的最大教训就是:先确认加固边界,再设计 payload,顺序不要反过来。