上一周在整理CTFshow pwn系列的前置基础,做到pwn 032这道题时,发现它把FORTIFY_SOURCE单独拎出来讲,还标了个Level 0。初看觉得很简单,可越往深挖越有意思——这玩意不是单纯的“防溢出开关”,它更像编译器在标准库函数调用上做的一道“尺寸校验关卡”。很多刚接触pwn的选手,checksec跑完只盯NX和PIE,完全忽略FORTIFY这一行,直到调试时发现strcpy被编译器偷偷换成了__strcpy_chk,程序一到溢出点就异常终止,这才知道还有这么个东西。
这篇文章适合两类人:一是刚入坑CTF pwn、还没系统性梳理过Linux用户态安全机制的萌新;二是已经有漏洞利用经验,但对编译器如何介入“安全检查”的底层逻辑不清楚的老人。我会从FORTIFY_SOURCE的原理讲起,拆开Level 0、1、2这三档差异,再给出一套能直接用的检测流程,最后聊一聊“开了FORTIFY之后,我们的利用思路应该怎么调整”。
1. FORTIFY_SOURCE 是来堵哪个窟窿的
1.1 所有问题的起点:C标准库不检查边界
C语言的设计哲学是信任程序员。strcpy、memcpy、sprintf、gets这些函数从诞生起就不负责校验目标缓冲区够不够大,写入越界属于“未定义行为”。这类函数在CTF里出现频率极高,栈溢出题的经典套路就是往一个固定大小的局部数组里塞超长输入,然后覆盖返回地址或栈上变量。
在早期Linux程序中,这种问题几乎裸奔。后来内核和用户态都引入了各种缓解机制,比如栈Canary、NX、ASLR、PIE,但这些都是“内存区域级别的防御”,在函数调用层并没有自动拦截。于是GCC和glibc联手搞出了FORTIFY_SOURCE,目标很直接:在编译器层面,对标准库函数里那些“写入长度可能超过对象大小”的调用做额外检查。
需要澄清一点,FORTIFY_SOURCE不是像NX那样让栈不可执行,也不是像Canary那样在函数序言里插入随机值。它的工作方式是:编译器在编译阶段尽量推导每个缓冲区对象的实际大小,然后决定是直接编译报警,还是把一个函数调用替换成带_chk后缀的安全版本,在运行时再去验证一次。
1.2 编译期检查与运行期检查,各自管什么
FORTIFY_SOURCE的检查分成两个阶段。
第一个阶段是编译期。如果编译器能够静态确定目标对象的大小,并且发现源数据长度明显超过了那个大小,它会在编译时输出一个warning,甚至在-Werror下直接编译失败。举例来说:
c复制#include <stdio.h>
#include <string.h>
int main(void) {
char dst[8];
strcpy(dst, "AAAAAAAAAAAAAAAAAAAA");
puts(dst);
return 0;
}
用下面的命令编译:
bash复制gcc -O2 -D_FORTIFY_SOURCE=2 -o demo demo.c
GCC会直接给出类似这样的警告:
text复制warning: '__builtin_memcpy' writing 20 bytes into a region of size 8 overflows the destination [-Wstringop-overflow=]
这个阶段很有价值:很多明显的栈溢出在编译阶段就暴露了,不需要等到运行时崩溃。
第二个阶段是运行期。当编译器无法静态确定长度,比如长度来自函数参数或外部输入时,它不会傻傻地放行,而是把调用替换成对应的_chk版本。以memcpy为例,__memcpy_chk的签名大致是:
c复制void *__memcpy_chk(void *dest, const void *src, size_t len, size_t destlen);
第四个参数destlen就是编译器推导出来的目标对象大小。运行时如果len > destlen,glibc直接调用__chk_fail终止进程,并把崩溃信息打出来。
c复制#include <stdio.h>
#include <string.h>
void write_buf(char *src, size_t n) {
char dst[8];
memcpy(dst, src, n);
puts(dst);
}
int main(void) {
char *payload = "AAAAAAAAAAAAAAAAAAAA";
write_buf(payload, 20);
return 0;
}
编译运行:
bash复制gcc -O2 -D_FORTIFY_SOURCE=2 -o demo2 demo2.c
./demo2
你会看到:
text复制*** buffer overflow detected ***: terminated
Aborted (core dumped)
程序直接消亡,你的payload根本没机会继续执行。
1.3 把它想成快递发货前的称重,而不是巡逻保安
很多人把FORTIFY_SOURCE和那几种缓解机制搞混,我习惯用一个类比:NX、ASLR、Canary是在程序运行期间布置的“巡逻保安”,时刻盯着内存区域;而FORTIFY_SOURCE更像是快递发货前的称重环节——包裹要发出去之前,先称一下重量,超重就拦下来。
称重环节能有效拦截“包裹超重”这一类问题,但它不会拦截“包裹里的物品本身有毒”,也不会管“运输途中的车辆被劫持”。同理,FORTIFY只针对标准库函数里的“越界写”做校验,对use-after-free、逻辑漏洞、“非标准库函数的溢出”基本无能为力。这一点理解清楚后,后续绕过思路会顺畅很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Level 0、1、2 这三档,编译器到底改了什么
2.1 Level 0:关闭状态,但很多人没真正理解它的含义
标题里写的“FORTIFY_SOURCE (Level 0)”指的就是关闭状态。在GCC编译时,如果显式指定-D_FORTIFY_SOURCE=0,或者干脆不定义_FORTIFY_SOURCE,那么__USE_FORTIFY_LEVEL就保持为0。这个状态下,上面提到的宏替换、_chk符号、编译期检查统统不会发生,strcpy就是strcpy,memcpy就是memcpy,是真正的“原汁原味”。
CTF中的绝大多数入门栈溢出题,编译命令基本长这样:
bash复制gcc -fno-stack-protector -no-pie -O0 -D_FORTIFY_SOURCE=0 -o pwn pwn.c
注意这里有个容易踩的坑:有些人以为只要定义了_FORTIFY_SOURCE为0,程序就“开了FORTIFY”。实际上,0和未定义在行为上是等价的,都是关闭。判断一道题到底有没有开FORTIFY,不能只看编译命令里有没有出现FORTIFY字样,要去看checksec输出,或者直接看二进制里有没有_chk符号。
Level 0虽然叫“关闭”,但它并不是坏事——对于新手学pwn来说,关闭状态意味着漏洞更容易触发,省去了和__chk_fail搏斗的烦恼。这也是CTFshow pwn系列把它放在“前置基础”这个位置的原因:先把关闭态的行为摸清,后面遇到开启了FORTIFY的题目,才能分辨出差异在哪。
2.2 Level 1 和 Level 2,差在哪
当_FORTIFY_SOURCE定义为1或2时,才真正进入检查状态。GCC文档里有一个描述:启用FORTIFY_SOURCE必须配合优化选项(-O1及以上),因为只有开启优化,编译器才愿意做足够的静态分析,去调用__builtin_object_size这类推导对象大小的内建函数。
Level 1和Level 2的核心差异,主要落在检查的“覆盖广度”上:
| 对比项 | Level 1 | Level 2 |
|---|---|---|
| 基本缓冲区溢出检查 | 有 | 有 |
| 对结构体成员写入的检查 | 不严格 | 更严格,会额外推导子对象大小 |
printf族中%n的使用限制 |
不限制 | 要求格式串必须位于只读区域,否则%n失效 |
| 编译告警的覆盖范围 | 基础场景 | 更积极,更容易在编译期发现越界写 |
简单说,Level 2相当于Level 1的“加强版”,它会把更多场景纳入检查范围。其中最直观的例子是printf的%n。在Level 2下,如果格式字符串不是只读的(比如放在栈上的char fmt[]里),那么尝试使用%n会被glibc拒绝。这一点对格式化字符串漏洞利用影响很大,后面我会专门展开。
2.3 实测:同一段代码在三个Level下的编译差异
为了让你直观感受差异,我做了一组小实验。测试代码如下:
c复制#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
char dst[8];
char fmt[32] = "AAAA%n\n";
if (argc > 1)
strcpy(dst, argv[1]);
else
strcpy(dst, "hello");
printf(fmt);
puts(dst);
return 0;
}
分别用三种命令编译:
bash复制gcc -O0 -D_FORTIFY_SOURCE=0 -o test0 test.c
gcc -O2 -D_FORTIFY_SOURCE=1 -o test1 test.c
gcc -O2 -D_FORTIFY_SOURCE=2 -o test2 test.c
反汇编看一下符号引用:
bash复制objdump -d test0 | grep -E "strcpy@plt"
objdump -d test1 | grep -E "strcpy@plt"
objdump -d test2 | grep -E "strcpy@plt"
结果很有代表性:
test0里调用的是strcpy@plt;test1和test2里出现的是__strcpy_chk@plt;test2里还会额外看到__printf_chk@plt,而test0和test1用的是普通printf@plt。
这说明Level 1和Level 2在函数替换策略上就开始分道扬镳了。另外,如果你非要在-O0下定义_FORTIFY_SOURCE=2,GCC会给你一个warning:
text复制warning: _FORTIFY_SOURCE requires compiling with optimization (-O)
然后悄悄放弃加固。所以很多CTF赛题编译时为了调试方便用了-O0,哪怕编译命令里写了-D_FORTIFY_SOURCE=2,实际也是没生效的。这个细节能帮你判断远程题目的真实状态。
3. 三分钟识别一道题到底有没有开FORTIFY
3.1 checksec是第一道快速筛查
拿到一个ELF文件,我习惯先跑一遍checksec。不管用pwntools里的checksec还是独立的checksec.sh,输出里都有FORTIFY这一栏。常见显示是:
text复制Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Disabled
如果显示FORTIFY: Disabled,说明这条防线默认没开,你正常往strcpy、sprintf里打超长数据,不会遇到_chk函数拦截。如果显示FORTIFY: Enabled,就要多留个心眼:题目里凡是涉及标准库写操作的函数,都可能带着一个额外的“尺寸校验参数”。
3.2 readelf和objdump二次确认
checksec的判定逻辑通常是去看二进制动态符号表里有没有__*_chk系列符号。不过为了确保判断准确,我也会手动复查:
bash复制readelf -s ./pwn | grep -E "__.*_chk"
如果看到类似下面这些符号,说明FORTIFY确实被开启了:
text复制0000000000000000 0 FUNC GLOBAL DEFAULT UND __memcpy_chk@GLIBC_2.3.4
0000000000000000 0 FUNC GLOBAL DEFAULT UND __strcpy_chk@GLIBC_2.3.4
0000000000000000 0 FUNC GLOBAL DEFAULT UND __printf_chk@GLIBC_2.3.4
再配合objdump -R ./pwn | grep chk查看动态重定位表,基本可以实锤。
还有一种情况:题目本地跑着好好的,一上远程就行为不一致。这种时候,除了看二进制本身,还要确认远程环境的libc版本和编译选项。因为FORTIFY_SOURCE是编译期特性,远程环境只能靠二进制里已有的_chk符号来判定,不能靠猜。
3.3 从编译残留信息和字符串里找线索
很多编译环境会把一些标志性字符串留在二进制里,用strings就能抓到:
bash复制strings ./pwn | grep -i fortify
如果编译时用了-D_FORTIFY_SOURCE=2,有时候你能在.GCC.command.line段里看到类似-D_FORTIFY_SOURCE=2的残留。这种方法不保证100%有效,因为有些构建工具会剥离这部分信息,但可以作为辅助手段。
另外可以看file ./pwn的输出,了解二进制是动态链接还是静态链接。静态链接的glibc版本不同,_chk符号的存在情况也不一样。不过CTF里绝大多数pwn题是动态链接,用ldd就能看出它链的是系统libc还是题目自带的libc。
4. 开了FORTIFY之后,利用思路要往哪几个方向调整
4.1 三条主线:绕开、利用不检查的函数、利用检查盲区
如果一道题确认开了FORTIFY,直接按“无脑栈溢出”的思路打大概率会失败,因为__strcpy_chk、__memcpy_chk不会让你轻易越界写。但FORTIFY不是密不透风的墙,我常用的思路有三条:
第一,寻找没有_chk变体的函数。比如strncpy本身只拷贝指定字节数,它的_chk版本__strncpy_chk虽然存在,但如果你能精确控制长度参数,让它在“尺寸校验范围内”完成一次危险写入,那还是有操作空间的。更常见的是某些自定义的拷贝函数,或者scanf族、getline这类不在传统FORTIFY重点检查清单里的函数。
第二,利用检查范围的盲区。FORTIFY的“目标对象大小”依赖编译器在编译期对指针的推导。如果指针经过多次传递、函数间跳转、或者来自全局变量,__builtin_object_size很可能返回-1,也就是“未知大小”。这种情况下_chk函数的第四个参数是-1,检查逻辑基本上等于放行,白白变成了普通函数。
第三,格式化字符串漏洞虽然受影响,但不是被完全封死。%p、%x、%s这些指令照常工作,泄露地址、读栈数据都不受影响。%n在FORTIFY=2下确实有限制,但只要格式串处于只读区域(比如.rodata段),%n依然可以写。如果题目把格式串放在栈上,你需要想办法让格式串指针指向一个只读地址,最常见的是用/bin/sh这类字符串天然在只读段,或者用%p先泄露地址,再构造指针链。
4.2 深入一个例子:栈溢出在FORTIFY=2下还可行吗
直接说结论:可行,但条件变苛刻了。假设有这样一个函数:
c复制void vuln(char *src, size_t len) {
char buf[16];
memcpy(buf, src, len);
}
如果编译器能看到buf大小16,并且把__memcpy_chk(buf, src, len, 16)插入进去,那么只要len > 16,运行时就触发__chk_fail。看起来没戏。
但如果你把长度参数玩出花来,比如整数溢出:len是一个size_t,而前面从int转换或计算时出现回绕,导致len变成一个极大值,但“检查逻辑”比对的是len > 16,所以照样会被拦。真正的关键在于绕过编译器对对象大小的推导。
比如把buf换成一个堆上的指针:
c复制void vuln(char *src, size_t len) {
char *buf = malloc(16);
memcpy(buf, src, len);
}
编译器能否推导出buf指向的对象大小,取决于它能不能在内联分析时看到malloc(16)这个分配。但一旦buf被放入结构体、传给其他函数、或者通过realloc改变大小,__builtin_object_size就很可能返回-1。我见过不少堆题,明明开了FORTIFY,但read溢出点照样可以覆盖堆块元数据,原因就在这里。
再说一个更隐蔽的技巧:不调用带检查功能的函数,直接手写拷贝循环。比如:
c复制for (int i = 0; i < len; i++) {
buf[i] = src[i];
}
编译器不会为这种循环插入_chk检查。虽然它可能做一些向量化优化,但不会改变“这是一次越界写”的事实。所以在分析题目时,重点不是看有没有__memcpy_chk,而是找出真正把外部输入搬进内存的代码路径。
4.3 常见函数的FORTIFY检查边界速查
为了方便打题时快速判断,我整理了一张自己常用的表:
| 函数 | 是否有_chk版本 | Level 1/2检查要点 | 绕过的常见关注点 |
|---|---|---|---|
strcpy |
是 | 目标对象大小需大于源字符串长度 | 源指针大小推导失败时,检查失效 |
memcpy |
是 | 目标对象大小需大于拷贝长度 | 堆指针、多级传递导致对象大小未知 |
sprintf |
是 | 目标缓冲区大小需大于格式化结果 | 格式化结果长度难以预测,可用长度参数做文章 |
snprintf |
是 | 会限制写入长度,相对安全 | 返回值可能大于目标大小,注意截断后的逻辑 |
printf |
是 | Level 2限制%n的格式串只读性 |
%p/%s泄露不受影响 |
gets |
无 | 该函数被完全禁用 | 如果程序里出现,基本等于白送 |
read |
是 | 目标缓冲区大小由编译期推导 | 输入长度可控且对象大小推导失败时,仍可溢出 |
strncpy |
是 | 拷贝长度受参数限制 | 不自动补\0,可能导致信息泄露或利用残留数据 |
malloc后memcpy |
取决于推导 | 编译器看得见分配则检查,看不见则放行 | 把分配和拷贝拆到不同函数里 |
这张表不是教科书,而是我打题时的经验总结。不同glibc版本、不同编译器优化级别,表现会有差异,建议以后遇到具体题,用objdump反汇编验证一下再定后续方案。
4.4 什么时候FORTIFY形同虚设
除了上面提到的“推导失败”场景,还有几类漏洞完全不在FORTIFY的管辖范围内。
一是UAF(use-after-free)。当你释放了一块堆内存,又通过悬垂指针写入数据时,glibc的分配器逻辑在管内存元数据,_chk函数根本没有机会介入,因为写入操作不涉及“拷贝大小超过对象”这种模式。
二是逻辑漏洞。比如你可以修改某个标志变量,绕过认证,或者导致某个循环多执行一次。这类问题根本没有缓冲区边界可言,FORTIFY自然无从查起。
三是系统调用层面的操作。glibc的read、write包装函数被替换成_chk之后还有检查,但如果程序直接用内联汇编调用syscall,或者调用mmap、mprotect这类跟内存权限有关的系统调用,FORTIFY同样管不着。
5. CTFshow前置基础题的学习价值与下一步
5.1 pwn 032这类题为什么值得好好做
CTFshow的pwn系列把FORTIFY_SOURCE放在“前置基础”阶段,其实非常合理。很多选手一上来就学各种花式利用技巧,却连checksec里有一行FORTIFY都没注意过,更别说理解__strcpy_chk和strcpy的区别了。前置基础题的目的就是把这些“看起来不起眼但关键时刻要命”的知识点补齐。
做这类题目时,我建议不要只追求“打出来”就完事,而是按这个流程走一遍:
- 用
checksec看完整保护,记下FORTIFY状态; - 用
readelf -s确认二进制里有没有_chk符号; - 找到所有涉及外部输入的标准库函数,判断它们是否会被FORTIFY拦截;
- 在本地gdb里设断点,观察函数是直接进入
strcpy@plt还是__strcpy_chk@plt; - 如果确认是关闭状态,对比“如果我把它开启重新编译”,漏洞利用路径会发生什么变化。
这样训练几次之后,你对“安全机制是怎么介入代码执行的”会有很直观的感知,远胜于死记硬背概念。
5.2 把FORTIFY和其他保护机制联动起来看
学习FORTIFY_SOURCE的另一个价值,是帮你建立“多机制协同”的视角。一道完整的pwn题,通常不是只有一个安全机制在起作用,而是几个机制叠在一起:
| 保护机制 | 防护目标 | 绕过方向 |
|---|---|---|
| Canary | 检测栈溢出,防止返回地址被篡改 | 泄露Canary、覆盖栈上变量而非返回地址、利用__stack_chk_fail之前的信息泄露 |
| NX | 让栈不可执行 | ROP、ret2libc、mprotect+shellcode |
| PIE | 随机化代码段基址 | 泄露地址、部分覆盖、盲打 |
| RELRO | 保护GOT表不被改写 | 延迟绑定、伪造GOT表项、ret2dlresolve |
| FORTIFY | 检测标准库函数越界写 | 利用绕过_chk、堆指针推导失败、格式化字符串技巧 |
单独看FORTIFY,感觉它就是一个“加强型函数安全检查”。放到整个链路里看,你会发现它的存在会压缩你的攻击面:不能再用简单的strcpy栈溢出打天下了,得结合其他机制一起考虑。这也是很多高级pwn题把FORTIFY打开的原因——它逼着你寻找更隐蔽的漏洞点。
5.3 自建一个干净的验证环境
学这个知识点,最怕“纸上谈兵”。我强烈建议你花半小时搭一个本地实验环境,用不同的编译选项对比行为。
基础环境需要:gcc、binutils、gdb、pwntools。在Ubuntu/Debian系上,一条命令装齐:
bash复制sudo apt install -y gcc binutils gdb python3-pip
pip3 install pwntools
然后准备一个简单的有漏洞程序:
c复制#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
char buf[16];
if (argc > 1) {
strcpy(buf, argv[1]);
printf("input: %s\n", buf);
}
return 0;
}
分别用三组命令编译:
bash复制gcc -fno-stack-protector -no-pie -O0 -D_FORTIFY_SOURCE=0 -o vuln0 vuln.c
gcc -fno-stack-protector -no-pie -O2 -D_FORTIFY_SOURCE=1 -o vuln1 vuln.c
gcc -fno-stack-protector -no-pie -O2 -D_FORTIFY_SOURCE=2 -o vuln2 vuln.c
然后用readelf -s观察差异,用gdb在strcpy@plt和__strcpy_chk@plt上下断点,最后用pwntools写一个简单的溢出脚本,看看每个版本分别在何时崩溃、何时被__chk_fail拦下。这个过程跑一遍,比读十篇分析文章都管用。
5.4 踩过坑后的几点具体建议
最后分享几个我实际踩过的坑。
第一,调试时不要用系统默认的编译选项。系统里有些发行版默认加了-D_FORTIFY_SOURCE=2,你写测试代码时没显式指定关闭,结果本地调试时老是莫名崩溃,还以为是gdb的问题。我习惯在测试前统一加上-D_FORTIFY_SOURCE=0,或者至少把编译命令写完整。
第二,看到__stack_chk_fail和__chk_fail是两码事。前者是Canary检查失败,后者是FORTIFY运行时检查失败。报错信息很像,但排查方向完全不同。用bt看栈回溯,能清楚看到是函数返回时被拦,还是在函数内调用_chk时被拦。
第三,FORTIFY_SOURCE的影响范围一定是“编译期决定的”。如果题目给了一个已经编译好的二进制,那就只跟二进制里有没有_chk符号有关,跟你本地的glibc版本没有半点关系。别再因为本地libc太新就怀疑FORTIFY是不是“动态启停”了。当然,远程环境里__chk_fail的具体崩溃信息、_chk符号的版本号可能会因libc不同而有细微差别,这时候用题目给的libc跑一下最稳。
第四,做题时永远优先找“最原始的写入点”。无论FORTIFY开没开,如果漏洞输入先经过一个自定义的read循环,或者被存进一个全局大数组后再拷贝,那_chk检查的触发条件就完全不一样了。找到真正发生危险写入的那一行,比盯着函数名猜来猜去高效得多。
如果你正在刷CTFshow pwn系列,我建议你在看完这篇之后,回头把之前做过的题全部重新跑一遍checksec,单独记录FORTIFY那一列。你会发现,有些题目其实开了FORTIFY但利用方式完全没变,有些题目只是开了Level 1,就已经把最基础的strcpy溢出路径堵死了。把这些记录汇总成自己的速查表,以后再遇到陌生题,能少走很多弯路。
