米米的博客

做了一点微小的工作

为了给家里的电信定制中兴 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、浏览器或验证码识别服务。

阅读全文 »

项目地址:
astra-dns
luci-app-astra-dns

在 OpenWrt 上折腾 DNS,很多人最后都会走到一个相似的场景:我想让路由器负责全家的 DNS,同时希望它能做广告过滤,还希望访问 Cloudflare 站点时可以自动返回一个更快、更稳定的优选 IP。

单独看,这些需求都不罕见。

广告过滤可以交给 AdGuard Home。DNS 转发、分流、重写可以交给 MosDNS。Cloudflare 优选 IP 可以通过 CloudflareSpeedTest 之类的工具定期测速,再把结果写进 DNS 规则里。代理相关的域名处理,还可能再接入 Mihomo 等组件。

这些工具都很好,也都各自解决了真实问题。但当它们被串在一起时,整个 DNS 链路就会变得有些重。

一个典型的 motivating example

假设我们在 OpenWrt 上已经用了 MosDNS。MosDNS 很适合做 DNS 转发、分流和规则化处理,也可以把某些域名的解析结果改写到指定 IP。于是,如果我们想做 Cloudflare 优选 IP,一个自然的方案是:

  1. 用 CloudflareSpeedTest 定期测出当前网络下表现最好的 Cloudflare IP。
  2. 把这些 IP 写入 MosDNS 的 rewrite 规则。
  3. 当域名解析结果落在 Cloudflare 地址段时,让 MosDNS 返回优选 IP。

这条链路本身是合理的。

但问题来了:MosDNS 并不是广告过滤器。它可以做规则匹配和 DNS 编排,却不直接提供 AdGuard Home 那种完整的广告过滤体验。如果我们还想拦截广告域名,就往往需要再引入一个 DNS 服务,比如 AdGuard Home。

于是原本的需求变成了这样:

1
2
3
4
5
客户端
-> OpenWrt DNS 入口(dnsmasq)
-> AdGuard Home 负责广告过滤
-> MosDNS 负责分流、转发和 Cloudflare IP 重写
-> 上游 DNS

这当然能工作,很多成熟配置也正是这么搭起来的。但它带来的代价也很明显:

  • 查询链路变长,每个 DNS 请求都可能跨过多个进程。
  • 每个组件都有自己的配置文件、缓存、日志和排错方式。
  • 出现问题时,很难第一时间判断到底是广告规则、rewrite 规则、上游 DNS,还是服务转发顺序出了问题。
  • OpenWrt 设备资源有限,长期运行多个 DNS 服务并不总是优雅。
  • 对普通用户来说,理解整条链路的心智成本很高。

Astra DNS 正是为了解决这个具体场景而设计的:当你只是想在路由器上同时获得广告过滤、DNS 转发和 Cloudflare 优选 IP 重写时,能不能不要把多个 DNS 服务串起来?

阅读全文 »
0%