中兴机顶盒固件及调试协议分析与 STB3.0 开启 ADB 方法
为了给家里的电信定制中兴 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
121. 版本协商(明文) — 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匹配一个硬编码字符串。报文均可从明文完整构造,并且与工具发出的内容完全一致。下面继续展开完整的分析过程,欢迎感兴趣的读者继续阅读。
分析材料
分析分为两条线,服务端与客户端相互印证:
- 服务端:从公开渠道下载线刷固件包,并解出
/system目录。关键文件是/system/app/ADBSetting.apk(设置中的 ADB 开关界面),以及它背后的几个 native 库:libztestbcfg_jni.so、libstbctrl.so、libgetkey.so、lib_losys.so、lib_des.so。 - 客户端:
stb3.0工具.exe是由 PyInstaller 打包的 Python 程序。用pyinstxtractor-ng解包,再经 Decompyle++ 反编译并逐函数核对字节码后,可以得到一个百余行的zte.py。它会连接机顶盒的 TCP 10042 端口,以约 1 秒的间隔发送五段硬编码报文,而不读取任何响应。文章开头代码重现了其中开启 ADB 所需的前四段,第五段固定公钥写入则只在后文进行分析,不再实际发送。报文主体是成块的二进制「密文」。
换言之,客户端只是一个纯粹的重放器,所有协议逻辑都在服务端。要解密报文,就得看固件里的服务端如何处理。
定位服务端
反编译ADBSetting.apk后,可以在AdbMainActivity中看到一个显眼的常量:private String n = "10042"。固件中实际上存在两条方向相反的配置通道:界面中的「连接配置工具」会调用PrivateSettingCommon.Utils.AdbConnectTool(ip + ":10042"),将用户填写的 PC 地址交给底层,这是盒子主动连接 PC 的ConnectTool路径;另一方面,com.ztestb.basecomp.RemoteStbCfgService是一个常驻服务,在netstat层面对应libztestbcfg_jni.so导出的RemoteSetCfg,其bind端口为htons(0x273A),即 10042,同时监听 IPv4 和 IPv6,这是盒子监听、PC 主动连接的路径。
stb3.0工具.exe使用的是后一条路径。这个服务就是中兴 MCSP/stbcfg 远端配置协议(CfgAgent)的 Android 实现:盒子开机后会启动一个 TCP 10042 配置服务,工程工具连接后即可下发命令。该工具重放的就是一组历史命令。
找到加解密函数
线索来自密文长度:除明文版本协商外,其余四段报文中的密文块长度只出现 16、32、80 和 736 字节,全部是 16 的倍数。这是 AES 块加密的典型特征。
沿着RemoteSetCfg的收包路径反汇编,解密点很快浮出水面。收帧函数的逻辑如下:
- 先接收 24 字节帧头,其中前 4 字节依次为
u16大端magic和u16大端密文长度,再按照该长度接收密文; magic == 0x1001:这是 16 字节的加密命令头,调用DecWithAes(key, buf, 8)(长度 8 向上取整到 16);magic == 0x1010:这是加密数据帧,调用DecWithAes(key, buf, len)。
DecWithAes来自libgetkey.so。这个库是一个不折不扣的 tiny-AES 移植:它导出了Nk/Nb/Nr/m_in/m_out/Rcon这些教科书式全局变量;Encrypt/Decrypt每次只处理一个 16 字节块,EncWithAes/DecWithAes则将它们串起来逐块加解密。块与块之间没有任何链接——这正是 ECB 模式。
密钥也由同一个库管理:
- 收帧函数会先将 32 字节缓冲区清零,再调用
SecretKey(buf, 0x20);后者通过snprintf(buf, 0x20, "%s", g_key)将全局密钥字符串复制给调用者; g_key位于.data段的 0x11004 处,出厂默认值是 8 字节的 ASCII 字符串|!jFa8o4。snprintf写入这 8 个字符和结尾的\x00,缓冲区其余部分仍为零,因此加解密函数使用的前 16 字节恰好是|!jFa8o4加 8 个\x00,符合 AES-128 的密钥长度;setSecretKey(src, len):长度不超过 16 时,通过snprintf替换全局密钥,用于会话密钥协商;resetSecretKey():将密钥恢复为默认值。
解析这五段报文时,第一段为明文;使用|!jFa8o4\x00…作为 AES-128-ECB 密钥解密其余报文中的加密帧,均可得到结构完整的明文——算法和密钥由此得到确认。
帧格式汇总
结合客户端报文和服务端解析逻辑,协议帧格式如下:1
2
3
4
5
6
7
8明文 hello 帧(magic 0x1020):
10 20 00 00 | u32长度大端 | "V6.0"
加密帧(magic 0x1001 / 0x1010),共24字节帧头:
10 <类型> | u16密文长度大端 | 00 02 | 18个零字节 | AES-128-ECB密文
0x1001 = 加密的16字节命令头:u16命令号 | u16零 | u32数据长度大端,补零到16
0x1010 = 加密的数据帧:明文补零到16的倍数后加密
原工具的五段报文逐段解读
第 1 段:V6.0。这是明文版本协商,用于告诉服务端按照 V6.0 协议处理。
第 2 段:命令 5(affirm),数据长度为 65 字节:1
16_AeKg_2t7z_@01_17_@A_$oMjO6fg.P_18_GdpR8jO30!d-rR_20_jD9!2y-dW8
它看起来像是由16/17/18/20四段组成的复合口令。但服务端命令 5 的处理函数实际上只执行strstr(buf, "20_jD9!2y-dW8")——只要明文中包含这个硬编码常量,就算鉴权通过;前三段只是装饰,根本不会被校验。所谓「鉴权」,其实只是一次子串匹配。
第 3 段:命令 0x15(21) + 命令 0x11(17):1
ToolType:16,licindex:yuduanhun
服务端通过sscanf(buf, "ToolType:%d,licindex:%63s", …)解析。licindex是工具作者的授权标识(网名),服务端并不校验;真正关键的是ToolType,它决定是否升级会话密钥:1
2
3
4// 服务端反汇编(0xf080附近),r3 = ToolType
subs r3, r3, #0x0e ; ToolType - 14
bics r3, r3, #4 ; 清掉bit2后再判断
bne skip ; 非0 → 跳过会话密钥生成
只有ToolType-14清除 bit2 后仍为 0 的版本(即 14 或 18 这类「新协议」工具)才会触发密钥升级:服务端调用getRandStr(buf, 9),使用srand48(time(NULL))和lrand48()%10填充前len-1个字符,因此实际结果是 8 位数字加结尾的\0;随后通过setSecretKey写入全局密钥,之后的帧便改用这个会话密钥加密。需要注意的是,ADBSetting.apk还存在另一套生成 6 位数字并写入zte.stbtool.randomcode属性的界面逻辑,不能将它与这里的 8 位 native 会话密钥直接等同。ToolType:16恰好进入「跳过」分支,密钥保持出厂默认值——因此,这个 EXE 只需使用固定报文,便可适用于老协议固件。
第 4 段:命令 0x2036(8246):1
cmdtype=ADB_ZTE,cmdparams=Open
服务端解析后,将其交给libstbctrl.so的GetCmdtypeAndParam分发。AdbZTECtrl函数(logcat 标签为Mcsp_STBCfg_android)的行为非常直接:1
2
3
4property_set("service.adb.tcp.port", "5555");
// cmdparam == "Open" 时:
system("stop adbd");
system("start adbd");
adbd重启后读取到 TCP 端口 5555,网络 ADB 也就开启了。
原工具第 5 段:命令 0x2036,命令明文共 730 字节,cmdparams=后面跟着 700 字符的 Base64 数据。明文补齐并加密后为 736 字节,加上加密命令头及帧头,完整报文共 800 字节:1
cmdtype=ADB_ZTE_KEY,cmdparams=QAAAANEfSsrPZvMSXMYv...(base64)...EAAQA=
AdbZTEKey函数从=后截取 Base64 字符串,并将这段 Base64 文本写入/storage/emulated/0/adb_keys_zte。对其进行 Base64 解码后可得到 524 字节,结构是 Android mincrypt使用的 2048 位RSAPublicKey序列化:1
2
3
4
5u32 len = 64 — 以32位字为单位的模数长度
u32 n0inv = 0xca4a1fd1 — Montgomery常数
u32 n[64] — 256字节、2048位RSA模数,小端字序
u32 rr[64] — 256字节的R² mod n,小端字序
u32 exponent = 65537 — 公开指数
需要注意,经过分析,这把公钥对于开启 ADB 并没有实际作用,因此文章开头的脚本省略了发送这一段。
完整链路
完整链路如下:
graph TD
A["TCP 10042"] --> B["收帧"]
B --> C["AES-128-ECB解密<br/>出厂固定密钥"]
C --> D["命令号0x0005<br/>strstr匹配字符串20_jD9!2y-dW8<br/>全网同一个常量"]
D --> E["命令0x15<br/>ToolType:16<br/>跳过会话密钥升级<br/>密钥保持出厂值"]
E --> F["命令0x2036<br/>cmdtype=ADB_ZTE,cmdparams=Open<br/>setprop 5555 + 重启adbd"]
F -. 原工具额外发送 .-> G["命令0x2036<br/>ADB_ZTE_KEY<br/>将Base64公钥文本写入adb_keys_zte<br/>当前固件无人读取,本文脚本不发送"]协议使用的密钥是固件中的出厂常量,鉴权只是一个硬编码子串,密钥升级又可以被客户端声明的 ToolType 绕过。三者叠加的结果是:对于 TCP 10042 服务已经监听、网络可达且仍支持该旧协议分支的固件,同一局域网中的设备可以构造相同报文,触发开启网络adbd的命令。stb3.0工具.exe只是将一次抓包固化为重放工具;也正因为上述设计,这种具有 2013 年代风格的固定报文在部分老协议固件上仍然有效。
总结
本次分析从一份公开固件和一个黑盒小工具出发,最终还原了这套旧协议中与开启 ADB 相关的路径:AES-128-ECB(密钥为|!jFa8o4,tiny-AES 实现在libgetkey.so中)、三层帧结构(明文 hello/加密命令头/加密数据)、以strstr常量为核心的鉴权、通过 ToolType 分支跳过的会话密钥升级,以及最终负责执行的AdbZTECtrl/AdbZTEKey两个函数。原工具的五段报文均已还原,但文章开头的 Python 脚本只生成并发送开启 ADB 所需的前四段,不会写入用途不明的固定公钥。实际能否开启并连接 ADB,则取决于目标固件是否启用了 10042 服务、是否保留相同协议分支,以及运行时的 ADB 认证状态。