米米的博客

做了一点微小的工作

在上一篇文章寻找一台理想的迷你 5G CPE 中,笔者提到了手上的中兴 F50,以及用 USB 网卡扩展有线连接的尝试。最近发现了 mu300-linux 项目,可以在 F50 上运行独立的 Linux 系统,支持 Ubuntu 和 OpenWrt,还能保留原来的 Android 系统,于是决定继续折腾一下这台设备。

不过,这台 F50 使用的是官方 B16 固件,最初既没有 root,也没有稳定可用的 ADB。手边只有一台 Mac,而网上不少教程又依赖 Windows 工具。最后走通的路线是:通过 USB 冷启动窗口进入下载模式,先将完整固件降级到 B09,再取得 Magisk root,最后安装带 LuCI 的 OpenWrt。

完成后,F50 默认启动 OpenWrt,USB 网络、ADB、SSH 和网页管理均可使用;原厂 B09 保留在 A 槽,可以切换回去。随后为了继续尝试 USB 网卡,笔者又将内核从 5.4 切换到 6.18.60,使 MU300 界面的 USB Host 控制接口可用。本文记录从 B16 开始的实际过程,以及其中几个踩到的坑。

设备与安装方案

本次使用的环境如下:

项目本次环境
设备中兴 F50,系统设备代号 MU300
原始固件F50_FLYMODEM_ZYV1.0.0B16
存储64 GB 版 eMMC,原有 userdata 约 20 GiB
操作电脑Apple Silicon Mac
连接方式USB 数据线,冷启动时同时负责供电
降级后的固件F50_FLYMODEM_ZYV1.0.0B09
Linux 项目版本mu300-linux v2026.11.02.1
系统OpenWrt 25.12.6,带 LuCI
内核首次安装 5.4.254,后切换至主线 6.18.60

整个过程可以分成四段:

flowchart LR
    A["官方 B16"] -->|"USB 下载模式降级"| B["官方 B09"]
    B -->|"匹配的 boot 与 TrustOS"| C["B09 + Magisk root"]
    C -->|"USB ADB 安装"| D["OpenWrt 默认启动<br/>保留 B09 Android"]
    D -->|"保留配置、更换内核"| E["Linux 6.18.60<br/>USB Host 控制接口可用"]

这里不要求 F50 自己能够访问互联网。固件和 Linux 镜像由 Mac 下载,设备端通过 USB 接收文件;即使没有 SIM 卡或者无线网络上游,仍然可以完成安装。

需要提前说明的是,这是一台 64 GB F50 的实测记录。后面的存储布局来自这台机器,不能直接套用到其他版本或固件上。降级流程也设置了恢复出厂请求,因此应当先备份原有数据。

阅读全文 »

最近,笔者想找一台小型 5G CPE,用来在出差和旅行时给下游设备提供蜂窝网络接入。要求是体积小、内置天线,方便携带;支持 5G 蜂窝网络,支持使用自己的 SIM 卡;同时具备 Wi-Fi 和 RJ45 网口;价格最好控制在 300 至 500 元。原本以为这样的产品应该不少,查了一圈才发现,家用 CPE 往往太大,而便携式随身 Wi-Fi 又经常省掉网口。几个条件放在一起,能选的设备就不多了。

本文记录一下筛选过程,以及目前留下的三个候选方案:Banana Pi BPI-R3 Mini、中兴 MU5001 和鲲鹏无限 C2000 Pro。

需求

笔者平时主要通过 CPE 的 Wi-Fi 连接手机和笔记本;出差时,有时还需要配置服务器,或者让一些没有 Wi-Fi 的设备临时联网。这时希望直接用网线接到 CPE 上,由它提供网络,拓扑如下:

flowchart TD
    A["5G 蜂窝网络"] --> B["迷你 5G CPE"]
    B -->|Wi-Fi| C["手机、笔记本"]
    B -->|RJ45| D["服务器 / 其他无 Wi-Fi 的设备"]

这里最不能妥协的是原生 RJ45 网口。USB 网络共享也能传输数据,但所连接的服务器或其他设备需要支持相应的网络共享方式和驱动;给随身 Wi-Fi 插上一块 USB-C 转 RJ45 网卡此前也尝试过,例如中兴 F50 就支持一些型号的有线网卡,但是实际用起来还是不太稳定。相比之下,有原生网口,直接支持用网线连接就要省事得多。

此外,笔者也发现,很多定位接近的产品配置了屏幕和大容量电池,但是对这个用途来说,它们都不是必需的。

阅读全文 »

现在不少电视和带屏设备的默认桌面越来越臃肿,广告、会员订阅和付费内容占据了显眼的位置,打开自己安装的应用反而要翻找一番。换成第三方桌面也未必能完全解决问题:一些方案闭源,想按自己的习惯定制并不容易;开源方案对遥控器方向键和触屏输入的支持又参差不齐,很难同时照顾好两种操作方式。

而在日常使用中,需要的也不只是一个应用启动器。传送和安装 APK、执行 Shell 命令、通过 Activity Launcher 一类工具打开应用内部入口,通常还得分别安装不同的 App。原本只是想把设备用得顺手一些,最后却要在一组工具之间来回切换。

笔者最初想给家里的小米电视和一台带屏天猫精灵做一个简单的桌面。电视上主要用遥控器,希望打开电视直播、Kodi 之类的应用时少翻几层菜单;音箱则是一块 1024×600 的横屏,运行 Android 8,主要靠触摸操作,而且没有常见的系统导航栏和返回键。

这两种设备看起来都是「横着放的一块 Android 屏幕」,实际用起来却很不一样。适合遥控器的菜单,放到触屏上可能能打开却不好退出;照着手机设计的界面,又未必适合坐在几米外用方向键操作。

Astra Launcher 就是从这些需求开始的:做一个面向 Android 8 及以上横屏设备的轻量桌面,同时兼顾触摸与遥控器,让常用应用、时间天气和几个系统入口都在容易找到的位置。局域网 APK 安装、Shell 和 Activity 浏览工具则收进设置页,需要时可以直接打开,不必再为每件小事单独找一个 App。

项目地址:astra-sec/astra-launcher

Astra Launcher 横屏主页预览
阅读全文 »

在此前的文章湖北电信 IPTV 机顶盒与局域网组播共存中,笔者介绍了如何保留原装机顶盒,同时把 IPTV 直播送进家庭局域网。通过 rtp2httpd 将组播转换为 HTTP 单播以后,电视、电脑和手机就可以使用普通播放器收看这些频道。

网络打通之后,电视这一端还需要一个顺手的播放器。

最初笔者使用 ProgTV 播放直播。它的功能和设置很丰富,对专业用户很有用,不过放到家里日常看电视的场景中,逐渐遇到了几个让人不太舒服的问题:

  1. 设置太多,家人用不上,反而增加了操作负担。 家里主要就是选台和看直播,大部分专业选项配置一次之后就不会再碰。对于不熟悉这些设置的家人来说,菜单越多,越容易不知道该选什么,误操作后也不容易恢复。
  2. 免费版有额外的启动延时。 笔者使用的版本会在启动时显示免费版延时提示。每次只是想看一会儿电视,却要先等这段限制结束,确实不太爽。
  3. 冷启动本身也偏慢。 在出现免费版延时提示之前,电视上就已经有一段蓝屏加载。笔者使用的 2.93.8 版本包含 .NET/Mono 运行时,运行时初始化也是很大的性能开销,再叠上免费版的额外等待,就很糟心了。

家里的需求其实很简单:打开应用后恢复上次看的频道,按遥控器上下键换台,展开列表时能知道现在正在播什么,需要时可以修改直播源。笔者因此想自己做一个播放器,把常用操作保留下来,减少需要家人理解的设置,也去掉额外插入的启动等待。

于是有了 Astra TV:一个使用 Kotlin 编写的 Android / Android TV IPTV 播放器。桌面入口显示为「电视直播」,方便家人找到。没有广告、账号或人为启动倒计时,直播源和节目单由用户自己配置。

项目地址:astra-sec/astra-tv

Astra TV 的首次配置、全屏播放、频道列表和设置界面
阅读全文 »

笔者最近给一台 TP-LINK TL-R5005PE-AC 企业路由器接入了两台蜂窝 CPE。上游分别是电信和联通的流量卡,每张卡每月均有 2TB 流量。两台 CPE 各用一根网线接入主路由的两个 WAN 口,链路测速基本一致。原本以为开启双 WAN 负载均衡后,两张卡就能一起分担流量,结果却是电信卡已接近耗尽,联通卡才用了约 20%。

为了解决这个问题,笔者先研究了官方固件中的选路逻辑,又在局域网机器上持续采集主路由的流量计数。期间一度得出了一个非常离谱的结论:即使取消了运营商绑定,甚至交换两条线路,统计出来的流量仍然严重偏向 WAN1,最高甚至接近 98:2。看起来无论怎么调整,路由器都更偏好字面意义上的「WAN1」。

但在进一步分析内核和硬件转发计数后,笔者发现这个结论的依据本身就存在问题:两个 WAN 在 Linux 系统中的接口统计根本不是同一种口径,WAN2 的大量硬件加速(HNAT)流量压根没有被统计进去。改用底层物理端口计数,并同时记录硬件与软件两条转发路径后,使用连接均衡设置,两条线路的累计流量终于接近各占一半。

这次排查遇到了两个叠加的坑:一是手动指定运营商导致 ISP 选路规则提前截流了一部分连接,二是硬件加速机制导致上层监控拿到了错误的流量比例。本文将记录完整排查过程,以及固件中几个容易被 UI 界面掩盖的实现细节。

两张 2TB 流量卡,为什么只用完一张?

本次的网络拓扑非常简单:

flowchart LR
    A["电信流量卡 + CPE"] --> C["TL-R5005PE-AC<br/>双WAN负载均衡"]
    B["联通流量卡 + CPE"] --> C
    C --> D["局域网设备"]

两个上游均为蜂窝网络,网速也基本一致。刚开始,笔者在路由器界面上启用了负载均衡,并按照实际线路给两个 WAN 口分别指定了运营商(WAN1 指定电信,WAN2 指定联通)。然而,流量卡后台显示的实际套餐消耗却大相径庭:一张 2TB 套餐已然耗尽,另一张才消耗了约五分之一。这个真实的套餐余量差异促使笔者展开排查。

从固件实现看「负载均衡」的真实逻辑

本文分析的设备为 TL-R5005PE-AC V1.0,固件版本为 1.0.1 Build 241108 Rel.60855n。解包根文件系统(rootfs)后,可以找到几套职责分明的程序:

文件或模块主要用途
/usr/sbin/isp_route按目标 IP 匹配运营商地址库并选择出口
/usr/sbin/load_balance根据接口实时负载选择较空闲线路(即带宽均衡)
/usr/sbin/default_balance、ipt_balance.ko对新选择请求进行轮询分配(即连接均衡)
special_route 相关模块保存和查询地址对绑定,复用已有的出口选择
/etc/init.d/hnat 及 Realtek FC 代码负责软件 / 硬件加速及其计量统计

其中不少控制逻辑直接写在 Shell 脚本里,但连接选择器和地址绑定的关键核心位于内核模块。为了核实细节,笔者提取了固件内核,随后对相关函数进行了分析。

分析表明,界面上的「负载均衡」并非一个统一处理所有连接的总开关。一个典型的 IPv4 报文进入路由后,会依次经过以下层级:

1
2
3
4
5
6
7
策略选路
↓
特殊应用选路及已有地址对绑定
↓
ISP运营商选路
↓
带宽均衡 / 连接均衡

连接跟踪(conntrack)中的 connmark 负责保存已经选择好的出口。后端的均衡器在恢复标记时,一旦发现标记非零,就会直接返回已有出口,不会再将该连接重新分配。因此,在分析「负载均衡为什么不均」之前,首先需要确认是否有更早的规则提前把出口强行选走了。

第一个坑:填写运营商,意外触发了前置选路规则

最早的时候,WAN1 被指定为电信,WAN2 被指定为联通,导致 ISP 选路(ISP Route)处于开启状态:

1
2
WAN1: isp_name=dianxin,  priority=user
WAN2: isp_name=liantong, priority=user

此时 isp_route 模块生成的匹配条件基于目标 IP 地址:

1
2
-m set --match-set ISP_CHINA_TELECOM dst
-m set --match-set ISP_UNICOM_CNC dst

这意味着,当一条连接尚未被更早的策略或绑定决定出口时,只要访问的目标 IP 落在电信地址库内,就会优先强制走电信线路;目标 IP 落在联通地址库内,则强制走联通线路。匹配成功后生成的 connmark 会被锁定,导致后续的普通负载均衡模块根本无法介入。

这一设计的初衷是为了减少跨网访问、降低延迟,但也会导致双 WAN 的流量不平衡。结合固件逻辑,如果终端实际访问的目标服务器大多落在电信地址库中,或者承载大流量的业务(如视频下载等)刚好匹配了电信 IP,这部分流量就会被提前锁定到电信线路,连接均衡器完全没有介入的机会。流量严重偏向电信也有了合理解释。

取消手动运营商指定后,再次检查发现两个 WAN 的状态均变更为 auto_check / non_set,isp_route 链清空且不再被入口规则引用。对于纯流量卡叠加的使用场景,彻底禁用 ISP 运营商选路是实现流量均衡的前提。

阅读全文 »

为了给家里的电信定制中兴 ZXV10 B863AV3.2-T 机顶盒(S905L3A-B,Android 9)开启 ADB,笔者从公开渠道找到了网上流传的「stb3.0 工具.exe」。该工具号称只需指定机顶盒的 IP,便可一键开启网络 ADB,无须拆机,也无须通过遥控器进入工厂菜单。经过分析,笔者发现它实际上由 Python 编写,而且似乎还经历过被破解、再传播的过程(目前在某鱼上售价 10 元左右)。这类工具在恩山等论坛流传已久,却几乎没人说清它究竟发送了什么。出于赛博考古的兴趣,笔者分析了工具代码及中兴机顶盒固件中的相关实现,并将结果整理成本文。

首先给出「stb3.0 工具」的核心代码,笔者重新整理了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#!/usr/bin/env python3
# 中兴机顶盒 10042 端口 RemoteSetCfg / CfgAgent 协议解密、构造与重放
import argparse
import socket
import struct
import time
from Crypto.Cipher import AES

# libgetkey.so .data 0x11004 处的出厂默认密钥,SecretKey() 用 snprintf "%s" 读出它
KEY = b'|!jFa8o4' + b'\x00' * 8

AFFIRM = '16_AeKg_2t7z_@01_17_@A_$oMjO6fg.P_18_GdpR8jO30!d-rR_20_jD9!2y-dW8'
TOOLTYPE = 'ToolType:16,licindex:yuduanhun'


def pad16(b: bytes) -> bytes:
return b + b'\x00' * (-len(b) % 16)


def enc_frame(ftype: int, payload: bytes) -> bytes:
"""加密帧0x10 | 帧类型 | u16密文长BE | 0x0002 | 18个零字节 | AES-128-ECB密文"""
ct = AES.new(KEY, AES.MODE_ECB).encrypt(pad16(payload))
return bytes((0x10, ftype, len(ct) >> 8, len(ct) & 0xFF, 0, 2)) + b'\x00' * 18 + ct


def cmd_msg(cmd: int, payload: bytes) -> bytes:
"""内层命令头u16命令号BE | u16零 | u32数据长BE,补零到16字节"""
return enc_frame(0x01, struct.pack('>HHI', cmd, 0, len(payload))) + (
enc_frame(0x10, payload) if payload else b'')


def decrypt_dump(blob: bytes) -> None:
"""按帧解析一段报文,解密并逐帧打印"""
i = 0
while i < len(blob):
magic = int.from_bytes(blob[i:i + 2], 'big')
if magic == 0x1020: # 明文 hello
ln = int.from_bytes(blob[i + 4:i + 8], 'big')
print(f' [0x{magic:04x}] hello: {blob[i + 8:i + 8 + ln]!r}')
i += 8 + ln
else: # 0x1001 命令头 / 0x1010 数据帧,均为 AES-128-ECB
ln = int.from_bytes(blob[i + 2:i + 4], 'big')
pt = AES.new(KEY, AES.MODE_ECB).decrypt(blob[i + 24:i + 24 + ln])
if magic == 0x1001:
cmd, _, dlen = struct.unpack('>HHI', pt[:8])
print(f' [0x1001] cmd 0x{cmd:04x}, datalen {dlen}')
else:
print(f' [0x1010] data: {pt.rstrip(bytes(16))!r}')
i += 24 + ln


def build_messages():
hello = bytes((0x10, 0x20, 0, 0)) + struct.pack('>I', 4) + b'V6.0'
return [
('1. 版本协商(明文)', hello),
('2. affirm 鉴权', cmd_msg(5, AFFIRM.encode())),
('3. 工具类型声明', cmd_msg(0x15, TOOLTYPE.encode()) + cmd_msg(0x11, b'')),
('4. 开启网络ADB', cmd_msg(0x2036, b'cmdtype=ADB_ZTE,cmdparams=Open')),
]


def send(host: str) -> None:
with socket.create_connection((host, 10042), timeout=5) as s:
for name, msg in build_messages():
s.sendall(msg)
print(f'已发送 {name}({len(msg)} 字节)')
time.sleep(1) # 原工具在每段报文之间等待约1秒


def main() -> None:
ap = argparse.ArgumentParser(description='中兴机顶盒 10042 协议工具')
ap.add_argument('--send', metavar='HOST', help='向机顶盒发送报文')
args = ap.parse_args()
if args.send:
send(args.send)
print(f'完成。可尝试: adb connect {args.send}:5555')
return
for name, msg in build_messages():
print(f'{name} — {len(msg)} 字节:')
decrypt_dump(msg)


if __name__ == '__main__':
main()

运行输出:

1
2
3
4
5
6
7
8
9
10
11
12
1. 版本协商(明文) — 12 字节:
[0x1020] hello: b'V6.0'
2. affirm 鉴权 — 144 字节:
[0x1001] cmd 0x0005, datalen 65
[0x1010] data: b'16_AeKg_2t7z_@01_17_@A_$oMjO6fg.P_18_GdpR8jO30!d-rR_20_jD9!2y-dW8'
3. 工具类型声明 — 136 字节:
[0x1001] cmd 0x0015, datalen 30
[0x1010] data: b'ToolType:16,licindex:yuduanhun'
[0x1001] cmd 0x0011, datalen 0
4. 开启网络ADB — 96 字节:
[0x1001] cmd 0x2036, datalen 30
[0x1010] data: b'cmdtype=ADB_ZTE,cmdparams=Open'

这个脚本依赖pycryptodome(pip install pycryptodome)。不带参数运行时,它会构造四段报文并自行解密输出;加上--send IP,则会按照原工具约 1 秒的发送间隔,将报文依次发送给机顶盒。

四段报文的整体语义是:「我是 V6.0 工具」→「口令对上」→「我是 16 号工具,授权标识 yuduanhun」→「打开 ADB」。在 TCP 10042 配置服务可达、且仍支持这套旧协议的固件上,它们已经构成原工具开启 ADB 所需的完整命令链。原工具还会发送第五段ADB_ZTE_KEY,向机顶盒写入一把固定的 RSA 公钥;由于在本文分析的固件中没有发现该文件的消费链路,文章开头的实用脚本出于最小权限原则有意省略了这一步。

为了弄清楚原理,笔者同时分析了固件中的服务端和工具客户端,完整还原了协议:它采用 AES-128-ECB,密钥是固件中的一个出厂常量;所谓「鉴权」,本质上是strstr匹配一个硬编码字符串。报文均可从明文完整构造,并且与工具发出的内容完全一致。下面继续展开完整的分析过程,欢迎感兴趣的读者继续阅读。

阅读全文 »
0%