Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

目录

  1. 模糊测试是什么
  2. Go 模糊测试的演进历程
  3. Go 原生 Fuzzing 快速上手
  4. 实战:模糊测试 JSON 解析器
  5. 实战:模糊测试 URL 路由匹配
  6. 实战:协议解析器模糊测试
  7. 高级技巧与最佳实践
  8. CI/CD 集成
  9. 参考资料

模糊测试是什么

模糊测试(Fuzzing)是一种通过向程序输入大量随机、非预期数据来发现 bug 和漏洞的自动化测试技术。与传统单元测试不同,模糊测试不是验证"正确的输入产生正确的输出",而是探索"什么样的输入会让程序崩溃"。

在 Go 生态中,模糊测试早在 2015 年就以 go-fuzz(由 Dmitry Vyukov 开发)的形式存在。Go 1.18 首次将模糊测试作为一等公民引入标准库,而 Go 1.24 进一步增强了 fuzzing 的能力。

模糊测试 vs 传统测试

单元测试:给定输入 → 断言输出         (验证正确性)
模糊测试:随机生成输入 → 观察行为     (发现错误)
属性测试:给定约束 → 验证不变量       (介于两者之间)

模糊测试的核心价值在于它能够发现开发者从未考虑过的边界情况。一个经典的例子是 strings.TrimSpace 函数:

go
// 你测试了这些:
TrimSpace("  hello  ")  // "hello"
TrimSpace("\t\nhello")   // "hello"
TrimSpace("")            // ""

// 但你可能没测试这些:
TrimSpace("\x00hello")   // Go 1.19 之前的 fuzzing 发现了此处的 bug
TrimSpace("\uFEFFhello") // BOM 字符

Go 模糊测试的演进历程

Go 模糊测试的历史可以划分为三个阶段:

阶段时间工具特点
1.02015-2021go-fuzz第三方工具,基于 AFL 思想,需要手动编写 Fuzz 函数
2.0Go 1.18 (2022)testing.F标准库原生支持,f.Add() 种子语料库
3.0Go 1.24 (2025)testing.F 增强语料库管理改进、覆盖率引导优化、字典支持

go-fuzz 时代

go-fuzz 的核心架构:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  初始语料库   │ ──▶ │  变异引擎    │ ──▶ │  Fuzz 函数   │
│  (corpus/)   │     │  (mutator)  │     │  (被测代码)  │
└──────────────┘     └──────────────┘     └──────┬───────┘

                                          ┌───────▼───────┐
                                          │  覆盖率引导    │
                                          │  (coverage)   │
                                          └───────┬───────┘

                                          ┌───────▼───────┐
                                          │  有趣的输入    │
                                          │  存入语料库    │
                                          └───────────────┘

go-fuzz 的核心接口非常简单:

go
// go-fuzz 要求导出 Fuzz 函数
func Fuzz(data []byte) int {
    // 返回 1 表示"有趣"(增加覆盖率)
    // 返回 0 表示"无趣"(丢弃)
    // panic 表示发现了 bug
}

Go 原生 Fuzzing 架构

Go 1.18 引入的 testing.F 采用了更现代化的设计:

go
func FuzzFoo(f *testing.F) {
    // 种子语料
    f.Add("seed1")
    f.Add("seed2")

    // 模糊测试函数
    f.Fuzz(func(t *testing.T, input string) {
        // 被测代码
        result := ProcessInput(input)
        // 如果 ProcessInput panic,则报告失败
    })
}

Go 原生 fuzzing 的关键改进:

  • 类型安全f.Fuzz 支持 string[]byteintfloat64bool 等基本类型
  • 最小化:发现崩溃后自动将输入最小化为最短的复现用例
  • 去重:自动识别相同的崩溃(基于堆栈签名)
  • 并行执行:多 worker 并行 fuzz,默认使用 GOMAXPROCS 个 worker

Go 1.24 在以下方面做了增强:

  • 字典引导变异:支持用户提供已知的关键字/魔术字节列表,让变异更有针对性
  • 语料库格式改进testdata/fuzz/FuzzFoo/ 目录下的语料文件现在支持结构化数据
  • seed 语料自动转换f.Add() 添加的种子自动持久化

Go 原生 Fuzzing 快速上手

第一个 Fuzz 测试

假设我们有一个简单的 URL 解析函数:

go
package urlparse

import "strings"

// ParseSimpleURL 解析简单的 "scheme://host/path" 格式 URL
func ParseSimpleURL(raw string) (scheme, host, path string, err error) {
    parts := strings.SplitN(raw, "://", 2)
    if len(parts) != 2 {
        return "", "", "", fmt.Errorf("invalid URL: %s", raw)
    }
    scheme = parts[0]
    rest := parts[1]

    idx := strings.Index(rest, "/")
    if idx == -1 {
        host = rest
        path = "/"
    } else {
        host = rest[:idx]
        path = rest[idx:]
    }
    return
}

对应的 fuzz 测试:

go
package urlparse

import (
    "strings"
    "testing"
)

func FuzzParseSimpleURL(f *testing.F) {
    // 种子语料:提供一些合法的输入
    f.Add("https://example.com/path")
    f.Add("http://localhost:8080/api/v1")
    f.Add("ftp://files.example.com/download")
    f.Add("://") // 边界条件

    f.Fuzz(func(t *testing.T, raw string) {
        scheme, host, path, err := ParseSimpleURL(raw)

        // 如果函数返回了错误,那就是预期的
        if err != nil {
            return
        }

        // 不变量检查:scheme 不应该为空
        if scheme == "" {
            t.Errorf("ParseSimpleURL(%q) returned empty scheme but no error", raw)
        }

        // host 不应该为空
        if host == "" {
            t.Errorf("ParseSimpleURL(%q) returned empty host but no error", raw)
        }

        // 如果 path 包含 scheme 分隔符,说明解析有问题
        if strings.Contains(path, "://") {
            t.Errorf("ParseSimpleURL(%q): path contains scheme separator", raw)
        }
    })
}

运行 fuzz 测试:

bash
# 运行 fuzz 测试(默认时间限制)
go test -fuzz=FuzzParseSimpleURL -fuzztime=30s

# 运行并指定并行 worker 数
go test -fuzz=FuzzParseSimpleURL -fuzztime=1m -parallel=4

# 只复现已发现的崩溃
go test -run=FuzzParseSimpleURL/崩溃ID

输出示例:

fuzz: elapsed: 3s, execs: 124567 (41522/sec), new interesting: 18 (total: 27)
fuzz: elapsed: 6s, execs: 251234 (41888/sec), new interesting: 22 (total: 31)
fuzz: elapsed: 9s, execs: 380001 (42688/sec), new interesting: 25 (total: 34)
--- FAIL: FuzzParseSimpleURL (9.01s)
    --- FAIL: FuzzParseSimpleURL (0.01s)
        fuzz_test.go:25: ParseSimpleURL("\x00://") returned empty scheme but no error

一旦发现崩溃,fuzzing 引擎会自动将输入最小化:

Failing input written to testdata/fuzz/FuzzParseSimpleURL/af45b3c9d8e1f0a2

实战:模糊测试 JSON 解析器

让我们对自定义的 JSON 解析器进行模糊测试。这是一个更接近真实场景的例子:

go
package jsonparser

import (
    "encoding/json"
    "testing"
)

// CustomJSONParser 是一个简单的 JSON tokenizer
type CustomJSONParser struct {
    input []byte
    pos   int
}

type JSONToken struct {
    Type  string // "object", "array", "string", "number", "boolean", "null"
    Value string
    Start int
    End   int
}

func NewParser(input []byte) *CustomJSONParser {
    return &CustomJSONParser{input: input, pos: 0}
}

func (p *CustomJSONParser) skipWhitespace() {
    for p.pos < len(p.input) {
        ch := p.input[p.pos]
        if ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r' {
            break
        }
        p.pos++
    }
}

func (p *CustomJSONParser) NextToken() (*JSONToken, error) {
    p.skipWhitespace()
    if p.pos >= len(p.input) {
        return nil, nil // EOF
    }

    ch := p.input[p.pos]
    switch ch {
    case '{':
        p.pos++
        return &JSONToken{Type: "object", Value: "{", Start: p.pos - 1, End: p.pos}, nil
    case '}':
        p.pos++
        return &JSONToken{Type: "object", Value: "}", Start: p.pos - 1, End: p.pos}, nil
    case '[':
        p.pos++
        return &JSONToken{Type: "array", Value: "[", Start: p.pos - 1, End: p.pos}, nil
    case ']':
        p.pos++
        return &JSONToken{Type: "array", Value: "]", Start: p.pos - 1, End: p.pos}, nil
    case '"':
        return p.parseString()
    case 't', 'f':
        return p.parseBoolean()
    case 'n':
        return p.parseNull()
    default:
        if ch >= '0' && ch <= '9' || ch == '-' {
            return p.parseNumber()
        }
        return nil, fmt.Errorf("unexpected character: %c at position %d", ch, p.pos)
    }
}

// 字符串解析实现...
func (p *CustomJSONParser) parseString() (*JSONToken, error) {
    start := p.pos
    p.pos++ // skip opening quote
    for p.pos < len(p.input) {
        ch := p.input[p.pos]
        if ch == '"' {
            p.pos++
            value := string(p.input[start+1 : p.pos-1])
            return &JSONToken{Type: "string", Value: value, Start: start, End: p.pos}, nil
        }
        if ch == '\\' {
            p.pos++ // skip backslash
        }
        p.pos++
    }
    return nil, fmt.Errorf("unterminated string at position %d", start)
}

func (p *CustomJSONParser) parseBoolean() (*JSONToken, error) {
    start := p.pos
    if p.pos+4 <= len(p.input) && string(p.input[p.pos:p.pos+4]) == "true" {
        p.pos += 4
        return &JSONToken{Type: "boolean", Value: "true", Start: start, End: p.pos}, nil
    }
    if p.pos+5 <= len(p.input) && string(p.input[p.pos:p.pos+5]) == "false" {
        p.pos += 5
        return &JSONToken{Type: "boolean", Value: "false", Start: start, End: p.pos}, nil
    }
    return nil, fmt.Errorf("invalid boolean at position %d", start)
}

func (p *CustomJSONParser) parseNull() (*JSONToken, error) {
    start := p.pos
    if p.pos+4 <= len(p.input) && string(p.input[p.pos:p.pos+4]) == "null" {
        p.pos += 4
        return &JSONToken{Type: "null", Value: "null", Start: start, End: p.pos}, nil
    }
    return nil, fmt.Errorf("invalid null literal at position %d", start)
}

func (p *CustomJSONParser) parseNumber() (*JSONToken, error) {
    start := p.pos
    // 处理负号
    if p.pos < len(p.input) && p.input[p.pos] == '-' {
        p.pos++
    }
    // 整数部分
    for p.pos < len(p.input) && p.input[p.pos] >= '0' && p.input[p.pos] <= '9' {
        p.pos++
    }
    // 小数部分
    if p.pos < len(p.input) && p.input[p.pos] == '.' {
        p.pos++
        for p.pos < len(p.input) && p.input[p.pos] >= '0' && p.input[p.pos] <= '9' {
            p.pos++
        }
    }
    // 科学计数法
    if p.pos < len(p.input) && (p.input[p.pos] == 'e' || p.input[p.pos] == 'E') {
        p.pos++
        if p.pos < len(p.input) && (p.input[p.pos] == '+' || p.input[p.pos] == '-') {
            p.pos++
        }
        for p.pos < len(p.input) && p.input[p.pos] >= '0' && p.input[p.pos] <= '9' {
            p.pos++
        }
    }
    value := string(p.input[start:p.pos])
    return &JSONToken{Type: "number", Value: value, Start: start, End: p.pos}, nil
}

模糊测试代码 —— 这是一个差分模糊测试(differential fuzzing)的经典例子:

go
func FuzzCustomJSONParser(f *testing.F) {
    // 种子语料
    seeds := []string{
        `{}`,
        `{"key": "value"}`,
        `{"nested": {"key": [1, 2, 3]}}`,
        `{"escaped": "hello\nworld"}`,
        `[true, false, null]`,
        `{"num": -3.14e10}`,
        `{"unicode": "中文"}`,
        `""`,
        `{`,
    }
    for _, seed := range seeds {
        f.Add([]byte(seed))
    }

    f.Fuzz(func(t *testing.T, data []byte) {
        parser := NewParser(data)

        var tokens []*JSONToken
        for {
            tok, err := parser.NextToken()
            if err != nil {
                // 解析失败是预期行为
                return
            }
            if tok == nil {
                break // EOF
            }
            tokens = append(tokens, tok)
        }

        // 差分测试:如果我们的解析器成功解析了,
        // 那么标准库 encoding/json 也应该能解析
        var result interface{}
        stdErr := json.Unmarshal(data, &result)

        // 如果我们解析成功但标准库失败,说明我们的解析器可能太宽松了
        if stdErr != nil && len(tokens) > 0 {
            // 验证我们生成的 token 序列是否可能有问题
            t.Logf("Tokens parsed but stdlib rejected: %s", string(data))
            // 这可能不是 bug,但值得关注
        }
    })
}

运行这个 fuzz 测试可能会发现:

fuzz: minimizing 68-byte failing input file...
--- FAIL: FuzzCustomJSONParser (0.05s)
    parser_fuzz_test.go:58: 
        panic: runtime error: slice bounds out of range [4:8]
        goroutine 75 [running]:
        ...

这类越界错误正是模糊测试最擅长发现的。


实战:模糊测试 URL 路由匹配

这是一个更贴近 Web 开发的实战场景 —— 对 HTTP 路由库进行 fuzzing:

go
package router

import (
    "strings"
    "testing"
)

// SimpleRouter 是一个简单的路径路由器
type SimpleRouter struct {
    staticRoutes  map[string]string // path -> handler name
    paramRoutes   []paramRoute      // /user/:id -> handler
}

type paramRoute struct {
    pattern string
    parts   []string // split by "/"
    handler string
}

func NewRouter() *SimpleRouter {
    return &SimpleRouter{
        staticRoutes: make(map[string]string),
    }
}

func (r *SimpleRouter) Add(method, path, handler string) {
    key := method + " " + path
    if strings.Contains(path, ":") {
        parts := strings.Split(strings.Trim(path, "/"), "/")
        r.paramRoutes = append(r.paramRoutes, paramRoute{
            pattern: path,
            parts:   parts,
            handler: handler,
        })
    } else {
        r.staticRoutes[key] = handler
    }
}

func (r *SimpleRouter) Match(method, path string) (handler string, params map[string]string, ok bool) {
    // 先查静态路由
    if h, exists := r.staticRoutes[method+" "+path]; exists {
        return h, nil, true
    }

    // 再查参数路由
    pathParts := strings.Split(strings.Trim(path, "/"), "/")
    for _, pr := range r.paramRoutes {
        if len(pr.parts) != len(pathParts) {
            continue
        }
        match := true
        params = make(map[string]string)
        for i, part := range pr.parts {
            if strings.HasPrefix(part, ":") {
                params[part[1:]] = pathParts[i]
            } else if part != pathParts[i] {
                match = false
                break
            }
        }
        if match {
            return pr.handler, params, true
        }
    }
    return "", nil, false
}

模糊测试代码:

go
func FuzzRouterMatch(f *testing.F) {
    // 种子:构建一个典型的路由表
    seeds := []struct {
        addMethod string
        addPath   string
        addHandle string
        reqMethod string
        reqPath   string
    }{
        {"GET", "/users", "ListUsers", "GET", "/users"},
        {"GET", "/users/:id", "GetUser", "GET", "/users/123"},
        {"POST", "/users", "CreateUser", "POST", "/users"},
        {"GET", "/", "Home", "GET", "/"},
    }
    for _, s := range seeds {
        f.Add([]byte(s.addMethod), []byte(s.addPath), []byte(s.addHandle),
            []byte(s.reqMethod), []byte(s.reqPath))
    }

    f.Fuzz(func(t *testing.T, addMethod, addPath, addHandle, reqMethod, reqPath []byte) {
        r := NewRouter()
        r.Add(string(addMethod), string(addPath), string(addHandle))

        _, _, ok := r.Match(string(reqMethod), string(reqPath))

        // 添加相同路径再查询,应该始终能找到
        if string(addMethod) == string(reqMethod) &&
            string(addPath) == string(reqPath) && !ok {
            t.Errorf("Added route %s %s but couldn't match",
                string(addMethod), string(addPath))
        }
    })
}

这个测试可能发现的 bug 类型:

  • 空路径处理(/ vs ""
  • 连续斜杠(/users//:id
  • 特殊字符(包含 %\x00 的路径)
  • 超长路径导致的内存问题

高级技巧与最佳实践

1. 使用字典引导变异

从 Go 1.24 开始,你可以通过语料库目录中的字典文件来引导变异方向:

bash
# 在 testdata/fuzz/FuzzJSONParser/ 下创建字典
cat > testdata/fuzz/FuzzJSONParser/dict << 'EOF'
"null"
"true"
"false"
"{\"key\":"
"[1,"
"\\u"
EOF

2. 差分模糊测试

用两个实现对比检查,这是发现逻辑错误的最有效方式:

go
func FuzzDifferentialEncoder(f *testing.F) {
    f.Fuzz(func(t *testing.T, data []byte) {
        result1 := myEncoder.Encode(data)
        result2 := stdlibEncoder.Encode(data)

        if result1 != result2 {
            t.Errorf("mismatch: my=%q std=%q input=%x", result1, result2, data)
        }
    })
}

3. 不变量检查

定义程序不该违反的属性:

go
f.Fuzz(func(t *testing.T, input string) {
    result := Compress(input)
    decompressed := Decompress(result)

    // 不变量:压缩再解压缩应该还原原始数据
    if decompressed != input {
        t.Errorf("round-trip failed: input=%q result=%q", input, decompressed)
    }

    // 不变量:压缩后不应该比原始数据大超过 10%
    if len(result) > len(input)*11/10 {
        t.Errorf("compression ratio too poor")
    }
})

4. Fuzzing 不是银弹

模糊测试不能替代:

  • 单元测试:验证已知的正确行为
  • 集成测试:验证组件间协作
  • 属性测试:验证数学性质

但模糊测试能发现这三者都覆盖不到的盲区。

5. 性能优化建议

bash
# 设置更长的超时(CI 中使用)
go test -fuzz=FuzzFoo -fuzztime=5m

# 限制内存(防止 OOM)
GOMEMLIMIT=2GiB go test -fuzz=FuzzFoo

# 使用已有的语料库加速
go test -fuzz=FuzzFoo -fuzztime=30s -keepfuzzing

CI/CD 集成

在 GitHub Actions 中集成模糊测试:

yaml
# .github/workflows/fuzz.yml
name: Fuzz Testing
on:
  schedule:
    - cron: '0 2 * * 0'  # 每周日凌晨 2 点

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
      - name: Run fuzz tests
        run: |
          for test in $(go test -list 'Fuzz.*' ./... 2>/dev/null | grep Fuzz); do
            echo "Running $test for 2 minutes..."
            go test -fuzz="^${test}$" -fuzztime=2m ./...
          done
      - name: Upload crash artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-crashes
          path: '**/testdata/fuzz/**'

推荐的 CI fuzzing 策略:

阶段频率时长目的
PR 检查每次 PR30s/包快速回归检测
Nightly每天5min/包浅层探索
Weekly每周30min/包深度探索

参考资料


本文生成于 2026-06-06,基于 Go 1.24 稳定版。代码示例均可在 Go 1.21+ 环境中运行(部分 1.24 特性已标注)。

上次更新于: