Tendermint Core 模糊测试(Fuzzing)实战指南:基于 go-fuzz 的五大输入目标与完整运行流程
Tendermint Core 模糊测试(Fuzzing)实战指南:基于 go-fuzz 的五大输入目标与完整运行流程
本文是 Tendermint Core(Go 版 BFT 共识引擎)仓库内模糊测试体系的实践指南,围绕 test/fuzz/README.md 展开。文章将带你理解该仓库如何使用 go-fuzz 对 mempool、p2p 地址簿、pex 协议、SecretConnection 加密连接与 JSON-RPC 服务五大模块进行模糊测试,掌握目录组织、Makefile 驱动、语料库(corpus)生成、崩溃样本(crashers)分析与 Bug 复现的完整闭环,可直接在本地按步骤运行起来。
模糊测试在 Tendermint 中的定位
模糊测试(Fuzzing)是一种通过向程序输入大量随机或变异数据、观察其是否崩溃(panic)、死锁或产生异常行为的自动化测试手段。在 Tendermint 这类长期运行、处理外部网络输入的去中心化系统中,恶意或畸形的数据可能来自任意节点,因此对关键解析路径做模糊测试是保障健壮性的重要一环。仓库根级测试说明 test/README.md 在 "Fuzzing" 一节明确指出:模糊测试针对系统的各类输入(various system inputs)展开,并指引读者查看 ./fuzz/README.md 获取细节——这正是本文所依据的主体文档。
Tendermint 的模糊测试构建在 go-fuzz 库之上(README 原文引用的是 dvyukov/go-fuzz 项目)。go-fuzz 的工作模型是:开发者编写一个接收 []byte 的 Fuzz(data []byte) int 函数,go-fuzz 会持续生成变异输入喂给该函数,通过覆盖率引导寻找能触发 panic、超时或内存问题的输入,并把导致崩溃的字节序列连同调用栈一起落盘。返回值约定如下(仓库所有 Fuzz 函数均遵循):
- 返回
1:输入被接受(语义上有效、处理成功),应作为有价值样本进入语料库; - 返回
0:输入被拒绝(如校验失败),不产生崩溃; - 返回
-1:输入从一开始就是无效的(如反序列化失败),go-fuzz 会直接丢弃。
五大模糊测试目标(Inputs)
依据 README 的 Inputs 清单,当前仓库为以下五个输入面提供了 fuzz 入口,每个目标对应一个独立的 Fuzz 函数与运行命令:
| 目标 | 模糊测试的函数/入口 | 对应 fuzz 文件 |
|---|---|---|
mempool CheckTx(使用进程内 kvstore ABCI 应用) |
mempool.CheckTx(data, nil, mempl.TxInfo{}) |
test/fuzz/mempool/v0/checktx.go、test/fuzz/mempool/v1/checktx.go |
p2p Addrbook#AddAddress |
addrBook.AddAddress(addr, addr) |
test/fuzz/p2p/addrbook/fuzz.go |
p2p pex.Reactor#Receive |
pexR.Receive(pex.PexChannel, peer, data) |
test/fuzz/p2p/pex/reactor_receive.go |
p2p SecretConnection#Read / Write |
fooConn.Write(data) 与 barConn.Read(...) |
test/fuzz/p2p/secret_connection/read_write.go |
| RPC JSON-RPC 服务 | 将数据作为 POST body 交给 mux.ServeHTTP |
test/fuzz/rpc/jsonrpc/server/handler.go |
下面逐一结合源码剖析各目标的构建方式,这既是你理解 go-fuzz 用法的范例,也是后续为 Tendermint 新增 fuzz 目标时的模板。
1. mempool CheckTx:进程内 kvstore 应用的交易注入
mempool 目标同时维护了 v0(CList 实现)与 v1(TxMempool 实现)两个版本,二者结构一致。以 test/fuzz/mempool/v0/checktx.go 为例:
func init() {
app := kvstore.NewApplication()
cc := proxy.NewLocalClientCreator(app)
appConnMem, _ := cc.NewABCIClient()
err := appConnMem.Start()
if err != nil {
panic(err)
}
cfg := config.DefaultMempoolConfig()
cfg.Broadcast = false
mempool = mempoolv0.NewCListMempool(cfg, appConnMem, 0)
}
func Fuzz(data []byte) int {
err := mempool.CheckTx(data, nil, mempl.TxInfo{})
if err != nil {
return 0
}
return 1
}
关键点:
- 进程内 ABCI 应用:通过
kvstore.NewApplication()(位于 abci/example/kvstore)创建示例应用,再用proxy.NewLocalClientCreator(app)建立本地 ABCI 客户端,避免走 socket 的网络开销,使 fuzz 循环足够快; - 配置收敛:
config.DefaultMempoolConfig()拿到默认内存池配置后显式关闭cfg.Broadcast = false,让CheckTx只做校验与入池,不触发交易广播,保证行为确定; - 返回值语义:
CheckTx返回错误(如交易超限、重复)时返回0(视为无效输入丢弃);成功接受则返回1,将该字节序列标记为有语义价值的样本。
v1 版本 test/fuzz/mempool/v1/checktx.go 除使用 mempoolv1.NewTxMempool(log, cfg, appConnMem, 0)(mempool/v1)并传入 log.NewNopLogger() 外,其余逻辑完全一致。值得一提的配套设计是 fuzz_test.go:以 test/fuzz/mempool/v0/fuzz_test.go 为例,TestMempoolTestdataCases 会遍历 testdata/cases 目录中的每个文件,把文件内容喂给 Fuzz 并捕获 panic——这保证历史发现的问题样本可以固化在仓库中,每次 go test 都会回归验证,防止旧崩溃被重新引入。
2. p2p Addrbook#AddAddress:地址簿的 JSON 解析
test/fuzz/p2p/addrbook/fuzz.go 负责测试 p2p 地址簿模块(p2p/pex/addrbook.go)的地址添加与挑选逻辑:
var addrBook = pex.NewAddrBook("./testdata/addrbook.json", true)
func Fuzz(data []byte) int {
addr := new(p2p.NetAddress)
if err := json.Unmarshal(data, addr); err != nil {
return -1
}
// Fuzz AddAddress.
err := addrBook.AddAddress(addr, addr)
if err != nil {
return 0
}
// Also, make sure PickAddress always returns a non-nil address.
bias := rand.Intn(100)
if p := addrBook.PickAddress(bias); p == nil {
panic(fmt.Sprintf("picked a nil address (bias: %d, addrBook size: %v)",
bias, addrBook.Size()))
}
return 1
}
剖析:
- 输入首先被
json.Unmarshal进p2p.NetAddress,解析失败直接返回-1,把垃圾字节快速挡在门外; - 随后将解析出的地址作为"目标地址"和"来源地址"双重身份传入
AddAddress,覆盖地址簿的去重、bucket 分配等路径; - 最精巧的是断言式模糊测试:每轮随机取
bias(0-99)调用PickAddress,一旦返回 nil 地址立即panic——即"地址簿里明明有地址,却挑不出来"这种逻辑漏洞会被 fuzz 变成崩溃样本。bias注释中的//nolint:gosec说明这里故意使用math/rand而非加密随机,因为 fuzz 场景只需分布合理,不要求不可预测。
注意 NewAddrBook 第二参数 true 表示使用 testdata/addrbook.json 作为种子数据,这是 README 中"testdata 可存放额外数据"的直接体现。
3. p2p pex.Reactor#Receive:对等交换协议的协议消息注入
test/fuzz/p2p/pex/reactor_receive.go 的目标是 pex(peer exchange)Reactor 的消息处理入口 Receive,这是最容易暴露协议解析漏洞的位置:
func init() {
addrB := pex.NewAddrBook("./testdata/addrbook1", false)
pexR := pex.NewReactor(addrB, &pex.ReactorConfig{SeedMode: false})
pexR.SetLogger(log.NewNopLogger())
peer := newFuzzPeer()
pexR.AddPeer(peer)
}
func Fuzz(data []byte) int {
cfg := config.DefaultP2PConfig()
cfg.PexReactor = true
sw := p2p.MakeSwitch(cfg, 0, "127.0.0.1", "123.123.123", func(i int, sw *p2p.Switch) *p2p.Switch {
return sw
})
pexR.SetSwitch(sw)
pexR.Receive(pex.PexChannel, peer, data)
return 1
}
实现细节:
init()中用./testdata/addrbook1(JSON 格式的地址簿,含几十条带 Attempts/Buckets 等字段的地址记录)初始化 reactor,并注册一个自定义的fuzzPeer;- 由于
MakeSwitch使用log.TestingLogger,不能在init()里执行(源码注释明确说明了这一点),所以每轮 Fuzz 内部都会重建一个p2p.Switch并SetSwitch,确保Receive拥有完整的运行环境; fuzzPeer通过内嵌service.BaseService实现了完整的p2p.Peer接口(Send、TrySend恒返回 true,ID固定为 ed25519 私钥派生的 nodeID),让 reactor 在处理消息时不会因 peer 能力不足而提前退出;- 模糊数据直接作为
Receive的消息体字节传入,覆盖PexAddrs消息的反序列化与处理逻辑。
4. p2p SecretConnection:加密连接的读写一致性
test/fuzz/p2p/secret_connection/read_write.go 面向 p2p 的秘密连接层(p2p/conn/secret_connection.go):
func Fuzz(data []byte) int {
if len(data) == 0 {
return -1
}
fooConn, barConn := makeSecretConnPair()
n, err := fooConn.Write(data)
if err != nil {
panic(err)
}
dataRead := make([]byte, n)
m, err := barConn.Read(dataRead)
if err != nil {
panic(err)
}
if !bytes.Equal(data[:n], dataRead[:m]) {
panic(fmt.Sprintf("bytes written %X != read %X", data[:n], dataRead[:m]))
}
return 1
}
测试思路:用 io.Pipe 构造一对内存管道连接(makeKVStoreConnPair),两端各自用 ed25519 密钥通过 sc.MakeSecretConnection 建立加密会话并交叉校验对端公钥;然后一端 Write 模糊数据、另一端 Read 回读,最后 bytes.Equal 断言写什么就能读到什么。一旦加密/分帧逻辑破坏数据完整性(例如流加密状态机出错),立即 panic。空输入返回 -1 属于防御性前置检查。
5. JSON-RPC 服务:HTTP 层输入注入
test/fuzz/rpc/jsonrpc/server/handler.go 模拟对 Tendermint JSON-RPC 服务(rpc/jsonrpc/server)的 POST 攻击:
var rpcFuncMap = map[string]*rs.RPCFunc{
"c": rs.NewRPCFunc(func(s string, i int) (string, int) { return "foo", 200 }, "s,i"),
}
var mux *http.ServeMux
func init() {
mux := http.NewServeMux()
buf := new(bytes.Buffer)
lgr := log.NewTMLogger(buf)
rs.RegisterRPCFuncs(mux, rpcFuncMap, lgr)
}
func Fuzz(data []byte) int {
req, _ := http.NewRequest("POST", "http://localhost/", bytes.NewReader(data))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
res := rec.Result()
blob, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
if err := res.Body.Close(); err != nil {
panic(err)
}
recv := new(types.RPCResponse)
if err := json.Unmarshal(blob, recv); err != nil {
panic(err)
}
return 1
}
该目标把模糊字节直接当作 HTTP POST 请求体,通过 httptest.NewRecorder 在内存中完成一次完整请求处理;随后强制要求响应体必须能反序列化为 types.RPCResponse——如果畸形输入导致服务器返回不可解析的响应或内部状态损坏,就会以 panic 暴露。注册的方法 "c" 签名 "s,i" 也顺带覆盖了 JSON-RPC 参数类型转换路径。
目录结构约定
README 给出了每个 fuzz 测试目录的标准结构(各目标目录实际遵循此约定,如 test/fuzz/p2p/addrbook):
| test
| |- corpus/
| |- crashers/
| |- init-corpus/
| |- suppressions/
| |- testdata/
| |- <testname>.go
各目录职责:
corpus/(语料库):存放语义上有效的字节序列样本,用于帮助 fuzzer 理解哪些字节序列在语义上是合法的。README 用一个非常直观的类比解释其价值:如果测试的是 PNG 解码器,就往 corpus 里放黑白 PNG;如果是 blockchain reactor,就放区块链消息。对 Tendermint 而言,corpus 里应放能成功通过校验的真实协议消息;init-corpus/(语料生成器):若存在,则包含一个用于生成 corpus 数据的脚本(本仓库中均为main.go,见下文"语料库的自动化生成");testdata/:可存放额外数据,如addrbook.json、addrbook1这类供测试环境使用的地址簿种子文件;crashers/:运行 fuzzer 后自动创建,存放所有导致 panic 的输入(含调用栈 trace);suppressions/:自动创建,存放被抑制的输入(即已知问题、重复崩溃或 fuzzer 判定不需要关注的样本);<testname>.go:当前目标的Fuzz入口文件(如checktx.go、fuzz.go、reactor_receive.go、read_write.go、handler.go)。
此外,每次运行 go-fuzz 还会在同目录生成 <testname>-fuzz.zip 归档文件(对应各测试包编译出的 fuzz 二进制与元数据)。
运行方式:Makefile 驱动的五个命令
README 提供了一组 make 命令,分别对应上述五个目标。这些命令与 test/fuzz/Makefile 中的 .PHONY 目标一一对应,完整源码如下:
.PHONY: fuzz-mempool
fuzz-mempool:
cd mempool && \
rm -f *-fuzz.zip && \
go-fuzz-build && \
go-fuzz
.PHONY: fuzz-p2p-addrbook
fuzz-p2p-addrbook:
cd p2p/addrbook && \
rm -f *-fuzz.zip && \
go run ./init-corpus/main.go && \
go-fuzz-build && \
go-fuzz
.PHONY: fuzz-p2p-pex
fuzz-p2p-pex:
cd p2p/pex && \
rm -f *-fuzz.zip && \
go run ./init-corpus/main.go && \
go-fuzz-build && \
go-fuzz
.PHONY: fuzz-p2p-sc
fuzz-p2p-sc:
cd p2p/secret_connection && \
rm -f *-fuzz.zip && \
go run ./init-corpus/main.go && \
go-fuzz-build && \
go-fuzz
.PHONY: fuzz-rpc-server
fuzz-rpc-server:
cd rpc/jsonrpc/server && \
rm -f *-fuzz.zip && \
go-fuzz-build && \
go-fuzz
对应 README 中的五个命令:
make fuzz-mempool
make fuzz-p2p-addrbook
make fuzz-p2p-pex
make fuzz-p2p-sc
make fuzz-rpc-server
每个命令的执行流程分四步:
- 清理旧产物:
rm -f *-fuzz.zip删除上次运行遗留的 fuzz 归档,避免干扰(注:仓库为只读,仅建议在本地克隆副本中运行); - 生成语料:带
init-corpus的目标(addrbook、pex、secret_connection)先执行go run ./init-corpus/main.go生成初始 corpus;mempool 与 rpc-server 目标则直接使用随仓库提交的testdata与已有 corpus; - 构建 fuzz 二进制:
go-fuzz-build将当前目录下定义的Fuzz函数编译为 go-fuzz 可执行格式并产出*-fuzz.zip; - 启动模糊测试:
go-fuzz进入持续变异与执行循环,实时打印执行次数、覆盖率与被发现的新输入。
运行前提:需要预先安装 go-fuzz 工具链(go-fuzz-build、go-fuzz 两个可执行文件),并保证仓库依赖可解析(见 go.mod)。fuzz 是长时间运行的进程,通常会持续数小时甚至数天,期间需持续关注终端输出中是否有新崩溃提示。
崩溃样本的分析与 Bug 复现
README 用一段完整的示例展示了崩溃样本的形态。运行 fuzzer 后,若发现崩溃,crashers/ 目录下会出现一组以 SHA-1 哈希命名的文件:
ls crashers/
61bde465f47c93254d64d643c3b2480e0a54666e
61bde465f47c93254d64d643c3b2480e0a54666e.output
61bde465f47c93254d64d643c3b2480e0a54666e.quoted
da39a3ee5e6b4b0d3255bfef95601890afd80709
da39a3ee5e6b4b0d3255bfef95601890afd80709.output
da39a3ee5e6b4b0d3255bfef95601890afd80709.quoted
文件命名约定与用途:
| 文件 | 内容 |
|---|---|
61bde465...(无扩展名) |
触发崩溃的原始字节序列,可直接喂回 Fuzz 函数复现 Bug |
61bde465....output |
崩溃报告:包含完整的 panic 调用栈(trace)与崩溃上下文 |
61bde465....quoted |
崩溃字节的转义/quoted 形式,便于复制进代码或写进 issue |
README 明确指出 Bug 报告的制作方式:
the crashing bytes generated by the fuzzer will be in
61bde465...,the respective crash report in61bde465....output,and the bug report can be created by retrieving the bytes in61bde465...and feeding those back into theFuzzfunction.
即:读取无扩展名文件拿到原始字节,编写一个最小复现(直接调用对应包的 Fuzz(bytes) 或在测试中触发同一入口),即可稳定复现并定位问题;同时把 output 中的调用栈附到 issue 中,帮助维护者快速定位。仓库中 mempool 的 TestMempoolTestdataCases(test/fuzz/mempool/v0/fuzz_test.go 与 test/fuzz/mempool/v1/fuzz_test.go)正是这种"回归固化"思想的落地:把崩溃字节放入 testdata/cases/,每次 go test 自动重放并断言不 panic。
语料库的自动化生成(init-corpus)
三个目标附带了 init-corpus/main.go 生成器,展示了"如何为不同协议构造语义有效的种子样本":
- test/fuzz/p2p/addrbook/init-corpus/main.go:生成 5 个 JSON 格式的
p2p.NetAddress样本(0.json至4.json),覆盖 IPv4 全零地址、回环地址、公网地址、默认 26656 端口(Tendermint 的默认 p2p 端口)以及 IPv6 地址2001:db8::68,写入corpus/目录; - test/fuzz/p2p/pex/init-corpus/main.go:用固定随机种子
rand.Seed(10)保证语料可复现,针对sizes := []int{0, 1, 2, 17, 5, 31}构造 6 组不同规模的地址列表(每组含若干随机 IPv4 与一个固定 IPv6 组播地址),经tmp2p.Message的PexAddrs字段 protobuf 序列化后写入corpus/。这样 fuzzer 一开始就能"看懂"合法的PexAddrs消息长什么样,再围绕其做变异; - test/fuzz/p2p/secret_connection/init-corpus/main.go:写入 8 个文本样本,包括 UUID 字符串、空串、纯空格、带缩进的 JSON、非法 JSON(
{"a": 12, "tsp": 999, k: "blue"})、数字字符串、引号字符串与普通句子,用于引导 SecretConnection 对各类文本内容的编码/分帧路径。
三个生成器都支持 -base 标志指定 corpus 目录的落盘位置(默认当前目录),并统一使用 os.MkdirAll(corpusDir, 0o755) 创建目录。
为 Tendermint 新增 fuzz 目标的最小模板
综合仓库现有五个目标的共性,可以总结出新增一个 fuzz 目标的固定套路,供你在自己的分支上扩展:
- 在
test/fuzz/<module>/<target>/下创建包文件,导出func Fuzz(data []byte) int; - 用
init()构建目标模块的真实运行环境:mempool 用proxy.NewLocalClientCreator+DefaultMempoolConfig,pex 用pex.NewReactor+p2p.MakeSwitch,rpc 用rs.RegisterRPCFuncs+httptest;凡是无法在init()中完成的环境准备(如依赖TestingLogger的MakeSwitch),就放进每轮Fuzz内部; - 按返回值约定控制样本筛选:
-1丢弃无效输入、0拒绝但不崩溃、1接受并进入语料; - 在
Fuzz内部嵌入断言(如PickAddress不得返回 nil、加密读写必须一致、RPC 响应必须可解析),把逻辑错误转化为可被 fuzzer 捕获的 panic; - 如需初始语料,提供
init-corpus/main.go并在 test/fuzz/Makefile 中登记make fuzz-<name>目标; - 在 test/README.md 的 Fuzzing 一节补一行说明,指向新目标。
总结
Tendermint Core 的模糊测试体系以 go-fuzz 为引擎,覆盖了交易入口(mempool)、节点发现(addrbook/pex)、传输安全(SecretConnection)与对外服务(JSON-RPC)四条最易受畸形数据冲击的链路。其工程实践有四点值得借鉴:用进程内 ABCI 应用换取 fuzz 吞吐、用 init-corpus 生成器把合法协议样本喂给变异器、在 Fuzz 函数内嵌断言把逻辑错误变成崩溃、用 testdata/cases + 回归测试把历史崩溃固化进测试集。理解这套结构后,你既可以直接运行五个 make fuzz-* 命令参与健壮性验证,也可以仿照最小模板为仓库其他模块补充 fuzz 目标。