[官文翻译]golang指南:开始模糊测试

415 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情


本文翻译自:
Tutorial: Getting started with fuzzing - The Go Programming Language


指南:开始模糊测试

该指南介绍了使用 Go 模糊测试的基础。使用模糊测试,可将随机数据运行于测试用于试图发现脆弱点或造成崩溃的输入。

一些可用模糊测试发现的脆弱点示例是 SQL 注入、缓存溢出、服务拒绝和跨站点脚本攻击。

在该指南中,你会编写一个针对简单函数的模糊测试,运行 go 命令,然后调试和修复代码中的问题。

对于该指南中出现的术语需要帮助的话,查看 Go Fuzzing glossary

你会通过以下几部分来完成:

  1. 为代码创建文件夹
  2. 添加要测试的代码
  3. 添加单元测试
  4. 添加模糊测试
  5. 修复两个 Bug
  6. 浏览附加资源

注意:  其它指南请查看 Tutorials

注意:  Go 模糊测试现在支持内建类型的子集,在 Go Fuzzing docs列出了相关内容,在将来会添加更多内建类型的支持。

前提

  • 安装 Go 1.18 或更高版本。  安装说明查看 Installing Go
  • 编辑代码的工具。  平时使用的任何文本编辑器都可以。
  • 一个命令终端  Go 在 Liunx 或 Mac 的任何终端上在 Windows 上的 PowerShell 或 cmd 上都能正常使用。
  • 支持模糊测试的环境。  带覆盖率指令的 Go 模糊测试现在仅在 AMD64 和 ARM64 架构上可用。

为代码创建文件夹

首先,为要编写的代码创建文件夹。

  1. 打开终端命令行,进入到 home 目录。

    在 Linux 或 Mac 上:

    $ cd
    

    在 Windows 上:

    C:> cd %HOMEPATH%
    

    下面的内容会显示 $ 作为提示符。你使用的命令在 Windows 上也能正常工作。

  2. 在命令行,为代码创建名为 fuzz 的目录。

    $ mkdir fuzz
    $ cd fuzz
    
  3. 创建模块管理代码。

    运行 go mod init 命令,设置为新代码的模块路径。

    $ go mod init example/fuzz
    go: creating new go.mod: module example/fuzz
    

    注意:  对于生产环境的代码,你会指定一个更符合自己需求的模块路径。更多信息查看 Managing dependencies

接下来,你会添加一些简单的代码用于反转一个字符串,后面会对它进行模糊测试。

添加要测试的代码

在该步骤中,你会添加一个函数用于返回一个字符串。

编写代码

  1. 使用你的文本编辑器,在 fuzz 目录下创建一个名为 main.go 的文件。

  2. 进入 main.go ,在文件的最上方,粘贴下面的包声明。

    package main
    

    一个独立的程序(相对的是库)总是在 main 包中。

  3. 在包声明的下面,粘贴下面的函数声明。

    func Reverse(s string) string {
        b := []byte(s)
        for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
            b[i], b[j] = b[j], b[i]
        }
        return string(b)
    }
    

    该函数接收一个 string ,每次循环一个 byte ,最后返回反转后的字符串。

    注意:  该代码基于 golang.org/x/example 里的 stringutil.Reverse 函数。

  4. 在 main.go 的最上面,包声明以下,粘贴以下 main 函数来初始化一个字符串,反转它,打印输出,然后重复。

    func main() {
        input := "The quick brown fox jumped over the lazy dog"
        rev := Reverse(input)
        doubleRev := Reverse(rev)
        fmt.Printf("original: %q\n", input)
        fmt.Printf("reversed: %q\n", rev)
        fmt.Printf("reversed again: %q\n", doubleRev)
    }
    

    该函数会运行几次 Reverse 操作,然后在命令行打印输出。这对于实践中查看代码很有帮助,对于调试也有潜在的帮助。

  5. main 函数使用 fmt 包,所以你需要导入它。

    代码的前几行看上去应该如下:

    package main
    
    import "fmt"
    

运行代码

在包含 main.go 的目录的命令行,执行代码。

$ go run .
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"

可以看到原始的字符串、反转后的字符串、然后是再次反转的字符串,它和原始的字符串是相等的。

现在代码在运行,可以测试它了。

添加单元测试

在该步骤中,你会编写一个基本的单元测试,用于测试 Reverse 函数。

编写代码

  1. 使用你的文本编辑器,在 fuzz 目录中创建一个名为 reverse_test.go 的文件。

  2. 粘贴以下代码到 reverse_test.go 中。

    package main
    
    import (
        "testing"
    )
    
    func TestReverse(t *testing.T) {
        testcases := []struct {
            in, want string
        }{
            {"Hello, world", "dlrow ,olleH"},
            {" ", " "},
            {"!12345", "54321!"},
        }
        for _, tc := range testcases {
            rev := Reverse(tc.in)
            if rev != tc.want {
                    t.Errorf("Reverse: %q, want %q", rev, tc.want)
            }
        }
    }
    

    这个简单的测试会断言列出的输入被正确反转。

运行代码

使用 go test 运行单元测试。

$ go test
PASS
ok      example/fuzz  0.013s

接下来,你会将单元测试变为模糊测试。

添加模糊测试

单元测试有限制,换句话说,每个输入都需要开发者添加到测试中。使用模糊测试的一个好处是它为代码提供输入,然后会识别你的测试用例可能不会达到的边界情况。

在该部分中,你会将单元测试转换为模糊测试,所以你可以用更少的工作生成更多的输入!

注意你可以在同一个 *_test.go 文件中保留单元测试、性能测试和模糊测试,但是对于本示例你会将单元测试转换为模糊测试。

编写代码

在你的代码编辑器中,使用下面的模糊测试替换 reverse_test.go 中的单元测试。

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // 使用 f.Add 添加种子用例库
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

模糊测试也有一些限制。在你的单元测试中,可以预知 Reverse 函数的期望输出,然后验证实际输出是否符合这些期望值。

例如,在测试用户 Reverse("Hello, world") 中,单元测试指定了 "dlrow ,olleH" 为返回值。

模糊测试时,你无法预知期望的输出,因为你无法控制输入。

尽管如此,Reverse 的一些属性可在模糊测试中验证。在该模糊测试中检验的两个属性是:

  1. 反转两次字符串维持原始值。
  2. 反转的字符串作为有效的 UTF-8 字符维持它的状态。

注意单元测试和模糊测试之间的语法区别:

  • 函数名以 FuzzXxx 开头而不是 TestXxx ,接收 *testing.F 参数而不是 *testing.T
  • 原来期望看到的 t.Run 的执行,现在看到的是 f.Fuzz ,它带有一个模糊测试目标,该目标的参数是 *testing.T 并且类型是可模糊测试的。单元测试中的输入会使用 f.Add 作为种子用例库。

确保新的包 unicode/utf8 已导入。

package main

import (
    "testing"
    "unicode/utf8"
)

将单元测试转换为模糊测试后,现在可以再次运行测试了。

运行代码

  1. 非模糊测试模式运行测试,确保种子输入通过测试。

    $ go test
    PASS
    ok      example/fuzz  0.013s
    

    如果文件中有其它的测试,然后你只想运行模糊测试时,也可以运行 go test -run=FuzzReverse

  2. 模糊测试模式运行 FuzzReverse ,看一下随机生成的字符串输入是否导致失败。使用 go test 运行测试,带一个新的标志,-fuzz

    $ go test -fuzz=Fuzz
    fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
    fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
    fuzz: minimizing 38-byte failing input file...
    --- FAIL: FuzzReverse (0.01s)
        --- FAIL: FuzzReverse (0.00s)
            reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd"
    
        Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
        To re-run:
        go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
    FAIL
    exit status 1
    FAIL    example/fuzz  0.030s
    

    模糊测试时发生了一个错误,导致问题的输入写入到了种子用例库文件中,当下次调用 go test 会运行该文件,即使不带 -fuzz 标志。 要查看导致错误的输入,在文本编辑器中打开写入到 testdata/fuzz/FuzzReverse 下的用例库文件。你的种子用例库文件可能会包含不同的字符串,但是格式是相同的。

    go test fuzz v1
    string("泃")
    

    用例库文件的第一行标示编码版本。之后的每一行代表创建用例库实体的各种类型值。 由于模糊测试目标只有一个输入,所以版本下面只有一个值。

  3. 不带  -fuzz 标志再次运行 go test ;会使用刚刚失败的种子用例实体:

    $ go test
    --- FAIL: FuzzReverse (0.00s)
        --- FAIL: FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a (0.00s)
            reverse_test.go:20: Reverse produced invalid string
    FAIL
    exit status 1
    FAIL    example/fuzz  0.016s
    

    因为我们的测试失败了,所以要开始调试。

修复非法字符错误

在该部分中,你会调试失败的地方,然后修复 bug 。

可随意花些时间考虑这个问题,然后你可以在继续之前自己修复这个问题。

诊断错误

你可以用一些不同的方式调试该错误。如果你在使用 VSCode 作为你的文本编辑器,可以 安装调试器 来诊断。

在该指南中,我们会将有用的调试日志输出到终端。

首先考虑 utf8.ValidString 的文档。

ValidString reports whether s consists entirely of valid UTF-8-encoded runes.

现在的 Reverse 函数按字节地反转字符串,然后这里就出现了我们的问题。 为了保持原始字符串用 UTF-8 编码后的码点,我们必须替换掉按字节反转字符串的处理。

要验证为何反转时,该输入(该例中是汉字 )导致 'Reverse' 生成了一个无效字符串,你可以检查反转后的码点数。

编写代码

在你的文本编辑器中,用下面的代码代替 FuzzReverse 中模糊测试的目标。

f.Fuzz(func(t *testing.T, orig string) {
    rev := Reverse(orig)
    doubleRev := Reverse(rev)
    t.Logf("Number of runes: orig=%d, rev=%d, doubleRev=%d", utf8.RuneCountInString(orig), utf8.RuneCountInString(rev), utf8.RuneCountInString(doubleRev))
    if orig != doubleRev {
        t.Errorf("Before: %q, after: %q", orig, doubleRev)
    }
    if utf8.ValidString(orig) && !utf8.ValidString(rev) {
        t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
    }
})

发生错误或者使用 -v 标志执行测试时,t.Logf 会将其打印到命令行。这可以帮助你调试特定的问题。

运行代码

使用 go test 运行测试。

$ go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
        reverse_test.go:16: Number of runes: orig=1, rev=3, doubleRev=1
        reverse_test.go:21: Reverse produced invalid UTF-8 string "\x83\xb3\xe6"
FAIL
exit status 1
FAIL    example/fuzz    0.598s

整个种子用例库使用每个字符都是单字节的字符串。尽管如此,字符如 泃 会需要几个字节。因此,按字节反转字符串会使多字节字符变成无效字符。

注意:  如果你对 go 处理字符串很好奇,可阅读博客 go 中的字符串、字节、rune和字符 加深理解。

对 bug 有了更好的理解之后,修改 Reverse 函数中的错误。

修复错误

要改正 Reverse 函数,我们来按码点反转字符串,代替按字节反转。

编写代码

在你的文本编辑器中,用下面的代码替换现在的 Reverse() 函数。

func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

主要的区别是 Reverse 现在遍历字符串中的每个 rune ,而不是每个 byte

运行程序

  1. 使用 go test 运行测试

    $ go test
    PASS
    ok      example/fuzz  0.016s
    

    测试现在可以通过了!

  2. go test -fuzz 再次进行模糊测试,看一下有没有新的 bug 。

    $ go test -fuzz=Fuzz
    fuzz: elapsed: 0s, gathering baseline coverage: 0/37 completed
    fuzz: minimizing 506-byte failing input file...
    fuzz: elapsed: 0s, gathering baseline coverage: 5/37 completed
    --- FAIL: FuzzReverse (0.02s)
        --- FAIL: FuzzReverse (0.00s)
            reverse_test.go:33: Before: "\x91", after: "�"
    
        Failing input written to testdata/fuzz/FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
        To re-run:
        go test -run=FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
    FAIL
    exit status 1
    FAIL    example/fuzz  0.032s
    

    我们可以看到字符串在反转两次之后和原始字符串不同。 这次的输入本身是无效的 unicode 字符。我们用字符串进行模糊测试时这怎么可能呢? 我们再调试一次。

修复两次反转的错误

在该部分中,你会调试两次反转的失败并修复该 bug 。

可随意花些时间考虑该问题,在继续之前你可以尝试自己修复该问题。

诊断错误

像前面一样,你可以用一些不同的方式调试该失败。在该情况中,使用 调试器 会是一个很好的途径。

在该指南中,我们会在 Reverse 函数中记录有用的调试信息。

仔细地查看反转后的字符串来定位该错误。在 go 中,字符串是只读的字节切片,它可以包含无效的 UTF-8 字节。 原始字符串是只有一个字节的字节切片, '\x91' 。当输入字符串设置为 []rune 时,go 会将字节切片编码成 UTF-8 ,并使用 UTF-8 字符 � 替换该字节。 当我们比较替换后的 UTF-8 字符和输入的字节切片时,很明显不相等。

编写代码

  1. 在你的文本编辑器中,使用下面的代码替换 Reverse 函数。

    func Reverse(s string) string {
        fmt.Printf("input: %q\n", s)
        r := []rune(s)
        fmt.Printf("runes: %q\n", r)
        for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
            r[i], r[j] = r[j], r[i]
        }
        return string(r)
    }
    

    这会帮助我们理解将字符串转换成 rune 切片时发生了什么错误。

运行代码

这一次,我们只想运行失败的测试以检查日志。 要做这一点,我们会使用 go test -run

$ go test -run=FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0
input: "\x91"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
        reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
        reverse_test.go:18: Before: "\x91", after: "�"
FAIL
exit status 1
FAIL    example/fuzz    0.145s

要在 FuzzXxx/testdata 运行一个指定的用例实体,可以给 {FuzzTestName}/{filename} 加上 -run 。在调试时这会有用。

知道了该输入是无效的 unicode ,我们来修复下 Reverse 函数中的错误。

修复错误

要修复该问题,如果 Reverse 的输入不是有效的 UTF-8 时,让我们返回一个错误。

编写代码

  1. 在你的文本编辑器中,使用下面的代码替换现有的 Reverse 函数。

    func Reverse(s string) (string, error) {
        if !utf8.ValidString(s) {
            return s, errors.New("input is not valid UTF-8")
        }
        r := []rune(s)
        for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
            r[i], r[j] = r[j], r[i]
        }
        return string(r), nil
    }
    

    如果输入字符串包含非有效 UTF-8 字符时,该改动会返回错误。

  2. 由于 Reverse 函数现在返回错误,修改 main 函数丢弃额外的错误值。使用下面的代码替换现有的 main 函数。

    func main() {
        input := "The quick brown fox jumped over the lazy dog"
        rev, revErr := Reverse(input)
        doubleRev, doubleRevErr := Reverse(rev)
        fmt.Printf("original: %q\n", input)
        fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
        fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
    }
    

    这些 Reverse 的调用会返回一个 nil 错误,这是因为输入的字符串是有效的 UTF-8 字符串。

  3. 你会需要导入 errors 和 unicode/utf8 包。main.go 中的导入语句看上去就该如下。

    import (
        "errors"
        "fmt"
        "unicode/utf8"
    )
    
  4. 修改 reverse_test.go 文件检查错误,如果错误是返回值生成的,则跳过测试。

    func FuzzReverse(f *testing.F) {
        testcases := []string {"Hello, world", " ", "!12345"}
        for _, tc := range testcases {
            f.Add(tc)  // 使用 f.Add 提供一个种子用例
        }
        f.Fuzz(func(t *testing.T, orig string) {
            rev, err1 := Reverse(orig)
            if err1 != nil {
                return
            }
            doubleRev, err2 := Reverse(rev)
            if err2 != nil {
                 return
            }
            if orig != doubleRev {
                t.Errorf("Before: %q, after: %q", orig, doubleRev)
            }
            if utf8.ValidString(orig) && !utf8.ValidString(rev) {
                t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
            }
        })
    }
    

    相比于返回,你也可以调用 t.Skip() 停止模糊测试输入的执行。

运行代码

  1. 使用 go test 运行测试

    $ go test
    PASS
    ok      example/fuzz  0.019s
    
  2. 使用 go test -fuzz=Fuzz 对其进行模糊测试,然后过几秒钟之后,用 ctrl-C 停止模糊测试。

    $ go test -fuzz=Fuzz
    fuzz: elapsed: 0s, gathering baseline coverage: 0/38 completed
    fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 4 workers
    fuzz: elapsed: 3s, execs: 86342 (28778/sec), new interesting: 2 (total: 35)
    fuzz: elapsed: 6s, execs: 193490 (35714/sec), new interesting: 4 (total: 37)
    fuzz: elapsed: 9s, execs: 304390 (36961/sec), new interesting: 4 (total: 37)
    ...
    fuzz: elapsed: 3m45s, execs: 7246222 (32357/sec), new interesting: 8 (total: 41)
    ^Cfuzz: elapsed: 3m48s, execs: 7335316 (31648/sec), new interesting: 8 (total: 41)
    PASS
    ok      example/fuzz  228.000s
    

    直到出现失败模糊测试会一直运行,除非你传递了 -fuzztime 标志。默认情况下,如果没有失败发生,会一直运行下去,进程可用 ctrl-C 中断。

  3. 使用 go test -fuzz=Fuzz -fuzztime 30s 进行模糊测试,没有发现失败的话,它会运行模糊测试30秒钟。

    $ go test -fuzz=Fuzz -fuzztime 30s
    fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
    fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workers
    fuzz: elapsed: 3s, execs: 80290 (26763/sec), new interesting: 12 (total: 12)
    fuzz: elapsed: 6s, execs: 210803 (43501/sec), new interesting: 14 (total: 14)
    fuzz: elapsed: 9s, execs: 292882 (27360/sec), new interesting: 14 (total: 14)
    fuzz: elapsed: 12s, execs: 371872 (26329/sec), new interesting: 14 (total: 14)
    fuzz: elapsed: 15s, execs: 517169 (48433/sec), new interesting: 15 (total: 15)
    fuzz: elapsed: 18s, execs: 663276 (48699/sec), new interesting: 15 (total: 15)
    fuzz: elapsed: 21s, execs: 771698 (36143/sec), new interesting: 15 (total: 15)
    fuzz: elapsed: 24s, execs: 924768 (50990/sec), new interesting: 16 (total: 16)
    fuzz: elapsed: 27s, execs: 1082025 (52427/sec), new interesting: 17 (total: 17)
    fuzz: elapsed: 30s, execs: 1172817 (30281/sec), new interesting: 17 (total: 17)
    fuzz: elapsed: 31s, execs: 1172817 (0/sec), new interesting: 17 (total: 17)
    PASS
    ok      example/fuzz  31.025s
    

    模糊测试通过!

    除了 -fuzz 之外,还有一些标志可用于 go test ,可在 文档 中查看。

结束语

干得漂亮!你已经在 go 中引入了模糊测试。

下一步是选择代码中想要进行模糊测试的函数,然后尝试一下!如果模糊测试在你的代码中发现了 bug ,考虑将添加到 战利品用例 中。

如果你遭遇了某个问题或者有某种特性的想法,可 提出一个 issue

要讨论和反馈该特性,可以在参与 Gophers Slack 的 #模糊测试频道

查看 go.dev/doc/fuzz 文档进一步了解。

完整代码

— main.go —

package main

import (
    "errors"
    "fmt"
    "unicode/utf8"
)

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev, revErr := Reverse(input)
    doubleRev, doubleRevErr := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
    fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}

func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

— reverse_test.go —

package main

import (
    "testing"
    "unicode/utf8"
)

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc) // 使用 f.Add 提供种子用例库
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err1 := Reverse(orig)
        if err1 != nil {
            return
        }
        doubleRev, err2 := Reverse(rev)
        if err2 != nil {
            return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}