Go的简单入门:开始使用模糊测试

本文详细介绍了如何在Go中进行模糊测试,包括创建测试目录、编写代码、添加单元测试和模糊测试,以及如何诊断和修复由于字符串处理错误导致的问题。通过模糊测试,可以发现代码中的漏洞和崩溃输入,确保程序的健壮性。

开始使用模糊测试

一、介绍

这篇指导介绍Go中基础的模糊测试。通过模糊测试,随机数据会针对您的测试运行,以尝试找出漏洞或导致崩溃的输入。例如,通过模糊测试能够发现SQL注入的漏洞,缓冲区溢出,拒绝服务或者跨域攻击。

在这篇指导中,你将为一个简单的函数写一个模糊测试,运行Go命令行,调试并修复代码中的问题。

你将通过下面的部分:

  1. 为你的代码创建一个目录;
  2. 添加代码用于测试;
  3. 添加单元测试;
  4. 添加模糊测试;
  5. 修复两个bug;
  6. 探索更多的资源;

二、准备

  • 安装Go1.18 或以上版本;
  • 一个用于编写代码的工具;
  • 命令行工具;
  • 一个支持模糊测试的环境。目前仅在 AMD64 和 ARM64 架构上使用覆盖检测进行模糊测试。

三、实践

3.1 为你的代码创建一个目录

  1. 打开命令行,进入到工作目录

    从为你将要写的代码创建目录开始。

    $ cd 
    $
    
  2. 通过命令行,创建一个名称为fuzz的目录

    $ mkdir fuzz
    $ cd fuzz/
    $
    
  3. 为你的代码创建一个模块

    运行go mod init命令,并给它一个新的代码模块路径

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

接下来,你将添加一些简单代码去反转字符串,它将用于模糊。

3.2 添加代码用于测试

在这一步,将添加函数用于反转字符串。

写代码
  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)
    }
    

    函数接受一个字符串,同时循环byte,最后返回反转的字符串。

    注意:这个代码基于golang.org/x/examplestringUtils.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 agained: %q\n", doubleRev)
}

这个函数将运行Reverse 操作,然后打印命令行的输出。这个是有用的,在实践中查看代码,也是有用的对于发现潜在的debug。

  1. 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 agained: "The quick brown fox jumped over the lazy dog"
$ 

你能看到原始数据,反转的结果数据,然后对结果再次旋转。它和原始数据相等。

现在代码正在运行,是时间去测试它了。

3.3 添加单元测试

在这一步,你将为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.325s
$ go test -v
=== RUN   TestReverse
--- PASS: TestReverse (0.00s)
PASS
ok  	example/fuzz	0.253s
$

接下来,你将改变单元测试变成模糊测试。

3.4 添加模糊测试

单元测试拥有局限性,即每次输入必须被开发者添加到测试。一个模糊测试的好处是它能为你的代码创建输入,并且能确认边缘化的用例,那些你没有想象出来的用例。

在这一部分你将转换单元测试变成模糊测试,以便于你能通过少量的工作产生更多输入。

注意,你将保留单元测试、基准测试和模糊测试在相同的*_test.go文件下面。但是对于这个案例,你将转换单元测试成为模糊测试。

写代码

在你的文本编译器中,使用下面的模糊测试替换单元测试。

func FuzzReverse(f *testing.F) {
	testcase := []string{"Hello World", " ", "!12345"}
	for _, tc := range testcase {
		f.Add(tc)
	}
	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)
		}
	})
}

Fuzzing也有一些局限性。在你的单元测试中,你能预测Reverse函数期待的输出,并且验证实际的输出满足预期。

例如,在测试用例Reverse("Hello, world")单元测试明确返回dlrow ,olleH

使用模糊查询,你不能预测期待的输出,因为你不能控制输入。

可是,这里有一些Reverse函数的属性,你能在fuzz测试中进行验证。其中两个能在模糊测试中验证的是:

  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	1.089s
    $
    
  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: elapsed: 0s, execs: 780 (19533/sec), new interesting: 5 (total: 8)
    --- FAIL: FuzzReverse (0.04s)
        --- FAIL: FuzzReverse (0.00s)
            reverse_test.go:20: Reverse produced invalid UTF-8 string "0\x86\xcb"
    
        Failing input written to testdata/fuzz/FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34
        To re-run:
        go test -run=FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34
    FAIL
    exit status 1
    FAIL	example/fuzz	1.106s
    $
    

    使用模糊测试的时候一个错误产生了,并且引起问题的输入是被写到了一个种子语料库文件。它将在下次go test被调用的时候运行,即便不带-fuzz标识。为了展示引起错误的输入,在编译器中打开被写到testdata/fuzz/FuzzReverse 目录下的语料库文件。你的语料库文件可能包含不同的字符串,但是格式是相同的。

    $ cat testdata/fuzz/FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34
    go test fuzz v1
    string("ˆ0")
    $
    

    语料库文件第一行包含了编码版本。以下每一行代表构成语料库条目的每种类型的值。因为模糊测试的目标仅获取一个值,在版本后面仅仅有一个值。

  3. 再次不带-fuzz运行go test,将使用新的失败种子语料库条目。

    $ go test
    --- FAIL: FuzzReverse (0.00s)
        --- FAIL: FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34 (0.00s)
            reverse_test.go:20: Reverse produced invalid UTF-8 string "0\x86\xcb"
    FAIL
    exit status 1
    FAIL	example/fuzz	1.076s
    $
    

因为我们的测试失败了,是时候进行调试了。

3.5 修复无效的字符串错误

在这一部分,你将调试错误,并修复错误。

在继续之前,请随意花一些时间思考这个问题并尝试自己解决问题。

诊断错误

这儿有一些不同的方法用来调试错误。如果你使用VS Code作为你的编译器,你能设置断点进行调试。

在这个教程中,你将打印有用的调试信息到你的控制台。

首先,思考对于utf8.ValidString的文档。

ValidString 报告 s 是否完全由有效的 UTF-8 编码符文组成。

当前的Reverse函数,一个字节一个字节的反转字符串,这就是我们的问题。为了保留原始字符串的 UTF-8 编码符文,我们必须逐个符文反转字符串。

为了检查当反转的时候为什么那个输入(在这种情况下,字符串ˆ0)造成了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)
	}
})

这个t.Logf行将打印到命令行,如果一个错误发生。获取如果执行测试带上-v,它能帮助你调试特定的议题。

运行代码

使用go test运行测试

$ go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34 (0.00s)
        reverse_test.go:16: Number of runes: orig=2, rev=3, doubleRev=2
        reverse_test.go:21: Reverse produced invalid UTF-8 string "0\x86\xcb"
FAIL
exit status 1
FAIL	example/fuzz	1.098s
$

整个种子语料库使用字符串,其中每个字符都是一个字节。可是,字符像^需要若干个字节。因此,一个字节一个字节的反转字符串是无效的对于多字节字符。

注意,如果你好奇关于Go处理字符串,阅读博客Strings, bytes, runes and characters in Go进行更深度的理解。

为了更好的理解错误,更正反向功能的错误。

修复错误

为了修复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现在是遍历字符串中的每个符文,而不是每一个字符。

运行代码
  1. 使用go test运行测试

    $ go test
    PASS
    ok  	example/fuzz	1.207s
    $
    
  2. 再次使用go test -fuzz=Fuzz进行模糊测试,去看现在是否有新的问题。

    $ go test -fuzz=Fuzz
    fuzz: elapsed: 0s, gathering baseline coverage: 0/9 completed
    fuzz: minimizing 31-byte failing input file
    fuzz: elapsed: 0s, gathering baseline coverage: 4/9 completed
    --- FAIL: FuzzReverse (0.03s)
        --- FAIL: FuzzReverse (0.00s)
            reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
            reverse_test.go:18: Before: "\xa0", after: "�"
    
        Failing input written to testdata/fuzz/FuzzReverse/1d81da97512e2e81bf08cdb8b161334d7d9639f5c5b18255c920ad25370ff2aa
        To re-run:
        go test -run=FuzzReverse/1d81da97512e2e81bf08cdb8b161334d7d9639f5c5b18255c920ad25370ff2aa
    FAIL
    exit status 1
    FAIL	example/fuzz	1.081s
    $
    

    我们能够看到,经过两次反转的结果是不同于原始结果。这次输入是一个无效的字符串。如果我们使用字符串进行模糊测试,这怎么可能?

    让我们再次调试。

3.6 修复两次反转的错误

在这一部分,我们将调试两次反转的错误,并修复这个错误。

在继续之前,自由的好一些时间去思考这个问题,并修复这个问题。

诊断错误

就像之前,有若干个方法调试错误,在这种场景下,使用断点是一个好的方法。

在这个教程中,我们将在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)-1; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
      }
      return string(r)
    }
    

    这将帮助我们理解发生了什么错误,当字符串转化成符文切片。

运行代码

这个时候,我们仅仅想运行失败的测试为了检查日志。为了做这个,我们将使用go test -run

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

为了运行包含在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("输入是一个无效的UTF-8")
    	}
    	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)-1; 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, doubleErr := Reverse(rev)
    	fmt.Printf("original: %q\n", input)
      fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
      fmt.Printf("reversed agained: %q, err: %v\n", doubleRev, doubleErr)
    }
    

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

  3. 你需要导入errorsunicode/utf8包。在main.go 中的导入语句看起来像这样:

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

    func FuzzReverse(f *testing.F) {
    	testcase := []string{"Hello World", " ", "!12345"}
    	for _, tc := range testcase {
    		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.491s
    $
    
  2. 使用带有go test -fuzz=Fuzz进行模糊化,然后若干秒后通过,使用ctrl-C停止模糊测试

    $ go test -fuzz=Fuzz
    fuzz: elapsed: 0s, gathering baseline coverage: 0/9 completed
    fuzz: elapsed: 0s, gathering baseline coverage: 9/9 completed, now fuzzing with 8 workers
    fuzz: elapsed: 3s, execs: 306930 (102280/sec), new interesting: 32 (total: 41)
    fuzz: elapsed: 6s, execs: 870767 (187915/sec), new interesting: 33 (total: 42)
    fuzz: elapsed: 9s, execs: 1293839 (141029/sec), new interesting: 34 (total: 43)
    ......
    fuzz: elapsed: 1m18s, execs: 10520829 (142569/sec), new interesting: 36 (total: 45)
    fuzz: elapsed: 1m21s, execs: 10909615 (129590/sec), new interesting: 37 (total: 46)
    fuzz: elapsed: 1m24s, execs: 11289776 (126692/sec), new interesting: 37 (total: 46)
    fuzz: elapsed: 1m27s, execs: 11689172 (133198/sec), new interesting: 37 (total: 46)
    fuzz: elapsed: 1m30s, execs: 12068776 (126454/sec), new interesting: 37 (total: 46)
    fuzz: elapsed: 1m33s, execs: 12461830 (131088/sec), new interesting: 37 (total: 46)
    fuzz: elapsed: 1m36s, execs: 12843006 (127040/sec), new interesting: 37 (total: 46)
    ^Cfuzz: elapsed: 1m38s, execs: 13067321 (134596/sec), new interesting: 37 (total: 46)
    PASS
    ok  	example/fuzz	98.729s
    lifeideMacBook-Pro:fuzz lifei$
    

    除非您通过 -fuzztime 标志,否则模糊测试将一直运行,直到遇到失败的输入。如果没有发生故障,默认是永远运行,并且可以使用 ctrl-C 中断该过程。

  3. 使用go test -fuzz=Fuzz -fuzztime 30s进行模糊化,如果没有发现失败,它将在退出之前模糊 30 秒。

    $ go test -fuzz=Fuzz -fuzztime 30s
    fuzz: elapsed: 0s, gathering baseline coverage: 0/46 completed
    fuzz: elapsed: 0s, gathering baseline coverage: 46/46 completed, now fuzzing with 8 workers
    fuzz: elapsed: 3s, execs: 432881 (144195/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 6s, execs: 881372 (149498/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 9s, execs: 1320812 (146554/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 12s, execs: 1735749 (138323/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 15s, execs: 2140404 (134860/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 18s, execs: 2553033 (137558/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 21s, execs: 2934209 (127072/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 24s, execs: 3289376 (118391/sec), new interesting: 3 (total: 49)
    fuzz: elapsed: 27s, execs: 3670350 (126988/sec), new interesting: 4 (total: 50)
    fuzz: elapsed: 30s, execs: 4071006 (133510/sec), new interesting: 4 (total: 50)
    fuzz: elapsed: 30s, execs: 4071006 (0/sec), new interesting: 4 (total: 50)
    PASS
    ok  	example/fuzz	30.994s
    $
    

    模糊化测试通过。

除了添加-fuzz标识,若干个新的标识能够添加到go test上,在文档中能够看到:custom-settings

点击文档,go.dev/doc/fuzz 进一步阅读。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值