Go-知识测试-模糊测试
- [1. 定义](#1. 定义)
- [2. 例子](#2. 例子)
- [3. 数据结构](#3. 数据结构)
- [4. tesing.F.Add](#4. tesing.F.Add)
- [5. 模糊测试的执行](#5. 模糊测试的执行)
- [6. testing.InternalFuzzTarget](#6. testing.InternalFuzzTarget)
- [7. testing.runFuzzing](#7. testing.runFuzzing)
- [8. testing.fRunner](#8. testing.fRunner)
- [9. FuzzXyz](#9. FuzzXyz)
- [10. RunFuzzWorker](#10. RunFuzzWorker)
- [11. CoordinateFuzzing](#11. CoordinateFuzzing)
- [12. 总结](#12. 总结)
建议先看:https://blog.csdn.net/a18792721831/article/details/140062769
1. 定义
模糊测试(Fuzzing)是一种通过构造随机数据对代码进行测试的测试方法,相比于单元测试,
它能提供更为全面的测试覆盖,从而找出代码中的潜在漏洞。
从1.18开始,Go开始正式支持模糊测试。
模糊测试要保证测试文件以_test.go
结尾。
测试方法必须以FuzzXxx
开头。
模糊测试方法必须以*testing.F
作为参数。
2. 例子
假设有个函数,根据输入内容,将输入进行翻转然后在和输入拼接,从而返回一个回文串(不一定是严格意义下的回文串)
函数如下:
Go
func PalindromeStr(in string) string {
b := []byte(in)
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 in + string(b)
}
接着使用单元测试
Go
func TestPalindromeStr(t *testing.T) {
testCase := []struct{ in, out string }{
{"abc", "abccba"},
{"abcdef", "abcdeffedcba"},
{"abcdefg", "abcdefggfedcba"},
{" ", " "},
}
for _, c := range testCase {
o := PalindromeStr(c.in)
if o != c.out {
t.Error("Not equal", c.out, "got:", o)
}
}
}
使用go test -v
执行示例测试,-v 表示控制台输出结果
接着使用模糊测试
Go
func FuzzPalindromeStr(f *testing.F) {
testCase := []string{"abc", "def", " ", "a", "aaa", "aaaaaaaaaaaaaaaaaaaa"}
for _, c := range testCase {
f.Add(c) // 输入测试种子
}
f.Fuzz(func(t *testing.T, a string) {
b := PalindromeStr(a)
// 返回结果进行判断,回文串的规则就是第一个字符和最后一个字符相同,依次类推
for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
if b[i] != b[j] {
t.Error("Not palindrome")
}
}
})
}
使用go test -fuzz=Fuzz -fuzztime=100s
启动模糊测试,-fuzz表示执行模糊测试,-fuzztime表示持续时间
发现执行了100s,也没法问题。
那么我们就自动构造一个错误,如果是utf-8字符,返回回文串,否则返回输入内容,模拟异常逻辑:
Go
func PalindromeStr(in string) string {
b := []byte(in)
if !utf8.Valid(b) {
return in
}
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 in + string(b)
}
因为单元测试中没有中文字符,所以单元测试通过,但是模糊测试呢:
报错了,同时testdata目录中有相应的输入和输出
非utf8字符串,触发了错误逻辑
3. 数据结构
由于模糊测试可以覆盖人类经常忽略的边界用例,因此模糊测试对于发现安全漏洞特别有价值。
模糊测试的结构如下:
看起来很像单元测试的扩展。
一个模糊测试可以分为两部分,一是通过 f.Add 添加随机种子,二是通过 f.Fuzz 函数开始随机测试。标记测试结果的方法与之前的单元测试是通用的。
模糊测试的testing.F
结构:
go在1.18中,在testing的包中增加了fuzz.go
文件,支持模糊测试:
Go
type F struct {
common // 通用测试结构,更多见 https://blog.csdn.net/a18792721831/article/details/140062769
fuzzContext *fuzzContext // 与 testContext 类似,用于控制执行
testContext *testContext
inFuzzFn bool // 标记 fuzz 是否在运行中
corpus []corpusEntry // 种子,语料库
result fuzzResult // 模糊测试结果
fuzzCalled bool // 是否启动
}
通用测试结构 common 提供了诸如标记测试结果的能力,而增加的 corpus 则用于保存通过 f.Add 添加的种子和测试过程中生成的随机输入。
每次执行 f.Add 都会生成一个 corpusEntry 对象,然后加入 corpus 语料库中。
corpusEntry 结构用于保存待测函数的所有输入:
Go
type corpusEntry = struct {
Parent string
Path string
Data []byte
Values []any // Values 要与待测函数索旭耀的参数完全一致
Generation int
IsSeed bool
}
f.Add 每次添加的种子个数需要与待测函数所需要的参数完全一致,因为测试执行时,每次取出一组种子作为函数的入参。
4. tesing.F.Add
Go
func (f *F) Add(args ...any) {
// 将输入的参数加入到数组中
var values []any
for i := range args {
// 模糊测试只能支持基本数据类型,对于复杂类型是不支持的
if t := reflect.TypeOf(args[i]); !supportedTypes[t] {
panic(fmt.Sprintf("testing: unsupported type to Add %v", t))
}
values = append(values, args[i])
}
f.corpus = append(f.corpus, corpusEntry{Values: values, IsSeed: true, Path: fmt.Sprintf("seed#%d", len(f.corpus))})
}
支持模糊测试的参数类型:
Go
var supportedTypes = map[reflect.Type]bool{
reflect.TypeOf(([]byte)("")): true,
reflect.TypeOf((string)("")): true,
reflect.TypeOf((bool)(false)): true,
reflect.TypeOf((byte)(0)): true,
reflect.TypeOf((rune)(0)): true,
reflect.TypeOf((float32)(0)): true,
reflect.TypeOf((float64)(0)): true,
reflect.TypeOf((int)(0)): true,
reflect.TypeOf((int8)(0)): true,
reflect.TypeOf((int16)(0)): true,
reflect.TypeOf((int32)(0)): true,
reflect.TypeOf((int64)(0)): true,
reflect.TypeOf((uint)(0)): true,
reflect.TypeOf((uint8)(0)): true,
reflect.TypeOf((uint16)(0)): true,
reflect.TypeOf((uint32)(0)): true,
reflect.TypeOf((uint64)(0)): true,
}
除了这些之外的类型都不支持。
5. 模糊测试的执行
在src/tesing/fuzz.go
的initFuzzFlags
中定义了模糊测试的参数:
Go
func initFuzzFlags() {
matchFuzz = flag.String("test.fuzz", "", "run the fuzz test matching `regexp`")
flag.Var(&fuzzDuration, "test.fuzztime", "time to spend fuzzing; default is to run indefinitely")
flag.Var(&minimizeDuration, "test.fuzzminimizetime", "time to spend minimizing a value after finding a failing input")
fuzzCacheDir = flag.String("test.fuzzcachedir", "", "directory where interesting fuzzing inputs are stored (for use only by cmd/go)")
isFuzzWorker = flag.Bool("test.fuzzworker", false, "coordinate with the parent process to fuzz random values (for use only by cmd/go)")
}
首先使用-fuzz=reg
触发模糊测试,-fuzztime=30s
指定模糊测试持续的时间,如果不指定,则一直运行。
-fuzzminimizetime
最小失败时间,默认一分钟,-fuzzcachedir
缓存目录,默认是命令执行目录,fuzzworker
工作目录,默认是命令执行目录。
6. testing.InternalFuzzTarget
在testing.M
中,对于单元测试,示例测试和性能测试,都有一个内部类型用于存储编译生成的执行参数。模糊测试也有:
在1.18中三种内部类型增加成4种了。
在编译的时候,load操作也增加了 Fuzz
开头的模糊测试函数
在渲染测试的main入口中,也增加了模糊测试的模板
在testing.M.Run中,增加了模糊测试的支持
InternalFuzzTarget的结构:
Go
type InternalFuzzTarget struct {
Name string
Fn func(f *F)
}
很简单,和单元测试等的结构非常类似,name和对应的func,func 的参数是 *testing.F
7. testing.runFuzzing
在runFuzzing中首先对全部的模糊测试进行匹配,找到本次期望执行的模糊测试case
接着构造testing.F
对象,调用testing.fRunner
执行case
8. testing.fRunner
在testing.fRunner中启动执行,类似于单元测试的 testing.tRunner。
性能测试是 testing.runN
示例测试是 testing.runExample
单元测试是 testing.tRunner
第一个defer函数主要处理这几个事情:失败后资源清理,保证测试报告完成,失败退出,等待子测试完成,成功输出报告。
第二个defer函数是等待所有的子测试完成后,发送信号,表示子测试结束。
9. FuzzXyz
接着回到模糊测试中:
Go
func FuzzPalindromeStr(f *testing.F) {
testCase := []string{"abc", "def", " ", "a", "aaa", "你好"}
for _, c := range testCase {
f.Add(c) // 输入测试种子
}
f.Fuzz(func(t *testing.T, a string) {
b := PalindromeStr(a)
// 返回结果进行判断,回文串的规则就是第一个字符和最后一个字符相同,依次类推
for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
if b[i] != b[j] {
t.Error("Not palindrome")
}
}
})
}
模糊测试可以认为是两部分,第一部分是输入测试种子,第二部分是判决模糊的参数执行是否成功。
在testing.F.Add中,将参数种子放到了testing.F.corpus里面,并且要求输入的种子参数的数量,每次Add的时候,必须和被测试方法的入参一致。
在第二部分的Fuzz中,首先对入参进行了校验:
入参是一个func类型的参数,并且第一个参数是*testing.T
的参数,后面是可变参数。
第一个*testing.T
主要是复用了单元测试的测试管理能力,比如报告输出,成功失败的标记等等。
后面的可变参数则是被模糊测试的函数入参列表。
接着对可变参数列表进行类型判断,只有基本类型才能模糊测试,如果入参中存在复杂类型,那么是无法模糊测试的。
接下来就是模糊的核心逻辑了,如何根据输入的参数种子,派生更多的入参用例:
模糊测试的goroutine分为三种
这三种的含义先存疑。
在CheckCorpus中,对入参种子和可变参数进行校验,确保种子数组中每一组都符合要求。
在ReadCorpus中,则是随机取出本次执行的参数,如果是指定fuzz的目录和id,那么会使用指定的目录下的指定参数去执行
ReadCorpus调用了internal的fuzz实现
在ReadCorpus中调用了readCorpusData
不过上面都是执行特定的模糊case 。
在前面已知模糊测试的goroutine中有三种:
Go
const (
seedCorpusOnly fuzzMode = iota
fuzzCoordinator
fuzzWorker
)
第一种 seedCorpusOnly 是 testing.runFuzzTests 中创建的,由testing.M调用
第二种 fuzzCoordinator 是 默认的,如果执行的go命令中没有指定 test.fuzzworker , 默认是 false
在 testing.runFuzzing 中创建的
第三种 fuzzWorker 是由 命令行参数指定的。
根据这里的逻辑,基本上可以看出,seedCorpusOnly 是读取指定模糊case, fuzzCoordinator是生成模糊case,fuzzWorker是执行case。
接着创建一个 testing.T 的对象,然后调用 testing.tRunner 执行case 。
testing.tRunner执行case的参数是 corpusEntry 类型的输入。
也就是说,对于模糊测试,会先直接调用测试种子执行,然后会根据执行情况,在进行随机参数。
如果是 fuzzCoordinator 类型的,那么执行 CoordinateFuzzing
如果是 fuzzWorker ,执行 RunFuzzWorker
如果是 seedCorpusOnly ,执行 run func,相当于直接用测试种子运行
在 fuzzWorker中,对于输出做了重定向。
10. RunFuzzWorker
RunFuzzWorker方法是在internal中实现的
如果一个case经过了10还没有被执行,就认为是饿死了
在serve方法中调用了workerServer.Fuzz 方法
并且是持续性调用的
在workerServer.fuzz中进行模糊测试
在workerServer.fuzz中,第一次调用直接使用种子
接着就是持续性测试了
在 mutator.mutate 中根据种子进行随机
会对一次随机的多个参数,随机选择一个参数,然后对这个参数进行随机
比如对于整型,会随机加或者减一个数
对于字符串,对字节码随机加减
也就是说,如果你的种子里面有中文,才会随机中文。
contains accepts calls calls passes returns TestDeps RunFuzzWorker fn workerServer.fuzz mutator.mutate fuzz.CorpusEntry error
11. CoordinateFuzzing
CoordinateFuzzing方法在internal中实现的
首先会对并发数,缓存目录,日志等进行初始化
根据并发数,创建多个 worker 执行模糊测试
在coordinate中也是持续测试
如果没有启动,那么就调用启动初始化等操作,如果收到了退出信号,那么就退出
如果随机输入已经生成,那么就使用随机输入调用
接着是for-select进行持续性测试,除非模糊测试失败,或者执行模糊测试的时候,有设置超时时间,或者主动退出等
在workerClient.fuzz中执行
也是调用 mutator.mutate 进行随机
同时,在调用FuzzXyz后,会记录case的执行情况,用于分析执行的覆盖率等等
12. 总结
Go 1.18 的 Fuzz 测试使用了一种称为 "coverage-guided fuzzing" 的技术来生成随机输入。这种技术的基本思想是通过监视被测试代码的覆盖率来引导输入的生成。
具体来说,Fuzz 测试首先使用你提供的种子值(seed values)来运行测试。然后,它会监视这些测试运行过程中哪些代码被执行了,以及输入值如何影响代码的执行路径。
接着,Fuzz 测试会尝试修改种子值或者组合种子值,生成新的输入,以尝试覆盖更多的代码路径。例如,如果你的种子值是字符串,Fuzz 测试可能会改变字符串的长度,添加、删除或修改字符,等等。
如果新的输入导致了更多的代码被执行,或者触发了新的代码路径,那么这个输入就会被保存下来,用作后续测试的种子值。这样,Fuzz 测试就可以逐渐 "学习" 如何生成能够触发更多代码路径的输入。
这种方法可以有效地发现一些难以预见的边界情况,特别是那些可能导致程序崩溃或者行为异常的情况。
需要注意的是,虽然 Fuzz 测试可以自动生成大量的输入,但是它并不能保证完全覆盖所有可能的输入。因此,你仍然需要编写单元测试和集成测试,以确保你的代码在预期的输入下能够正确工作。
Coverage-Guided Fuzzing 相关论文和链接
Coverage-guided fuzzing 是一种基于代码覆盖率的模糊测试技术,通过生成输入数据并监控代码覆盖率来发现潜在的错误和漏洞。这种方法的核心思想是通过最大化代码覆盖率来提高测试的有效性。
- "American Fuzzy Lop (AFL)"
AFL 是一种流行的 coverage-guided fuzzing 工具,由 Michał Zalewski 开发。虽然 AFL 本身不是一篇论文,但它的设计和实现对该领域有着重要影响。
- "Fuzzing: Brute Force Vulnerability Discovery"
这篇论文由 Michael Sutton, Adam Greene, 和 Pedram Amini 撰写,详细介绍了模糊测试的基本概念和技术,包括 coverage-guided fuzzing。
- "Coverage-based Greybox Fuzzing as Markov Chain"
这篇论文由 Marcel Böhme, Van-Thuan Pham, 和 Abhik Roychoudhury 撰写,提出了一种基于覆盖率的灰盒模糊测试方法,并将其建模为马尔可夫链。
- "AFLFast: A Framework for Extremely Fast Fuzzing"
这篇论文由 Marcel Böhme, Van-Thuan Pham, Manh-Dung Nguyen, 和 Abhik Roychoudhury 撰写,介绍了 AFLFast,这是一种改进的 AFL 版本,通过优化输入生成策略来提高模糊测试的效率。
- "LibFuzzer: A Library for Coverage-Guided Fuzz Testing"
LibFuzzer 是 LLVM 项目的一部分,提供了一个用于覆盖率引导模糊测试的库。虽然没有正式的论文,但其设计和实现文档非常详细。
- "Fuzzing with Code Fragments"
这篇论文由 Patrice Godefroid, Hila Peleg, 和 Rishabh Singh 撰写,提出了一种基于代码片段的模糊测试方法,通过组合代码片段来生成新的测试输入。
- "Evaluating Fuzz Testing"
这篇论文由 Marcel Böhme, Van-Thuan Pham, Manh-Dung Nguyen, 和 Abhik Roychoudhury 撰写,评估了不同模糊测试工具和技术的有效性,包括 coverage-guided fuzzing。
- "Fuzzing: Art, Science, and Engineering"
这篇论文由 Patrice Godefroid 撰写,全面介绍了模糊测试的艺术、科学和工程,包括 coverage-guided fuzzing 的技术细节和应用。
这些论文和资源提供了关于 coverage-guided fuzzing 的深入理解和最新研究成果。通过阅读这些文献,你可以更好地理解这种技术的原理、实现和应用。