2015 年 7 月 28 日,世界上应用最广泛的 DNS 服务器 bind9 爆出了一个严重的拒绝服务漏洞(CVE-2015-5477)。

一点背景知识:DNS 是把域名映射到 IP 地址的服务。当你访问 google.com 时,计算机就会问你所在小区的 DNS 服务器,google.com 的 IP 地址是什么?如果你的邻居刚好也在访问 google.com,DNS 服务器就会直接返回其 IP;不然,这个 DNS 服务器就会去问 Google 官方的 DNS 服务器,得到 google.com 的 IP 地址,并返回给你。这个小区的 DNS 服务器叫做递归 DNS;递归 DNS 挂了,会导致它服务的区域无法上网。Google 官方的 DNS 服务器叫做权威 DNS;权威 DNS 挂了,会导致它所服务的网站从地球上消失。

DNS 递归查询DNS 递归查询
DNS 递归查询 (图片来源

这个漏洞严重到什么程度呢?只要发一个 UDP 数据包,就能搞挂一台 DNS 服务器。不管是递归 DNS 还是权威 DNS,不管是 bind9 做了什么样的配置,只要这个数据包被 bind9 进程接收了,它就会立刻抛出异常,终止服务。

LUG DNS 的维护者 Roy Zhang 从 Debian Security Notice 中得知了这一漏洞并很快打上了补丁。我写 POC 测试了一些 DNS 服务器,把学校 DNS 搞挂了,并报告了网络中心 james 大大(随后收到感谢),测试的大多数运营商 DNS 和规模较小的一些 public DNS 也受该漏洞影响。现在距离漏洞公开已经超过 72 小时,但这个严重的漏洞尚未得到足够的重视。在此把 POC (Proof of Concept exploit code) 放出来,也与大家分享写 POC 的过程。

漏洞在哪里

要及时得知漏洞信息,建议订阅你所关心的发行版的 Security Tracker。比如 Debian 关于此次漏洞的 公告,从 Source 栏可以链接到漏洞来源(一般是 CVE)和其他发行版的安全公告。Description 是这样的:

1
named in ISC BIND 9.x before 9.9.7-P2 and 9.10.x before 9.10.2-P3 allows remote attackers to cause a denial of service (REQUIRE assertion failure and daemon exit) via TKEY queries.

进一步了解这个漏洞的最好途径就是源码。修复这个漏洞,bind9 的代码做了哪些修改,漏洞就出在什么地方。问 Google 找到 bind9 的源码树(Gitweb),在 commit log 里能够发现这么一行

1
2015-07-14 	Mark Andrews	add CVE-2015-5477

这只是一个说明,真正的代码修改在它之前。我们可以翻阅 commit log,找到真正的代码修改。

Screenshot from 2015-08-01 10:28:53Screenshot from 2015-08-01 10:28:53

细心的读者也许发现了,commit 的时间是 2015 年 7 月 14 日,这是半个月以前的事啊!是的,漏洞修复和公开的流程就是这样。

  1. 漏洞报告,此时是只有漏洞报告人和 bind9 的安全团队知道的。
  2. bind9 进行漏洞修复。
  3. 通知给一些 “重要厂商”(包括主要发行版、有合作关系的大公司)。
  4. 在协商好的时间公开发布。
    如果大家盯着一些开源软件的仓库看,会发现一些安全漏洞被修复了,但网络上几乎搜不到任何信息。几天过后,CVE 数据库里能查到了,各大发行版发布安全公告,hacker news 之类的媒体也开始报道。也就是说,当我们从 “官方渠道” 得知一个漏洞的时候,已经不是 0day 了,连 1day 都不是。

祸起 ASSERTION

言归正传。这个漏洞的修复很简单,只是增加了 name = NULL; 这一句话。问题描述说,非法的数据包会导致 assertion fail 并退出。

DNS 查询是一个 UDP 数据包,提出一个问题;DNS 服务器会响应一个 UDP 数据包,告诉所查询问题的答案。DNS 查询和响应的数据包格式是相同的,都由问题、回答、权威信息、附加信息等部分构成。

DNS 请求格式(图片来源

有问题的代码块是这样(在 dns_tkey_processquery 函数中):

Screenshot from 2015-08-01 10:43:52Screenshot from 2015-08-01 10:43:52

调用过程是这样:

  1. 从 DNS 请求的 QUESTION 块中找到待查询的名字,存入 qname。比如我们查询 google.com,QUESTION 块中就有一个问题,其名字是 google.com。
  2. 从 DNS 请求的 ADDITIONAL 块中找到与待查询名字(qname)相符的名字,存入 name。对于合法的 TKEY 请求,这一块放的应该是 transaction key(这不重要,有兴趣的同学可以去看 RFC 2930)。
  3. 如果在 ADDITIONAL 块中没找到,再去尝试从 ANSWER 块中找。(尼玛 Win2000 的开发人员脑抽了吗,明明这是一个问题,却把 TKEY 放到答案块里,这是闹哪样)
    我们再看 dns_message_findname 的实现。

Screenshot from 2015-08-01 10:46:36Screenshot from 2015-08-01 10:46:36

它会检查输入的 name 指针是否为空,如果不是空指针,就通过 REQUIRE 宏抛出异常(确实,C 语言没有异常……效果差不多而已)这是一个好的编码实践:在函数调用开头检查参数的合法性。然而,正如这段代码中的注释所说,检查得太严格了……结果是要放进 name 指针的,为啥要管 name 指针之前的值是不是空呢!

问题来了

如果 dns_tkey_processquery 中的第一个 dns_message_findname 对 name 赋了非空值,而函数的返回值不是成功,就会以非空的 name 值调用第二个 dns_message_findname,造成异常。(漏洞的修复方法就是在第二次调用之前,把 name 赋值为空)

以下 dns_message_findname 的实现中:

  1. target(也就是调用者的 qname)去 ADDITIONAL 块里查找,如果没找到,返回一个错误码,这不会有问题。
  2. 如果找到了,name 就会被赋值。下面我们要设法让它返回失败。
  3. 接下来检查所找到记录的类型。如果是 dns_rdatatype_any(即 ANY)或 dns_rdatatype_tkey(即 TKEY)类型的记录,就返回成功,否则返回失败。
    Screenshot from 2015-08-01 11:17:37Screenshot from 2015-08-01 11:17:37

我们只要在 ADDITIONAL 块中,添加一个名字相同,但类型不匹配的记录就行了!比如,我们的 QUESTION 块放一个询问 ring0.me 的 TKEY 记录,再在 ADDITIONAL 块中放一个 ring0.me 的 A 记录,就会触发异常,导致 bind9 进程退出。

构造 payload

这个漏洞只是会造成 bind9 退出,并不能用来执行任意指令,因此比起大多数缓冲区溢出漏洞来说,写 payload(攻击载荷)要容易得多。这主要就是分析 DNS 协议,一方面是看 RFC,另一方面是看 bind9 解析 DNS 请求部分的源码。再次安利读 C 源码的工具组合 vim + cscope。POC 在这里

在用 C 语言做网络编程的时候,首先要注意不能让编译器在结构体的成员之间插入多余的 padding,这可以用 __attribute__((packed)) 这个 GCC 扩展语法来实现。其次,可以使用如下图所示的位域成员来表示网络协议中的每一位,而无需自己做位运算。

Screenshot from 2015-08-02 00:18:11Screenshot from 2015-08-02 00:18:11

用 GDB 挂上有漏洞的 bind9,运行 POC。只需向目标服务器发出一个 UDP 数据包的 DNS 查询,bind9 就会收到 SIGABRT 信号并终止。从调用栈可以看到,正是 dns_tkey_processquery 调用了 dns_message_findname 后触发的 isc_assertion_failed。既不需要握手,也不依赖服务器端的某些特殊配置,这个 DNS 漏洞的攻击条件酷似去年 Linux SCTP 协议栈的缓冲区溢出漏洞,只要一个数据包就够了。

Screenshot from 2015-08-02 00:25:29Screenshot from 2015-08-02 00:25:29

我开始编写这个 POC 的时候脑抽了,没想到可以写个 A 记录,于是就写了个 SIG 记录(是需要包含一个域名并且不同于 TKEY 的记录),但是无法到达 dns_tkey_processquery 这个函数。用 gdb 调试,才发现 SIG 记录的签名会在更早的阶段被检查,如果签名错误,这个 DNS 请求的处理就会终止。如果发现 POC 不按预想的工作,gdb 还是蛮重要的。

结语

本来是一个防御式编程的好实践,却成了一个拒绝服务漏洞的来源。我们可以说,编写 dns_tkey_processquery 的人不够细心,没有注意到所调用的函数要求传入的输出参数要初始化为 NULL。我们也可以说,这段代码的测试不彻底,没有测试到这种会造成断言失败的情况。但这个情况的确是非常边角,碰巧测试到了是缘分,测试没有覆盖到是本分。也许只有代码自动验证之类的黑科技才能拯救这些逻辑错误了。

当程序遇到不曾预料的情况时,UNIX 的编程哲学是 fail fast,快速失败而非带病运行。在开发和测试的时候,fail fast 是必须的,能够让我们尽早、尽可能准确地定位 bug。然而,对一些可用性至关重要的网络服务来说,在生产环境中,带病运行不失为一种更好的选择(可以通过日志和报警来让运维人员发现异常情况)。

不同于一般的客户端漏洞和服务器漏洞,该漏洞影响了互联网的基础设施——DNS。很多运营商运维能力薄弱,DNS 服务器常年不升级,更容易沦陷。CNCERT 之类的官方组织应该发布安全公报,提醒各运营商、高校等升级自己的 DNS 服务器。

Comments