源码防护-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
    ...

实战策略:

  1. 优先找堆漏洞:Fortify 主要针对栈缓冲区,堆分配通常无保护
  2. 找动态缓冲区:alloca、malloc 后的操作
  3. 找自定义实现:程序自己写的 my_strcpy 类函数
  4. 格式化字符串:%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地址随机化❌ 不相关
RELROGOT 保护❌ 不相关
Fortify源码级函数替换-

关键认知:Fortify 是编译时最佳努力保护,不是强制安全边界。它:

  • 只保护用 <string.h> 等标准头文件的代码
  • 只保护编译器能推断大小的缓冲区
  • 不阻止逻辑漏洞(整数溢出、UAF、类型混淆等)

总结:Pwn 中的应对

遇到 Fortify 开启的目标:

  1. 不要慌:它不是 Canary/NX 那种硬保护,只是"让危险函数变安全”
  2. 找替代路径:动态内存、自定义循环、系统调用
  3. 堆利用优先:Fortify 对堆几乎无影响
  4. 信息泄露:__chk_fail 报错可能泄露内存布局(虽然程序会退出)

更详细的请前往FORTIFY_SOURCE(编译时安全检查) - GKLBB - 博客园看这位师傅的文章。