Go语言 HTTP 服务模糊测试教程
- Published on
作为开发人员,我们并不总能预见到程序或函数可能接收到的所有可能输入。
即使我们可以定义主要的边界情况,但仍然无法预测程序在面对一些奇怪的意外输入时会如何表现。换句话说,我们通常只能发现预期会出现的bug。
这就是模糊测试(Fuzzing
)派上用场的地方。在本教程中,你将学习如何在 Go 中进行模糊测试。
什么是模糊测试?
模糊测试是一种自动化的软件测试技术,它涉及向计算机程序输入大量有效的、接近有效的或无效的随机数据,并观察其行为和输出。模糊测试的目标是揭示通过传统测试方法可能无法发现的bug、崩溃和安全漏洞。
这段Go代码在正常情况下运行良好,除非你提供某些特定输入:
func Equal(a []byte, b []byte) bool {
for i := range a {
// 可能会因运行时错误而崩溃:索引超出范围
if a[i] != b[i] {
return false
}
}
return true
}
这个示例函数在两个切片长度相等时可以完美工作。但当第一个切片比第二个长时,它会发生崩溃(索引超出范围错误
)。此外,当第二个切片是第一个切片的子集时,它也不会返回正确的结果。
模糊测试技术通过用各种输入轰炸这个函数,可以轻松发现这个bug。
将模糊测试集成到团队的软件开发生命周期(SDLC)中也是一个很好的实践。例如,微软在其SDLC中使用模糊测试作为阶段之一,以发现潜在的bug和漏洞。
Go中的模糊测试
虽然已经有许多模糊测试工具存在很长时间了(例如 oss-fuzz
),但自 Go 1.18 开始,模糊测试被添加到了Go的标准库中。现在它作为常规测试包的一部分,因为它是测试的一种。你还可以将它与其他测试原语一起使用,这很方便。
在Go中创建模糊测试的步骤如下:
- 在
_test.go
文件中,创建一个以Fuzz
开头并接受*testing.F
的函数 - 使用
f.Add()
添加语料库种子,让模糊测试器基于它生成数据 - 使用
f.Fuzz()
调用模糊测试目标,传递我们的目标函数接受的模糊测试参数 - 使用常规的
go test
命令启动模糊测试器,但要加上--fuzz=Fuzz
标志
注意,模糊测试参数只能是以下类型:
- string, byte, []byte
- int, int8, int16, int32/rune, int64
- uint, uint8, uint16, uint32, uint64
- float32, float64
- bool
上面Equal函数的简单模糊测试可能如下所示:
// 模糊测试
func FuzzEqual(f *testing.F) {
// 添加种子语料库
f.Add([]byte{'f', 'u', 'z', 'z'}, []byte{'t', 'e', 's', 't'})
// 带有模糊测试参数的模糊测试目标
f.Fuzz(func(t *testing.T, a []byte, b []byte) {
// 调用我们的目标函数并传递模糊测试参数
Equal(a, b)
})
}
默认情况下,模糊测试会永远运行,所以你要么需要指定时间限制,要么等待模糊测试失败。你可以使用 --fuzz
参数指定要运行的测试。
go test --fuzz=Fuzz -fuzztime=10s
如果执行过程中出现任何错误,输出应该类似于这样:
go test --fuzz=Fuzz -fuzztime=30s
--- FAIL: FuzzEqual (0.02s)
--- FAIL: FuzzEqual (0.00s)
testing.go:1591: panic: runtime error: index out of range
Failing input written to testdata/fuzz/FuzzEqual/84ed65595ad05a58
To re-run:
go test -run=FuzzEqual/84ed65595ad05a58
注意,导致模糊测试失败的输入被写入 testdata
文件夹中的文件,可以使用该输入标识符重新运行:
go test -run=FuzzEqual/84ed65595ad05a58
testdata
文件夹可以提交到代码库中,并用于常规测试,因为模糊测试在没有 --fuzz
标志的情况下也可以作为常规测试运行。
HTTP服务的模糊测试
通过为你的 HandlerFunc
编写测试并使用 httptest
包,也可以对 HTTP 服务进行模糊测试。如果你需要测试整个 HTTP 服务而不仅仅是底层函数,这会非常有用。
让我们现在介绍一个更真实的示例,比如一个在请求体中接受用户输入的 HTTP Handler,然后为它编写模糊测试。
我们的处理程序接受一个带有 limit
和 offset
字段的 JSON 请求,用于对一些静态模拟数据进行分页。让我们先定义类型:
type Request struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type Response struct {
Results []int `json:"items"`
PagesCount int `json:"pagesCount"`
}
然后我们的处理函数解析 JSON,对静态切片进行分页,并在响应中返回新的 JSON。
func ProcessRequest(w http.ResponseWriter, r *http.Request) {
var req Request
// 解码 JSON 请求
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 对一些静态数据应用 offset 和 limit
all := make([]int, 1000)
start := req.Offset
end := req.Offset + req.Limit
res := Response{
Results: all[start:end],
PagesCount: len(all) / req.Limit,
}
// 发送 JSON 响应
if err := json.NewEncoder(w).Encode(res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
你可能已经注意到,这个函数处理切片操作不太好,很容易发生崩溃。另外,如果试图除以0,它也会崩溃。如果我们能在开发期间或仅使用单元测试就发现这一点那很好,但有时并不是所有东西都能被我们看到,而且我们的处理程序可能会将输入传递给其他函数等等。
按照上面的 FuzzEqual
示例,让我们为 ProcessRequest
处理程序实现一个模糊测试。首先我们需要为模糊测试器提供示例输入。这是模糊测试器将用来修改并尝试的数据。我们可以制作一些示例 JSON 请求,并使用 f.Add()
和 []byte
类型。
func FuzzProcessRequest(f *testing.F) {
// 为模糊测试器创建示例输入
testRequests := []Request{
{Limit: -10, Offset: -10},
{Limit: 0, Offset: 0},
{Limit: 100, Offset: 100},
{Limit: 200, Offset: 200},
}
// 添加到种子语料库
for _, r := range testRequests {
if data, err := json.Marshal(r); err == nil {
f.Add(data)
}
}
// ...
}
之后我们可以使用 httptest
包创建一个测试 HTTP 服务器并向其发出请求。
注意:由于我们的模糊测试器可能生成无效的非 JSON 请求,最好直接跳过它们并使用 t.Skip()
忽略。我们也可以跳过 BadRequest
错误。
func FuzzProcessRequest(f *testing.F) {
// ...
// 创建测试服务器
srv := httptest.NewServer(http.HandlerFunc(ProcessRequest))
defer srv.Close()
// 带有单个 []byte 参数的模糊测试目标
f.Fuzz(func(t *testing.T, data []byte) {
var req Request
if err := json.Unmarshal(data, &req); err != nil {
// 跳过模糊测试期间可能生成的无效 JSON 请求
t.Skip("invalid json")
}
// 将数据传递给服务器
resp, err := http.DefaultClient.Post(srv.URL, "application/json", bytes.NewBuffer(data))
if err != nil {
t.Fatalf("unable to call server: %v, data: %s", err, string(data))
}
defer resp.Body.Close()
// 跳过 BadRequest 错误
if resp.StatusCode == http.StatusBadRequest {
t.Skip("invalid json")
}
// 检查状态码
if resp.StatusCode != http.StatusOK {
t.Fatalf("non-200 status code %d", resp.StatusCode)
}
})
}
我们的模糊测试目标有一个类型为 []byte
的单个参数,其中包含完整的 JSON 请求,但你可以更改它以具有多个参数。
现在一切都准备好了,可以运行我们的模糊测试了。在对 HTTP 服务器进行模糊测试时,你可能需要调整并行工作器的数量,否则负载可能会使测试服务器不堪重负。你可以通过设置 -parallel=1
标志来做到这一点。
go test --fuzz=Fuzz -fuzztime=10s -parallel=1
go test --fuzz=Fuzz -fuzztime=30s
--- FAIL: FuzzProcessRequest (0.02s)
--- FAIL: FuzzProcessRequest (0.00s)
runtime error: integer divide by zero
runtime error: slice bounds out of range
正如预期的那样,我们会看到上述错误被发现。
我们还可以在 testdata
文件夹中看到模糊测试输入,看看是哪个 JSON 导致了这个失败。这是文件的示例内容:
go test fuzz v1
[]byte("{"limit":0,"offset":0}")
要修复这个问题,我们可以引入输入验证和默认设置:
if req.Limit <= 0 {
req.Limit = 1
}
if req.Offset < 0 {
req.Offset = 0
}
if req.Offset > len(all) {
start = len(all) - 1
}
if end > len(all) {
end = len(all)
}
有了这个改变,模糊测试将运行10秒并在没有错误的情况下退出。
结论
为你的 HTTP 服务或任何其他方法编写模糊测试是发现难以发现的 bug 的好方法。模糊测试器可以检测到只在某些奇怪的意外输入时才会发生的难以发现的 bug。
看到模糊测试成为 Go 内置测试库的一部分是很棒的,这使得它很容易与常规测试结合使用。注意:在 Go 1.18 之前,开发人员使用 go-fuzz
,这也是一个很好的模糊测试工具。