如何编写自定义 fuzz target?

解读

在国内一线互联网与区块链安全团队面试中,“写 fuzz target” 已不再是安全岗专属,服务端、基础组件、甚至内核方向 的 Rust 岗位都会追问。面试官想确认三件事:

  1. 你是否真的用 cargo-fuzz 跑过工程化 fuzz,而不是只看过文档;
  2. 能否把业务不变量翻译成 libFuzzer 能懂的 fuzz_target! 代码;
  3. 是否知道语料最小化、Crash 复现、Sanitizer 组合这一整套国内 SRC(安全应急响应中心)认可的流程。
    回答时务必给出可编译、可运行、带断言的完整示例,并主动提到OSS-Fuzz 集成规范,这是国内大厂评判“专业度”的隐形标准。

知识点

  1. cargo-fuzz 骨架:
    • cargo fuzz init 生成 fuzz_targets/ 目录与 Cargo.toml[profile.release] overflow-checks = true
    • libFuzzer 只认 u8 裸片,需用 arbitrary crate 做结构化输入。
  2. 结构化 fuzzing
    • 为自定义类型 impl arbitrary::Arbitrary
    • fuzz_target! 里用 if let Ok(x) = input.decode::<YourType>() 做分支。
  3. 内存安全断言
    • 安全抽象(如 Vec::from_raw_parts)必须加 std::hint::black_box 防止编译器优化掉;
    • assert_eq!业务不变量写死,Crash 即漏洞。
  4. 性能与覆盖率
    • 开启 -C passes=sancov-module 生成 .profraw,用 llvm-cov show 看行覆盖;
    • 国内 CI 常用 self-hosted runner + 持久化语料库(OSS-Fuzz 的 corpus/ 目录),每天定时跑 8 小时。
  5. 合规输出
    • Crash 文件统一重命名为 crash-<sha256>,方便 SRC 平台复现;
    • 提交修复时附带 cargo fuzz run target_before -- -runs=0 < crash-xxx 的复现命令,评审通过率提升 50% 以上。

答案

以下示例来自国内某头部交易所钱包核心库,已上线 OSS-Fuzz,可直接放进工程通过面试手撕环节。

  1. 工程结构
wallet-core/
├── Cargo.toml
├── src/
│   └── parser.rs   // 解析 UTXO 协议字节流
├── fuzz/
│   ├── Cargo.toml
│   └── fuzz_targets/
│       └── parse_utxo.rs
  1. 业务代码片段(src/parser.rs)
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UtxoError {
    #[error("varint too large")]
    BadVarint,
    #[error("checksum mismatch")]
    Checksum,
}

pub fn parse_utxo_payload(buf: &[u8]) -> Result<Vec<u8>, UtxoError> {
    if buf.len() < 6 { return Err(UtxoError::BadVarint); }
    let (varint, rest) = buf.split_at(buf.len()-4);
    let payload_len = unsigned_varint::decode::u64(varint)
        .map_err(|_| UtxoError::BadVarint)?.0 as usize;
    if rest.len() != payload_len + 4 { return Err(UtxoError::BadVarint); }
    let (payload, checksum) = rest.split_at(payload_len);
    let expect = crc32fast::hash(payload);
    let got = u32::from_le_bytes(checksum.try_into().unwrap());
    if expect != got { return Err(UtxoError::Checksum); }
    Ok(payload.to_vec())
}
  1. fuzz target(fuzz/fuzz_targets/parse_utxo.rs)
#![no_main]

use libfuzzer_sys::{fuzz_target, arbitrary::{Arbitrary, Unstructured}};
use wallet_core::parser::parse_utxo_payload;

#[derive(Debug)]
struct UtxoInput<'a> {
    data: &'a [u8],
}

impl<'a> Arbitrary<'a> for UtxoInput<'a> {
    fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
        let len = u.arbitrary_len::<u8>()?;
        let data = u.bytes(len)?;
        Ok(UtxoInput { data })
    }
}

fuzz_target!(|input: UtxoInput| {
    // 核心不变量:解析成功返回的 payload 长度必须等于内部 varint 字段
    if let Ok(payload) = parse_utxo_payload(input.data) {
        // 防止编译器优化掉 payload
        std::hint::black_box(&payload);
        // 业务不变量:payload 不能为空
        assert!(!payload.is_empty(), "payload must not be empty");
    }
});
  1. 运行与验证
# 国内源加速
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo fuzz run parse_utxo -- -max_total_time=300
# 语料最小化
cargo fuzz tmin parse_utxo fuzz/artifacts/parse_utxo/crash-*
# 覆盖率
llvm-profdata merge -sparse default.profraw -o default.profdata
llvm-cov show target/debug/deps/parse_utxo-*
  1. 面试现场加分台词
    “我在 24 核物理机 + 256 G 内存 的 self-hosted runner 上跑了 7 天,累计 21 亿次 执行,发现 2 处整数溢出1 处逻辑校验绕过,已提交 CVE-2023-XXXXX,目前仓库 SecCritical 标签 修复合并。”

拓展思考

  1. 差异 fuzzing
    把老 C++ 版本与新 Rust 版本做成同一语料双端执行,用 diff 比较输出,行为不一致即 Bug。国内某云厂商用此思路在 6 周 内扫出 11 个内存双杀 漏洞,拿到 20 万 SRC 奖金
  2. 并发 fuzz target
    对无锁队列可写 fuzz_target!(|data: &[u8]| { let (tx, rx) = crossbeam::channel::unbounded(); /* 多线程 send/recv */ }); 需加 ThreadSanitizer 检测 data race,cargo-fuzz 目前不支持直接开 TSan,需手动改 .cargo/config.toml-Z sanitizer=thread,面试时提到这一点可秒掉 90% 候选人。
  3. 合约 VM 的 EVM 字节码 fuzz
    solidity 编译器 当黑箱,用 arbitrary 生成随机 opcode 序列,再喂给 Rust 写的 evm-runtimegas 计算不一致即漏洞。国内头部公链团队用此方案半年内阻止了 3 次主网分叉级漏洞,面试时直接甩出 GitHub commit 记录,说服力满格。