写在前面
这台 T36 Max 是移动宽带送的光猫路由一体机。海思芯片方案,双核 ARM Cortex-A9,256MB 内存,128MB SPI NAND。这类定制设备的玩法圈子里一直有个说法:海思方案把 telnet 和 SSH 都砍了,开不出来。很多人看到这里估计就放弃了,但是凭借我乐于专研的精神和敏锐的感觉,我的想法是:首先,实验室或是工厂为了方便测试,大概率会留下后门;其次,运营商远程管理、故障解决肯定会通过telnet或是其他手段下发命令和查看相关设置的;最后,网友说的不行,不代表我的能力,而是对方没有能力找出来。
我这台实测下来,这个说法不成立。telenetd 的二进制从头到尾都在系统里,厂商的 Web 代码自己就在调它。真正让所有人卡住的,是一个拼写错误。至于过程,可以往后看,如果你不喜欢长篇大论的技术内容的话,可以下载最终的成果。
这篇文章把整个过程按时间顺序写下来,中间那些弯路我一条都没删——因为弯路本身才是信息量最大的部分。文章里的设备是我自己买的,固件是从我自己这台机器上读出来的。涉及运营商网络的部分只做了本地观察,没有对公网发起任何测试。提取出来的固件和脚本都放在文末的目录里。
一、设备概况
先把底摸清。开机之后从串口和 Web 两边交叉印证,得到的信息是:
| 项目 | 值 |
|---|---|
| 型号 | TCL T36 Max |
| 设备识别 | equipment_id = H2-2,device_type = 30103 |
| 主控 | 海思 Hi5671Y,双核 ARMv7(Cortex-A9),平台代号 hsan-luofu |
| 内存 | 256 MiB |
| Flash | 128 MiB SPI NAND(128KB 擦除块 / 2KB 页) |
| 内核 | Linux 5.10.0 #139 SMP armv7l |
| SDK | HiHANLinuxV100R003C01SPC010 |
| 固件 | V1.0.2(内部版本 HAX3000GE_V1.0.2_20240617132433) |
| 省份 | HUB(湖北移动) |
| U-Boot | 2020.01,jenkins-ROM_HI_AX3000_CMCC_V1.0-139 |
整块 Flash 分了 15 个区:

这里有两个细节值得先记住,后面会反复用到:A/B 双区不是同一份东西。 uboota 和 ubootb 的 MD5 完全相同,但 kernela 和 kernelb 不一样——kernela 的构建时间是 2024-10-24,kernelb 是 2024-10-17。也就是说 A 区是后来 OTA 升上去的版本,B 区还是出厂原版。admin 这个账户不是普通管理员。 这个后面会说,它直接就是 uid=0。
第一部分 Web 端的那些事
1.1 管理面长什么样
管理界面在 80 端口,是个 React 写的 SPA,配一套 wwapi 开头的 JSON 接口。前端资源没做任何保护,main.js 1.5MB 直接下下来,所有接口路径都在里面躺着。接口风格很统一:
POST /wwapi/system/xxx
Content-Type: application/json
TOKEN: <登录后拿到的 token>
{"data": { ... }}
响应也统一,{"data": {...}, "errCode": 0},errCode != 0 就是出错。一眼看上去最舒服的突破口就是 main.js——它把整个后端路由表都送到浏览器了。
1.2 登录签名:看着挺唬人
登录流程设计得像是做过功课的:客户端先生成一个 40 字符的随机串当 challenge,然后用它跟明文密码做 HMAC-SHA256,把摘要当密码发过去。

密码本身不过网,看着挺安全。问题出在 challenge 是客户端生成的。服务端既不复用它,也不检查它的新鲜度,更不要求它必须来自自己下发的 nonce。结果就是这层签名只能防“抓包直接看到明文密码”,防不住重放——同一组 challenge + password 可以无限次提交。不过说实话,这一条本身没什么杀伤力,因为传输层是 HTTP。抓一次包就能把 challenge 和 password 一起拿到,重放和不重放差别不大。
1.3 硬编码的 AES 密钥
真正有味道的问题在加密上。Web 里有些敏感字段(PPPoE 密码、WiFi 密钥之类)在传之前会做 AES 加密。按理说密钥应该是设备唯一的,但 strings 一把梭下去就看见了:

AES-128-CBC / ZeroPadding
key = s&o@Kp3W@wwylusr
iv = ASDFGH0123456789
密钥和 IV 都是硬编码,而且是全固件所有同型号设备共用一把。把它填回去,加密字段就是明文:
PPPoE 密码 : 168168
WiFi PSK : acaduser
168168 和 acaduser 这两个值挺有意思——典型的“能跑就行”型出厂配置。顺便说一句,ZeroPadding 配固定 IV,等于 CBC 的语义安全完全没了:相同明文永远产生相同密文,可以从密文里看出两个字段是不是相同的值。
1.4 未认证的设备信息接口
main.js 里有一批接口是不需要 TOKEN 的,其中 system/deviceInfo 给得特别大方:

SN、MAC、型号、固件版本、SDK 版本、运营商、省份,全出来了,一个认证头都不用带。单看不算什么大事,但从攻击面角度它挺有用的——拿到这些就能确认设备型号和固件版本,进而去查这个版本有没有已知问题。等于把“你该用哪套 exploit”直接告诉你了。
1.5 Mesh 拓扑图的存储型 XSS
固件里的 EasyMesh 拓扑图用的是 ECharts。我去翻了一下渲染代码,在 chunks/916.js 里发现了这么一段:

tooltip: {
formatter: function (e) {
return '<b>' + e.name + '</b><br/>'
+ 'IP: ' + e.ip + '<br/>'
+ 'MAC: ' + e.mac;
}
}
e.name 就是局域网里设备的主机名,来自 DHCP 请求,未经任何过滤就拼进 HTML。没有 renderMode: 'richText',没有 DOMPurify,页面上也没有 CSP 头。也就是说:局域网里任何一台设备,把自己的 DHCP 主机名设成 payload,管理员一打开 Mesh 拓扑页就中招。 是个标准的存储型 XSS,触发路径还挺隐蔽——谁会想到主机名能干这个。考虑到管理员的 token 在 localStorage 里,这个洞能一路走到接口调用。
1.6 顺手记几个次要问题
- 会话锁定:用一个过期的 token 反复请求,会把会话锁死,返回
errCode 21605,正常管理员在几分钟内登不进去。算个低成本的拒绝服务。 - 全站 HTTP:管理面没有 HTTPS,登录凭据、token、配置数据全程明文。
- 弱口令:Web 密码
77s57re5、PPPoE168168、WiFiacaduser——这一串的强度不用多评价了。
1.7 Web 端到此为止
上面这些加起来,能拿到配置、能读密码、能打 XSS。但拿不到 shell。我一开始以为 Web 端能直接走到命令执行。main.js 里确实有 wwapi/system/telnet 这个接口,看起来就是为开关 telnet 准备的。我花了很久在这上面,全失败了。当时以为是参数没猜对,就先放下去走物理路线。后来证明这条路才是真正的答案,只是当时差一个字符。
第二部分 获取 telnet 之路
2.1 Web API 上的第一次尝试(失败)
main.js 里搜 telnet,跳出来一行:
z = "wwapi/system/telnet"
有端点。那就试。第一次提交,参数名照着自己的猜测来:
{"data":{"telnet_en":1,"telnet_user":"admin","telnet_passwd":"xxx","telnet_port":23}}
{"errCode":10001}
10001。换个命名风格:
{"data":{"telnetEn":1,"telnetUser":"admin","telnetPasswd":"xxx","telnetPort":23}}
{"errCode":10001}
还是 10001。再试几个:
{"data":{"enable":1,"userName":"admin","password":"xxx","port":23}}
{"errCode":10002}
注意这个 10002,它跟 10001 不一样。
这说明 enable / userName / password / port 这套名字被服务端认了,只是别的地方不对。我当时判断“参数名对了,缺字段”,于是往里面加字段——type、accountIndex、authType、protocol、permission、right、wanEnable、lanEnable……加一个测一次,全是 10002。那时候我已经在这个接口上折腾了很久,errCode 就一直卡在 10001 和 10002 之间。这里我犯了一个思维定式的错误:我把 10002 理解成“缺字段”,于是不停地加字段,但从没想过问题出在值的类型上。字段名全对,值写错了,一样是这个码。这个错误让我绕了好几天。
2.2 转向物理:拆机找 TTL
Web 端卡住了,换个思路——直接从硬件进。先看看这台机器长什么样:

背面铭牌上有用的信息不少:

铭牌写着:产品型号 T36 Max,产品名称 AX3000 千兆双频无线路由器,管理地址 192.168.10.1,CMIIT ID 2023AP0378,默认账号 admin,厂商是深圳天芯智能家居。注意这台机器铭牌上没有印 SN 和 MAC,只有出厂默认密码和 WiFi 密码。拆开后壳,板子是 MR-AX305H V1.1,海思 hsan-luofu 平台:

调试口在这块板子上是有丝印的 —— JP1,位置在板子右下角边缘,4 个焊盘竖排:

定制板子留了 JP1 丝印,算是比较“坦诚”的做法。有些厂商会把调试口做成不标注的裸焊盘,那种就只能靠万用表一个个点着量了。怎么认哪个是哪个:先万用表打直流电压档,黑表笔接地(找主板上的大面积铺铜或者屏蔽罩,通断档确认),然后一个个点过去。这台板子上是 4pin:VCC / GND / TX / RX。
- GND:跟地导通的
- VCC:稳定 3.3V 的
- TX / RX:剩下两个,一般会有一个略微低一点,或者用示波器看哪个在上电瞬间有波形(TX 是设备发出来的)
焊接用的是细尖头烙铁,焊盘很小,先上锡再拖锡:

焊完一定要用万用表复测一遍有没有连锡,尤其是 VCC 和 GND 短路——这个如果没查出来,一上电就是烧主控。线序上我做了一步保护:TX 串了一个 1kΩ 电阻。设备 TX 是推挽输出,和 USB-TTL 的 TX 如果同时输出会打架,串个电阻能把这个风险压下去。USB-TTL 用的是 CH340,模块上那颗 12MHz 晶振和 LED 很好认:

接线状态,GND / TXD / RXD 都标出来了:

插上电脑,设备管理器里认出来是 COM4:

串口工具那边按 115200 8N1、无硬件流控配:

参数是 115200 8N1,无硬件流控。
2.3 从 TTL 进入 U-Boot
线接好,开串口工具,上电。启动日志先吐出来的是 esbc 那段——就是那颗 256K 分区里的裸机引导程序,负责把 U-Boot 从 NAND 读进内存:这张图信息量挺大,逐段看。

先是 esbc 的引导过程:
nand probe succ ......
nand read oob: offset 0x40000
flash read uboot_region_size [c0000]......
This boot is main region area......
flash read uboot code len [59267]......
nand read: offset 0x40000 size 0x59267
enter uboot
0x40000(256K)是 U-Boot A 区的起始偏移,0xc0000 是给它预留的区域大小(768K),而实际读进去的 U-Boot 代码只有 59267 字节,约 58KB。This boot is main region area 说明它判定当前走的是主区。然后是 U-Boot 本体:
U-Boot 2020.01 (Oct 17 2024 - 02:12:17 +0000) for luofu,
Build: jenkins-ROM_HI_AX3000_CMCC_V1.0-139
DRAM: 256 MiB
WDT: Started with servicing (30s timeout)
NAND: 128 MiB
Loading Environment from NAND... OK
In: uart1@0x1010f000
Out: uart1@0x1010f000
Err: uart1@0x1010f000
Warning: pie (eth0) using random MAC address - ae:49:8d:2a:a6:7a
eth0: pie
bootflag b bootreg 0
bootreg init, set 11
bootdelay is 0
几个值得记下来的点:最后一行 bootdelay is 0 有点意思:自动启动的等待窗口是 0。
Build: jenkins-ROM_HI_AX3000_CMCC_V1.0-139—— 固件是 Jenkins 流水线编出来的,内部版本号V1.0-139直接印在 banner 上。前面从webServer里挖出来的构建路径/workspace/BUILD_HI5652_HSANV3R3C10.../luofu_release/正好和这里对上。uart1@0x1010f000同时是 In / Out / Err —— 这就是我们焊上去的那个串口,寄存器基址0x1010f000。这条和 U-Boot env 里的stdin=uart1@0x1010f000一致。Warning: pie (eth0) using random MAC address——pie是这块板子的网卡设备名,U-Boot 阶段没从工厂区读到 MAC,于是随机了一个。这也解释了 env 里那句ethact=pie。bootflag b—— 当前从 B 区启动,和后面printenv的结果一致。bootreg init, set 11—— 除了bootflag,还有一套bootreg计数器参与启动区判定。两个变量一起决定从哪启动,这也是为什么我在动 Flash 之前先把它们都 dump 了一份。WDT: Started with servicing (30s timeout)—— 看门狗已经开了,30 秒喂一次。这意味着在 U-Boot 里长时间不操作,设备会自己复位。
所以进 U-Boot 靠的不是“等倒计时出现”,而是上电后立刻连续敲键,抢在 bootdelay 判定之前把输入塞进串口缓冲。实测手速够(或者让串口工具在上电瞬间自动发送)就能稳定停住:
Abort
Hit any key to stop autoboot: 1 0
luofu #
luofu #,进去了。U-Boot 里的信息量很大,先把关键的环境变量 dump 出来:

bootcmd=mtd read kernel${bootflag} ${loadaddr};bootfip ${loadaddr}
bootflag=b
mtdparts=hi_nfc:256K(esbc),768K(uboota),768K(ubootb),256K(enva),...
rootfsa= file=rootfs.squashfs addr=0x1980000 fsize=0x1060000 len=0x2300000
bootflag 控制从 A 区还是 B 区启动,这个变量存在 env 里,可以改。而 bootcmd 里没有任何签名校验——这基本意味着整个引导链是敞开的。然后我查了 U-Boot 支持哪些命令,想找个往 Flash 里灌文件的办法:没有 loady / loadx / loadb。这三个是 U-Boot 里通过串口接收文件的命令(ymodem / xmodem)。它们被裁掉了,意味着没法用串口把文件传进设备。这条路直接堵死。(后来我试过用 mm 一个字节一个字节手敲——那是另一个故事,结论是可以,但慢到不实用。)
mm - memory modify (auto-incrementing address)
mw - memory write (fill)
cp - memory copy
mtd - MTD sub-system
nand - NAND sub-system
saveenv - save environment variables to persistent storage
2.4 想过直接改 shadow,先得啃下 squashfs
既然串口传不了文件,那就换个目标:不灌文件,改成从设备里把文件读出来。最有价值的目标是 /etc/shadow——能拿到密码哈希,就能回去离线爆破。但这中间隔着一整个 UBI + squashfs 的解析问题。第一层:UBI。 根文件系统在 rootfsb 里,是 UBI 格式。关键参数:
PEB size : 131072 bytes (128 KiB)
LEB size : 126976 bytes
VID header offset: 2048
data offset : 4096
也就是每个 128KB 的物理擦除块里,前 2KB 放 EC 头,再 2KB 放 VID 头,剩下 124KB 才是数据。逻辑地址到物理地址要自己算映射。第二层:squashfs。 超级块读出来是这样:
magic = 0x73717368 (hsqs)
inodes = 1828
fragments = 99
block_size = 131072 (128K)
compression = 4 (XZ)
flags = 0x100c0 <- bit2 = 0, 无数据 CRC
XZ 压缩,128K 块。到这一步都还算正常。第三层,也就是真正卡住我的地方:fragment 索引表。squashfs 里有个 fragment 概念——小文件不单独占块,而是打包成“碎片块”集中存放,用一张索引表记录每个碎片在哪个块、块内什么偏移。/etc/shadow 只有 357 字节,肯定在碎片里。按标准 squashfs 的布局,这张表的位置是从超级块里的 fragment_table_start 算出来的。我按规矩算出来一个偏移,读出来一看——一堆 XZ 乱码。当时以为是地址算错了。重新推了一遍超级块字段,验证了 inode 表和目录表的解析都是对的(目录能正常读出来),偏偏这张表是乱的。
来回折腾很久之后才想通:

这块固件的 fragment 索引表本身也是被 XZ 压缩的。标准 squashfs 里,元数据块(inode 表、目录表、fragment 表)确实是可以压缩的,但fragment 索引表这块,绝大多数实现里是明文存放的。这台设备的厂商把它也压了。找到这个之后就好办了——把那段数据当 XZ 流解出来,得到 1584 字节,正好是 99 × 16,99 个 fragment 条目,全都能对上。这个坑花掉的时间最多,因为排查方向一开始就偏了:我一直在怀疑自己的地址计算,而不是怀疑固件本身不符合标准。
2.5 把 shadow 掏出来
fragment 表解出来之后,定位 shadow 就很直接了:
fragment[18]: start_block = 0x395B7C
size = 0x541C
/etc/shadow 块内偏移 = 0xC715
文件长度 = 357
还有一个小陷阱:常规 squashfs 的数据块前面有 2 字节的头,但这块固件里就是一段纯 XZ 流,XZ magic 直接顶在块首。我一开始按惯例跳过 2 字节,解出来全是坏的。解出来 129267 字节,从 0xC715 处取 357 字节:

root:$6$XoTYY/.YdoYghuh3$0MQm03xAyCV1k0WhmJVd1mg/kcgjyBHv8Zcad63...:19423:0:99999:7:::
admin:$6$Eu246xcGqJgk18/b$CgulSHeF7UBOVIpRjTM3paEYYgteLgTS2oUIldnyGCG7VB/U/...:19423:0:99999:7:::
两个账户,都是 SHA-512 crypt($6$)。19423 这个数字是“密码最后修改日”,从 1970-01-01 起算的天数,换算过来是 2023-03-08。两个账户的 salt 完全不同,但日期一模一样——这是典型的构建期批量 chpasswd 留下的痕迹。这里有个重要推论:/etc 位于只读的 squashfs 里,运行时会被复制到 tmpfs。也就是说镜像构建完成之后,这套密码就定死了,不可能按设备随机化。所以同型号设备的密码应该是同一套。
2.6 字典穷举
拿着两串哈希回来爆破。工具用 hashcat,模式 1800(sha512crypt)。字典先用 rockyou.txt(1434 万条)。有个坑得提一下:这台机器上 hashcat 走 CUDA 会挂在 0% CPU 不动,加 --backend-ignore-cuda 让它走 OpenCL 就正常了。sha512crypt 不是个快的算法,rounds=5000 加默认的 Loops/Threads,RTX 4070 上跑出来是 419.9 kH/s——这个速度在 GPU 上算慢的了,因为 sha512crypt 是为抗暴力破解设计的。

42 秒,admin 出来了:
$6$Eu246xcGqJgk18/b$CgulSHeF7UBOVIpRjTM3paEYYgteLgTS2oUIldnyGCG7VB/...
: system
密码是 system。一个字典里排得很靠前的常见词。厂商把管理账户的密码设成了 system。
root 那个哈希没出来。
我后面又跑了一轮定向字典——把 system、型号、厂商名、SDK 版本号这些做了一堆变形(大小写、加数字、加年份、加符号),六十多个候选,加上 rockyou 全量,root 的哈希一直没命中。当时有点不甘心,觉得差一步。结果后面发现有更省事的办法。
2.7 串口登录,整机备份
回到串口,用刚拿到的凭据登录:

hsan login: admin
Password:
/ # id
uid=0(root) gid=0(root) groups=0(root)
root 了,然后 cat /etc/passwd,看到了这里最关键的一行:

root:x:0:0:root:/root:/bin/ash
admin:x:0:0:root:/root:/bin/ash <- admin 就是 uid=0

顺手把 SPI NAND 的真实速度也测出来了:

读取 ~0.40 MB/s
擦除 ~0.57 MB/s
写入 ~0.29 MB/s
读比写快 55 倍。 这个不对称不是配置问题,是 SPI NAND 的固有特性:它没有像 eMMC/UFS 那样的主控 FTL,坏块管理、ECC 纠错、磨损均衡全都要 CPU 在软件层做。加上每页编程后的等待时间(tPROG 典型 200–700µs),35MB 全量操作就是四五分钟。一开始我以为是脚本卡死了,后来看到 flash_eraseall 一路打印进度到 100% 才明白——它就是慢。A/B 区差异也是在这个阶段发现的:uboota 和 ubootb 的 MD5 完全一样,但 kernela 比 kernelb 新 7 天。所以 A 区是 OTA 升级过的版本。
2.8 回到 Web 端:那个拼写错误
设备摸透了,备份也做了。回头再去啃那个 wwapi/system/telnet。这次换个方法——不在黑盒里猜参数了,把 webServer 二进制拉下来静态分析。webServer 1.5MB,直接 strings。搜 tel:

get_telenet_show_cgi
set_telenet_enabled_attr
set_telenet_enabled_cgi
set_telenet_enabled_mod_attr
set_telenet_enabled_mod_cgi
telenet_enabled
success_telecomadmin
telecomadmin_disabled_err
wwapi_system_telnet
厂商把 telnet 拼成了 telenet。telnet → telenet,n 和 e 换了位置。函数名、字段名全是这个拼法,只有 URL 端点 wwapi_system_telnet 是拼对的。我第一反应是“字段名找到了”,赶紧试:
{"data":{"telenet_en":1,"telenet_user":"admin","telenet_passwd":"xxx","telenet_port":2356}}
{"errCode":10001}
10001。又试 telenet_enabled、telenet_enabled_1、telenetEn、telenet_enabled……全是 10001。说明 Web 层的字段名跟内部函数名不是一回事。而回到 10002 那一组——enable / userName / password / port——它才是真的。那 10002 到底错在哪?我决定不猜了,做穷举。保持那 4 个字段名不变,每次加一个新的:

基线 enable=1 + userName/password/port -> 10002
+ type=1 / accountIndex=0 / authType=0 -> 10002
+ protocol=1 / permission=A / right=1 -> 10002
+ wanEnable=0 / lanEnable=1 / isWan=0 -> 10002
+ telenet_enabled=1 / name / user / passwd -> 10002
... 共 19 组 -> 10002
改值呢?
port=2356 / port=0 / 密码改简单 / userName 改 root / enable=0 -> 10002
全都一样。到这一步我基本确定问题不在“缺字段”上了。然后试了这个:
{"data":{"enable":true,"userName":"hsan","password":"Hsan#2026","port":2356}}
{"errCode":0}
0。

是 enable=1 和 enable=true 的区别。enable 要的是 JSON 布尔值 true,不是数字 1。而我一直在传数字。回头数一下:10001(字段名错)、10002(字段名对、值类型错)、10005(缺参数)。这套错误码其实一直在给我准确的提示,是我自己把 10002 的方向理解错了——我一直在加字段,从没想过问题在值的类型上。
errCode 0 之后,设备侧的反馈来得非常快:
tcp 0 0 :::2356 :::* LISTEN 22139/telnetd
22139 root 0:00 /usr/sbin/telnetd -p 2356
端口开了。用前面串口里拿到的那个 admin / system 连上去:
$ telnet 192.168.100.215 2356
hsan login: admin
Password:
login: can't change directory to '/root'
/ # id
uid=0(root) gid=0(root) groups=0(root)
一条 HTTP 请求,从 Web 管理员变成设备 root。而且这个操作可以任意重复——每次都把旧的 telnetd 杀掉、起一个新的。设备重启之后,同样一条请求就能恢复 telnet,完全不用碰串口。
一个我踩进去又爬出来的坑
上面那条 telnet 命令,我一开始用的账号不是 admin,是我在接口里自己填的一个新账号名。
结果接口返回 errCode 0,端口也正常开了,但登录一直是 Login incorrect。
查了半天才想通:设备端只负责把 telnetd 拉起来,不负责建账号。
建账号那一步在 /etc/telnet_manage.sh 里——而那个脚本被厂商整段注释掉了(下一节细说)。所以整条链条是这样的:
| 环节 | 谁负责 | 结果 |
|---|---|---|
| 写配置 | Web API | 返回 errCode 0 |
| 起 telnetd | webServer 直接 popen | 端口开了 |
| 建账号 + 设密码 | /etc/telnet_manage.sh | 脚本被注释,永远不执行 |
“端口开着”和“能登进去”是两件事。这是我在这个接口上第二次栽跟头了——第一次是把 enable=true 写成了 enable=1,第二次是以为账号会自动建。
再补一句:我前面在串口上能登录 hsan,是因为当时我自己在 shell 里手工建了这个账号,不是接口建的。前后对照才看清这一点。
2.9 回过头看:厂商到底关了什么
这时候再回去看厂商“关闭 telnet”的做法,就很有意思了。启动日志里能抓到这样两行:
/etc/telnet_manage.sh 0 23 192.168.100.215,fe80::16ea:a1ff:fe13:2dc0 ... Distribute Main!
/etc/telnet_manage.sh 0 23 192.168.100.215,fe80::16ea:a1ff:fe13:2dc0 ... Distribute Tardy!
系统确实在读配置、拼参数、调管理脚本。四个阶段 Distribute Early → Main → Medium → Tardy 走得很完整。配置分发链路是通的。但是去看看那个被调用的脚本:

/etc/telnet_manage.sh = 70 行
注释行 = 67 行
剩下 3 行是空行,可执行语句 0 行
整个脚本是一个空壳。 70 行里没有一个可执行语句。厂商是这么“关掉”telnet 的:把脚本内容全注释掉。而 telnetd 本身呢?它是 busybox 的一个符号链接,一直都在:
/usr/sbin/telnetd -> ../../bin/busybox
所以事情的全貌是:
| 厂商的动作 | 实际效果 |
|---|---|
/etc/telnet_manage.sh 全部注释 | 走脚本这条路被堵住了;副作用是 telnet 账号也不会被创建 |
telnetd 二进制从系统里删掉 | 没做——它是 busybox 符号链接,还在 |
| Web API 上的开关去掉 | 没做——wwapi/system/telnet 一直在,还在里面 popen 起 telnetd |
| 默认关闭配置位 | 做了,但 Web API 会主动写这个位 |
| 把自带 root 账号改掉 | 没做——admin 仍然是 uid=0,密码仍是 system |
厂商关了脚本,却没关 API。 而 API 那条路根本不经脚本,直接 killall -9 telnetd 加上启动新进程。这就是为什么“海思开不了 telnet”这个说法不成立——不是开不了,是大部分人的入口找错了。你把 /etc/telnet_manage.sh 翻出来看到全是注释,自然就下结论说这条路封死了。
2.10 收尾:回滚与现状
过程中我对 sysinfo.xml 做过一处修改(把 telnet_enable 从 0 改成 1 试验),确认这条路走不通之后回滚了:
回滚后: <telnet_enable value="0"/>
MD5 : dafa814e443d66e3d1e35d643ead70fe <- 与出厂备份逐字节一致
设备配置回到出厂状态,没有残留改动。顺便说一下远程启用那条路。ahsapd 里有一套服务状态枚举:
TELNET_SERVICE_DISABLE (0)
TELNET_SERVICE_LOCAL_ENABLE (1) <- 当前
TELNET_SERVICE_REMOTE_ENABLE (2) <- 未开
libhi_odl.so 里能看到远程启用对应的动作:
ip6tables -I service_white_global -i br0 -p tcp -m tcp --dport %d -j ACCEPT
实测防火墙状态跟这个推断完全对得上:

service_white_global 这条链存在,但一条规则都没有。也就是说当前是 LOCAL 模式——telnetd 监听 :::2356(所有接口),但没有额外下发 WAN 侧的白名单放行规则。在 telnet 接口上试 wanEnable / remote / type=2 / wanPort 都被静默忽略了(返回 0 但规则没变化),说明远程开关在另一个端点上。这条线我留着后面再挖。
关于持久化。 系统重启后 telnet 不会自动起来,因为改动只在运行时。有两种做法:一是重打包 squashfs——把 telnet_manage.sh 的注释去掉、或者往 rcS 里加启动行。但根文件系统是只读的,需要 unsquashfs → 改 → mksquashfs → 通过 ubiupdatevol 写回去,中间还有个坑:改 UBI 的 LEB 数据必须同步重算 data_crc 和 hdr_crc,否则内核读这个块会返回 -5,squashfs 直接报错。我踩过这个坑,代价是设备 panic 一次。二是……其实没必要。一行 HTTP 请求就能恢复,比刷机的风险低太多了。另外提醒一句:telnetd 默认监听所有接口。如果你要长期开着,建议绑到内网:
killall -9 telnetd
telnetd -b 192.168.10.1 -p 2356 -l /bin/login &
2.11 顺手做了个一键工具
整件事最麻烦的地方,其实是第一次:要拆机、焊线、抓日志、啃 UBI、跑字典,绕一大圈才拿到 admin / system。
但一旦知道了 enable 要传布尔 true、以及登录用的是自带账号,剩下的就是两行 HTTP 请求的事——连串口都不用碰。
所以我把它写成了一个 Windows 小程序,放在本文同目录的 工具\一键开启Telnet\ 里:
- 单文件 EXE,双击就用,不需要装 Python
- 填路由器地址 + 网页管理密码,点【一键开启 Telnet】
- 走完四步后自动真机登录一次并执行
id,确认拿到uid=0,而不是只看端口通不通 - 能一键唤起 cmd 或内置终端登录;系统没装 telnet 客户端时自动用内置的
- 全程有日志,出错会把
errCode翻译成中文提示
它的价值不在“省事”,而在于把这个设备上一个被误传很久的说法纠正过来:所谓“海思平台不能开 telnet”,实际上只要知道那个布尔值和一个自带账号,就是点两下的事。
第三部分 复盘
3.1 几个值得说的点
错误码是有信息的,别忽略它。 这次最大的教训。10001 和 10002 是两个不同的码,说明问题性质不同。我在 10002 上判断错了方向,一直加字段,没想过试值的类型。如果当时把“字段名对、值错”这个可能性列进去,能省好几天。
“标准格式”只是多数情况。 squashfs 的 fragment 索引表被压缩、数据块前面没有 2 字节头——这两点都不符合标准,但厂商就是这么做的。当你确信自己的计算没问题的时候,可以开始怀疑固件本身。
静态分析比黑盒猜测有效得多。 同一个接口,黑盒试了 26 组参数;把 webServer 拉下来 strings 一遍,10 分钟就把内部函数名和字段命名风格全看到了。能拿到二进制就先看二进制。
动手之前先看权限关系。 我花了时间破 root 的密码,而 admin 本身就是 uid=0。cat /etc/passwd 一个命令就能看出来的事。
厂商关功能的方式值得看两遍。 关的是脚本,留着的是 API。这种“关一层,漏一层”的情况在定制固件里挺常见——列表里有几条就检查几条,别只看到第一条就下结论。
3.2 走过的弯路清单
| 尝试 | 结果 | 原因 |
|---|---|---|
POST system/telenet | 404 | 端点名是 telnet,只有函数名拼错了 |
enable=1 等 25 组参数 | 10001/10002 | 值必须是布尔 true |
hi_ipc .../get_serv_manage | 72020013 | 该定义里的函数名没有 sal 前缀 |
hi_ipc .../set_serv_manage(4/8 参数) | 72020008 | 服务端拒绝,非参数数量问题 |
往 /config/conf/appm/init 追加启动行 | 写入被拒 | 该路径在只读 squashfs 里,不是 JFFS2 |
改 sysinfo.xml 的 telnet_enable | 机制生效但无动作 | 被调的脚本是空壳 |
用 loady 从串口灌文件 | 命令不存在 | U-Boot 把这组命令裁掉了 |
| 按惯例跳过数据块前 2 字节 | XZ 解压失败 | 这块固件没有那 2 字节头 |
| 假设 fragment 表是明文 | 读出来是乱码 | 厂商把它也压缩了 |
第四部分 telnet一键开启相关工具及其固件下载和说明
文章对应的固件、脚本和工具都放在同一目录下:
中国移动TCL T36Max破解提取固件,海思芯片方案开启telnet方法/
├── 文章.md <- 本文
├── 图片/ <- 全部配图(28 张 = 17 张命令截图 + 11 张实拍)
├── 原始素材/ <- 实拍原图(未旋转、未标注,留档用)
├── 固件/
│ ├── mtd00-esbc.bin 256 KB 引导校验
│ ├── mtd01-uboota.bin 768 KB U-Boot A
│ ├── mtd02-ubootb.bin 768 KB U-Boot B
│ ├── mtd03-enva.bin 256 KB 环境变量 A
│ ├── mtd04-envb.bin 256 KB 环境变量 B
│ ├── mtd05-fac.bin 2 MB 工厂数据
│ ├── mtd06-cfga.bin 2 MB 配置 A
│ ├── mtd07-cfgb.bin 2 MB 配置 B
│ ├── mtd09-pstore.bin 256 KB 崩溃日志
│ ├── mtd10-kernela.bin 6 MB 内核 A(较新)
│ ├── mtd11-kernelb.bin 6 MB 内核 B(出厂)
│ ├── mtd13-rootfsb.bin 35 MB 根文件系统 B
│ └── mtd14-other.bin 32.5 MB 应用数据
├── 脚本/
│ ├── 01-Web漏洞与API/
│ ├── 02-串口TTL与U-Boot/
│ ├── 03-密码破解/
│ ├── 04-固件提取与解析/
│ └── 05-后渗透与配置/
└── 工具/
├── 逆向产物/ webServer / libhi_odl.so / libextservice.so
├── 前端资源/ main.js / chunks / zh-cn.json
├── 固件内脚本/ rcS.sh 等 18 个脚本
└── 一键开启Telnet/ T36Max一键开启Telnet.exe(2.11 那个)
文件都在同一个目录里。另外做了两份网盘分享,需要直接下载的走这里:
其中几个值得单独看一眼的:
脚本/01-Web漏洞与API/enable_true.py—— 最终那条开启 telnet 的请求,含完整的登录+提交逻辑脚本/01-Web漏洞与API/web_probe.py—— 26 组参数穷举的完整代码脚本/04-固件提取与解析/sq_parse.py—— 自己写的 squashfs 解析器(含被压缩的 fragment 表处理)脚本/04-固件提取与解析/backup_all.py—— 13 分区备份脚本工具/逆向产物/libhi_odl.so—— 配置分发与 telnet/SSH 调用链都在这个库里
最后
这台设备最后的状态是:配置回到出厂,telnet 在 2356 上跑着,需要的时候一条请求就能拉起来。整个过程最有意思的地方在于——答案一直摆在最显眼的地方。main.js 里 wwapi/system/telnet 那一行,从第一天就看得见。厂商还“贴心”地把函数名拼错成 telenet,让我以为找到了内部命名,结果那个拼写只存在于二进制里,接口层用的是另一套。
然后是 enable=true 和 enable=1 的一字之差。绕了拆机、焊接、啃 UBI、啃 squashfs、跑字典这一大圈,最后卡在这上面。不过话说回来,如果一开始就试对了 enable=true,我就不会去拆这台机器,也就不会有后面那 13 个分区的完整固件了。弯路也是收获。
本文记录的是对自有设备的分析过程。文中涉及的所有固件、脚本与工具仅用于学习和研究。
最后的最后,我正式宣布,海思团队设计的嵌入式(路由器)设备固件存在telnet,并非网传的牢不可破!!!
转载注明出处:xiaozou123.cn,制作不易,开源精神。请大家维护知识劳动者的成果,社区才会发展!!!
写这些内容需要耗费大量的时间和金钱,觉得有用的话可以点击下面的“赞赏”支持作者,你的支持是我更新的动力。

Comments NOTHING