提升Go-Fuzz状态:类型别名修复、字典支持与新变异策略

本文介绍了针对Go语言覆盖率模糊测试工具go-fuzz的三项关键改进:修复类型别名导致的崩溃问题、集成字典支持以提升语法感知程序的测试效果,以及开发新的变异策略如插入重复字节和LEB128编码。

提升Go-Fuzz的状态

在我的冬季实习期间,我利用近期Go审计的发现,对go-fuzz(一个用于Go项目的基于覆盖率的模糊测试工具)进行了多项改进。我专注于三个增强功能,以提高Go模糊测试活动的有效性并为用户提供更好的体验。我贡献了修复类型别名问题、集成字典支持以及开发新的变异策略。

什么是go-fuzz?

go-fuzz通过向程序提供随机输入并监控错误来发现软件缺陷。它由两个主要组件组成:go-fuzz和go-fuzz-build。go-fuzz-build组件负责源代码插桩。一旦目标程序的源代码被插桩,代码就会被编译,然后二进制文件被go-fuzz用于模糊测试活动。

用户首先对工具的源代码进行插桩,以提取运行时覆盖等信息。然后,go-fuzz使用一组给定的输入执行程序,这些输入在每次交互时发生变异,以尝试增加覆盖率并触发导致崩溃的意外行为。用户提供的harness是模糊测试的入口点,并调用要模糊测试的函数。它向go-fuzz返回一个值,指示输入是否应在输入语料库中丢弃或提升。

go-fuzz在发现新缺陷方面非常成功,该工具已帮助发现GitHub上突出的200多个缺陷,以及在Trail of Bits审计期间的更多缺陷。

使用类型别名进行插桩

此缺陷仅在Go版本1.16及更高版本中,以及通过os包而非fs包与文件系统交互时发生。由于许多项目与文件系统交互,此问题非常重要,因此我提出的改进对于提高go-fuzz的可用性至关重要。

尽管存在此缺陷的变通方法,但仍需要通过添加使用fs包的语句来修改代码。这不是理想的解决方案,因为它需要手动修改代码,可能会影响模糊测试harness。

另一种解决问题的方法是简单地使用Go版本1.15。然而,这也有问题,因为我们并不总是能够用较低版本的Go运行要模糊测试的项目。因此,我们希望找到一个彻底的解决方案,不需要这些限制。

错误没有指向崩溃的根本原因,因此我们需要进行详细分析。

缺陷复现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package homedir

import (
	"os"
	"fmt"
)

func HomeDir() {
	p := "text.txt"
	info, _ := os.Stat(p)

	if info.Mode().Perm()&(1<<(uint(7))) & 1 != 0 {
		fmt.Println("test")
	}
}

为了成功使用go-fuzz-build对程序进行插桩,我们需要提供一个模糊测试harness。由于我们只想对程序进行插桩,harness不需要调用HomeDir函数。因此,我在另一个文件中实现了harness,但与HomeDir函数在同一个包中,以便可以在不调用函数的情况下进行插桩,从而允许我们调查问题。

1
2
3
4
5
package homedir

func Fuzz(data []byte) int {
	return 1
}

查看此代码后,go-fuzz-build崩溃的原因似乎更加令人困惑。崩溃与fs包相关,但程序中未使用fs包:

1
2
failed to execute go build: exit status 2
homedir.go:13: undefined: fs in fs.FileMode

缺陷分类

有趣的是,此缺陷并非由go-fuzz的特定提交引入,而是在go-fuzz与Go版本1.16及更高版本一起使用时出现,这意味着从Go版本1.15到1.16的某些更改必须是此问题的原因。

由于崩溃发生在编译插桩源代码时,找出go-fuzz-build崩溃的位置很容易。插桩以某种方式出错,并生成了无效代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//line homedir.go:1
package homedir

//line homedir.go:1
import (
//line homedir.go:1
	_go_fuzz_dep_ "go-fuzz-dep"
//line homedir.go:1
)

import (
	"os"
	"fmt"
)

//line homedir.go:8
func HomeDir() {
//line homedir.go:8
	_go_fuzz_dep_.CoverTab[20570]++
	p := "text.txt"
	info, _ := os.Stat(p)

//line homedir.go:13
	if func() _go_fuzz_dep_.Bool {
//line homedir.go:13
		__gofuzz_v1 := fs.FileMode(info.Mode().Perm() & (1 << 7))
//line homedir.go:13
		_go_fuzz_dep_.Sonar(__gofuzz_v1, 0, 725889)
//line homedir.go:13
		return __gofuzz_v1 != 0
//line homedir.go:14
	}() == true {
//line homedir.go:14
		_go_fuzz_dep_.CoverTab[5104]++
		fmt.Println("test")
	} else {
//line homedir.go:14
		_go_fuzz_dep_.CoverTab[24525]++
//line homedir.go:14
	}

//line homedir.go:14
}

//line homedir.go:15
var _ = _go_fuzz_dep_.CoverTab

根本原因分析

原始程序第13行的表达式(info.Mode().Perm() & (1 << 7))被显式转换为fs.FileMode类型。此类型转换是插桩执行的修改之一。类型转换本身是正确的,因为info.Mode().Perm()的类型是fs.FileMode。真正的问题是,虽然使用了fs包,但缺少对其的导入。因此,编译器无法解析类型转换,编译失败。

然而,这并没有回答为什么go-fuzz-build在Go版本1.16及更高版本中崩溃,而在较低版本中不崩溃的问题。我们通过查看1.15和1.16之间的差异找到了答案:os包中的FileMode类型从Go 1.15中的type FileMode uint32更改为Go 1.16中的type FileMode = fs.FileMode

本质上,FileMode类型从具有底层uint32类型的类型定义更改为具有在fs包中定义的类型目标的类型别名。类型别名不会创建新类型。相反,它只是为原始类型定义一个新名称。因此,go-fuzz-build使用的类型检查器将fs.FileMode识别为应用于类型转换的类型,而不是os包中定义的类型别名。如果类型别名和原始类型在同一个包中,这应该不是问题,但如果有多个包,则应将相应的导入语句添加到插桩代码中。

提议的修复

理想情况下,此问题的修复应该是面向未来的。虽然可以硬编码fs.FileMode的情况,但这还不够,因为未来的Go版本或模糊测试代码使用的外部包中可能会引入其他类型别名,因此需要更多修复。我提议的修复解决了这个问题。

我提议的修复包括以下步骤。首先,分析每个插桩文件的类型检查器输出,查找在未导入包中定义的类型。如果存在此类类型,将添加带有相应包的导入语句。然而,可能存在此类类型存在但插桩不使用它执行类型转换的情况。这将使添加的导入语句变为未使用的导入,因此编译器将拒绝编译代码。因此,必须删除未使用的添加导入。为此,将在编译前执行goimports(一个优化导入的程序)。然后,编译成功。

由于初始化器由定义别名的包导入,因此保证包仅执行一次。因此,我们不必担心导入语句会改变源代码的语义。

字典支持

通用模糊测试器的变异引擎在设计上对变异二进制数据格式(如图像或压缩数据)非常有效。然而,通用模糊测试器在变异语法感知程序的输入时表现不佳,因为它们只接受对底层语法有效的输入。此类目标的常见示例是解析人类可读数据格式(如语言SQL)或基于文本的协议(如HTTP、FTP)的程序。

大多数时候,为了在此类人类可读目标上取得良好结果,必须构建一个符合语法的自定义变异引擎,这既复杂又耗时。

另一种方法是使用模糊测试字典。模糊测试字典是与所需语法相关的有趣关键字的集合。这种方法允许通用变异引擎将关键字随机插入输入中。这使模糊测试器获得比字节变异更多的有效输入。

到目前为止,go-fuzz的变异引擎使用称为“令牌捕获”的技术生成关键字列表,并将这些关键字插入变异的输入中。该技术通过查找硬编码值直接从插桩代码中提取有趣的字符串。这种方法的理由是,如果输入需要某种语法,程序中会有检查输入有效性的硬编码语句。虽然令牌捕获是正确的,但它有一个重要的缺点:不仅提取了相关的关键字,还提取了与输入无关的关键字,如日志消息字符串。这是有问题的,因为向字符串列表添加噪声会降低模糊测试器的整体有效性。

另一种方法是让用户提供包含与目标程序使用的特定语法相关的有趣关键字的字典。我提议了一个修改,允许向go-fuzz传递-dict参数,并提供包含关键字(以AFL/libFuzzer格式)的字典文件和一个级别参数,以对字典文件中的令牌提供更细粒度的控制。

以下示例说明了SQL的令牌字典语法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
false]
function_abs=" abs(1)"
function_avg=" avg(1)"
function_changes=" changes()"
function_char=" char(1)"
function_coalesce=" coalesce(1,1)"
function_count=" count(1)"
function_date=" date(1,1,1)"
(...)
keyword_ADD="ADD"
keyword_AFTER="AFTER"
keyword_ALL="ALL"
keyword_ALTER="ALTER"
keyword_ANALYZE="ANALYZE"
keyword_AND="AND"
(...)

通过采用AFL和libFuzzer使用的字典的相同语法,我们可以重用包含特定模糊测试目标的重要关键字的现有字典,而无需以新格式重新定义关键字。

新变异策略

模糊测试器的有效性取决于其变异算法的质量,以及它们是否导致更多样化的输入和增加的代码覆盖率。为此,我为go-fuzz开发了三种新的变异策略。

插入重复字节

此策略通过插入随机重复次数的随机字节来变异模糊测试器的先前输入。这是libFuzzer的插入随机字节策略的变体,增加了某些字节重复的情况的可能性。

洗牌字节

另一种受libFuzzer启发的变异策略,洗牌字节选择具有随机长度的输入的随机子部分,并使用Fisher-Yates洗牌算法对其进行洗牌。

小端基128

最后但并非最不重要的是,我通过实现小端基128(LEB128)编码改进了InsertLiteral变异策略。与上面字典部分讨论的插入字符串字面量的过程类似,通过这种改进的策略,变异引擎扫描源代码中的硬编码整数值,并将其插入输入中以进行变异。

将字符串插入输入中很简单,因为字符串具有直接的字节表示。整数值则不是这种情况,因为根据整数的长度(8、16、32和64位)以及整数存储的端序(小端或大端),有多种格式将值存储在字节中。因此,变异引擎需要能够以不同格式插入整数字面量,因为它们可能被模糊测试的程序使用。

LEB128是一种可变长度编码算法,能够使用很少的字节存储整数。特别是,LEB128可以存储没有前导零字节的小整数值以及任意大的整数。此外,LEB128编码有两种不同的变体,必须分别实现:无符号LEB128和有符号LEB128。

由于其效率,这种编码非常流行,并用于许多项目,如LLVM、DWARF和Android DEX。因此,go-fuzz对其的支持非常有用。

go-fuzz的未来

Go版本1.18的最新发布引入了对模糊测试的一方支持。因此,go-fuzz已走到生命尽头,未来的改进很可能仅限于缺陷修复。尽管如此,增强go-fuzz仍然有用,因为它是一个众所周知的解决方案,拥有有用的工具生态系统,如trailofbits/go-fuzz-utils,并且可能仍在旧项目中使用。

我希望提议的改进将被上游采纳到go-fuzz中,以便每个人都可以从中受益,发现和修复新缺陷。尽管Go的新内置模糊测试器由于其易用性而将获得普及,但我们希望Go开发人员继续从go-fuzz中汲取灵感,它迄今为止取得了巨大成功。看到Go项目模糊测试的未来将会很有趣。

我非常感谢有机会在Trail of Bits实习并从事go-fuzz项目——这是一次很好的学习经历。我要感谢我的导师Dominik Czarnota和Rory Mackie的指导和支持。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计