随机输入猜中“Go!?”这 4 个字节的概率是 40 亿分之一,但 go test -fuzz 不到一秒就找到了。靠的是覆盖率引导:每当一个输入走到了新的代码分支,就把它留作后续变异的起点。这篇用一个藏着 bug 的小函数实测发现、缩小、保存、重放的全过程。
go test(不开 fuzz),只跑种子语料 hello 和 Gx:
=== RUN FuzzParse
=== RUN FuzzParse/seed#0
=== RUN FuzzParse/seed#1
--- PASS: FuzzParse (0.00s)
--- PASS: FuzzParse/seed#0 (0.00s)
--- PASS: FuzzParse/seed#1 (0.00s)
PASS
ok lab 0.103s种子 hello 与 Gx 都通过,普通的 go test 没有任何异常。bug 藏在要求输入以 4 个特定字节开头的分支后面,盲目随机几乎不可能碰到。
退出码 1,总用时 1.3 秒;输出(节选,省略了 panic 的调用栈):
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 4 workers
fuzz: minimizing 31-byte failing input file
fuzz: elapsed: 1s, minimizing
--- FAIL: FuzzParse (0.75s)
testing.go:2076: panic: 找到了 bug
Failing input written to testdata/fuzz/FuzzParse/a7e5da185dd54932
To re-run:
go test -run=FuzzParse/a7e5da185dd54932
FAIL
FAIL lab 0.862s
同一个函数的“不会 panic”版本 FuzzSafe,fuzz 10 秒,看统计:
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 873053 (290973/sec), new interesting: 4 (total: 5)
fuzz: elapsed: 6s, execs: 1781700 (302910/sec), new interesting: 4 (total: 5)
fuzz: elapsed: 9s, execs: 2692431 (303492/sec), new interesting: 4 (total: 5)
fuzz: elapsed: 10s, execs: 3001500 (284524/sec), new interesting: 4 (total: 5)
PASS
ok lab 10.200s
重复 5 次冷启动(每次清空语料缓存和 testdata),看多久找到 bug、最初的失败输入多长、最终缩小到多长:
第 1 次:0.70 秒内找到;最初的失败输入 31 字节;最终保存的输入 string("Go!?")
第 2 次:0.77 秒内找到;最初的失败输入 31 字节;最终保存的输入 string("Go!?")
第 3 次:0.67 秒内找到;最初的失败输入 31 字节;最终保存的输入 string("Go!?")
第 4 次:0.75 秒内找到;最初的失败输入 31 字节;最终保存的输入 string("Go!?")
第 5 次:0.62 秒内找到;最初的失败输入 31 字节;最终保存的输入 string("Go!?")fuzz 每走到新的代码分支就保留这个输入再变异:先凑出 G,再凑出 Go,依次逐字节逼近。冷启动 5 次都在 0.62–0.77 秒内命中;无 bug 的对照版本跑 10 秒只留下 4 个“有新覆盖”的输入,执行速度约 29 万次/秒。
文件 testdata/fuzz/FuzzParse/a7e5da18…(文件名是内容哈希):
go test fuzz v1
string("Go!?")找到的失败输入先是 31 字节,fuzz 随后进入 minimizing 阶段把它缩短,最终保存的是只有 4 字节的 "Go!?"——正是触发条件本身。5 次实验的结果完全一致。
退出码 1;--- FAIL: FuzzParse (0.00s) | --- FAIL: FuzzParse/a7e5da185dd54932 (0.00s) | panic: 找到了 bug [recovered, repanicked] | FAIL lab 0.119s
失败输入以 go test fuzz v1 格式存进 testdata/fuzz/FuzzParse/。之后普通 go test 会重放它,bug 修好之前一直 FAIL,修好之后就是永久的回归测试,可以提交进版本库。
GOCACHE/fuzz 里有 3 个语料文件,共 93 字节(这是 fuzz 过程中发现的“有新覆盖”的输入)
fuzz 过程中发现的“有趣”输入缓存在 GOCACHE/fuzz,下一次从它们开始,所以重复运行更快(没清缓存时我测到 0.1–0.4 秒)。本文的冷启动数据是清空缓存之后的结果。
-fuzz regexp Run the fuzz test matching the regular expression. When specified, the command line argument must match exactly one package within the main module, and regexp must match exactly one fuzz test within that package. Fuzzing will occur after tests, benchmarks, seed corpora -fuzzminimizetime t Run enough iterations of the fuzz target during each minimization attempt to take t, as specified as a time.Duration (for example, -fuzzminimizetime 30s). The default is 60s. The special syntax Nx means to run the fuzz target N times
-fuzz 只能匹配一个包里的一个 fuzz 函数,-fuzzminimizetime 控制缩小阶段的预算。本文只用了 -fuzztime 与 -parallel。