米米的博客

做了一点微小的工作

笔者最近给一台 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匹配一个硬编码字符串。报文均可从明文完整构造,并且与工具发出的内容完全一致。下面继续展开完整的分析过程,欢迎感兴趣的读者继续阅读。

阅读全文 »

本文在 iPad mini 1 和 iOS 7 环境下测试。

在之前的文章利用 checkm8 漏洞访问 iPad 文件系统中,笔者记录了如何维修一台屏幕完全损坏的 iPad mini 1,并使用 Arduino、checkm8 和 SSH Ramdisk 访问其中的文件系统。当时已经可以把照片等文件复制出来,也可以通过修改 SpringBoard 配置解除锁屏密码的尝试次数限制,但要在屏幕上手工尝试最多 10000 组四位数字,仍然不太现实。

最近,笔者继续研究了旧款 iOS 设备的 Data Protection 机制,发现普通 SSH Ramdisk 不能直接用于密码破解,并制作出一套可以访问设备 UID 密钥的 iOS 6 Ramdisk,成功在设备端自动尝试锁屏密码。

iOS 如何使用锁屏密码保护数据

iOS 并不会直接保存锁屏密码。设备初始化时会生成一组用于保护不同类别文件的 Class Key,并把它们包装后保存在 System Keybag 中。这台设备的 Keybag 位于数据分区的/mnt2/keybags/systembag.kb。用户输入密码后,系统会将密码、Keybag 中的 Salt 以及设备内部独有的 UID 密钥一起送入密钥派生函数(KDF),计算出 Passcode Key,再用它解开 Keybag 中的 Class Key。只有这一整套过程成功,系统才能访问受到 Data Protection 保护的文件。

这也解释了为什么不能把 Keybag 复制到性能更高的电脑上离线破解。UID 密钥写在芯片内部,软件无法直接读出,只能请求设备的 AES 硬件使用它进行运算。因此,即使候选密码在 Mac 上生成,最关键的 KDF 仍然必须由这台 iPad 自己完成。

早期 iOS 安全研究项目 iphone-dataprotection 提供了一个名为bruteforce的设备端程序。它会读取 System Keybag,依次测试四位数字密码,并通过 AppleKeyStore 和硬件 AES 验证结果。找到正确密码后,程序还会生成 Passcode Key 和所有 Class Key,供后续解密数据使用。后文提到的bruteforce就是这个工具。

阅读全文 »

笔者最近在折腾一台深圳海冈 - 敖阳 MPPT 太阳能控制器。机器可以通过附带的 Wi-Fi 模块接入「涂鸦智能」App,查看电池状态和调整参数。虽然通过涂鸦集成也能接入 Home Assistant,但笔者还是希望进一步摆脱云端依赖,用 ESP32 和 ESPHome 直接与控制器主板通信。

要实现这个目标,首先需要弄清楚 Wi-Fi 模块和主控之间交换的是什么数据。于是,笔者拆开控制器,拆焊原本的 Wi-Fi 模块,设计了一块用于引出信号的转接板,再接入逻辑分析仪进行被动抓包。

目前已经确认:这台设备使用的是 9600 baud、8N1 的标准涂鸦 MCU 串口协议。双向通信中的心跳、DP 下发和状态上报都已通过长度及校验和验证;电池电压、电量相关的数据也有了初步对应关系。本文记录从拆机到协议解析的过程,ESPHome 替换模块的部分留待后续验证。

原机 Wi-Fi 模块

打开控制器后,可以看到主板上焊接固定了 Wi-Fi 模块,连接位置附近印有WIFI。下图中可以看到两块板之间的连接引脚及焊点。

原机Wi-Fi模块通过六路引脚直接焊接固定在主控板上

模块上带有CBU字样,标签型号为CBU-IPEX,并带有二维码和外接天线。原机能够使用涂鸦 App,是判断协议方向的一条线索。不过,仅凭模块标签还不能确定主控侧的通信格式,需要抓到实际数据才能验证。

阅读全文 »

在文章将菲斯曼设备接入 Home Assistant 中,笔者介绍了支持接入菲斯曼设备的 Home Assistant 集成。不过,对于如何安装所需的 Wi-Fi 模块硬件还没有展开介绍。本文将详细记录。

笔者家中使用的是一台菲斯曼(Viessmann)Vitopend 100-W 燃气壁挂炉,具体型号为 L1P33-A1JD。它有夏季和冬季两种主要运行模式:夏季模式只供应生活热水,冬季模式还会启用采暖。机器自带的 LCD 控制面板可以查看状态和设置温度,但每次切换模式都要到壁挂炉前操作,还是不够方便。

菲斯曼的官方产品资料显示,A1JD/A1HD 系列可以选配 Wi-Fi 模块。于是笔者购买了与这台机器匹配的模块,标签上的名称是 GME WiFi Gateway。安装完成后,就可以通过手机远程切换模式、设置采暖和热水温度,并查看壁挂炉的运行状态。

下面记录实际安装成功的过程。不同型号的内部结构和接口可能不同,购买模块前要先确认兼容性。

准备工作

需要准备的东西很简单:

  • 与壁挂炉型号匹配的 Wi-Fi 模块;
  • 一把十字螺丝刀;
  • 安装了「菲斯曼互联」应用的手机,以及可用的家庭 Wi-Fi 网络。

壁挂炉的电控盒中有市电线路。拆机前必须关闭壁挂炉并从外部断开电源,不能只按控制面板上的开关键。确认显示屏已经熄灭后再操作。如果不熟悉电气设备,建议让菲斯曼售后或专业人员安装。

拆下前盖

壁挂炉前盖由底部的螺钉固定。断电后,从机器底部找到左右两侧的固定螺钉,用十字螺丝刀将它们松开。

松开壁挂炉前盖底部的固定螺钉

螺钉松开后,从前盖下部轻轻向外拉,再向上提起,使前盖脱离上方的挂钩。取下前盖后,就能看到金属炉体和下方的控制单元。

拆下前盖后的壁挂炉内部
阅读全文 »

在文章将公牛智家设备接入 Home Assistant 和将菲斯曼设备接入 Home Assistant 中,笔者介绍了将相关智能家居设备接入 Home Assistant 的方法。最近,笔者又遇到了一个类似的需求:把「网上国网」中的用电量、电费账单和账户余额也接入 Home Assistant。

网上国网 App 本身已经可以查看每日用电量和月度账单,但每次都要打开 App 查询并不方便,也无法直接参与 Home Assistant 的能源统计和自动化。例如,接入以后可以在家庭仪表盘中统一查看本月累计用电量,在电量异常增加时发送通知,或者结合其他设备分析空调、暖气等大功率电器的使用情况。

目前已经有一些通过国家电网网页获取数据的开源项目,不过这类方案通常需要运行 Docker 和浏览器,利用 Playwright 等工具模拟网页登录;遇到图形验证码时,还可能需要额外配置 OCR 或大模型服务。为了减少部署组件和长期维护成本,笔者开发了原生 Home Assistant 集成 hass-state-grid,直接使用网上国网 App 的接口获取数据,不依赖 Docker、浏览器或验证码识别服务。

阅读全文 »
0%