Go-19-Fuzzing

Golang在1.18引入的第三个特性就是 Fuzzing 模糊测试:构造随机数据来找出代码里的漏洞或者可能导致程序崩溃的输入。

单元测试有局限性,每个测试输入必须由开发者指定加到单元测试的测试用例里。fuzzing的优点之一是可以基于开发者代码里指定的测试输入作为基础数据,进一步自动生成新的随机测试数据,用来发现指定测试输入没有覆盖到的边界情况。

通过fuzzing可以找出的漏洞包括SQL注入、缓冲区溢出、拒绝服务(Denial of Service)攻击和XSS(cross-site scripting)攻击等

这里通过编写反转字符串函数通过 fuzz test 来发现并修改问题

第一步:实现基本功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)
}

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)
}

输出的结果是

1
2
3
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"

第二步:编写单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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)
}
}
}

执行单元测试发现,一切正常

1
ok      example/fuzz    0.003s

第三步:添加模糊测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
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)
}
})
}

注意点:

  1. Reverse函数如果是一个错误的版本(直接return返回输入的字符串),虽然可以通过上面的模糊测试,但没法通过第二步的单元测试,所以模糊测试与单元测试是互补的关系
  2. go test 只会使用种子语料库,而不会生成随机测试数据。通过这种方式可以用来验证种子语料库的测试数据是否可以测试通过
  3. 如果reverse_test.go文件里有其它单元测试函数或者模糊测试函数,但只想运行FuzzReverse模糊测试函数,我们可以执行go test -run=FuzzReverse命令
  4. 如果要基于种子语料库生成随机测试数据用于模糊测试,需要给go test命令增加 -fuzz参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root@13ce5bc74ac3:/code/fuzz# go test -fuzz .
fuzz: elapsed: 0s, gathering baseline coverage: 0/7 completed
fuzz: elapsed: 0s, gathering baseline coverage: 7/7 completed, now fuzzing with 2 workers
fuzz: elapsed: 0s, execs: 648 (17784/sec), new interesting: 1 (total: 8)
--- FAIL: FuzzReverse (0.04s)
--- FAIL: FuzzReverse (0.00s)
hello_test.go:32: Number of runes: orig=1, rev=2, doubleRev=1
hello_test.go:36: Reverse produced invalid UTF-8 string "\x9e\xdb"

Failing input written to testdata/fuzz/FuzzReverse/d6a654c77ca8db001e0bbe2cbb5493efcd20e17777911523bf59bd30bd33e199
To re-run:
go test -run=FuzzReverse/d6a654c77ca8db001e0bbe2cbb5493efcd20e17777911523bf59bd30bd33e199
FAIL
exit status 1
FAIL example/fuzz 0.048s
  1. 运行之后会生成testdata的文件夹,那么下次即使没有带上 -fuzz 参数,也会使用该数据进行模糊测试

    路径:./testdata/fuzz/FuzzReverse/d6a654c77ca8db001e0bbe2cbb5493efcd20e17777911523bf59bd30bd33e199,内容是:

    1
    2
    go test fuzz v1  //语料库文件里的第1行标识的是编码版本
    string("۞")

    从第2行开始,每一行数据对应的是语料库的每条测试数据(corpus entry)的其中一个参数,按照参数先后顺序排列,因为fuzz target函数func(t *testing.T, orig string)只有orig这1个参数作为真正的测试输入,也就是每条测试数据其实就1个输入,因此在上面示例的testdata/fuzz/FuzzReverse目录下的文件里只有string(“۞”)这一行

第四步:修复Bug

模糊测试中得出的错误为 Reverse produced invalid UTF-8 string "\x9e\xdb"

Reverse函数是按照字节(byte)为维度进行字符串反转,这就是问题所在。比如字符string("۞") 如果按照字节反转,反转后得到的就是一个无效的字符串了。因此为了保证字符串反转后得到的仍然是一个有效的UTF-8编码的字符串,需要按照rune进行字符串反转。

1
2
3
4
5
6
7
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)
}

运行 go test 命令单元测试通过,表示老的数据能够正常的处理,但是如果再次执行 go test -fuzz 会出现新的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
root@13ce5bc74ac3:/code/fuzz# go test -fuzz .
fuzz: elapsed: 0s, gathering baseline coverage: 0/9 completed
fuzz: minimizing 38-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 4/9 completed
--- FAIL: FuzzReverse (0.01s)
--- FAIL: FuzzReverse (0.00s)
hello_test.go:34: Before: "\xe5", after: "�"

Failing input written to testdata/fuzz/FuzzReverse/91862839dc552bd95b4e42be6576a6c198f0d4c8fc2884c953030d898573b014
To re-run:
go test -run=FuzzReverse/91862839dc552bd95b4e42be6576a6c198f0d4c8fc2884c953030d898573b014
FAIL
exit status 1
FAIL example/fuzz 0.011s

结构就是对一个字符串做了2次反转后得到的和原字符串不一样,这次测试输入本身是非法的unicode

1
2
3
4
5
6
7
8
9
10
func Reverse(s string) (string, error) {
if !utf8.ValidString(s) { //判断是否是合法的 utf-8编码
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
}

修改对应的引用和单元测试后,通过测试

语法

Go模糊测试和单元测试在语法上有如下差异:

  1. Go模糊测试函数以FuzzXxx开头,单元测试函数以TestXxx开头
  2. Go模糊测试函数以 *testing.F作为入参,单元测试函数以*testing.T作为入参
  3. Go模糊测试会调用f.Add函数和f.Fuzz函数。
    • f.Add函数把指定输入作为模糊测试的种子语料库(seed corpus),fuzzing基于种子语料库生成随机输入。
    • f.Fuzz函数接收一个fuzz target函数作为入参。fuzz target函数有多个参数,第一个参数是*testing.T,其它参数是被模糊的类型(注意:被模糊的类型目前只支持部分内置类型,
      • string, []byte
      • int, int8, int16, int32/rune, int64
      • uint, uint8/byte, uint16, uint32, uint64
      • float32, float64
      • bool

参考链接

  1. https://go.dev/doc/tutorial/fuzz
  2. https://segmentfault.com/a/1190000041650681
  3. https://segmentfault.com/a/1190000041467510
  4. https://go.googlesource.com/proposal/+/master/design/draft-fuzzing.md