name: harness-writing type: technique description: > 跨语言编写有效模糊测试 harness 的技术。 在创建新的模糊目标或改进现有 harness 代码时使用。
编写模糊测试 Harnesses
模糊测试 harness 是接收来自模糊测试器的随机数据并将其路由到被测系统(SUT)的入口点函数。harness 的质量直接决定了哪些代码路径被测试以及是否找到关键漏洞。编写不良的 harness 可能错过整个子系统或产生不可复现的崩溃。
概述
harness 是模糊测试器随机字节生成与应用 API 之间的桥梁。它必须将原始字节解析为有意义的输入,调用目标函数,并优雅地处理边缘情况。任何模糊测试设置中最重要的部分是 harness——如果编写不当,应用程序的关键部分可能无法覆盖。
关键概念
| 概念 | 描述 |
|---|---|
| Harness | 接收模糊测试器输入并调用被测代码的函数 |
| SUT | 被测系统——被模糊测试的代码 |
| 入口点 | 模糊测试器所需的函数签名(如 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) {
// 确保最小大小以提供有意义的输入
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) {
// 确保正好是 2 个 4 字节数字
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;
}
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);
// 提取不同类型的数据
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);
char* result = concat(&str1[0], str1.size(), &str2[0], str2.size(), allocation_size);
if (result != NULL) {
free(result);
}
return 0;
}
为什么有帮助: FuzzedDataProvider 处理从字节流提取结构化数据的复杂性。对于需要多个不同类型参数的 API 特别有用。
模式:交错模糊测试
使用场景: 当应在单个 harness 中测试多个相关操作时
实现:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size < 1 + 2 * sizeof(int32_t)) {
return 0;
}
// 第一个字节选择操作
uint8_t mode = data[0];
// 接下来的字节是操作数
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;
}
// 防止编译器优化掉调用
printf("%d", result);
return 0;
}
优势:
- 编写一个 harness 比多个单独 harness 更快
- 单一共享语料库意味着一项操作的感兴趣输入可能对其他操作也有趣
- 可以发现操作之间交互的漏洞
何时使用:
- 操作共享相似输入类型
- 操作逻辑相关(如算术运算、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 的 harness:
#![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 库自动处理将原始字节反序列化为您的 Rust 结构,减少样板代码并确保有效结构构造。
限制: arbitrary 库不提供反向序列化,因此您无法手动构造映射到特定结构的字节数组。这最好从空语料库开始(对 libFuzzer 很好,对 AFL++ 有问题)。
高级用法
技巧和窍门
| 技巧 | 为什么有帮助 |
|---|---|
| 从解析器开始 | 漏洞密度高,入口点清晰,易于 harness |
| 模拟 I/O 操作 | 防止阻塞 I/O 导致挂起,实现确定性 |
| 使用 FuzzedDataProvider | 简化从原始字节提取结构化数据 |
| 重置全局状态 | 确保每次迭代独立且可复现 |
| 在 harness 中释放资源 | 防止长期活动中内存耗尽 |
| 避免在 harness 中记录日志 | 记录日志慢——模糊测试需要每秒 100-1000 次执行 |
| 首先手动测试 harness | 在启动活动前用已知输入运行 harness |
| 早期检查覆盖率 | 确保 harness 到达预期代码路径 |
使用 Protocol Buffers 的结构感知模糊测试
对于高度结构化的输入格式,考虑使用 Protocol Buffers 作为中间格式与自定义突变器:
// 在 .proto 文件中定义输入格式
// 使用 libprotobuf-mutator 生成有效突变
// 确保模糊测试器突变消息内容,而非 protobuf 编码本身
这种方法设置更多,但防止模糊测试器浪费时间在不可解析输入上。参见结构感知模糊测试文档获取详情。
处理非确定性
问题: 随机值或时间依赖性导致不可复现崩溃。
解决方案:
- 用从模糊测试器输入种子的确定性 PRNG 替换
rand():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) {
// 每次迭代前重置全局状态
global_reset();
target_function(data, size);
// 清理资源
global_cleanup();
return 0;
}
原理: 全局状态可能导致 N 次迭代后崩溃而非特定输入,使漏洞不可复现。
实用 Harness 规则
遵循这些规则以确保有效模糊测试 harness:
| 规则 | 原理 |
|---|---|
| 处理所有输入大小 | 模糊测试器生成空、极小、极大输入——harness 必须优雅处理 |
永不调用 exit() |
调用 exit() 停止模糊测试器进程。如需,在 SUT 中使用 abort() |
| 加入所有线程 | 每次迭代必须完成后再开始下一次迭代 |
| 快速 | 目标每秒 100-1000 次执行。避免记录日志、高复杂性、过多内存 |
| 保持确定性 | 相同输入必须始终产生相同行为以确保可复现性 |
| 避免全局状态 | 全局状态降低可复现性——如不可避免,在迭代间重置 |
| 使用狭窄目标 | 不在同一 harness 中模糊测试 PNG 和 TCP——不同格式需要单独目标 |
| 释放资源 | 防止长期活动中内存泄漏导致资源耗尽 |
注意: 这些指南不仅适用于 harness 代码,还适用于整个 SUT。如果 SUT 违反这些规则,考虑修补它(参见模糊测试障碍技术)。
反模式
| 反模式 | 问题 | 正确方法 |
|---|---|---|
| 全局状态不重置 | 非确定性崩溃 | 在 harness 开始时重置所有全局变量 |
| 阻塞 I/O 或网络调用 | 挂起模糊测试器,浪费时间 | 模拟 I/O,使用内存缓冲区 |
| harness 中内存泄漏 | 资源耗尽杀死活动 | 返回前释放所有分配 |
SUT 中调用 exit() |
停止整个模糊测试过程 | 使用 abort() 或返回错误代码 |
| harness 中大量记录日志 | 减少每秒执行次数多个数量级 | 在模糊测试期间禁用记录日志 |
| 每次迭代太多操作 | 减慢模糊测试器 | 保持迭代快速且专注 |
| 混合不相关输入格式 | 语料库条目跨格式无用 | 不同格式使用单独 harness |
| 未验证输入大小 | harness 在边缘情况崩溃 | 访问 data 前检查 size |
工具特定指导
libFuzzer
Harness 签名:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// 您的代码在此处
return 0; // 非零返回值保留供将来使用
}
编译:
clang++ -fsanitize=fuzzer,address -g harness.cc -o fuzz_target
集成技巧:
- 使用
FuzzedDataProvider.h提取结构化输入 - 使用
-fsanitize=fuzzer编译以链接模糊测试运行时 - 添加 sanitizer(
-fsanitize=address,undefined)以检测更多漏洞 - 使用
-g在崩溃时提供更好堆栈跟踪 - libFuzzer 可以从空语料库开始——无需种子输入
运行:
./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)) {
// 从标准输入读取输入
ssize_t len = read(0, buf, sizeof(buf));
if (len <= 0) break;
// 调用目标函数
target_function(buf, len);
}
return 0;
}
编译:
afl-clang-fast++ -g harness.cc -o fuzz_target
集成技巧:
- 使用持久模式(
__AFL_LOOP)获得 10-100 倍加速 - 考虑延迟初始化(
__AFL_INIT())以跳过设置开销 - AFL++ 要求语料库目录中至少一个种子输入
- 使用
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]| {
// 您的代码在此处
});
带结构化输入(arbitrary 库):
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: YourStruct| {
data.check();
});
创建 harness:
cargo fuzz init
cargo fuzz add my_target
集成技巧:
- 使用
arbitrary库自动结构反序列化 - 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 {
// 调用目标函数
target(data)
// 返回代码:
// -1 如果输入无效
// 0 如果输入有效但不有趣
// 1 如果输入有趣(如添加新覆盖)
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() |
用 abort() 替换 exit() 或返回错误 |
| 内存不足错误 | harness 或 SUT 中内存泄漏 | 释放分配,使用泄漏 sanitizer 查找泄漏 |
| 空输入崩溃 | harness 未验证大小 | 添加 if (size < MIN_SIZE) return 0; |
| 语料库不增长 | 输入约束太多或格式太严格 | 使用 FuzzedDataProvider 或结构感知模糊测试 |
相关技能
使用此技术的工具
| 技能 | 如何应用 |
|---|---|
| libfuzzer | 使用带 FuzzedDataProvider 的 LLVMFuzzerTestOneInput harness 签名 |
| aflpp | 支持带 __AFL_LOOP 的持久模式 harness 以提升性能 |
| cargo-fuzz | 使用 Rust 特定 fuzz_target! 宏与 arbitrary 库集成 |
| 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 书 编写 Rust 模糊测试 harness 的全面指南,与 cargo-fuzz 和 arbitrary 库配合。
视频资源
- 有效文件格式模糊测试 - 关于编写文件格式解析器 harness 的会议演讲
- C/C++ 项目的现代模糊测试 - 涵盖 harness 设计模式的教程