PSV-2020-0211:Netgear R8300 UPnP栈溢出漏洞分析

漏洞简介

PSV-2020-0211对应Netgear R8300型号路由器上的一个缓冲区溢出漏洞,Netgear官方在2020年7月31日发布了安全公告,8月18日SSD公开了该漏洞的相关细节。该漏洞存在于设备的UPnP服务中,由于在处理数据包时缺乏适当的长度校验,通过发送一个特殊的数据包可造成缓冲区溢出。利用该漏洞,未经认证的用户可实现任意代码执行,从而获取设备的控制权。

该漏洞本身比较简单,但漏洞的利用思路值得借鉴,下面通过搭建R8300设备的仿真环境来对该漏洞进行分析。

漏洞分析

环境搭建

根据官方发布的安全公告,在版本V1.0.2.134中修复了该漏洞,于是选取之前的版本V1.0.2.130进行分析。由于手边没有真实设备,打算借助qemu工具来搭建仿真环境。文章通过qemu system mode的方式来模拟整个设备的系统,我个人更偏向于通过qemu user mode的方式来模拟单服务。当然,这两种方式可能都需要对环境进行修复,比如文件/目录缺失、NVRAM缺失等。

binwalk对固件进行解压提取后,运行如下命令启动UPnP服务。

1
2
# 添加`--strace`选项, 方便查看错误信息, 便于环境修复
<extracted squashfs-root>$ sudo chroot . ./qemu-arm-static --strace ./usr/sbin/upnpd

运行后提示如下错误,根据对应的目录结构,通过运行命令mkdir -p tmp/var/run解决。

1
18336 open("/var/run/upnpd.pid",O_RDWR|O_CREAT|O_TRUNC,0666) = -1 errno=2 (No such file or directory)

之后再次运行上述命令,提示大量的错误信息,均与NVRAM有关,该错误在进行IoT设备仿真时会经常遇到。NVRAM中保存了设备的一些配置信息,而程序运行时需要读取配置信息,由于缺少对应的外设,因此会报错。一种常见的解决方案是"劫持"NVRAM读写相关的函数,通过软件的方式来提供相应的配置。

网上有很多类似的模拟NVRAM行为的库,我个人经常使用Firmadyne框架提供的libnvram库:支持很多常见的api,对很多嵌入式设备进行了适配,同时还会解析固件中默认的一些NVRAM配置,实现方式比较优雅。采用该库,往往只需要做很少的改动,甚至无需改动,就可以满足需求。

参考libnvram文档,编译后然后将其置于文件系统中的firmadyne路径下,然后通过LD_PRELOAD环境变量进行加载,命令如下。

1
<extracted squashfs-root>$ sudo chroot . ./qemu-arm-static --strace -E LD_PRELOAD=./firmadyne/libnvram.so.armel ./usr/sbin/upnpd

运行后提示缺少某个键值对,在libnvram/config.h中添加对应的配置,编译后重复进行测试,直到程序成功运行起来即可,最终libnvram/config.h的变化如下。

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
diff --git a/config.h b/config.h
index 9908414..6598eba 100644
--- a/config.h
+++ b/config.h
@@ -50,8 +50,10 @@
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_bipaddr", nvram_set, "192.168.0.255") \
+ ENTRY("lan_ipaddr", nvram_set, "192.168.200.129") \
+ ENTRY("lan_bipaddr", nvram_set, "192.168.200.255") \
ENTRY("lan_netmask", nvram_set, "255.255.255.0") \
/* Set default timezone, required by multiple images */ \
ENTRY("time_zone", nvram_set, "EST5EDT") \
@@ -70,6 +72,10 @@
/* 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") \
+ /* Used by Netgear router: enable upnpd log */ \
+ ENTRY("upnpd_debug_level", nvram_set, "3") \
+ /* Used by "Netgear R8300" */ \
+ ENTRY("hwrev", nvram_set, "MP1T99")

需要说明的是,libnvram还会尝试去定位固件中的全局符号router_defaultsNvrams,并加载其中存在的键值对,对应的代码如下。其中,调用nvram_set_default_*的顺序为:nvram_set_default_builtin()nvram_set_default_table(a)。也就是说,上面NVRAM_DEFAULTS中的键值对会先被加载,然后再加载全局符号router_defaultsNvrams中存在的键值对。因此,在libnvram/config.h中添加的键值对有可能会被覆盖(比如lan_ipaddr),为了保证自定义的键值对生效,对libnvram/nvram.c的修改如下。

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
int nvram_set_default(void) {
int ret = nvram_set_default_builtin();
PRINT_MSG("Loading built-in default values = %d!\n", ret);

#define NATIVE(a, b) \
if (!system(a)) { \
PRINT_MSG("Executing native call to built-in function: %s (%p) = %d!\n", #b, b, b); \
}

#define TABLE(a) \
PRINT_MSG("Checking for symbol \"%s\"...\n", #a); \
if (a) { \
PRINT_MSG("Loading from native built-in table: %s (%p) = %d!\n", #a, a, nvram_set_default_table(a)); \
}

#define PATH(a) \
if (!access(a, R_OK)) { \
PRINT_MSG("Loading from default configuration file: %s = %d!\n", a, foreach_nvram_from(a, (void (*)(const char *, const char *, void *)) nvram_set, NULL)); \
}

NVRAM_DEFAULTS_PATH
#undef PATH
#undef NATIVE
#undef TABLE

return nvram_set_default_image();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
diff --git a/nvram.c b/nvram.c
index 1df6d86..cfa1491 100644
--- a/nvram.c
+++ b/nvram.c
@@ -569,8 +569,6 @@ int nvram_set_int(const char *key, const int val) {
}

int nvram_set_default(void) {
- int ret = nvram_set_default_builtin();
- PRINT_MSG("Loading built-in default values = %d!\n", ret);

#define NATIVE(a, b) \
if (!system(a)) { \
@@ -593,6 +591,9 @@ int nvram_set_default(void) {
#undef NATIVE
#undef TABLE

+ PRINT_MSG("Loading built-in default values = %d!\n", nvram_set_default_builtin());
+
return nvram_set_default_image();
}

程序成功运行效果如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
<extracted squashfs-root>$ sudo chroot . ./qemu-arm-static -E LD_PRELOAD=./firmadyne/libnvram.so.armel ./usr/sbin/upnpd
nvram_get_buf: upnpd_debug_level
sem_lock: Triggering NVRAM initialization!
nvram_init: Initializing NVRAM...
# ... <omit>
nvram_match: upnp_turn_on (1) ?= "1"
nvram_match: true
ssdp_http_method_check(203):
ssdp_discovery_msearch(1007):
ST = 20
ssdp_check_USN(212)
service:dial:1
USER-AGENT: Google Chrome/84.0.4147.125 Windows

漏洞分析

upnp_main()中,在(1)recvfrom()用来读取来自socket的数据,并将其保存在v55指向的内存空间中。在(2)调用ssdp_http_method_check(),传入该函数的第一个参数为v55,即指向接收的socket数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int upnp_main()
{
char v55[4]; // [sp+44h] [bp-20ECh]

// ...
while ( 1 )
{
// ...
if ( (v20 >> (dword_C4580 & 0x1F)) & 1 )
{
v55[0] = 0;
v28 = recvfrom(dword_C4580, v55, 0x1FFFu, 0, (struct sockaddr *)&v63, (socklen_t *)&v71); // (1)
// ...
if ( v29 )
{
if ( v28 )
{
// ...
if ( acosNvramConfig_match("upnp_turn_on", "1") )
ssdp_http_method_check( v55, (int)&v59, (unsigned __int16)(HIWORD(v63) << 8) | (unsigned __int16)(HIWORD(v63) >> 8)); // (2)
// ...

ssdp_http_method_check()中,在(3)处调用strcpy()进行数据拷贝,其中v40指向栈上的局部缓冲区,v3指向接收的socket数据。由于缺乏长度校验,当构造一个超长的数据包时,拷贝时会出现缓冲区溢出。

1
2
3
4
5
6
7
8
9
10
11
signed int ssdp_http_method_check(const char *a1, int a2, int a3)
{
int v40; // [sp+24h] [bp-634h]

v3 = a1;
// ...
wrap_vprintf(3, "%s(%d):\n", "ssdp_http_method_check", 203);
if ( dword_93AE0 == 1 )
return 0;
strcpy((char *)&v40, v3); // (3) stack overflow
// ...

漏洞利用

upnpd程序启用的缓解措施如下,可以看到仅启用了NX机制。另外,由于程序的加载基址为0x8000,故.text段地址的最高字节均为\x00,而在调用strcpy()时存在NULL字符截断的问题,因此在进行漏洞利用时需要想办法绕过NULL字符限制的问题。

1
2
3
4
5
6
$ checksec --file ./upnpd
Arch: arm-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8000)

SSD公开的漏洞细节中给出了一个方案:通过stack reuse的方式来绕过该限制。具体思路为,先通过socket发送第一次数据,往栈上填充相应的rop payload,同时保证不会造成程序崩溃;再通过socket发送第二次数据用于覆盖栈上的返回地址,填充的返回地址用来实现stack pivot,即劫持栈指针使其指向第一次发送的payload处,然后再复用之前的payload以完成漏洞利用。SSD公开的漏洞细节中的示意图如下。

实际上,由于recvfrom()函数与漏洞点strcpy()之间的路径比较短,栈上的数据不会发生太大变化,利用stack reuse的思路,只需发送一次数据即可完成利用,示意图如下。在调用ssdp_http_method_check()前,接收的socket数据包保存在upnp_main()函数内的局部缓冲区上,而在ssdp_http_method_check()内,当调用完strcpy()后,会复制一部分数据到该函数内的局部缓冲区上。通过覆盖栈上的返回地址,可劫持栈指针,使其指向upnp_main()函数内的局部缓冲区,复用填充的rop gadgets,从而完成漏洞利用。

另外在调用strcpy()后,在(4)处还调用了函数sub_B60C()。通过对应的汇编代码可知,在覆盖栈上的返回地址之前,也会覆盖R7指向的栈空间内容,之后R7作为参数传递给sub_B60C()。而在sub_B60C()中,会读取R0指向的栈空间中的内容,然后再将其作为参数传递给strstr(),这意味[R0]中的值必须为一个有效的地址。因此在覆盖返回地址的同时,还需要用一个有效的地址来填充对应的栈偏移处,保证函数在返回前不会出现崩溃。由于libc库对应的加载基址比较大,即其最高字节不为\x00,因此任意选取该范围内的一个不包含\x00的有效地址即可。

在解决了NULL字符截断的问题之后,剩下的部分就是寻找rop gadgets来完成漏洞利用了,相对比较简单。同样,SSD公开的漏洞细节中也包含了完整的漏洞利用代码,其思路是通过调用strcpy gadget拼接出待执行的命令,并将其写到某个bss地址处,然后再调用system gadget执行对应的命令。

在给出的漏洞利用代码中,strcpy gadget执行的过程相对比较繁琐,经过分析后,在upnpd程序中找到了另一个更优雅的strcpy gadget,如下。借助该gadget,可以直接在数据包中发送待执行的命令,而无需进行命令拼接。

1
2
3
4
5
.text:0000B764 MOV             R0, R4  ; dest
.text:0000B768 MOV R1, SP ; src
.text:0000B76C BL strcpy
.text:0000B770 ADD SP, SP, #0x400
.text:0000B774 LDMFD SP!, {R4-R6,PC}

补丁分析

Netgear 官方在R8300-V1.0.2.134_1.0.99版本中修复该漏洞。函数ssdp_http_method_check()的相关伪代码如下,可以看到,在补丁中调用的是strncpy()而非原来的strcpy(),同时还对局部缓冲区&v40进行了初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
signed int ssdp_http_method_check(const char *a1, int a2, int a3)
{

int v40; // [sp+24h] [bp-Ch]

v3 = a1;
// ...
memset(&v40, 0, 0x5DCu);
v52 = 32;
sub_B814(3, "%s(%d):\n", "ssdp_http_method_check", 203);
if ( dword_93AE0 == 1 )
return 0;
v51 = &v40;
strncpy((char *)&v40, v3, 0x5DBu); // patch
// ...

小结

本文通过搭建Netgear R8300型号设备的仿真环境,对其UPnP服务中存在的缓冲区溢出漏洞进行了分析。漏洞本身比较简单,但漏洞利用却存在NULL字符截断的问题,SSD公开的漏洞细节中通过stack reuse的方式实现了漏洞利用,思路值得借鉴和学习。

相关链接

  • Security Advisory for Pre-Authentication Command Injection on R8300, PSV-2020-0211
  • SSD Advisory – Netgear Nighthawk R8300 upnpd PreAuth RCE
  • Netgear Nighthawk R8300 upnpd PreAuth RCE 分析与复现
  • Firmadyne libnvram

本文首发于安全客,文章链接:https://www.anquanke.com/post/id/217606