0x00 问题背景

谷歌安全团队对dnsmasq进行了测试并发现了多个漏洞,其中的CVE-2017-14491是一个堆溢出漏洞,存在RCE的风险。不过其在相关博客中只给出了PoC脚本测试步骤和相关的报错asan,需要我们自己分析过程中的调用流程,进一步有可能开发出RCE的exp脚本。

攻击流程可分为三步:

  1. 攻击者伪造成为dnsmasq的上游DNS服务器,即运行PoC脚本。
  2. 攻击者在客户端向dnsmasq发送PTR请求,dnsmasq不存在相应PTR记录便向上游DNS查询,然后获得PTR的查询结果缓存并应答客户端。
  3. 攻击者再次在客户端向dnsmasq发送PTR请求,dnsmasq便解析展开之前的PTR记录,由于数据包的构建都在堆上,而且上游的恶意的PTR相应记录的大小超过了堆上分配的内存空间,最后造成了堆溢出。

攻击面初步猜想就是攻击者当控制上游DNS服务器后,通过配置特定的PTR响应和客户端的反向查询,即可实现对dnsmasq主机的远程代码执行。

0x01 调试环境

另外需要说明的是:

  1. 关于存在漏洞的软件版本,根据谷歌的博客公告,dnsmaqs全部版本都存在此CVE堆溢出漏洞,并且早于2.76和用于此commit的版本堆溢出都没有限制,否则只能溢出两个字节。
  2. 软件可直接下载tar压缩包clone下来checkout相应版本,并在Makefile中加入-g选项编译安装,方便后续调试。
  3. sudo gdb dnsmasq启动dnsmasq后使用set args -p 53535 --no-daemon --log-queries -S 127.0.0.2 --no-hosts --no-resolv设置启动参数进行调试。
  4. dns请求默认是有重传机制,而且在调试过程中会中断程序,重传会导致程序重复执行某些代码影响调试,所以在dig时可指定不进行重传:

0x02 流程追踪

数据包的堆分配

谷歌给出的asan是基于2.78test2版本的dnsmasq,其中堆的分配是在dnsmasq.c中的safe_malloc函数:

调试的2.75版本也同样存在safe_malloc,只不过其内部使用的是malloc分配堆:

其中的daemon是全局可以访问的结构体,而daemon->packet主要是用于存储数据包内容的内存空间,通过safe_malloc会为其在堆上分配空间,为后续的数据包构建做准备。在dnsmasq.c:96处下断点,可以看到daemon->packet_buff_sz为0x1000大小:

malloc之后分配的堆空间起始地址为0x648f00:

接下来运行PoC看看程序崩溃时的环境:

其中有以下三点要注意:

  1. PoC在执行第一次PTR查询时,程序没有产生崩溃,而是在在第二次崩溃。
  2. 在bt的输出中,#1的answer_request函数的参数中header为0x648f00是最开始为数据包分配的堆地址,同时limit为0x64900,两者相差正好0x1000,可能是限制堆溢出的操作。
  3. 那么问题来了:a.为什么第一次不会崩溃;b.为什么看似有限制但还是堆溢出了。

第一次查询

通过下断点得知第一次PTR反向查询过程中,首先也会调用dnsmasq.c:1004处的check_dns_listeners函数,然后将listener传入dnsmasq.c:1515处的reccive_query函数,在其定义处,可以看到局部变量header指针指向的就是构建数据包的那块堆的起始地址:

在后续的操作中,首先会在forward.c:1178处会接收udp请求,将请求数据包的内容存储在堆中:

继续跟进在forward.c:1398~1415行中是先本地查询,如果没有结果向上游DNS服务器查询:

所以在第一次查询中会进入dnamasq.c:1409的forward_query函数,在其内部对sendto函数或发送完数据包的522行处下断点,即可看到其在堆上构建的向上游服务器查询的数据包:

同样的思路,在dnsmasq发送完向上游DNS的PTR请求后,肯定要接收响应数据,所以对recvfrom函数下断点,即可知道其在dnsmasq.c:1510中会调用reply_query函数,在其内部首先会接收上游服务器的响应,数据包的存储也还是用的daemon->packet,但是在这里也使用recvfrom函数的参数来确定了接收数据包的长度和堆分配的长度一致,所以在存储时没有产生溢出:

紧接着reply_query函数会对数据包头部进行整理,然后把得到的响应数据包通过send_from函数传给客户端,而且值得注意的是原始PoC中构造的DNS响应数据包的大小本身也是没有超过daemon->packet_buff_sz,即分配的堆空间大小:

第二次查询

第二次查询的前半部分和第一次查询类似,也是由check_dns_listeners进入receive_query函数,在dnsmasq接到客户端的第二次PTR请求后,还是会进过先调用answer_request函数然后经过forward_query函数对客户端响应。实际上和开头提到的一样,查询在进入answer_request函数就崩溃了,崩溃附近的源代码如下:

当产生崩溃时,查看bt full得知anscount的值为0x51,即循环了81次后,再次调用cache_find_by_addr造成非法的内存引用产生崩溃。这里的源代码逻辑就是通过循环,调用cache_find_by_addr将cache保存至crecp指针中,并通过cache_get_name获取ptr记录的name,再调用add_resource_record添加记录,经过81此后在add_resource_record中产生堆溢出。

记录里的record cache应该是通过第一次查询的结果向内存中保存了相关的数据结构,观察cache.c文件后对cache_insert函数下断点,可得知第一次查询后的函数堆栈:

对应的源代码则是在第一次查询中,在把响应发送给客户端之前,调用process_reply函数,再其内部调用extract_addresses函数,通过遍历循环响应中的ancount,把记录中的name等信息cache_insert至crec结构体构成的双向链表中:

第一个cache_insert函数执行后,可知其crec地址为0x64a2d0,并且后续的crec结构体中都把PoC中向前引用的name给完全解析扩展开,这样就一下增大了响应数据包的大小,造成后续的堆溢出:

溢出原因

具体跟进add_resource_record函数,可以定位到rfc1035.c:1440行的do_rfc1035_name函数,该函数类似于一个copy操作,就是把解析的域名放入响应数据包的RDATA字段,由于解析域名后的数据包就扩展的很大,超出了分配的堆空间,所以造成了溢出:

再次下断点b rfc1035.c:1855 if anscount == 0x51 ,查看在即将产生崩溃时的上下文环境。由于PoC的数据包在扩展解析后直接溢出到了接近0x64a300的位置,而在cache_find_by_addr函数的内部会访问到第一个crec结构体的地址0x64a2d0,由于该地址被Z字符溢出,所以最终造成了非法地址的引用:

但有趣的是,在add_resource_record函数的末尾是有对溢出的检查:

不过溢出的与否只是影响了返回值,而且在返回后影响的只是anscount变量是否加1,并未其他的安全处理,所以这里的安全检查就形同虚设了。

0x03 补丁分析

为了省事,这里就以最新版本的dnsmasq补丁看一下修复的原理,首先是定义了CHECK_LIMIT函数,如果指针和要写入的size超过了限制就直接跳转:

跳转之后直接返回,也就不能执行写入操作了:

0x04 遗留问题

  1. 开始时使用PoC测试2.78test2版本没有产生崩溃,还需要关闭操作系统上的安全措施进行测试看能不能产生崩溃。
  2. 还需要根据堆溢出的环境来构建RCE的exp,但思路是如果再次反向查询一个ptr,如果服务器再次记录就有可能引起cache_unlink操作,就有可能造成任意地址读写实现RCE。
  3. 经胡牛的提示,只能溢出两个字节的版本是这样算出来的4096+12+1024-5131+1=2

0x05 相关参考