提升 Go 模糊测试:类型别名修复、字典支持与新变异策略

本文基于 Go 审计发现,对 go-fuzz 进行了三项改进:修复类型别名导致的崩溃问题,集成 AFL/libFuzzer 格式字典支持,并开发了插入重复字节、字节洗牌和 LEB128 编码等新变异策略,提升模糊测试效果。

提升 Go 模糊测试的状态

在我实习期间,我利用最近 Go 审计的发现对 go-fuzz 进行了多项改进。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 设计