CppCon 2015 学习:Beyond Sanitizers
Sanitizers,一类基于编译时插桩(instrumentation)的动态测试工具,用来检测程序运行时的各种错误。
Sanitizers 简介
- 基于编译时插桩:编译器在编译代码时自动插入检测代码。
- 动态运行时检测:程序运行时实时检查错误。
- 常见类型:
- ASan(AddressSanitizer):检测内存相关错误,如越界访问、使用后释放(Use-After-Free)、内存泄漏等。
- UBSan(UndefinedBehaviorSanitizer):检测未定义行为。
- TSan(ThreadSanitizer):检测数据竞争。
- MSan(MemorySanitizer):检测未初始化内存读取。
ASan(AddressSanitizer)
- 主要发现 内存访问错误:
- 堆、栈和全局变量的缓冲区溢出
- Use-After-Free
- 双重释放
- 内存泄漏(需要配合 LeakSanitizer)
ASan (AddressSanitizer) 报告示例,展示了一个全局数组越界访问的错误检测。
代码示例
int global_array[100] = {-1};
int main(int argc, char **argv) {return global_array[argc + 100]; // 越界访问,导致错误
}
编译与运行命令
clang++ -O1 -fsanitize=address a.cc
./a.out
ASan 报告解析
10538 ERROR: AddressSanitizer global-buffer-overflow
READ of size 4 at 0x000000415354 thread T0
#0 0x402481 in main a.cc:3
#1 0x7f0a1c295c4d in __libc_start_main ??:0
#2 0x402379 in _start ??:0
0x000000415354 is located 4 bytes to the right of global
variable ‘global_array’ (0x4151c0) of size 400
- 错误类型:global-buffer-overflow(全局缓冲区越界)
- 错误位置:
main
函数,源代码第3行 - 读取大小:4字节(int)
- 内存地址:访问的地址比
global_array
末尾多了4字节,越界访问了
总结
- ASan 能精准检测内存越界,定位代码行和具体访问的地址。
- 帮助快速发现并修复这类难调试的内存错误。
这是一个典型的 AddressSanitizer (ASan) 检测到的 use-after-free 错误案例。
解释逐行含义:
int *array = new int[100]; // 分配了 100 个 int 的堆内存
delete [] array; // 正确地释放了这块内存
return array[argc]; // 错误!使用了已经释放的内存:Use-After-Free
ASan 报告详解:
==30226== ERROR: AddressSanitizer heap-use-after-free
READ of size 4 at 0x7faa07fce084 thread T0
- 程序试图读取已释放内存中的 4 个字节(即一个
int
),地址是0x7faa07fce084
#0 0x40433c in main a.cc:4
- 错误发生在
main()
函数的第 4 行,也就是return array[argc];
freed by thread T0 here:
#0 0x4058fd in operator delete[](void*) _asan_rtl_
#1 0x404303 in main a.cc:3
- 指出堆内存是在第 3 行被释放的:
delete [] array;
previously allocated by thread T0 here:
#0 0x405579 in operator new[](unsigned long) _asan_rtl_
#1 0x4042f3 in main a.cc:2
- 内存是在第 2 行分配的:
new int[100]
总结
- 错误:释放内存后继续访问(Use-After-Free)
- ASan(
-fsanitize=address
)很好地捕捉了这个问题 - 解决:不要在
delete[]
之后再使用该指针
改正代码
int main(int argc, char **argv) {int *array = new int[100];int result = array[argc]; // 在 delete 之前使用delete [] array;return result;
}
这是一个 AddressSanitizer (ASan) 报告的 stack-use-after-return 错误。
错误解释:函数返回后访问其局部变量
原始代码
int *g;
void LeakLocal() {int local;g = &local; // 把局部变量的地址赋值给全局指针
}
int main() {LeakLocal(); return *g; // 函数返回后访问已经无效的栈内存
}
出错原因
local
是LeakLocal()
的局部变量,分配在栈上- 当
LeakLocal()
返回时,栈帧被销毁 g
仍然指向已销毁的栈空间 → 未定义行为
ASan 报告解析
==19177==ERROR: AddressSanitizer: stack-use-after-return
- 错误类型:函数返回后访问栈上已释放的变量
READ of size 4 at 0x7f473d0000a0 thread T0
#0 0x461ccf in main a.cc:8
- 第 8 行读取了已无效的栈地址,大小为 4 字节(即一个
int
)
Address is located in stack of thread T0 at offset 32 in frame
#0 0x461a5f in LeakLocal() a.cc:2
- 说明地址属于函数
LeakLocal()
中的局部变量local
This frame has 1 object(s):[32, 36) 'local' <== Memory access at offset 32
local
占用的是偏移[32, 36)
的栈空间,ASan 检测到了你访问了这块空间
修复建议
不要返回或存储局部变量的地址
// 错误
int *g;
void LeakLocal() {int local;g = &local; // local 是局部变量,生命周期短
}
正确做法:使用堆分配或传值
int *g;
void LeakLocal() {g = new int(42); // 分配在堆上
}
int main() {LeakLocal();int result = *g;delete g; // 别忘了释放return result;
}
补充:为什么 detect_stack_use_after_return=1
重要?
默认 ASan 并不会检测这种错误行为,因为栈帧地址可能被重用。加上这个选项后,ASan 会将局部变量放入“后备堆”,从而保留它的元数据以检测使用。
ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out
ThreadSanitizer (TSan) 是什么?
TSan(ThreadSanitizer) 是一种 动态检测工具,用于捕捉 并发相关的错误,如:
- 数据竞争(Data Race)
- 死锁(Deadlock)
- 锁使用错误(如两次解锁)
- 条件变量误用
- 多线程中的未定义行为
最常见的错误:数据竞争(Data Race)
数据竞争定义:
两个线程并发访问同一个内存地址,至少一个是写操作,并且它们之间没有同步机制(如锁)。
例子:
int counter = 0;
void *increment(void *) {counter++; // 竞态:多个线程同时修改return nullptr;
}
int main() {pthread_t t1, t2;pthread_create(&t1, nullptr, increment, nullptr);pthread_create(&t2, nullptr, increment, nullptr);pthread_join(t1, nullptr);pthread_join(t2, nullptr);return 0;
}
这个代码在逻辑上没错,但多个线程访问 counter
没有加锁,会导致 不可预测结果。
如何使用 TSan ?
使用支持 TSan 的编译器(如 Clang):
clang++ -fsanitize=thread -g -O1 race.cc -o race
./race
TSan 报告内容(典型):
WARNING: ThreadSanitizer: data race (pid=1234)Write of size 4 at 0x0000004005d0 by thread T1:#0 increment race.cc:4Previous write of size 4 at 0x0000004005d0 by thread T2:#0 increment race.cc:4Location is global 'counter' of size 4 at 0x0000004005d0
SUMMARY: ThreadSanitizer: data race in counter
修复建议:使用同步机制
使用 std::mutex
保护共享数据:
#include <mutex>
std::mutex mtx;
int counter = 0;
void *increment(void *) {std::lock_guard<std::mutex> lock(mtx);counter++; // 安全访问return nullptr;
}
小结
工具 | 用于检测 |
---|---|
ASan | 内存越界、use-after-free、栈错误等 |
TSan | 并发错误:数据竞争、死锁等 |
UBSan | 未定义行为(例如整数溢出、类型转换) |
MSan | 未初始化内存读取(MemorySanitizer) |
这是一个经典的 数据竞争(Data Race) 例子,使用了 C++11 的 std::thread
:
示例代码:
int X;
std::thread t([&]{ X = 42; }); // 线程 T1 写入 X
X = 43; // 主线程同时写入 X
t.join(); // 等待 T1 完成
问题说明:
- 两个线程 没有同步机制 地同时写入
X
。 - 由于
X
是共享变量,多个写入操作之间未加锁,形成数据竞争。 - 编译器或 CPU 可能对这些写入操作重排,结果 不可预测。
-fsanitize=thread
捕捉到这一点,并报告了数据竞争。
TSan 报告解释:
WARNING: ThreadSanitizer: data race
Write of size 4 at ... by thread T1:#0 main::$_0::operator()() const race.cc:4
Previous write ... by main thread:#0 main race.cc:5
- 两个写操作 都对
X
进行了修改,但彼此没有任何同步。 - T1 和主线程 同时访问同一地址 → 违反了 C++ 的数据竞争定义。
正确做法:同步
用 std::mutex
保护共享变量:
#include <mutex>
int X;
std::mutex m;
int main() {std::thread t([&] {std::lock_guard<std::mutex> lock(m);X = 42;});{std::lock_guard<std::mutex> lock(m);X = 43;}t.join();
}
或者使用原子变量:
#include <atomic>
std::atomic<int> X;
int main() {std::thread t([&]{ X.store(42); });X.store(43);t.join();
}
MSan(MemorySanitizer)简介:
MSan 是一种检测 未初始化内存读取(use of uninitialized memory) 的工具。它不会像 ASan/TSan 那样关注内存越界或数据竞争,而是专注于:
变量或内存内容还未被初始化,就被读取或使用了。
常见问题:
1. 使用未初始化的栈变量:
int main() {int x;int y = x + 1; // x 没有初始化return y;
}
运行时会被 MSan 报告为 使用未初始化值。
2. 使用未初始化的结构体字段:
struct Point {int x, y;
};
int main() {Point p;int z = p.x + p.y; // p.x 和 p.y 未初始化return z;
}
使用 MSan 的编译方式:
clang++ -fsanitize=memory -fPIE -pie -g test.cc -o test
注意:MSan 只在 Clang 上支持,并且不支持一些系统库(如 glibc),常用于 LLVM instrumented 环境或自定义 libc(如 musl)。
与其他 Sanitizer 的对比:
Sanitizer | 检查内容 |
---|---|
ASan | 内存越界、use-after-free 等 |
TSan | 多线程数据竞争 |
MSan | 读取未初始化内存 |
UBSan | 未定义行为(除零、溢出等) |
这个例子展示了 MSan(MemorySanitizer) 如何检测栈上数组中未初始化的元素被访问的问题。
逐行解释:
int main(int argc, char **argv) {int x[10]; // 数组 x 没有被完全初始化x[0] = 1; // x[0] 初始化了return x[argc]; // x[argc] 可能未初始化
}
- 如果
argc == 0
,x[0]
会被访问,是安全的; - 如果
argc == 1
,会访问x[1]
—— 没有被初始化,MSan 报告错误。
MSan 报告内容解读:
WARNING: Use of uninitialized value#0 0x7f1c31f16d10 in main a.cc:4
Uninitialized value was created by an
allocation of 'x' in the stack frame of
function 'main'
意思是:
- 程序尝试读取
x[...]
,但那个位置没有被赋过值; - 堆栈分配的
x[10]
数组中,除x[0]
外,其余元素未初始化; - 静态分析看不出来这个问题,但运行时 MSan 抓到了。
修复方式:
int main(int argc, char **argv) {int x[10] = {0}; // 初始化所有元素为 0x[0] = 1;return x[argc];
}
或者更安全地,使用 std::array
或 std::vector
并进行显式初始化。
UBSan(UndefinedBehaviorSanitizer)概览:
UBSan 是用于检测 C/C++ 程序中未定义行为(UB, Undefined Behavior) 的工具。
它不像 ASan(内存)或 TSan(线程)那样专注于特定问题,而是涵盖一大类语言层级的错误。
UBSan 可以检测的问题类型包括:
问题类型 | 示例代码 | 描述 |
---|---|---|
整数溢出 | int x = INT_MAX + 1; | 有符号整数溢出 |
除以零 | int x = 1 / 0; | 数学未定义 |
不对齐访问 | 强制访问未对齐的结构体字段 | 会在某些硬件上崩溃 |
类型混淆(type punning) | 错误地使用 union 来存不同类型 | 会破坏内存语义 |
无效的虚函数调用 | 基类未构造完成就调用虚函数 | 未定义行为 |
空指针解引用 | int *p = nullptr; *p = 5; | 经典崩溃场景 |
访问已析构的对象(use-after-lifetime) | 用完对象后还访问其成员 | 会产生悬空指针 |
enum 值超范围 | 设置了未定义的枚举值 | 违反类型安全 |
如何使用 UBSan:
clang++ -fsanitize=undefined -g main.cpp -o main
./main
你还可以加上 -fno-sanitize-recover=undefined
让程序遇到 UB 直接崩溃。
示例:
int main() {int a = 10;int b = 0;int c = a / b; // 除以 0return c;
}
UBSan 输出:
runtime error: division by zero
解释:UBSan 报告中的 int overflow
示例代码:
int main(int argc, char **argv) {int t = argc << 16;return t * t;
}
假设 argc = 1
,那么:
t = 1 << 16 = 65536
t * t = 65536 * 65536 = 4,294,967,296
这个结果超出了int
所能表示的范围(通常为 −2,147,483,648 到 2,147,483,647),属于有符号整数溢出,根据 C++ 标准,这是 未定义行为(UB)。
UBSan 检测结果:
runtime error: signed integer overflow: 65536 * 65536 cannot be represented in type 'int'
UBSan 成功捕捉到了这个未定义行为并发出警告。
总结:
-fsanitize=undefined
能在运行时捕捉很多细节错误,像整数溢出、除零、虚函数错误等。- 编写性能关键或安全敏感的 C++ 程序时强烈建议启用 UBSan。
说明:Sanitizers 找出了成千上万的 bug
Sanitizers(如 ASan、TSan、MSan、UBSan)是 Clang/LLVM 提供的一组运行时工具,能在开发和测试阶段捕捉各种常见但难以发现的错误。
它们能发现的问题包括:
Sanitizer | 检测内容 | 示例错误类型 |
---|---|---|
ASan (AddressSanitizer) | 内存地址相关 | Use-after-free、越界访问 |
TSan (ThreadSanitizer) | 多线程数据竞争 | Data race、竞争条件 |
MSan (MemorySanitizer) | 未初始化内存读取 | 使用未初始化变量 |
UBSan (UndefinedBehaviorSanitizer) | 未定义行为 | 整数溢出、无效类型转换 |
为什么重要?
- 可以自动检测 微妙且难以调试的问题
- 广泛用于 Chromium、Firefox、Linux kernel、LLVM 等大型项目
- 显著减少生产环境中的崩溃和安全漏洞
如果你正在开发 C/C++ 项目,强烈建议:
clang++ -fsanitize=address,undefined -g your_file.cc
来在开发阶段抓住更多潜在问题。
补充说明:Sanitizers 不够,还需要更多手段
虽然 ASan、TSan、MSan、UBSan 非常强大,但它们 不是银弹(silver bullet):
为什么它们“不够”?
- 仅在运行测试时生效
→ 如果你的测试用例覆盖不到某路径,Sanitizer 就无法发现那里的问题。 - 不能证明程序正确性
→ 它们只能发现具体的已触发的 bug,而不是形式验证(formal verification)。
那我们还能做什么?
1. Fuzzing(模糊测试)
- 自动生成大量随机输入来覆盖不同路径
- 特别适合发现崩溃、内存越界等
- 与 Sanitizers 组合使用效果最佳
➜libFuzzer + ASan
是黄金搭档
2. Hardening(安全加固)
- 即便 bug 存在,也让攻击者难以利用
- 举例:
- 堆栈保护(stack canaries)
- 控制流完整性(CFI)
- 堆隔离(heap isolation)
- clang 的
-fstack-protector-strong
,-fsanitize=cfi
, 等
总结
技术 | 用途 |
---|---|
Sanitizers | 找 bug(依赖测试) |
Fuzzing | 提升测试质量、触发隐藏 bug |
Hardening | 让 bug 更难被攻击者利用 |
Formal Methods (进阶) | 用数学方法“证明”程序正确 |
Fuzzing (模糊测试) 简单理解:
- 自动或半自动地给程序输入大量随机、无效、意料之外的数据
- 观察程序是否崩溃、异常或出现安全漏洞
- 帮助发现输入验证不足或边界条件错误
为什么用Fuzzing?
- 人工写测试用例不可能覆盖所有路径
- 随机试探程序极限,更容易找到隐藏bug
- 很适合发现内存越界、空指针解引用、格式化字符串漏洞等
例子:
你有个函数处理网络数据包:
- 正常输入:符合协议的包
- Fuzzing 输入:随意乱写的包,或不完整的包
程序如果没做好异常处理,可能崩溃或挂起,fuzzer就能发现这类问题。
Generation-based Fuzzing 就是:
- 自动生成大量测试输入,直接喂给目标程序
- 结合 Sanitizers 一起用,能即时发现崩溃、内存错误等
- 输入可以是完全随机的无效数据(用来测试程序对异常输入的鲁棒性)
- 也可以是符合规则的有效数据,比如像 csmith 生成的 C 代码,确保测试语法和语义的复杂性
- Chromium 安全团队用它找了成千上万的漏洞
- 虽然强大,但依旧只是测试的开始,不能保证覆盖所有场景
Mutation-based Fuzzing 主要流程是:
- 先收集一批真实的测试样本(corpus),比如从网络抓取或从已有测试用例集里拿
- 对这些样本做变异(mutation),比如随机修改、插入、删除某些字节
- 用变异后的输入继续测试程序,看程序是否崩溃或出错
- 通常在实战中比纯生成式 fuzzing 效果更好,因为基于真实数据的变异更贴近真实使用场景
- 但面对高度结构化的输入格式(比如 C++ 代码)时,变异很容易破坏格式,导致无效输入,测试难度加大
“控制流引导(覆盖率引导)模糊测试”,它的原理就是:
- 在程序运行时,插入代码来监控哪些代码路径被执行了,也就是收集代码覆盖率信息;
- 在对输入数据进行变异后,运行程序观察是否触发了新的代码路径;
- 如果新输入覆盖了之前未执行过的代码,就把它加入到测试输入库(corpus)中;
- 这样不断反馈,模糊测试就能更高效地探索程序的不同执行路径,发现更多潜在的bug。
相比普通的随机变异,覆盖率引导的模糊测试效率可以提高十倍甚至上千倍。
比较著名的模糊测试工具有AFL(American Fuzzy Lop)和libFuzzer,都是基于这个思想。
AFL-fuzz的工作机制:
- 编译时插桩:在程序编译阶段插入额外代码,记录控制流信息。
- 传统方式是在汇编层面插桩
- 现代方式利用LLVM编译器的插桩功能
- 边计数器(edges counters):使用64K个计数器来跟踪程序中所有控制流边(从一个代码块到另一个代码块的跳转)的执行次数。
- 每条边的计数器只有8位,表示执行次数的区间(1次,2次,3次,4-7次,8-15次,16-31次,32-127次,128次以上)
- 计数器使用哈希映射,有可能发生碰撞,但这样做极大提升了效率
- 驱动进程和目标进程分离:AFL-fuzz作为驱动,负责生成测试输入、启动目标程序(被测试程序)作为独立进程,收集目标程序的执行反馈。
这个设计保证了高效的反馈循环,能够快速发现新覆盖的代码路径,从而智能地变异输入。
理# AFL-fuzz 真不是简单的玩具,而是实实在在用于工业级项目的强大工具。它已经发现了大量漏洞,涵盖了广泛的开源和闭源软件库,包括: - 图像处理库:libjpeg-turbo、libpng、libtiff、mozjpeg、ImageMagick、libraw、libde265 等
- 浏览器和插件:Mozilla Firefox、Internet Explorer、Apple Safari、Adobe Flash、JavaScriptCore
- 安全和网络:OpenSSL、GnuTLS、OpenSSH、tcpdump、wireshark、wpa_supplicant
- 系统和工具:glibc、clang/llvm、systemd、bash、dpkg、curl、libyaml
- 多媒体处理:ffmpeg、FLAC、libsndfile、wavpack
- 其他各种:SQLite、LibreOffice、poppler、clamav、redis、perl、Qt、SleuthKit、dnsmasq
并且还有更多广泛的工具、库、系统组件都受益于AFL的发现能力。
LLVM libFuzzer 利用 -fsanitize-coverage=
选项做代码覆盖率的插桩,支持以下模式:
- func / bb / edge:跟踪函数、基本块或边是否被执行
- indirect-calls:跟踪间接调用的调用者-被调用者对
- 8bit-counters:类似AFL的8状态计数器,记录边的执行次数分段(1, 2, 3, 4-7, 8-15, 等)
它支持在进程内统计和程序退出时写入磁盘数据,方便实时反馈和后续分析。
推荐结合 ASan、MSan、UBSan 一起使用,检测内存、线程和未定义行为。
性能开销约10%以内,8bit计数器在多线程环境可能表现不佳。
LLVM libFuzzer 是一个轻量级的进程内控制流引导模糊测试器,特点包括:
- 需要你自己提供一个测试入口函数,标准接口是:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size);
- 通过编译时参数
-fsanitize-coverage=edge[,indirect-calls][,8bit-counters]
实现代码覆盖率插桩。 - 可以配合 Sanitizers (ASan、MSan、UBSan、LeakSanitizer)一起用,提高检测能力。
- 链接时需要加上 libFuzzer 库。
- 虽然算法上没有 AFL-fuzz 那么复杂和成熟,但对库和API的模糊测试非常合适,尤其是针对接口、函数等。
- 主要目标是库级别测试,而非完整大型应用。
libFuzzer 的使用流程总结:
- 准备测试语料库(corpus)
把已有的测试样例放进一个文件夹(比如叫CORPUS
),可以为空目录,libFuzzer 也会自动生成变异测试用例。 - 运行fuzzer
./my-fuzzer CORPUS
-jobs=N
:指定并行任务数,多个工作线程共同作用于同一个语料库。-max_len=N
:限制单个测试输入的最大长度(默认64字节)。-help
:查看更多可调节参数。
- 输出
libFuzzer会将新发现的有价值的测试输入保存回CORPUS
目录中,方便持续扩大和改进语料库。 - 遇到bug或超时
fuzz过程会停止,并将导致崩溃的输入保存,方便调试。 - 可选
把 libFuzzer 产生的语料库拿去给 AFL-fuzz 用,可以结合两者优点。
这个 FreeType fuzzer 例子展示了用 libFuzzer 对 FreeType 字体库进行模糊测试,结合了 ASan 和 UBSan 来捕获各种问题,比如:
void TestOneInput(const uint8_t *data, size_t size) {FT_Face face;if (size < 1) return;if (!FT_New_Memory_Face(library, data, size, 0, &face)) {FT_Done_Face(face);}}
Results with FreeType (ASan+UBsan)
#45999 left shift of negative value -4592
#45989 leak in t42_parse_charstrings
#45987 512 byte input consumes 1.7Gb / 2 sec to process
#45986 leak in ps_parser_load_field
#45985 signed integer overflow: -35475362522895417 * -8256 cannot be represented in t
#45984 signed integer overflow: 2 * 1279919630 cannot be represented in type 'int
#45983 runtime error: left shift of negative value -9616
#45966 leaks in parse_encoding, parse_blend_design_map, t42_parse_encoding
#45965 left shift of 184 by 24 places cannot be represented in type ‘int’
#45964 signed integer overflow: 6764195537992704 * 7200 cannot be represented in type
#45961 FT_New_Memory_Face consumes 6Gb+
#45955 buffer overflow in T1_Get_Private_Dict/strncmp
#45938 shift exponent 2816 is too large for 64-bit type ‘FT_ULong’
#45937 memory leak in FT_New_Memory_Face/FT_Stream_OpenGzip
#45923 buffer overflow in T1_Get_Private_Dict while doing FT_New_Memory_Face
#45922 buffer overflow in skip_comment while doing FT_New_Memory_Face
#45920 FT_New_Memory_Face takes infinite time (in PS_Conv_Strtol)
#45919 FT_New_Memory_Face consumes 17Gb on a small input
- 整数溢出(signed integer overflow)
- 内存泄漏(leak)
- 缓冲区溢出(buffer overflow)
- 非法移位操作(left shift of negative value)
- 性能问题(某些输入导致极大内存消耗或无限时间运行)
通过 fuzzing,FreeType 发现了大量潜在缺陷和安全隐患,且给出了编号(如 #45999),方便追踪和修复。
这种自动化测试大幅提升了代码质量和安全性,尤其是对复杂的二进制格式解析器(如字体文件)非常有效。
SSL_CTX *sctx;int Init() { ... }extern "C" void LLVMFuzzerTestOneInput(unsigned char * Data, size_t Size) {static int unused = Init();SSL *server = SSL_new(sctx);BIO *sinbio = BIO_new(BIO_s_mem());BIO *soutbio = BIO_new(BIO_s_mem());SSL_set_bio(server, sinbio, soutbio);SSL_set_accept_state(server);BIO_write(sinbio, Data, Size);SSL_do_handshake(server);SSL_free(server);}
这是一个用 libFuzzer 测试 OpenSSL 的例子,关键点:
Init()
函数初始化 SSL 环境,只执行一次(用static int unused = Init();
保证)- 创建新的 SSL 对象
SSL_new(sctx)
- 新建两个内存 BIO(
BIO_s_mem()
),分别用于模拟输入输出 - 绑定 BIO 给 SSL(
SSL_set_bio
),设定为服务端接受状态(SSL_set_accept_state
) - 把 fuzz 输入数据写进输入 BIO(
BIO_write(sinbio, Data, Size);
) - 触发 SSL 握手过程(
SSL_do_handshake(server);
),这里可能暴露握手相关漏洞 - 最后释放 SSL 对象(
SSL_free
)
这个 fuzzer 通过模糊传入各种不确定的数据,测试握手流程是否有崩溃、内存错误等,适合捕捉解析输入数据时的漏洞。
Control-flow-guided fuzzing is not the end
Concolic execution(结合具体执行与符号执行)的核心思路是:
- 具体执行(Concrete Execution):运行程序,实际输入驱动代码执行路径。
- 符号执行(Symbolic Execution):用符号变量代替具体输入,跟踪程序路径条件。
- 分析未覆盖的分支:通过跟踪路径条件,找出程序中哪些分支路径还没被执行过。
- SMT求解器(Satisfiability Modulo Theories solver):利用逻辑求解器求出新的输入,使程序沿着未走过的路径执行。
这样,测试工具不断产生新的测试用例,有针对性地覆盖更多代码路径,极大提高测试覆盖率。
不过,这技术计算量大,执行慢,尤其是复杂程序时,开销非常大,所以说“很重”。
Data-flow-guided fuzzing 的关键点是:
- 拦截数据流:在程序执行时,监控数据如何在代码中流动,尤其是关注条件判断中的输入。
- 分析比较操作的输入:找出哪些输入数据被用来做条件判断(if、switch等)。
- 智能修改测试输入:根据比较操作的输入,针对性地修改测试用例,尝试触发新的代码路径。
- 基于 LLVM libFuzzer 和 go-fuzz 实现:这些工具利用数据流信息来引导模糊测试,提升效率。
- 结合污点分析(如 DFSan):通过污点追踪技术,准确定位输入数据影响的代码位置,做更精细的变异。
这种方法比纯随机变异更“聪明”,因为它理解输入如何影响程序执行,从而更快发现隐藏的 bug。
威胁 #1:
- 缓冲区溢出(buffer overflow)或使用后释放(use-after-free)漏洞,攻击者通过这些漏洞,
- 篡改虚函数指针(vptr)或函数指针,
- 使得程序执行被攻击者控制的代码。
也就是说,攻击者利用内存错误,将程序中用于多态调用的指针(vptr)或普通函数指针改写成任意地址,从而劫持控制流。
这种攻击极具破坏力,通常导致远程代码执行或提权。
解决方案就是用 Control Flow Integrity (CFI):
- CFI 通过在编译时插入检查,确保程序运行时的间接调用(比如虚函数调用)只能跳转到合法的函数地址,防止攻击者修改指针后跳转到恶意代码。
- 用
clang++
加上-fsanitize=cfi-vcall
和-flto
编译链接,即可启用CFI对虚函数调用的保护。 -flto
(Link Time Optimization)能让编译器在链接阶段对整个程序做优化,确保CFI检查能够覆盖跨模块的调用。
这能有效防止利用缓冲区溢出或use-after-free改写vptr导致的控制流劫持。
Clang/LLVM 实现的 CFI 具体机制是:
- 独立处理每个不相交的类层次结构
认为每个类层次是封闭的(比如 Chrome 代码库这样),不允许外部类扩展。 - 统一布局所有 vtable
把每个类层次的 vtable 按照一定的幂次方大小对齐,放在连续的内存区域里。 - 虚函数调用时的检查
- 编译时确定该调用点允许调用的函数集合(严格限制调用目标)
- 运行时,执行一系列检查:
- 地址是否在允许范围内(range check)
- 地址对齐是否正确(alignment check)
- 地址对应的函数是否在允许集合内(bitset lookup)
这些检查确保虚函数调用只能跳转到合法的目标函数,防止攻击者利用内存漏洞劫持控制流。
Clang/LLVM CFI 里的 bitset lookup 优化,具体包括:
- 小于等于64位的bitset可以直接用寄存器操作,不需要额外内存加载,提高运行时性能。
- 如果bitset是全1(即允许调用所有函数),则跳过检查,避免无用开销。
- 通过优化vtable布局,使得同一个类层次里的虚函数地址更加集中,从而缩小bitset的大小,进一步提升效率。
总结就是:用位图(bitset)快速判断调用合法性,结合这些技巧减少性能损耗。
Clang/LLVM CFI 在 x86_64 汇编层的典型实现示例:
- All ones(允许所有函数调用):
- 只做最简单的范围检查,没用 bitset,直接允许调用。
- 如果偏移超范围,跳转到 CRASH。
- 否则调用虚函数指针。
- <= 64 bits(小于等于64位的 bitset 优化):
- 计算偏移(rax - base)
- 用 rol (rotate left) 对偏移做混淆处理,防止攻击。
- 比较偏移是否越界。
- 用
bt
指令检查偏移位是否在允许的 bitset 中。 - 如果检查失败,跳 CRASH。
- 成功则调用。
- Full check(完整的多字节 bitset 检查):
- 类似上面,但 bitset 比较大(64位)
- 用
testb
测试某个位是否允许 - 失败跳 CRASH。
CRASH
位置通过ud2
指令(非法指令)终止程序,防止利用漏洞继续执行。
这体现了 CFI 的实时动态检查,既保证安全,又通过优化减少性能损失。
Clang/LLVM CFI 的更多用法和实际应用情况,关键点总结如下:
更多 CFI 类型支持
- 非虚函数调用(non-virtual calls):用
-fsanitize=cfi-nvcall
做检查,防止非法非虚函数调用。 - C 风格间接调用(通过函数指针):
-fsanitize=cfi-icall
保障间接调用安全。 - 多态类型的类型转换:
- 基类到派生类转换(downcast):
-fsanitize=cfi-derived-cast
void*
转换为类指针:-fsanitize=cfi-unrelated-cast
这些能检测非法类型转换,避免潜在的类型混淆漏洞。
- 基类到派生类转换(downcast):
CFI 在 Chromium 里的使用情况
- 在 Linux 和 Android 下支持,开启了多种 CFI 检查项。
- macOS 和 Windows 平台支持正在完善中。
- 运行时开销极低,CPU 占用增加不到 1%。
- 代码体积增加大约 7%。
- 在迁移过程中发现并修复了很多真实的漏洞和错误。
更好的或不同的 CFI 方案
- 不强制需要 LTO(Link Time Optimization),有些方案允许模块边界跨 DSO(动态共享对象)界限做 CFI 检查。
- 例如 MSVC 的 Control Flow Guard(CFG)用
/d2guard4
和/Guard:cf
实现,支持跨模块保护。 - 不过跨 DSO 的 CFI 设计可能带来复杂性或安全隐患,需要权衡。
总结来说,CFI 技术正在不断完善,目标是以最小的性能和代码开销,实现对 C++ 虚函数调用、间接调用及类型转换的有效保护。Chromium 是实际应用中的典范案例。
威胁 #2:栈缓冲区溢出(stack-buffer-overflow),攻击者通过溢出栈上的缓冲区,覆盖了返回地址,从而控制程序流程,执行任意代码。
具体威胁
- 攻击者向局部数组写入超过其边界的数据
- 覆盖函数调用栈中的返回地址(ret address)
- 函数返回时跳转到攻击者指定的地址,执行恶意代码(典型的栈溢出攻击)
为什么严重?
- 返回地址是函数调用流程控制关键
- 一旦被覆盖,攻击者可劫持程序执行流
- 可造成任意代码执行、权限提升、系统破坏
常见防护措施
- 栈保护机制(Stack Canaries):在返回地址之前放置随机值,函数返回时校验,检测溢出
- 地址空间布局随机化(ASLR):随机化栈、堆和库的内存地址,难以猜测溢出目标地址
- 控制流保护(比如前面提到的 CFI):减少溢出后控制流被篡改的机会
- 现代编译器选项,如
-fstack-protector
系列 - 内存安全语言或工具辅助,比如使用 Sanitizers 检测溢出
SafeStack 的核心思想是 将局部变量和控制流数据(如返回地址)分开存放:
- 本地变量(特别是数组等可能溢出的数据)放在独立的、专门 mmap 的内存区域
- 返回地址、帧指针等保存在正常的栈上
- 这样即使发生栈缓冲区溢出,也无法覆盖返回地址,攻击者无法轻易劫持程序流程
SafeStack 特点
- 保护返回地址免受栈溢出攻击
- 但仍有部分隐患,比如虚函数表指针(vptr)和函数指针可能被覆盖
- 建议和 CFI(Control Flow Integrity) 结合使用,增强安全性
使用示例
clang++ -fsanitize=safe-stack your_code.cpp -o your_program
性能
- Chromium 实际测试 CPU 开销 < 1%,相当轻量
这段汇编代码展示了 SafeStack 在函数调用时如何操作“unsafe stack”(存放局部变量的安全外栈)指针:
int main() {int local_var = 0x123456;bar(&local_var);
}
push %r14
push %rbx
push %rax
mov 0x207d0d(%rip),%r14
mov %fs:(%r14),%rbx # Get unsafe_stack_ptr
lea -0x10(%rbx),%rax # Update unsafe_stack_ptr
mov %rax,%fs:(%r14) # Store unsafe_stack_ptr
lea -0x4(%rbx),%rdi
movl $0x123456,-0x4(%rbx)
callq 40f2c0 <_Z3barPi>
mov %rbx,%fs:(%r14) # Restore unsafe_stack_ptr
xor %eax,%eax
add $0x8,%rsp
pop %rbx
pop %r14
retq
mov %fs:(%r14),%rbx
:从线程局部存储(TLS)读取当前unsafe_stack_ptr
lea -0x10(%rbx),%rax
:在 unsafe stack 上为局部变量预留空间mov %rax,%fs:(%r14)
:更新 unsafe stack 指针,表示空间已分配movl $0x123456,-0x4(%rbx)
:把局部变量local_var = 0x123456
写入 unsafe stack- 调用函数
bar
,传入局部变量地址(指向 unsafe stack) - 返回时,恢复 unsafe stack 指针和调用栈指针,保护了正常栈上的返回地址不被局部变量覆盖
关键点: - 传统栈(
%rsp
) 仅存放返回地址、保存寄存器等,局部变量移至单独的 unsafe stack 区域 - 即使局部变量溢出也不会破坏返回地址
%fs:(%r14)
是一个线程局部存储段寄存器的偏移,存储 unsafe stack 指针(实现线程安全)
总结
- 传统测试容易让人产生“代码安全”错觉,但不能保证没有漏洞。
- 使用 Sanitizers(ASan、TSan、MSan、UBSan)可以快速发现基本的内存和线程问题。
- 借助引导型模糊测试(guided fuzzing),比如 LLVM libFuzzer 和 AFL-fuzz,可以更深入地挖掘隐藏的安全隐患和漏洞。
- 代码加固是进一步提升安全的关键手段,包括:
- **CFI(Control Flow Integrity)**保护虚函数调用、非虚成员调用、各种类型转换和间接调用,防止控制流被劫持。
- SafeStack隔离局部变量和返回地址,防止栈缓冲区溢出导致返回地址被篡改。