写在前面

这台 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
Flash128 MiB SPI NAND(128KB 擦除块 / 2KB 页)
内核Linux 5.10.0 #139 SMP armv7l
SDKHiHANLinuxV100R003C01SPC010
固件V1.0.2(内部版本 HAX3000GE_V1.0.2_20240617132433)
省份HUB(湖北移动)
U-Boot2020.01,jenkins-ROM_HI_AX3000_CMCC_V1.0-139

整块 Flash 分了 15 个区:

128MB SPI NAND 分区

这里有两个细节值得先记住,后面会反复用到: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,把摘要当密码发过去。

T36 Max 管理端登录流程的终端分析输出,客户端生成 40 字符 challenge 后与密码做 HMAC-SHA256 提交

密码本身不过网,看着挺安全。问题出在 challenge 是客户端生成的。服务端既不复用它,也不检查它的新鲜度,更不要求它必须来自自己下发的 nonce。结果就是这层签名只能防“抓包直接看到明文密码”,防不住重放——同一组 challenge + password 可以无限次提交。不过说实话,这一条本身没什么杀伤力,因为传输层是 HTTP。抓一次包就能把 challenge 和 password 一起拿到,重放和不重放差别不大。

1.3 硬编码的 AES 密钥

真正有味道的问题在加密上。Web 里有些敏感字段(PPPoE 密码、WiFi 密钥之类)在传之前会做 AES 加密。按理说密钥应该是设备唯一的,但 strings 一把梭下去就看见了:

从 T36 Max 的 webServer 二进制里 strings 出的硬编码 AES-128-CBC 密钥与固定 IV
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 给得特别大方:

未带认证头请求 T36 Max 的 deviceInfo 接口,直接返回 SN、MAC、型号、固件版本与运营商省份

SN、MAC、型号、固件版本、SDK 版本、运营商、省份,全出来了,一个认证头都不用带。单看不算什么大事,但从攻击面角度它挺有用的——拿到这些就能确认设备型号和固件版本,进而去查这个版本有没有已知问题。等于把“你该用哪套 exploit”直接告诉你了。

1.5 Mesh 拓扑图的存储型 XSS

固件里的 EasyMesh 拓扑图用的是 ECharts。我去翻了一下渲染代码,在 chunks/916.js 里发现了这么一段:

ECharts tooltip 未转义
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、PPPoE 168168、WiFi acaduser——这一串的强度不用多评价了。

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 端卡住了,换个思路——直接从硬件进。先看看这台机器长什么样:

TCL T36 Max 路由器正面,白色立式机身印有中国移动与 TCL 双标,四根外置天线装在后侧

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

T36 Max 机身背面铭牌,标注产品名称、型号 T36 Max、管理地址、CMIIT ID 与默认账号 admin

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

T36 Max 主板全貌,右下角用红圈标出 JP1 四孔 UART 调试串口位置

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

T36 Max 主板上 JP1 四孔 UART 焊盘特写,旁边可见 PL5 位号与 Wi-Fi 指示灯丝印

定制板子留了 JP1 丝印,算是比较“坦诚”的做法。有些厂商会把调试口做成不标注的裸焊盘,那种就只能靠万用表一个个点着量了。怎么认哪个是哪个:先万用表打直流电压档,黑表笔接地(找主板上的大面积铺铜或者屏蔽罩,通断档确认),然后一个个点过去。这台板子上是 4pin:VCC / GND / TX / RX。

  • GND:跟地导通的
  • VCC:稳定 3.3V 的
  • TX / RX:剩下两个,一般会有一个略微低一点,或者用示波器看哪个在上电瞬间有波形(TX 是设备发出来的)

焊接用的是细尖头烙铁,焊盘很小,先上锡再拖锡:

T36 Max 主板 JP1 焊盘焊接完成后的特写,四根细线分别焊在四个焊点上

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

CH340G 型 USB 转 TTL 小板特写,引出针脚标着 5V、3V3、TXD、RXD、GND,板上另有 12MHz 晶振

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

T36 Max 主板与 CH340 模块之间的杜邦线接线状态,红字标注 GND、TXD、RXD 的对应关系

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

Windows 设备管理器端口列表,CH340 被识别为 USB-SERIAL CH340 并占用 COM4 端口

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

串口终端工具的会话配置页,波特率 115200、8 数据位、1 停止位、无校验、无硬件流控

参数是 115200 8N1,无硬件流控。

2.3 从 TTL 进入 U-Boot

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

T36 Max 串口终端里的 U-Boot 启动输出实拍,含 59267 字节引导代码、256MiB 内存与随机 MAC 提示

先是 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 出来:

T36 Max 的 U-Boot 环境变量与支持命令列表,含 bootcmd、bootflag、mtdparts 与 rootfsa 参数
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 表和目录表的解析都是对的(目录能正常读出来),偏偏这张表是乱的。

来回折腾很久之后才想通:

squashfs 非标准结构

这块固件的 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 字节:

从 T36 Max 固件镜像里提取出的 /etc/shadow 内容,可见 root 与 admin 两个 SHA-512 crypt 密码哈希
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 是为抗暴力破解设计的。

hashcat 以 1800 模式爆破 T36 Max 管理员密码哈希,42 秒从 rockyou 字典命中 admin 的明文

42 秒,admin 出来了:

$6$Eu246xcGqJgk18/b$CgulSHeF7UBOVIpRjTM3paEYYgteLgTS2oUIldnyGCG7VB/...
  : system

密码是 system。一个字典里排得很靠前的常见词。厂商把管理账户的密码设成了 system。

root 那个哈希没出来。

我后面又跑了一轮定向字典——把 system、型号、厂商名、SDK 版本号这些做了一堆变形(大小写、加数字、加年份、加符号),六十多个候选,加上 rockyou 全量,root 的哈希一直没命中。当时有点不甘心,觉得差一步。结果后面发现有更省事的办法。

2.7 串口登录,整机备份

回到串口,用刚拿到的凭据登录:

串口 root shell 实测
hsan login: admin
Password:
/ # id
uid=0(root) gid=0(root) groups=0(root)

root 了,然后 cat /etc/passwd,看到了这里最关键的一行:

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

T36 Max 13 个 MTD 分区逐区 dump 并做 MD5 校验的整机备份过程输出

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

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:

从 T36 Max 的 webServer 二进制里 strings 出的符号,telnet 相关函数名全被厂商拼写成 telenet
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 个字段名不变,每次加一个新的:

T36 Max 的 wwapi/system/telnet 接口 19 组参数穷举结果,追加字段与改值都固定返回 errCode 10002
基线 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。

一行 API 拿到 root

是 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
起 telnetdwebServer 直接 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

实测防火墙状态跟这个推断完全对得上:

T36 Max 上 ip6tables 的 service_white_global 链状态,链存在但里面一条规则都没有

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/telenet404端点名是 telnet,只有函数名拼错了
enable=1 等 25 组参数10001/10002值必须是布尔 true
hi_ipc .../get_serv_manage72020013该定义里的函数名没有 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 那个)

文件都在同一个目录里。另外做了两份网盘分享,需要直接下载的走这里:

  • 固件(13 个 MTD 分区,解压后 88 MB):下载,访问密码 Mnqk
  • 工具(文中用到的脚本、逆向产物,以及 2.11 那个一键开启 Telnet):下载,访问密码 7kou

其中几个值得单独看一眼的:

  • 脚本/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,制作不易,开源精神。请大家维护知识劳动者的成果,社区才会发展!!!

写这些内容需要耗费大量的时间和金钱,觉得有用的话可以点击下面的“赞赏”支持作者,你的支持是我更新的动力。

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