在CloudFlare,我们有很多github.com/miekg/dns Go DNS library的用户,并且我们确保尽可能多的促进它的发展。因此,当dmitry vyukov发布了go-fuzz并开始发现Go标准库中成千上万的bug的时候,我们的就很明确了。
Fuzzing是一种通过持续提供自动变化的输入来测试软件的一种技术。对C/C++来说,非常成功的afl-fuzz工具,由Michal Zalewski使用仪表源覆盖率来判断其推送程序到新路径的变化,最终命中许多罕见测试的分支。
go-fuzz 应用了和Go程序相同的技术,通过重写(如godebug所做的)来检测。afl-fuzz和go-fuzz之间有趣的差异是前者通常对未修改的程序做文件输入操作,而后者请求你去写一个Go function并且传递输入参数进去。前者通常为每一个输入起一个新的进程,后者则通常在无重启的情况下保持调用go function。
关于这个差异没有比较可靠的技术推论(实际上 afl 最近也采取了像 go-fuzz 一样的表现能力),但是它很可能由于他们操作所在的不同生态系统:Go 编程通常公开证据确凿,行为端正的API,是测试人员编写一个良好的 wrapper,不通过调用搞混状态。而且,Go编程往往更容易深入,更可预测,显然多亏了 GC 和内存管理,但是对一般的群体排斥产生意想不到的情形和副作用。另一方面,许多遗留的 C 代码库非常棘手,简单而稳定的输入接口是值得性能权衡的。
回到我的 DNS 库。RRDNS,我们内部的 DNS 服务器,使用 github.com/miekgs/dns 用于它的所有解析需要,并已被证明可以胜任找一个任务。 然而,它在一些边缘极端的案例中有点脆弱,并且有一些不正常的数据包的跟踪记录。让人欣慰的是,找事 Go,不是 C,我们有条件 recover() panics 而不需要担心以疯狂的内存状态结束。以下就是我们正在做的:
func ParseDNSPacketSafely(buf []byte, msg *old.Msg) (err error) { defer func() { panicked := recover() if panicked != nil { err = errors.New("ParseError") } }() err = msg.Unpack(buf) return }
我们找了一个机会是的库更加强壮,所以我们写了这样一个最初的fuzzing function样本:
func Fuzz(rawMsg []byte) int { msg := &dns.Msg{} if unpackErr := msg.Unpack(rawMsg); unpackErr != nil { return 0 } if _, packErr = msg.Pack(); packErr != nil { println("failed to pack back a message") spew.Dump(msg) panic(packErr) } return 1 }
用来创建一个我们用来执行压力和回归测试的套件的初始输入的语料库,并试用gibhub.com/miekg/pcap 来按照 packet 写文件。
package main import ( "crypto/rand" "encoding/hex" "log" "os" "strconv" "github.com/miekg/pcap" ) func fatalIfErr(err error) { if err != nil { log.Fatal(err) } } func main() { handle, err := pcap.OpenOffline(os.Args[1]) fatalIfErr(err) b := make([]byte, 4) _, err = rand.Read(b) fatalIfErr(err) prefix := hex.EncodeToString(b) i := 0 for pkt := handle.Next(); pkt != nil; pkt = handle.Next() { pkt.Decode() f, err := os.Create("p_" + prefix + "_" + strconv.Itoa(i)) fatalIfErr(err) _, err = f.Write(pkt.Payload) fatalIfErr(err) fatalIfErr(f.Close()) i++ } }
(CC BY 2.0 image by JD Hancock)
接着我们和 go-fuzz 一起编译自己写的 Fuzz 方法, 然后在一台试验用的机器上启动 fuzzer。go-fuzz做的第一件事就是减少语料库,会丢弃一些触发相同代码路径的数据包 packet,然后再修改输入值,在一个循环中不断传递给 Fuzz()。 能成功运行的(return 1)或者是可以扩展代码覆盖范围的(expand code coverage)输入值会被保留下来,用作下次循环。如果程序挂掉了,一个小的报告(包含输入和输出)会被保存下来,然后重启程序。 如果你想更深入了解 go-fuzz 的话,可以观看其作者在 GopherCon 上的演讲,或者读 README 文档。
程序崩溃发生了,通常由“索引越界”(index out of bounds)引起的。当崩溃频繁时 go-fuzz 就变得越来越慢了,且效率低下;当 CPU 在不停运行时,我决定修复一些 bug。
在一些情境下我决定更改分析的模式,比如 reslicing 和使用 len() 而不是维护一个偏移量。然而这些更改可能会引起问题 —— 我还远不能算是优秀——所有我采用了 Fuzz 来监视新旧代码之间的区别,在修复之后,如果新的分析器拒绝了正常数据包或者行为有改变时就退出运行:
func Fuzz(rawMsg []byte) int { var ( msg, msgOld = &dns.Msg{}, &old.Msg{} buf, bufOld = make([]byte, 100000), make([]byte, 100000) res, resOld []byte unpackErr, unpackErrOld error packErr, packErrOld error ) unpackErr = msg.Unpack(rawMsg) unpackErrOld = ParseDNSPacketSafely(rawMsg, msgOld) if unpackErr != nil && unpackErrOld != nil { return 0 } if unpackErr != nil && unpackErr.Error() == "dns: out of order NSEC block" { // 97b0a31 - rewrite NSEC bitmap [un]packing to account for out-of-order return 0 } if unpackErr != nil && unpackErr.Error() == "dns: bad rdlength" { // 3157620 - unpackStructValue: drop rdlen, reslice msg instead return 0 } if unpackErr != nil && unpackErr.Error() == "dns: bad address family" { // f37c7ea - Reject a bad EDNS0_SUBNET family on unpack (not only on pack) return 0 } if unpackErr != nil && unpackErr.Error() == "dns: bad netmask" { // 6d5de0a - EDNS0_SUBNET: refactor netmask handling return 0 } if unpackErr != nil && unpackErrOld == nil { println("new code fails to unpack valid packets") panic(unpackErr) } res, packErr = msg.PackBuffer(buf) if packErr != nil { println("failed to pack back a message") spew.Dump(msg) panic(packErr) } if unpackErrOld == nil { resOld, packErrOld = msgOld.PackBuffer(bufOld) if packErrOld == nil && !bytes.Equal(res, resOld) { println("new code changed behavior of valid packets:") println() println(hex.Dump(res)) println(hex.Dump(resOld)) os.Exit(1) } } return 1 }
我对健壮性很是满意。因为我们在 RRDNS 中用到的是 ParseDNSPacketSafely 包装代码,就没有期望能找到一些安全漏洞。但是,我错了!
DNS 域名由标签组成,显示时通常用点号分隔。为了节省空间,标签可以使用指向其他名称的指针代替。因此,如果我们知道自己将 example.com 编码到偏移为 15 的位置,那么,www.example.com 可以被包装成 www.+ PTR(15)。我们发现了一个处理指向空域名的指针时的 bug:当遇到域名 (0x00) 结尾时,如果没有读到标签,“.”(空域名)将作为一个特例返回。 问题是这个特例没有意识到指针,而且它将引导解析器从指向的空域名而不是原始域名的尾部重新开始读取。
例如,如果解析器在偏移 60 的位置遇到了指向偏移 15 的指针,而且 msg[15]==0x00,接下来,解析器将会从偏移 16 而不是 61 重新开始,引起死循环。这是一种潜在的拒绝服务漏洞。
A) 解析到位置60,发现了一个DNS域名 | ... | 15 | 16 | 17 | ... | 58 | 59 | 60 | 61 | | ... | 0x00 | | | ... | | | ->15 | | -------------------------------------------------> B) 跟随指针转到位置15 | ... | 15 | 16 | 17 | ... | 58 | 59 | 60 | 61 | | ... | 0x00 | | | ... | | | ->15 | | ^ | ------------------------------------------ C) 返回一个空名称 ".", 特例被触发 D) 从位置16而不是61错误的重新开始 | ... | 15 | 16 | 17 | ... | 58 | 59 | 60 | 61 | | ... | 0x00 | | | ... | | | ->15 | | --------------------------------> E) 重复
我们给自己的服务器打了补丁,同时把修复非公开地发送给库维护人员,然后我们公开了一个 PR。(碰巧,当我们发布 RRDNS 更新时,Miek 独立发现并修复了两个 bug。)
得益于灵活的 fuzzing API,go-fuzz 可以优雅的发现会导致 crash 的输入,但是不仅于此,它还可以用于查找所有边界用例有问题的场景。
有用的应用包括,通过在 Fuzz() 函数添加对 crash 的判断,来检查输出的合法性;还可以比较一个 unpack-pack 链两端是否相同,甚至是比较同一个函数的不同版本的行为是否有区别。
例如,我们的DNSSEC引擎加载的时候,我碰到一个诡异的bug,只会在正式环境或者压力测试的时候发生:NSEC记录期望只在其类型的bitmap上设置若干位,但是偶尔会出现下面的情况
deleg.filippo.io. IN NSEC 3600