关于 harness-writing
harness-writing 面向编写模糊测试 harness 的开发者,说明如何将模糊测试器输入传递给应用代码。内容涵盖输入处理、结构化数据、确定性,以及 C/C++ 和 Rust 的 harness 模式,帮助可重复地测试目标 API。
主要功能
- 01选择合适的模糊测试入口
- 02处理输入长度并进行校验
- 03从字节中提取结构化数据
- 04在单个 harness 中测试相关操作
- 05重置状态并保持运行确定性
- 06管理模糊测试期间的资源
适用场景
- 01为解析器或校验器创建 harness
- 02测试需要结构化输入的 API
- 03在测试停滞时提升代码覆盖率
- 04确保崩溃能在不同运行中复现
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ trailofbits/ skills --skill harness-writing安装后在 Claude Code 中输入/harness-writing确认已加载。
附带文件agents/openai.yamlassets/trail-of-bits-mark.svg
详细介绍
编写模糊测试 Harness
模糊测试 Harness 是入口函数,用于接收模糊测试器生成的随机数据,并将其传递给被测系统(SUT)。Harness 的质量直接决定了哪些代码路径会被执行,以及能否发现关键漏洞。编写不当的 Harness 可能会遗漏整个子系统,或导致崩溃无法复现。
概述
Harness 是模糊测试器生成随机字节与应用程序 API 之间的桥梁。它必须将原始字节解析为有意义的输入、调用目标函数,并妥善处理边缘情况。任何模糊测试配置中最重要的部分都是 Harness——如果编写不当,应用程序的关键部分可能无法得到覆盖。
关键概念
| 概念 | 描述 |
|---|---|
| Harness | 接收模糊测试器输入并调用待测目标代码的函数 |
| SUT | 被测系统(System Under Test)——正在进行模糊测试的代码 |
| 入口点 | 模糊测试器要求的函数签名(例如 LLVMFuzzerTestOneInput) |
| FuzzedDataProvider | 用于从原始字节中按结构提取类型化数据的辅助类 |
| 确定性 | 确保相同输入始终产生相同行为的特性 |
| 交错式模糊测试 | 根据输入执行多个操作的单个 Harness |
适用时机
在以下情况下使用此技术:
- 首次创建新的模糊测试目标
- 模糊测试活动的代码覆盖率较低,或未能发现漏洞
- 模糊测试期间发现的崩溃无法复现
- 目标 API 需要复杂或结构化的输入
- 需要同时测试多个相关函数
在以下情况下跳过此技术:
- 项目中已有经过充分测试的 Harness
- 工具提供的自动 Harness 生成满足需求
- 目标已有完善的模糊测试基础设施
快速参考
| 任务 | 模式 |
|---|---|
| 最简 C++ Harness | extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) |
| 最简 Rust Harness | `fuzz_target!( |
| 大小验证 | if (size < MIN_SIZE) return 0; |
| 转换为整数 | uint32_t val = *(uint32_t*)(data); |
| 使用 FuzzedDataProvider | FuzzedDataProvider fuzzed_data(data, size); |
| 提取类型化数据(C++) | auto val = fuzzed_data.ConsumeIntegral<uint32_t>(); |
| 提取字符串(C++) | auto str = fuzzed_data.ConsumeBytesWithTerminator<char>(32, 0xFF); |
分步指南
第 1 步:识别入口点
在代码库中查找以下函数:
- 接受外部输入(解析器、验证器、协议处理程序)
- 解析复杂数据格式(JSON、XML、二进制协议)
- 执行安全关键操作(身份验证、加密)
- 圈复杂度高或分支众多
适合作为目标的通常包括:
- 协议解析器
- 文件格式解析器
- 序列化/反序列化函数
- 输入验证例程
第 2 步:编写最简 Harness
先编写尽可能简单的 Harness 来调用目标函数:
C/C++:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
target_function(data, size);
return 0;
}
Rust:
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
target_function(data);
});
第 3 步:添加输入验证
拒绝过小或过大、缺乏实际意义的输入:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// Ensure minimum size for meaningful input
if (size < MIN_INPUT_SIZE || size > MAX_INPUT_SIZE) {
return 0;
}
target_function(data, size);
return 0;
}
**原因:**模糊测试器会生成各种大小的随机输入。Harness 必须能够处理空输入、极小输入、超大输入或格式错误的输入,且不能导致 Harness 本身出现意外问题(SUT 中发生崩溃是可以的——这正是我们要寻找的问题)。
第 4 步:构造输入
对于需要类型化数据(整数、字符串等)的 API,可以使用类型转换或 FuzzedDataProvider 等辅助工具:
简单类型转换:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size != 2 * sizeof(uint32_t)) {
return 0;
}
uint32_t numerator = *(uint32_t*)(data);
uint32_t denominator = *(uint32_t*)(data + sizeof(uint32_t));
divide(numerator, denominator);
return 0;
}
使用 FuzzedDataProvider:
#include "FuzzedDataProvider.h"
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
FuzzedDataProvider fuzzed_data(data, size);
size_t allocation_size = fuzzed_data.ConsumeIntegral<size_t>();
std::vector<char> str1 = fuzzed_data.ConsumeBytesWithTerminator<char>(32, 0xFF);
std::vector<char> str2 = fuzzed_data.ConsumeBytesWithTerminator<char>(32, 0xFF);
concat(&str1[0], str1.size(), &str2[0], str2.size(), allocation_size);
return 0;
}
第 5 步:测试并迭代
运行模糊测试器并监控:
- 代码覆盖率(是否到达所有值得关注的路径?)
- 每秒执行次数(速度是否足够?)
- 崩溃可复现性(能否通过已保存的输入复现崩溃?)
迭代改进 Harness,提升这些指标。
常见模式
模式:超越字节数组——转换为整数
用例: 目标需要整数或浮点数等原始类型时
实现:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// Ensure exactly 2 4-byte numbers
if (size != 2 * sizeof(uint32_t)) {
return 0;
}
// Split input into two integers
uint32_t numerator = *(uint32_t*)(data);
uint32_t denominator = *(uint32_t*)(data + sizeof(uint32_t));
divide(numerator, denominator);
return 0;
}
Rust 等效实现:
fuzz_target!(|data: &[u8]| {
if data.len() != 2 * std::mem::size_of::<i32>() {
return;
}
let numerator = i32::from_ne_bytes([data[0], data[1], data[2], data[3]]);
let denominator = i32::from_ne_bytes([data[4], data[5], data[6], data[7]]);
divide(numerator, denominator);
});
原理: 任何 8 字节输入都有效。模糊测试器会学习到输入必须恰好为 8 字节,并且每次位翻转都会产生一个新的、可能有意义的输入。
模式:使用 FuzzedDataProvider 处理复杂输入
用例: 目标需要多个字符串、整数或可变长度数据时
实现:
#include "FuzzedDataProvider.h"
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
FuzzedDataProvider fuzzed_data(data, size);
// Extract different types of data
size_t allocation_size = fuzzed_data.ConsumeIntegral<size_t>();
// Consume variable-length strings with terminator
std::vector<char> str1 = fuzzed_data.ConsumeBytesWithTerminator<char>(32, 0xFF);
std::vector<char> str2 = fuzzed_data.ConsumeBytesWithTerminator<char>(32, 0xFF);
char* result = concat(&str1[0], str1.size(), &str2[0], str2.size(), allocation_size);
if (result != NULL) {
free(result);
}
return 0;
}
优点: FuzzedDataProvider 能处理从字节流中提取结构化数据的复杂工作。对于需要多个不同类型参数的 API,它尤其有用。
模式:交错式模糊测试
用例: 需要在单个测试框架中测试多个相关操作时
实现:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size < 1 + 2 * sizeof(int32_t)) {
return 0;
}
// First byte selects operation
uint8_t mode = data[0];
// Next bytes are operands
int32_t numbers[2];
memcpy(numbers, data + 1, 2 * sizeof(int32_t));
int32_t result = 0;
switch (mode % 4) {
case 0:
result = add(numbers[0], numbers[1]);
break;
case 1:
result = subtract(numbers[0], numbers[1]);
break;
case 2:
result = multiply(numbers[0], numbers[1]);
break;
case 3:
result = divide(numbers[0], numbers[1]);
break;
}
// Prevent compiler from optimizing away the calls
printf("%d", result);
return 0;
}
优点:
- 编写一个测试框架比编写多个独立的测试框架更快
- 单一共享语料库意味着对某个操作有意义的输入,对其他操作也可能有意义
- 能够发现操作之间交互产生的错误
适用情况:
- 操作使用相似的输入类型
- 操作在逻辑上相关(例如,算术操作、CRUD 操作)
- 所有操作共用一个语料库是合理的
模式:使用 Arbitrary 进行结构感知模糊测试(Rust)
用例: 对使用自定义结构体的 Rust 代码进行模糊测试时
实现:
use arbitrary::Arbitrary;
#[derive(Debug, Arbitrary)]
pub struct Name {
data: String
}
impl Name {
pub fn check_buf(&self) {
let data = self.data.as_bytes();
if data.len() > 0 && data[0] == b'a' {
if data.len() > 1 && data[1] == b'b' {
if data.len() > 2 && data[2] == b'c' {
process::abort();
}
}
}
}
}
使用 arbitrary 的测试框架:
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: your_project::Name| {
data.check_buf();
});
添加到 Cargo.toml:
[dependencies]
arbitrary = { version = "1", features = ["derive"] }
优点: arbitrary crate 会自动将原始字节反序列化为 Rust 结构体,减少样板代码,并确保结构体构造有效。
局限性: arbitrary crate 不支持反向序列化,因此无法手动构造映射到特定结构体的字节数组。它最适合从空语料库开始使用(适用于 libFuzzer,但对 AFL++ 来说有问题)。
高级用法
提示与技巧
| 提示 | 优点 |
|---|---|
| 从解析器开始 | 错误密度高,入口点明确,易于构建测试框架 |
| 模拟 I/O 操作 | 防止阻塞 I/O 导致挂起,并确保确定性 |
| 使用 FuzzedDataProvider | 简化从原始字节中提取结构化数据的过程 |
| 重置全局状态 | 确保每次迭代彼此独立且可复现 |
| 在测试框架中释放资源 | 防止长时间运行的测试活动耗尽内存 |
| 避免在测试框架中记录日志 | 日志速度慢——模糊测试需要达到每秒数百至数千次执行 |
| 先手动测试测试框架 | 启动测试活动前,先用已知输入运行测试框架 |
| 尽早检查覆盖率 | 确保测试框架能够到达预期的代码路径 |
使用 Protocol Buffers 进行结构感知模糊测试
对于高度结构化的输入格式,可以考虑使用 Protocol Buffers 作为中间格式,并配合自定义变异器:
// Define your input format in .proto file
// Use libprotobuf-mutator to generate valid mutations
// This ensures fuzzer mutates message contents, not the protobuf encoding itself
这种方法需要更多设置,但能防止模糊测试器在无法解析的输入上浪费时间。详情请参阅结构感知模糊测试文档。
处理非确定性
问题: 随机值或时间依赖会导致崩溃无法复现。
解决方案:
- 将
rand()替换为由模糊测试器输入设定种子的确定性 PRNG:uint32_t seed = fuzzed_data.ConsumeIntegral<uint32_t>(); srand(seed); - 模拟返回时间、PID 或随机数据的系统调用
- 避免从
/dev/random或/dev/urandom读取数据
重置全局状态
如果 SUT 使用全局状态(单例、静态变量),请在每次迭代之间重置它:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// Reset global state before each iteration
global_reset();
target_function(data, size);
// Clean up resources
global_cleanup();
return 0;
}
理由: 全局状态可能导致程序在迭代 N 次后崩溃,而不是由特定输入触发,从而使错误无法复现。
实用 Harness 规则
遵循以下规则,确保模糊测试 Harness 有效:
| 规则 | 原因 |
|---|---|
| 处理所有输入大小 | Fuzzer 会生成空、小型和超大输入,Harness 必须能够妥善处理 |
绝不调用 exit() | 调用 exit() 会停止 Fuzzer 进程。如有需要,可在 SUT 中使用 abort() |
| 等待所有线程结束 | 每次迭代都必须在下一次迭代开始前运行完毕 |
| 保持快速 | 目标是每秒执行 100 到 1000 次。避免日志记录、高复杂度和过度占用内存 |
| 保持确定性 | 相同输入必须始终产生相同行为,以确保结果可复现 |
| 避免使用全局状态 | 全局状态会降低可复现性;如无法避免,请在迭代之间重置 |
| 使用范围明确的目标 | 不要在同一个 Harness 中同时对 PNG 和 TCP 进行模糊测试;不同格式需要单独的目标 |
| 释放资源 | 防止内存泄漏在长时间测试活动中导致资源耗尽 |
**注意:**这些准则不仅适用于 Harness 代码,也适用于整个 SUT。如果 SUT 违反这些规则,请考虑对其进行修补(参见模糊测试障碍技巧)。
反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 未重置全局状态 | 导致崩溃具有非确定性 | 在 Harness 开始时重置所有全局状态 |
| 阻塞式 I/O 或网络调用 | 导致 Fuzzer 挂起、浪费时间 | 模拟 I/O,使用内存缓冲区 |
| Harness 中存在内存泄漏 | 资源耗尽会终止测试活动 | 返回前释放所有分配的内存 |
在 SUT 中调用 exit() | 停止整个模糊测试进程 | 使用 abort() 或返回错误代码 |
| Harness 中有大量日志记录 | 每秒执行次数会大幅下降 | 在模糊测试期间禁用日志记录 |
| 每次迭代执行过多操作 | 降低 Fuzzer 速度 | 保持迭代快速且专注 |
| 混合无关的输入格式 | Corpus 条目无法跨格式复用 | 为不同格式创建单独的 Harness |
| 未验证输入大小 | Harness 会在边界情况下崩溃 | 访问 data 前检查 size |
特定工具指南
libFuzzer
Harness 签名:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// Your code here
return 0; // Non-zero return is reserved for future use
}
编译:
clang++ -fsanitize=fuzzer,address -g harness.cc -o fuzz_target
集成提示:
- 使用
FuzzedDataProvider.h提取结构化输入 - 使用
-fsanitize=fuzzer编译,以链接模糊测试运行时 - 添加 Sanitizer(
-fsanitize=address,undefined)以检测更多错误 - 使用
-g,以便在发生崩溃时获得更好的堆栈跟踪 - libFuzzer 可以从空的 Corpus 开始,无需种子输入
运行:
./fuzz_target corpus_dir/
资源:
AFL++
AFL++ 支持多种 Harness 风格。为获得最佳性能,请使用持久模式:
持久模式 Harness:
#include <unistd.h>
int main(int argc, char **argv) {
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_INIT();
#endif
unsigned char buf[MAX_SIZE];
while (__AFL_LOOP(10000)) {
// Read input from stdin
ssize_t len = read(0, buf, sizeof(buf));
if (len <= 0) break;
// Call target function
target_function(buf, len);
}
return 0;
}
编译:
afl-clang-fast++ -g harness.cc -o fuzz_target
集成提示:
- 使用持久模式(
__AFL_LOOP),可提升 10 到 100 倍速度 - 可考虑延迟初始化(
__AFL_INIT()),以减少初始化开销 - AFL++ 要求 Corpus 目录中至少有一个种子输入
- 使用
AFL_USE_ASAN=1或AFL_USE_UBSAN=1构建 Sanitizer 版本
运行:
afl-fuzz -i seeds/ -o findings/ -- ./fuzz_target
cargo-fuzz(Rust)
Harness 签名:
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// Your code here
});
使用结构化输入(arbitrary crate):
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: YourStruct| {
data.check();
});
创建 Harness:
cargo fuzz init
cargo fuzz add my_target
集成提示:
- 使用
arbitrarycrate 自动反序列化结构体 - cargo-fuzz 封装了 libFuzzer,因此 libFuzzer 的所有功能都可用
- cargo-fuzz 会自动使用 Sanitizer 编译
- Harness 放在
fuzz/fuzz_targets/目录中
运行:
cargo +nightly fuzz run my_target
资源:
go-fuzz
Harness 签名:
// +build gofuzz
package mypackage
func Fuzz(data []byte) int {
// Call target function
target(data)
// Return codes:
// -1 if input is invalid
// 0 if input is valid but not interesting
// 1 if input is interesting (e.g., added new coverage)
return 0
}
构建:
go-fuzz-build
集成提示:
- 对能增加覆盖率的输入返回 1(可选,模糊测试器可以自动检测)
- 对无效输入返回 -1,以降低类似变异的优先级
- go-fuzz 会自动处理持久化
运行:
go-fuzz -bin=./mypackage-fuzz.zip -workdir=fuzz
故障排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 每秒执行次数较低 | Harness 速度过慢(日志、I/O、复杂度) | 对 Harness 进行性能分析,移除瓶颈,模拟 I/O |
| 未发现崩溃 | 覆盖率未触及有问题的代码 | 检查覆盖率,改进 Harness 以触及更多路径 |
| 崩溃无法复现 | 存在非确定性或全局状态 | 移除随机性,在每次迭代之间重置全局变量 |
| 模糊测试器立即退出 | Harness 调用了 exit() | 将 exit() 替换为 abort() 或返回错误 |
| 内存不足错误 | Harness 或 SUT 存在内存泄漏 | 释放分配的内存,使用泄漏检测器查找泄漏 |
| 空输入时崩溃 | Harness 未验证大小 | 添加 if (size < MIN_SIZE) return 0; |
| 语料库未增长 | 输入限制过多或格式过于严格 | 使用 FuzzedDataProvider 或结构感知模糊测试 |
相关技能
使用此技术的工具
| 技能 | 应用方式 |
|---|---|
| libfuzzer | 使用带有 FuzzedDataProvider 的 LLVMFuzzerTestOneInput Harness 签名 |
| aflpp | 支持使用 __AFL_LOOP 的持久模式 Harness,以提升性能 |
| cargo-fuzz | 使用 Rust 专用的 fuzz_target! 宏,并集成 arbitrary crate |
| atheris | Python Harness 接收字节并调用 Python 函数 |
| ossfuzz | 要求 Harness 遵循特定目录结构,以支持云端模糊测试 |
相关技术
| 技能 | 关联 |
|---|---|
| coverage-analysis | 衡量 Harness 的有效性——是否触及目标代码? |
| address-sanitizer | 检测 Harness 发现的错误(缓冲区溢出、释放后使用) |
| fuzzing-dictionary | 提供令牌,帮助模糊测试器通过 Harness 中的格式检查 |
| fuzzing-obstacles | 当 SUT 违反 Harness 规则时(退出、非确定性),对其进行修补 |
资源
重要外部资源
libFuzzer 中的输入拆分 - Google 模糊测试文档 介绍如何在单个模糊测试 Harness 中处理多个输入参数,包括使用魔数分隔符和 FuzzedDataProvider。
使用 Protocol Buffers 进行结构感知模糊测试 一种高级技术,将 protobuf 用作中间格式,并通过自定义变异器确保模糊测试器变异的是消息内容,而不是格式编码。
libFuzzer 文档 LLVM 官方文档,涵盖 Harness 要求、最佳实践和高级功能。
cargo-fuzz 手册 使用 cargo-fuzz 和 arbitrary crate 编写 Rust 模糊测试 Harness 的综合指南。
视频资源
- 有效的文件格式模糊测试 - 关于为文件格式解析器编写 Harness 的会议演讲
- C/C++ 项目的现代模糊测试 - 介绍 Harness 设计模式的教程