Foundry EVM Sancov:用 LLVM SanitizerCoverage 为 Rust 原生代码做覆盖率引导模糊测试
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
本文围绕crates/evm/sancov子包的 README(crates/evm/sancov/README.md)展开,讲解 Foundry 中一套针对原生 Rust 代码(预编译实现、revm 内部逻辑等)的覆盖率引导模糊测试机制:通过RUSTC_WRAPPER注入 LLVM SanitizerCoverage 插桩,在模糊测试时用原生代码的边覆盖(edge coverage)指导变异,并把比较运算的操作数(trace-cmp)注入模糊词典以"破解"比较守卫。读完本文,你可以完整掌握sancov_edges、sancov_trace_cmp、corpus_dir三个配置项的语义与组合行为、sancov 构建包装脚本的编写方式、覆盖率回调的底层实现(__sanitizer_cov_trace_pc_guard等),以及如何用forge test --showmap-out把持久化语料回放为 AFL 风格的覆盖率文件。
1. 解决什么问题:EVM 字节码覆盖之外,还要"看见" Rust 原生代码
Foundry 的模糊/不变量测试默认以EVM 字节码层面的边覆盖作为引导信号。但一笔交易在执行过程中还会经过大量原生 Rust 代码——预编译合约(precompile)的实现、revm 内部的处理逻辑等。这些代码中的边界检查、余额校验、溢出守卫,对 EVM 边覆盖来说是"黑盒"。
foundry-evm-sancov(crates/evm/sancov/Cargo.toml,包描述即 "SanitizerCoverage callbacks for coverage-guided fuzzing of native Rust code")提供两类能力:
- 边覆盖采集:实现 LLVM SanitizerCoverage 回调,把插桩后 Rust 代码的 CFG 边命中记录到一个由模糊执行器提供的缓冲区,作为变异引导信号;
- 比较操作数采集(trace-cmp):捕获插桩代码中比较指令的操作数,交给模糊器的词典,帮助它求解比较守卫(如余额检查、溢出保护等分支条件)。
按 README 的说法,其使用前提是:forge 必须以某种能注入 sancov 编译标志的RUSTC_WRAPPER重新构建。只有经过插桩的 crate 才会触发这些回调,因此运行时不需要额外过滤(crates/evm/sancov/src/lib.rs 第 11~12 行的模块文档同样强调 "Only crates compiled with sancov instrumentation … will trigger these callbacks — no runtime filtering needed")。
2. 配置项语义:sancov_edges、sancov_trace_cmp与corpus_dir的组合行为
README 给出的最小配置如下([invariant]段,fuzz 测试同理对应[fuzz]段):
[invariant] sancov_edges = true sancov_trace_cmp = true corpus_dir = "corpus/invariant"这三个开关的行为在 crates/config/src/fuzz.rs 的FuzzCorpusConfig中有精确实现:
| 配置项 | 默认值 | 作用 |
|---|---|---|
corpus_dir | None | 语料目录;一旦设置即开启覆盖引导模糊测试(is_coverage_guided()),产生新覆盖的序列会被持久化并参与变异 |
sancov_edges | false | 从 SanitizerCoverage 插桩的原生 crate 采集边覆盖,作为引导信号 |
sancov_trace_cmp | false | 从插桩 crate 捕获比较操作数并注入模糊词典;独立于sancov_edges |
配置项对应的判定函数(crates/config/src/fuzz.rs):
collect_edge_coverage():corpus_dir已设置、show_edge_coverage或sancov_edges三者任一成立即需要采集某种边覆盖;collect_evm_edge_coverage():!sancov_edges && (corpus_dir.is_some() || show_edge_coverage)。这就是 README 中"开启sancov_edges时 EVMEdgeCovInspector被自动禁用"的出处——源码注释解释其理由为:sancov 已提供覆盖信号,Solidity 处理侧的 EVM 命中只会稀释信号。注意仅 trace-cmp 模式不会禁用 EVM 边覆盖,因为 trace-cmp 只贡献词典条目、不提供边覆盖;collect_evm_cmp_log():EVM 比较操作数采集同样在sancov_edges开启时被禁用(分支前沿frontier_dir的采集不受影响,因为它面向 Solidity 字节码分支);sancov_active():任一 sancov 模式开启即为真。
README 中的三句话配置语义可以精确概括为:
sancov_edges = true→ EVM 边覆盖被替换为 sancov 边覆盖(EdgeCovInspector自动禁用);corpus_dir已设置且sancov_edges关闭 → 自动采集 EVM 边覆盖与 EVM 比较操作数,用于覆盖引导模糊测试(这是不带插桩时的普通覆盖引导模式);sancov_trace_cmp = true→ 只额外从插桩原生代码追加比较操作数到词典,与边覆盖采集正交。
3. 回调实现:边覆盖如何被记录
foundry-evm-sancov的全部实现集中在单文件 crates/evm/sancov/src/lib.rs,其结构与 LLVM SanitizerCoverage 的约定一一对应:
3.1 边覆盖(pc-guard)回调
LLVM 插桩后,每条 CFG 边对应一个u32guard 槽位,运行时在每个插桩点调用__sanitizer_cov_trace_pc_guard。lib.rs 中:
__sanitizer_cov_trace_pc_guard_init(start, stop)(lib.rs#L95-L104):程序启动时由 SanitizerCoverage 运行时调用,把[start, stop)的 guard 槽位依次写入从 1 开始的编号(GUARD_COUNTER),使每个 guard 获得稳定 ID;__sanitizer_cov_trace_pc_guard(guard)(lib.rs#L111-L118):每次边命中时读取 guard 中的 ID 并调用record_hit。
record_hit(lib.rs#L44-L82)的设计兼顾热路径性能:
- 全局
COVERAGE_MAP_PTR/LEN(AtomicPtr/AtomicUsize)指向执行器提供的命中缓冲区;若未激活则直接返回; - 快路径:持读锁查
GUARD_LOOKUP(guard ID → 稠密索引的映射); - 慢路径:持写锁为新 guard ID 分配稠密索引(
NEXT_SANCOV_IDX.fetch_add); - 对缓冲区中对应槽位执行
wrapping_add(1)计数。
执行器侧通过三个 API 管理这个缓冲区:set_coverage_map(ptr, len)激活、clear_coverage_map()停用、is_active()查询;sancov_edge_count()返回目前已发现的唯一 sancov 边数(lib.rs#L21-L35、lib.rs#L84-L87)。
3.2 比较操作数(trace-cmp)回调
trace-cmp 部分暴露__sanitizer_cov_trace_cmp1/2/4/8、__sanitizer_cov_trace_const_cmp1/2/4/8以及__sanitizer_cov_trace_switch共 9 个回调(lib.rs#L179-L258),全部汇入record_cmp:
- 只在
is_active()(即 trace-cmp 模式开启)时记录;arg1 == 0 && arg2 == 0直接丢弃; - 每条线程用
thread_local!的CMP_OPERANDS缓冲,上限MAX_CMP_OPERANDS = 512; - 操作数以大端、右对齐方式写入 32 字节缓冲(
buf[24..]),CmpSample { width, value }携带原始比较位宽(8/16/32/64);arg2仅在非零且与arg1不同时才记录,去重冗余; switch回调中cases[0]为分支数、cases[2..]为各 case 常量,回调对最多 16 个 case 常量逐一与比较值val配对记录,帮助模糊器命中多路分支。
采集到的样本由drain_cmp_operands()一次性取出(mem::take)、clear_cmp_operands()清空(lib.rs#L166-L177)。
3.3 执行器侧接线
模糊执行器(crates/evm/evm的 executors)把两条线索串起来:
- 每次执行调用前,若
sancov_edges || sancov_trace_cmp,创建 RAII 的SancovGuard(sancov::SancovGuard::new(...))来激活/设置覆盖缓冲与 trace-cmp 捕获(crates/evm/evm/src/executors/mod.rs 等处,fuzz 与 invariant 的多个执行路径均如此); - 调用结果结构携带
sancov_coverage: Option<Vec<u8>>(边命中缓冲)与sancov_cmp_values: Option<Vec<CmpSample>>(比较操作数),并提供merge_sancov_coverage等方法把命中并入历史图(executors/mod.rs、#L1640-L1676); - 不变量执行器随后把 sancov trace-cmp 操作数按类型注入模糊词典(crates/evm/evm/src/executors/invariant/mod.rs:
if let Some(cmp_values) = &call_result.sancov_cmp_values { ... }); crates/forge/src/runner.rs在装配 fuzz/invariant 执行器时透传配置:executor.inspector_mut().collect_sancov_edges(fuzz_config.corpus.collect_sancov_edges())等(crates/forge/src/runner.rs、#L4044-L4047);- 语料回放阶段,若请求了 sancov 覆盖却未观察到任何命中,runner 会给出提示:"sancov coverage requested but no hits observed (build is likely not sancov-instrumented)"(crates/forge/src/runner.rs)——这是判断"构建时是否真的插桩成功"的实用信号。
4. 构建方法:编写注入 sancov 标志的RUSTC_WRAPPER
README 给出的完整包装脚本(可按需把your_target_crate替换为你要插桩的 crate,如预编译实现所在 crate):
#!/usr/bin/env bash RUSTC="$1"; shift CRATE_NAME="" PREV="" for arg in "$@"; do [ "$PREV" = "--crate-name" ] && CRATE_NAME="$arg" && break PREV="$arg" done if [ "$CRATE_NAME" = "your_target_crate" ]; then exec "$RUSTC" "$@" \ -Cpasses=sancov-module \ -Cllvm-args=-sanitizer-coverage-level=3 \ -Cllvm-args=-sanitizer-coverage-trace-pc-guard \ -Cllvm-args=-sanitizer-coverage-trace-compares else exec "$RUSTC" "$@" fi脚本要点:
- 从
cargo透传的编译参数中解析--crate-name,只对目标 crate 追加插桩参数,其余 crate 原样编译,控制插桩体积与开销; -Cpasses=sancov-module启用 LLVM 的 SanitizerCoverage 模块路径;-sanitizer-coverage-level=3是最高覆盖粒度;-sanitizer-coverage-trace-pc-guard产生第 3.1 节的 pc-guard 回调(对应sancov_edges);-sanitizer-coverage-trace-compares产生第 3.2 节的比较回调(对应sancov_trace_cmp依赖的__sanitizer_cov_trace_cmp*系列)。
然后构建:
RUSTC_WRAPPER=./sancov-wrapper.sh cargo build --profile fuzz --bin forge注意事项:
- 插桩必须发生在forge 二进制自身的构建期(而不是被测 Solidity 项目),因为回调函数被链接进 forge;运行时若回调无人实现,插桩代码无法工作;
- 只有经过插桩的 crate 会产生回调命中,未插桩构建上运行
--showmap-domain sancov会得到空结果并伴随告警(见 docs/dev/showmap.md 的 Caveats); - 运行期配置与构建期插桩相互独立:
sancov_edges打开但二进制未插桩时,功能不会报错,只是观察不到 sancov 命中,runner 会在语料回放时提示(第 3.3 节)。
5. 语料回放为 AFL 风格文件:--showmap-out
README 最后一节指出,要把持久化语料的覆盖导出为 AFLafl-showmap风格文件(用于跨模糊器比较),使用forge test --showmap-out <DIR>,完整说明见 docs/dev/showmap.md。该文档给出的工作流:
# 1. 先跑一次带 corpus_dir 的 campaign,让语料目录有内容 forge test # 2. 回放并导出覆盖 forge test \ --showmap-out coverage_data \ --showmap-approach foundry \ --showmap-domain evm关键 flag 摘要(详见 docs/dev/showmap.md 的 Flags 表):
| Flag | 说明 |
|---|---|
--showmap-out <DIR> | 输出根目录,必填,开启 showmap 模式 |
--showmap-approach <NAME> | 方法前缀,与测试标识拼成目录名(默认replay) |
--showmap-domain <evm\|sancov\|both> | 要导出的位图(默认evm);sancov 域导出即本文档主题的覆盖文件 |
--showmap-per-input | 逐语料条目出文件,而非每测试一份聚合 |
--showmap-corpus-dir <PATH> | 覆盖回放用的语料目录 |
输出文件中每行是<id>:<count>,sancov 域的行格式为sancov_0x<guard_idx:04x>(guard 索引在链接期分配,跨进程确定),EVM 域为evm_<bytecode_hash[:16hex]>_<pc:04x>——两种域可在--showmap-domain both下同文件混排,下划线分隔保证了<id>:<count>解析无歧义。
6. 实践要点小结
- 两种 sancov 模式分工不同:
sancov_edges改变引导信号(并自动关闭 EVM 边覆盖),sancov_trace_cmp只增强词典;只想要"破解比较守卫"的能力而保留 EVM 引导时,单独开启 trace-cmp 即可(crates/config/src/fuzz.rs 的注释明确了这一设计意图)。 - 构建期插桩 + 运行期开关缺一不可:
RUSTC_WRAPPER注入编译标志(第 4 节脚本)→cargo build --profile fuzz --bin forge→ 配置sancov_edges/sancov_trace_cmp并设置corpus_dir跑 campaign。 - 验证插桩是否生效:无命中时 runner 的 "build is likely not sancov-instrumented" 提示(crates/forge/src/runner.rs)是最直接的诊断手段。
- 热路径开销可控:guard ID 到稠密索引的读写锁映射、
is_active()的原子指针判空、trace-cmp 的线程局部缓冲与 512 条上限(crates/evm/sancov/src/lib.rs),都是为高频 EVM 执行场景设计的轻量实现。 - 相关机制的延伸阅读:EVM 边覆盖与词典的默认配置在 crates/config/src/fuzz.rs(
FuzzCorpusConfig各字段),showmap 回放与差分覆盖工具链在 docs/dev/showmap.md。
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考