在CloudFlare,我们有很多github.com/miekg/dns Go DNS library的用户,并且我们确保尽可能多的促进它的发展。因此,当dmitry vyukov发布了go-fuzz并开始发现Go标准库中成千上万的bug的时候,我们的就很明确了。  Hot Fuzz 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 而不需要担心以疯狂的内存状态结束。以下就是我们正在做的: 1 2 3 4 5 6 7 8 9 10 11 12 13  | 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样本: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15  | 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 写文件。 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  | 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.0image 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 来监视新旧代码之间的区别,在修复之后,如果新的分析器拒绝了正常数据包或者行为有改变时就退出运行: 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 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66  | 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" {
         
         return 0
     }
       if unpackErr != nil && unpackErr.Error() == "dns: bad rdlength" {
         
         return 0
     }
       if unpackErr != nil && unpackErr.Error() == "dns: bad address family" {
         
         return 0
     }
       if unpackErr != nil && unpackErr.Error() == "dns: bad netmask" {
         
         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 重新开始,引起死循环。这是一种潜在的拒绝服务漏洞。 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  | 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。) 
 
 不仅是 crash 和 hang
 得益于灵活的 fuzzing API,go-fuzz 可以优雅的发现会导致 crash 的输入,但是不仅于此,它还可以用于查找所有边界用例有问题的场景。 有用的应用包括,通过在 Fuzz() 函数添加对 crash 的判断,来检查输出的合法性;还可以比较一个 unpack-pack 链两端是否相同,甚至是比较同一个函数的不同版本的行为是否有区别。 例如,我们的DNSSEC引擎加载的时候,我碰到一个诡异的bug,只会在正式环境或者压力测试的时候发生:NSEC记录期望只在其类型的bitmap上设置若干位,但是偶尔会出现下面的情况 1  | deleg.filippo.io.  IN  NSEC    3600    \000.deleg.filippo.io. NS WKS HINFO TXT AAAA LOC SRV CERT SSHFP RRSIG NSEC TLSA HIP TYPE60 TYPE61 SPF
  |  
 问题的原因是我们的"pack和send"代码使用了一个[]byte的缓存来避免频繁的GC和内存分配,因此dns.msg.PackBuffer(buf []byte)调用时有可能会传入之前使用的“脏”数据。 1 2 3 4 5 6 7 8 9 10 11 12  | var bufpool = sync.Pool{  
     New: func() interface{} {
         return make([]byte, 0, 2048)
     },
 }
   [...]
       data := bufpool.Get().([]byte)
     defer bufpool.Put(data)
       if data, err = r.Response.PackBuffer(data); err != nil {
  |  
 不过,如果buf不是一个用0填充的数组的话,某些支持github.com/miekgs/dns的打包程序是不支持的,包括NSEC rdata,它只会与已有的位进行或运算,对于不需要的位,是不会清除的。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23  | case `dns:"nsec"`:  
     lastwindow := uint16(0)
     length := uint16(0)
     for j := 0; j < val.Field(i).Len(); j++ {
         t := uint16((fv.Index(j).Uint()))
         window := uint16(t / 256)
         if lastwindow != window {
             off += int(length) + 3
         }
         length = (t - window*256) / 8
         bit := t - (window * 256) - (length * 8)
           msg[off] = byte(window) 
         msg[off+1] = byte(length + 1) 
           
 --->    msg[off+2+int(length)] |= byte(1 << (7 - bit)) 
           lastwindow = window
     }
     off += 2 + int(length)
     off++
 }
  |  
 修复很简单:我们评估了一些把 buffer 清零的方法,代码更新如下 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17  | var zeroBuf = make([]byte, 65535)
   var bufpool = sync.Pool{  
     New: func() interface{} {
         return make([]byte, 0, 2048)
     },
 }
   [...]
       data := bufpool.Get().([]byte)
     defer bufpool.Put(data)
     copy(data[0:cap(data)], zeroBuf)
       if data, err = r.Response.PackBuffer(data); err != nil {
  |  
 注意:一个最近的优化把循环清零改成调用memclr,所以1.5上线后会比copy()快很多。 
 
 不过这样修复挺恶心的!怎么样才能让我们的库工作正常,而不用关心传入的参数是不是用到了缓存?幸运的是,这正是 fuzzing 擅长的:确保所有的代码路径分支有相同的表现。 现在我要做的就是写一个 Fuzz() 函数,这个函数首先会解析消息体,然后将其打包成两个不同的 buffer:一个用 0 填充,一个用 0xff 填充。只要结果有区别,就能告诉我们底层的 buffer 与实际输出哪里匹配不上了。 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  | func Fuzz(rawMsg []byte) int {  
     var (
         msg         = &dns.Msg{}
         buf, bufOne = make([]byte, 100000), make([]byte, 100000)
         res, resOne []byte
           unpackErr, packErr error
     )
       if unpackErr = msg.Unpack(rawMsg); unpackErr != nil {
         return 0
     }
       if res, packErr = msg.PackBuffer(buf); packErr != nil {
         return 0
     }
       for i := range res {
         bufOne[i] = 1
     }
       resOne, packErr = msg.PackBuffer(bufOne)
     if packErr != nil {
         println("Pack failed only with a filled buffer")
         panic(packErr)
     }
       if !bytes.Equal(res, resOne) {
         println("buffer bits leaked into the packed message")
         println(hex.Dump(res))
         println(hex.Dump(resOne))
         os.Exit(1)
     }
       return 1
 }
  |  
 到此我希望能够解决所有的问题,不过 go-fuzz 做的很不错,我们会继续收到报错,然后修复问题。 总之,所有的修复完成并且 go-fuzz 不再报错后,我们可以把 buffer 填充 0 的逻辑去掉,而不用再对整个代码库进行测试! 你能想象 fuzzing 每天支撑 430 亿的请求量吗?我们正在招聘,伦敦、旧金山和新加坡都有职位!  |