源码防护-fortify_source
由于第一篇FORTIFY刚接触也没咋碰到,了解的也不多
现在回过头来发现当时写的糊里糊涂,
所以本篇继续深入FORTIFY防护
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参数的含义:
struct S { char a[10]; char b[20]; };
struct S s;
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:已知大小的 malloc
char *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, const char *src, size_t destlen) {
if (strlen(src) >= destlen) // 检查:源字符串长度 >= 目标大小?
__chk_fail(); // 溢出检测!调用 __stack_chk_fail 类似机制
return strcpy(dest, src); // 安全时才执行
}
触发条件:当 GCC 能静态推断出缓冲区大小也就是destlen时才会插入检查
Pwn 中的实际影响
场景1:完全阻止经典栈溢出
void vulnerable() {
char buf[64];
gets(buf); // 被替换成 __gets_chk,直接检测溢出
strcpy(buf, attacker_controlled); // 同上
}
结果:溢出瞬间触发 buffer overflow detected,程序 abort
场景2:绕过点——动态大小无法检查
void tricky(char *input, size_t len) {
char *buf = malloc(len); // 动态分配,编译时不知道大小
strcpy(buf, input); // 无法替换为 __strcpy_chk!
}
关键:malloc 返回值的大小编译器无法静态确定,不插入检查
场景3:结构体/指针混淆
struct packet {
int type;
char data[0]; // 柔性数组
};
void parse(void *raw) {
struct packet *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(); // 读入负数如 -1
memcpy(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 _chk
0000000000401230 __strcpy_chk
0000000000401280 __memcpy_chk
...
实战策略:
- 优先找堆漏洞:Fortify 主要针对栈缓冲区,堆分配通常无保护
- 找动态缓冲区:
alloca、malloc后的操作 - 找自定义实现:程序自己写的
my_strcpy类函数 - 格式化字符串:
%n系列不受 Fortify 影响
典型题目特征
// 这种题目 Fortify 防不住(CTF常见套路)
void vuln() {
char *buf = malloc(64);
int idx = read_int();
buf[idx] = read_char(); // 任意写,无函数调用
// 或者
read(0, buf, 0x1000); // read 系统调用,无 _chk 版本
}
与其他保护的关系
| 保护机制 | 防护层面 | Fortify 能否替代 |
|---|---|---|
| Stack Canary | 二进制运行时 | ❌ 不同机制,互补 |
| NX | 内存权限 | ❌ 完全不相关 |
| ASLR | 地址随机化 | ❌ 不相关 |
| RELRO | GOT 保护 | ❌ 不相关 |
| Fortify | 源码级函数替换 | - |
关键认知:Fortify 是编译时最佳努力保护,不是强制安全边界。它:
- 只保护用
<string.h>等标准头文件的代码 - 只保护编译器能推断大小的缓冲区
- 不阻止逻辑漏洞(整数溢出、UAF、类型混淆等)
总结:Pwn 中的应对
遇到 Fortify 开启的目标:
- 不要慌:它不是 Canary/NX 那种硬保护,只是"让危险函数变安全”
- 找替代路径:动态内存、自定义循环、系统调用
- 堆利用优先:Fortify 对堆几乎无影响
- 信息泄露:
__chk_fail报错可能泄露内存布局(虽然程序会退出)
更详细的请前往FORTIFY_SOURCE(编译时安全检查) - GKLBB - 博客园看这位师傅的文章。