bigywater.tech
Go · 104

模糊测试:go test -fuzz 如何生成和缩小用例

随机输入猜中“Go!?”这 4 个字节的概率是 40 亿分之一,但 go test -fuzz 不到一秒就找到了。靠的是覆盖率引导:每当一个输入走到了新的代码分支,就把它留作后续变异的起点。这篇用一个藏着 bug 的小函数实测发现、缩小、保存、重放的全过程。

0.62–0.77 秒
5 次冷启动里,fuzz 发现 4 字节才能触发的 panic 所用的时间
31 → 4 字节
最初失败的输入与缩小后保存的输入 "Go!?"
约 29 万次/秒
这个小函数上 fuzz 的执行速度(4 个 worker)

种子语料看不出问题

实测 · 本机执行输出
  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。

分步演示

模糊测试
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。被测函数是自己写的四层嵌套条件(只有 "Go!?" 触发 panic);冷启动 = 每次清空 GOCACHE/fuzz 和 testdata 后运行;执行速度来自 FuzzSafe(同样的分支结构、不 panic)运行 10 秒的统计;-parallel=4;读过的文档:go help testflag 中 -fuzz 与 -fuzzminimizetime
    • 没有验证:fuzz 的变异策略细节(没有读源码)、真实项目里 bug 的发现时间、更复杂输入类型(结构体、多参数)的 fuzz、-fuzzminimizetime 的实际效果、CI 里运行 fuzz 的做法、Linux 上的速度
    京ICP备2026067502号-1京公网安备11010502063487号