- 目标设备:Cudy TR3000(AX3000 Wi-Fi 6 旅行路由器,aarch64)
- 漏洞固件:TR3000-R47-2.4.21-20251009-140812-sysupgrade.bin(2.4.21,2025-10-09 构建)
- 修复固件:TR3000-R47-2.5.21-20260708-183709-sysupgrade.bin(2.5.21,2026-07-08 构建)
- 漏洞入口:
POST /cgi-bin/luci/rpc/app,方法net.set_wan(需认证) - 漏洞类型:命令注入,Web 认证用户可获取 root 权限执行任意命令
情报来源只有 NVD 条目和 Cudy 自己的安全公告,大意是“net.set_wan 接口存在命令注入,已认证攻击者可执行任意命令,2.5.21 修复”。和往常一样,这两份情报我只当线索用:入口名、漏洞类型、修复版本。下面的所有分析、数据流和 sink 都是从 2.4.21 固件里自己挖出来并动态验证的。
结论和直觉相反,先列出来:
net.set_wan对 WAN 配置字段(proto、username、password、mtu、server……)完全不做校验,原样写进 UCI,之后 netifd 的 proto 脚本把这些值拼进 pppd 的命令行或配置文件。- 直觉上最好用的注入面是 mtu:ppp.sh 里
${mtu:+mtu $mtu mru $mtu}不加引号,shell 分词后确实能把任意 token 注入 pppd argv(比如pty /tmp/pwn.sh)。这条路我验证到 argv 一级是真的,但再往下走不通——pppoe/pptp 这类协议会加载通道插件,pppd 的pty选项只在 tty 通道的connect_tty()里才会被执行,pppoe 通道下注入的pty是惰性的。这一点是本次复现最容易走偏的地方,有动态负对照证实。 - 可用的 sink 在 l2tp 路径:l2tp.sh 把 username/password 只包一层双引号就经 heredoc 写进
/tmp/l2tp/options.wan,闭引号加换行就能注入任意 pppd 选项行;xl2tpd 建立隧道后会用file /tmp/l2tp/options.wan拉起 pppd。这条链路我在仿真环境全链跑通,注入命令以uid=0(root)执行。 - 官方 2.5.21 的修复是给 JSON-RPC 层加了 datatypes 字段校验,但 mtu 和 proto 不在校验表里,且校验函数对表外字段直接放行——mtu 分词注入面在修复版里依然存在(只是 pppoe 场景下如前所述惰性)。
一、入口定位与 Lua 字节码反编译
TR3000 的 Web 管理界面是 LuCI 改的,业务接口走 JSON-RPC:POST /cgi-bin/luci/rpc/app,body 形如 {"id":1,"method":"net.set_wan","params":[...]}。未认证请求被 dispatcher 直接拒掉(实测返回 HTTP/1.1 403 Forbidden,空 body),所以漏洞前提是拿到一个登录 session。
net.set_wan 的实现不在 JavaScript 里,而在路由器的 Lua 侧。按 LuCI 惯例找到 /usr/lib/lua/luci/apprpc/net.lua,但 file 一看不是文本:
$ file net.lua
net.lua: data
$ xxd net.lua | head -2
00000000: 1b4c 7561 5100 0104 0404 0804 0000 0000 .LuaQ...........
是预编译的 Lua 字节码,而且是厂商改过的变种:标准 Lua 5.1 头第 5 字节是 0x51(version 5.1)没错,但 opcode 表和常量类型号都和标准 5.1 对不上,unluac 直接报错。对比固件里的 /usr/bin/lua 字符串和已知变种后确认了两处改动:
- 头部
0x0B偏移处的instruction size等字段布局与标准不同,需要把头修正成标准 5.1 布局(0x0B处改回 4); - 常量类型号 9 是厂商自加的,标准 5.1 里字符串是类型 4、没有类型 9——把类型 9 的常量改判为类型 3(number 之后的映射)后 unluac 才能完整反编译。
写了个小脚本(先修头、再逐函数把 type9 常量降级为 type3)预处理后,unluac 反编译出完整的 net.lua(约 1600 行伪 Lua)。set_wan 在反编译结果的第 132 到 979 行,就是下面分析的对象。
二、set_wan 数据流:从 JSON-RPC 到 UCI
反编译出来的 set_wan 逻辑非常直白。入口第一件事就是把 proto 原样写进 UCI,没有任何白名单:
function L0_1(A0_2, A1_2) -- A0_2 = "wan", A1_2 = params 表
...
L3_2 = uci
L3_2.set(L4_2, "network", A0_2, "proto", A1_2.proto) -- net.lua:145 附近
之后按 proto 分支写 username、password、server、ac、service 等字段,全部原样 uci.set,没有一处格式或字符校验。mtu 的处理在反编译第 537-568 行,同一个值会写多个键:
if A1_2.mtu then
if A1_2.proto == "static" then
uci.set(..., "dhcp_mtu", A1_2.mtu)
else
uci.set(..., A1_2.proto .. "_mtu", A1_2.mtu) -- 如 pppoe_mtu
end
uci.set(..., "mtu", A1_2.mtu) -- 同时写通用 mtu 键
最后 uci.commit("network"),然后按板型走两条路:从 /etc/config/system 读 board.type,是 router 就 sys.fork_apply({"network","igmpproxy","xl2tpd"}) 热应用配置,否则直接 os.execute("reboot")。TR3000 是 router,所以走 fork_apply——netifd 收到 reload 后会按新的 UCI 配置调用 /lib/netifd/proto/<proto>.sh 重建 WAN 口。
到这里,整条链路都没有任何校验或过滤:JSON-RPC params → uci.set → /etc/config/network → netifd proto 脚本,攻击者控制的字符串原封不动地抵达 shell 脚本层。
三、Sink 分析:两条注入面
3.1 mtu 分词 → pppd argv 注入
/lib/netifd/proto/ppp.sh 负责 pppoe/pptp/pppoa 等协议的 pppd 拉起,第 158 行:
${mtu:+mtu $mtu mru $mtu} \
"$@" $pppd_options
注意 ${mtu:+mtu $mtu mru $mtu} 里 $mtu 没有引号。mtu 来自 UCI,UCI 来自 set_wan,所以把 mtu 设成 1480 pty /tmp/pwn_38709.sh,shell 分词后 pppd 的 argv 里就会多出两个独立 token:pty 和 /tmp/pwn_38709.sh。这一点有动态证据:5.2 节把 netifd 要 exec 的完整 argv 抓了出来,注入 token 确实在里面。
3.2 pppd 的 pty 选项:谁消费,在哪执行
pty <script> 是 pppd 的官方选项:让 pppd 用伪终端作为串行通道,
