二进制安全-源码防护Fortify_Source
源码防护-fortify_source
Fortify Source 是什么
本质:GCC 的编译时/运行时缓冲区溢出检测机制,属于源码级保护(不是二进制层面的 NX/Canary 那种)
保护对象:标准库中的高风险函数(strcpy、memcpy、sprintf、gets 等),一般主要是和字符串相关的函数。
FORTIFY_SOURCE 是 GCC/glibc 提供的一种编译时 + 运行时联合防护机制,通过将危险的 C 库函数替换为带边界检查的安全版本(__*_chk 后缀函数),在编译期和运行期检测缓冲区溢出。
核心思想: 编译器在编译时尽可能推断出缓冲区大小,如果能在编译期判定溢出就直接报错;如果编译期无法确定,就插入运行时检查代码,在溢出发生的瞬间终止程序。
Fortify Source干了什么
要想知道Fortify Source干了什么,那就要先看看如果没有Fortify Source会发生什么
char buf[32];strcpy(buf, user_input); //①memcpy(buf, src, len); //②sprintf(buf, "%s", str); //③read(fd, buf, 1024); //④
先来看这段源码,毫无疑问的,①②③④这四条语句都有安全漏洞:
①完全没有长度检查,溢出
②len作为变量可能会>32
③str也可能长度>32
④1024>32毫无疑问的溢出
而编译器对这些代码没有任何反应,也就是说,对生成的代码不会做任何的边界检查
Fortify Source的工作机制
工作流程:
源代码:strcpy(buf, src) │ ▼ ┌───────────────────────────┐ │ 预处理阶段(宏替换) │ │ #include <string.h> │ │ FORTIFY 宏将 strcpy 替换为 │ │ __builtin___strcpy_chk() │ └───────────┬───────────────┘ │ ▼ ┌───────────────────────────────────┐ │ 编译阶段(GCC 内置函数处理) │ │ │ │ GCC 尝试用 __builtin_object_size() │ │ 推断 buf 的大小 │ │ │ ├──── 能确定大小 ────────────────────┤ │ │ │ │ ├─ 编译期可判定溢出 │ │ │ → 编译错误/警告 │ │ │ │ │ ├─ 编译期可判定安全 │ │ │ → 优化为普通 strcpy(零开销) │ │ │ │ │ └─ 编译期无法确定 │ │ → 插入运行时检查 │ │ → 调用 __strcpy_chk() │ │ │ ├──── 不能确定大小 ──────────────────┤ │ → 退化为普通 strcpy(无法保护) │ └──────────────────────────────────┘
而工作流程中的三个关键组件:
┌───────────────────────────────────────────────────┐│ ││ 1. __builtin_object_size(ptr, type) ││ GCC 内置函数,编译时推断对象的剩余大小 ││ ││ 2. 头文件中的 FORTIFY 宏(<string.h> 等) ││ 将标准函数替换为 __builtin___xxx_chk() 调用 ││ ││ 3. glibc 中的 __xxx_chk() 函数 ││ 运行时执行实际的边界检查 ││ │└───────────────────────────────────────────────────┘
原理详解
主要是对 __builtin_object_size()这个函数进行分析
__builtin_object_size(ptr, type)// ptr: 指向某个对象的指针// type: 推断模式(0-3)// 返回值: ptr 指向位置到对象末尾的剩余字节数// 如果无法确定,返回 (size_t)-1
type参数的含义:
structS {char a[10]; char b[20]; };structSs;char *p = &s.a[5];__builtin_object_size(p, 0);// type=0: 整个对象的剩余大小 = sizeof(S) - 5 = 25// 从 p 到整个结构体 s 的末尾__builtin_object_size(p, 1);// type=1: 最内层子对象的剩余大小 = sizeof(a) - 5 = 5// 从 p 到成员 a[] 的末尾// type 0,1: 不确定时返回 (size_t)-1(最大值,相当于"不限制")// type 2,3: 不确定时返回 0(最小值,相当于"零空间")
实际的情况:
// 情况1:栈上局部数组 → 编译器知道大小char buf[64];__builtin_object_size(buf, 0); // → 64 ✓ 确定// 情况2:已知大小的 mallocchar *p = malloc(100);__builtin_object_size(p, 0); // → 100 ✓ 有时能推断// 情况3:动态大小char *p = malloc(n); // n 是变量__builtin_object_size(p, 0); // → (size_t)-1 ✗ 无法确定// 情况4:数组偏移char buf[64];char *p = buf + 10;__builtin_object_size(p, 0); // → 54 ✓ 编译器能算// 情况5:条件分支char a[10], b[20];char *p = (condition) ? a : b;__builtin_object_size(p, 0); // → 10 (取最小值,保守估计)
编译时:替换危险函数
// 源码char buf[10];strcpy(buf, "hello"); // 编译器能判断是否安全// 实际编译后strcpy(buf, "hello"); // 直接使用普通的strcpy
解析:在这里编译器在编译期就能够静态判断出 “hello”字符串的长度为6字节,小于buf的长度,故直接使用原版的strcpy减少开销
// 源码char buf[10];strcpy(buf, "hello world"); // 编译器判断溢出// 实际编译// error: call to __builtin___strcpy_chk will always overflow
解析:编译器在编译时期就能够确定溢出,所以会直接报错误
// 源码char buf[10];strcpy(buf, user_input); // 编译器看到固定大小缓冲区// 实际编译后(伪代码)__strcpy_chk(buf, user_input, 10); // 多了长度检查参数
解析:因为在编译时期 未能判断 是否安全,但是已知缓冲区的大小,GCC 插入了 __*_chk 版本函数,多传入目标缓冲区大小参数,从而在 运行时进行检查
运行时:长度检查
// __strcpy_chk 内部逻辑char *__strcpy_chk(char *dest, constchar *src, size_t destlen) {if (strlen(src) >= destlen) // 检查:源字符串长度 >= 目标大小? __chk_fail(); // 溢出检测!调用 __stack_chk_fail 类似机制returnstrcpy(dest, src); // 安全时才执行}
触发条件:当 GCC 能静态推断出缓冲区大小也就是destlen时才会插入检查
Pwn 中的实际影响
场景1:完全阻止经典栈溢出
voidvulnerable(){char buf[64]; gets(buf); // 被替换成 __gets_chk,直接检测溢出strcpy(buf, attacker_controlled); // 同上}
结果:溢出瞬间触发 buffer overflow detected,程序 abort
场景2:绕过点——动态大小无法检查
voidtricky(char *input, size_t len){char *buf = malloc(len); // 动态分配,编译时不知道大小strcpy(buf, input); // 无法替换为 __strcpy_chk!}
关键:malloc 返回值的大小编译器无法静态确定,不插入检查
场景3:结构体/指针混淆
structpacket {int type;char data[0]; // 柔性数组};voidparse(void *raw){structpacket *p = raw;strcpy(p->data, source); // 编译器不知道data实际大小,可能不检查}
绕过/利用技术
方法1:目标选择
找没保护的函数:
-
自定义的循环复制(不用 strcpy/memcpy) -
动态分配内存后的操作 -
第三方库(未用 Fortify 编译)
// 这个有保护strcpy(buf, input);// 这个无保护(GCC看不到大小)memcpy(ptr, input, len); // 如果ptr是malloc返回的// 这个也无保护for (i = 0; i < len; i++) // 自定义循环 buf[i] = input[i];
方法2:整数溢出绕过长度检查
// __memcpy_chk 检查:if (n > destlen) abort();// 但如果 n 是计算出来的,先让整数下溢size_t n = attacker_controlled;n = n - 100; // 如果n很小,下溢变成极大值// 但检查时用原始的n?不,这里要看具体场景
更实际的例子:
char buf[64];int len = read_int(); // 读入负数如 -1memcpy(buf, data, len); // 转size_t后变成极大值,但检查可能用有符号比较?
方法3:竞争条件/TOCTOU(极少见)
// 检查和使用之间的时间差__strcpy_chk(buf, src, 64); // 检查 strlen(src) < 64 通过后// 但 src 在另一个线程被修改?(几乎不可能利用)
方法4:直接攻击 __chk_fail 本身
// 如果能覆盖 GOT 表中的 __stack_chk_fail 或 __chk_fail// 但 Full RELRO 下 GOT 只读,且这属于"用溢出修溢出检测",难度极高
CTF/实战中的真实案例
案例:绕过思路总结
# 检查二进制是否开启 Fortify$ checksec ./target...FORTIFY: YES # 说明有 __*_chk 函数# 查看具体哪些函数被保护了$ readelf -s ./target | grep _chk0000000000401230 __strcpy_chk0000000000401280 __memcpy_chk ...
实战策略:
-
优先找堆漏洞:Fortify 主要针对栈缓冲区,堆分配通常无保护 -
找动态缓冲区: alloca、malloc后的操作 -
找自定义实现:程序自己写的 my_strcpy类函数 -
格式化字符串: %n系列不受 Fortify 影响
典型题目特征
// 这种题目 Fortify 防不住(CTF常见套路)voidvuln(){char *buf = malloc(64);int idx = read_int(); buf[idx] = read_char(); // 任意写,无函数调用// 或者 read(0, buf, 0x1000); // read 系统调用,无 _chk 版本}
与其他保护的关系
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Fortify | 源码级函数替换 |
|
关键认知:Fortify 是编译时最佳努力保护,不是强制安全边界。它:
-
只保护用 <string.h>等标准头文件的代码 -
只保护编译器能推断大小的缓冲区 -
不阻止逻辑漏洞(整数溢出、UAF、类型混淆等)
总结:Pwn 中的应对
遇到 Fortify 开启的目标:
-
不要慌:它不是 Canary/NX 那种硬保护,只是”让危险函数变安全” -
找替代路径:动态内存、自定义循环、系统调用 -
堆利用优先:Fortify 对堆几乎无影响 -
信息泄露: __chk_fail报错可能泄露内存布局(虽然程序会退出)
如果想要更详细地了解Fortify Source请前往 [FORTIFY_SOURCE(编译时安全检查) – GKLBB – 博客园]
https://www.cnblogs.com/GKLBB/p/19603331
看这位师傅的文章。
夜雨聆风
