这次整理淘宝的mini-wua和WUA,主要想把两件事说清楚:这两串密文里装了什么,以及解开以后,能从里面的检测位读出什么。沿着两条生成链看下去,会发现它们都带着一段18字节的状态,基础排列也一样。mini还会改写其中两个半字节,再带上一些时间、计数和行为记录。这些细节放在一起看,两种容器的关系就比较清楚了。
手头分析的是淘宝iOS 10.66.30,样本里的SDK标记是 6.4.219,系统为iOS 16.5。涉及的AVMP/UVM5容器主要是 llc_core(535@136,original_7) 和 SDS(45@17,original_5)。后面的结构和位表都按这份分析包来讲。
名字先对一下。下文把新协议分支叫作mini-wua,它在OC返回字典中的键是x-mini-wua;另一份结果的键就是wua。这里看的是新mini和普通WUA生成分支。
| 从外到内 | mini-wua / x-mini-wua | WUA |
|---|---|---|
| 字符串外观 | i + Base64 |
index_ + Base64 |
| 加密和包装 | 交替XOR、对齐;除前2字节外按8字节分组,一组做特殊XOR,其余做DES-ECB | 正文交替XOR,再zlib压缩,加4字节帧头,最后AES-128-CBC/PKCS7 |
| 解开后的正文 | 14字节头,后接5个带1字节长度的组件:EEID、7字节布尔段、11字节四位段、32字节固定记录、扩展记录 | 5字节头,后接EEID、连续18字节状态,以及本分支29个以NUL结尾的辅助字段 |
| 头部内容 | 偏移3、4提供XOR材料;偏移5到8保存CRC | 前2字节提供XOR材料;第3字节是EEID长度,第4字节0x7b表示7+11,第5字节是辅助字段数量 |
| 后面演示样本的长度 | EEID 98字节,扩展48字节,正文共215字节 | EEID 98字节,正文共209字节 |
mini的处理顺序稍微绕一点。从正文偏移5开始,先做body[i] ^= body[3:5][(~i)&1],再把前2字节以外的部分补齐到8字节边界。已经对齐就不补,否则补1到7个相同的长度值。设分组数为n,编号body[0] % n的那一组逐字节异或body[0],其余组走DES。最后做Base64,加上前缀i。解密时把这个顺序倒过来即可。
WUA有个地方需要留意:AES解开、拆帧、解压以后,还剩一层XOR。要从偏移5开始,用正文前2字节交替异或,才能拿到真正的逻辑正文。普通压缩分支的帧头是00000002,00000003则表示直接复制正文。
两者的密钥来源也不同。mini用的是应用侧共享的固定DES材料;WUA会先查SDS里的wua_key_index,再用这个index找到配置值value。在这次核对的路径里,K = IV = MD5(value)的小写hex文本的前16个ASCII字节。这里取的是hex文本,直接拿16字节MD5摘要来用,结果会不一样。后面会把恢复出的密钥直接列出来,文内样本也用这套材料加密。
想对照反编译结果的话,可以从original_7的fn_014b1c(mini)、fn_01238c(WUA正文)和fn_0131d4(共同状态打包)看起。mini的PID回填在fn_01614c,WUA进入SDS加密的位置是original_5的fn_004090。共同状态之外,mini还带了计数、时间、触摸和事件履历,WUA则有一组辅助文本;两份结果各自带出的记录也有看的必要。
EEID暂时还留着一块空白。这次两份正文里装的是同一段EEID,读取并装入容器的路径已经找到,但它内部怎么生成、各字段到底是什么,还没有完整恢复。所以公开样本里把整段换成了占位内容。
解开以后,先看这18字节
看到“144 bit”,很容易把它理解成144个独立的检测。实际排布是:前7字节放55个布尔字段和1个填充位,后11字节放22个四位字段。合起来是 78个字段、144个位位置。一个布尔结果可能合并好几个子检查;四位字段也可能是位图、分类或分数,另外还有常量和保留位置。读结果时需要连着代码看。
下面都以这18字节为起点,从byte0数到byte17;bit0的掩码是0x01。表里的“组”指VM调度表的组号,用来找到结果从哪里来。一组可以调用多个函数,同一组也可能出现在不同位置。脚本按每字节bit7 → bit0输出,四位段则先高半字节、再低半字节,方便逐项对照。
大致翻一下这些检查,能看到几类内容:
| 类别 | 里面查了些什么 |
|---|---|
| 越狱、安装环境 | 路径和环境变量、roothide符号、bootstrap/XPC、AMFI、Mach-O加密标记、provision分类及安装痕迹 |
| 调试、插桩 | 进程跟踪与父进程、Frida线程/路径/映像组合、函数地址归属和入口跳转 |
| 网络、系统信息 | HTTP代理配置、隧道接口名、设备/内核信息双来源、在Mac运行的接口特征 |
| 行为、历史 | HID评分、轨迹重复或直线特征、触摸和启动历史、UT事件时间窗口 |
| 外部回复、缓存、校验 | 命令回复、probe缓存、UI状态、PID和标识派生校验,另有常量与填充 |
这次采到的两份结果中,下面五个布尔字段都为1。后面的脱敏样本保留了这几个值:
| 位置 | 这个值怎么读 | 代码位置 |
|---|---|---|
byte0.bit7,0x80,组1 |
越狱痕迹组合为1。单项记录及未注入启动对照中,能看到roothide初始化符号、bootstrap查询条件和jb-domain XPC回复等。这个bit不能直接辨认某一种越狱;bootstrap条件还接受1102,不能只凭返回码就说某个服务存在 | original_7:fn_008574、fn_0085b4、fn_008774等,共17个子检查 |
byte2.bit4,0x10,组23 |
selector 8的命令回复经过atoi,低8位非零。具体业务对象还没确认,目前先按这层代码含义来读 |
original_7:fn_00d118 |
byte2.bit2,0x04,组9 |
Frida痕迹组合为1。子检查涉及线程名、函数入口等,线程名名单里有pool-frida、gmain、gdbus、gum-js-loop。组合为1不代表六个子项都命中 | original_7:fn_00c534、fn_00c690等,共6个子检查 |
byte3.bit5,0x20,组28 |
UT事件1013的缓存满足近期时间条件。代码判断带符号的now-last < 31,同时检查相关缓存;它会受行为和时钟变化影响 |
original_7:fn_00f2ac;缓存写入方fn_00f124 |
byte4.bit7,0x80,组38 |
异步UI查询的反向缓存标志为1。这个值本身没有说明设备做过什么改装 | original_7:fn_00d040 |
举个容易看错的例子:本例byte2 = 0x14 = 0x10 | 0x04,所以byte2.bit2是1。0x20在byte3里,对应它的bit5。先把字节位置和mask对上,后面的解释才不会串位。
Frida这一项还得把采样方式交代进去。这次用了Frida,也做了临时的ptrace/sysctl前导处理;已有未注入启动对照里,组9的结果为0。也就是说,观察工具自己就会留下这类痕迹,调试位同样受这次插桩影响。越狱组合还有单项记录和未注入对照支持,判断时会多一些依据。
另外,HTTP代理/隧道组合(byte0.bit3)、Mac接口(byte3.bit4)、设备/内核双来源掩码(byte13低四位)这次都是0。不过调度里有开关、阶段和缓存,某一位为0,并不能说明相关子检查都刚刚重新跑过。
两份状态还有个差别:mini会把byte8、byte9的低四位改成PID双累加校验,WUA在这两处保留组54/55的原值,本例都是2。公开样本把PID换成了12345,相应的mini校验也重新算过。另有四个标识CRC选定位改成了占位值0,也就是组50到53的byte0.bit4、byte1.bit7、byte1.bit5和byte3.bit2。这些校验位置就不要拿来还原原设备的结果了。
把代码和样本放在一起跑
为了让这篇文章可以直接复现,我把两份密文放进了下面的脚本。它们是从这次采样的环境状态做出的脱敏重加密样本:EEID换成98字节ASCII占位内容,随机头、时间、PID、标识派生位,以及计数、持久标记和履历也都换过。mini固定记录里的机型表号设为占位值0。WUA辅助字段从公开模板重建,只留下应用标识、公开版本和SDK标记。设备原始密文、真实EEID及其散列、原SDS配置值没有贴出来。
如果想解自己的真实样本,可以先看这张密钥表:
| 解密对象 | 密钥怎么填 | 补充说明 |
|---|---|---|
| 分析包的新mini,DES-ECB | ASCII:639d7z1a;hex:36333964377a3161;ECB不需要IV |
从淘宝10.66.30分析包的原生算法19初始化流程取出,已解开该版本的真实记录,也解开了一份10.66.20的真实样本 |
WUA的pDU6配置,AES-128-CBC |
K与IV的ASCII均为9343461d7f674a85;hex均为39333433343631643766363734613835 |
已解开当前真实WUA和先前保存的WUA样本。这组材料来自对应的SDS配置,不能只看应用版本相同,就套到其他index或配置上 |
DES这把密钥可以直接传给脚本。WUA表里给的是最终的16字节K和16字节IV,拿它们解密就不用再做MD5了。这里也容易把ASCII和hex混起来:9343461d7f674a85是16个ASCII字符,使用--aes-key-hex时,要填的是表里32个hex字符那一串。
文内两份样本就用上表这套材料加密:mini用639d7z1a,WUA用pDU6对应的K和IV。正文中的设备标识和履历换成了占位内容,密钥则保留恢复出的值。这两份样本用来核对容器和检测位,不能据此推断服务端是否接受。
把下面整段保存为verify_wua.py,默认就会解开内置样本。要处理真实token,再选mini或wua模式,直接传密文字符串,或用@文件读取首个非空行。运行需要Python 3.9或更新版本,以及OpenSSL,代码不依赖原工程;OpenSSL 3的DES legacy provider处理也已经写进去。
WUA可以直接传匹配的AES密钥和IV,也可以传index对应的SDS配置值,让脚本派生。index本身只是配置的查找名称,不是密钥。两种方式的用法都放在代码后面。
#!/usr/bin/env python3
import argparse
import base64
import hashlib
from pathlib import Path
import subprocess
from urllib.parse import unquote_plus
import zlib
# 脱敏重加密样本;不是设备原始token。
MINI_SAMPLE = (
"iKgQw12upvMOCfPPkp3VDBZEfJ6YPpwOeY1b5N9KmqYYugjkVOgNxHJ2mdni2Xb5NHfU0ZuwxehGHXCzzCnKHJGv"
"jAfa5In3SzQ368ZlKR/s5KXBIxUJj4sgZT8+gFEYcQmA9SMsdnLPM4TwMcuVhfPN5KtHCoF4QSD0eEjE4HjgeOMi"
"B88bEJuVGJ/JwqE6FIHoxWYd0ZFi//hq3DAnxxWrUQ5Y+g5/aGL/ZhRjEM94K0Bq3DAnxxWrUUPOrUS4/J8z9C7W"
"SEHa4ha8hdNsrjOHavlKVPLn/QXg="
)
WUA_SAMPLE = (
"pDU6_AllASBSvCnPIrenANUMZnDaTuvFMMjzeZWPVREaDevEXZy8n3nKTTTMQ0KZ2bB9QumW/ROgYHhb+K3itSJn"
"mPQmj0CIqLYr7MkWtO7mRNeBLnfy7LrkjW74B4LDLWp6V/hWk/o+KWaectFS9Q5i7f86sXKto2HKpFszw3G9czjM"
"L5FaCX03zBn9E2OwMZ3TDo8VLoNhKEqeuHNXw/uMotly9Omt63ynDU8sv1UYK8pM="
)
DES_KEY = b"639d7z1a"
WUA_KEY = b"9343461d7f674a85"
WUA_IV = WUA_KEY
DEMO_EEID = (b"PUBLIC-EEID-PLACEHOLDER-NOT-A-DEVICE-" * 3)[:98]
DEMO_PID = 12345
EXPECTED_MINI = "80001420800000002a2f0000000000000000"
EXPECTED_WUA = "800014208000000022220000000000000000"
# 55个布尔字段,按byte内bit7到bit0排列;byte6.bit0另作填充。
BOOL_GROUPS = [
1, 0, 3, 50, 15, 19, 2, 20,
51, 12, 52, 0, 8, 4, 5, 10,
33, 11, 0, 23, 0, 9, 25, 26,
6, 27, 28, 7, 30, 53, 29, 35,
38, 40, 41, 36, 37, 42, 43, 44,
46, 47, 45, 49, 58, 60, 61, 62,
63, 64, 65, 66, 69, 70, 71,
]
# 22个四位字段,按byte7到byte17,每字节先高后低。
NIBBLE_GROUPS = [
0, 0, 31, 54, 32, 55, 67, 22, 5, 13, 14,
16, 17, 18, 21, 24, 59, 34, 39, 48, 56, 57,
]
GROUP_NAMES = {
1: "越狱痕迹组合", 2: "进程跟踪/父进程/可选硬件调试",
3: "型号字符串首字节", 4: "Mach-O加密标记",
5: "provision可解析分类", 6: "USB/Bluetooth HID组合",
7: "在Mac运行的接口特征", 9: "Frida痕迹组合",
11: "初始化安装状态组合", 13: "国家码缺失/代理/隧道位图",
14: "加载映像名称位图", 15: "HTTP代理/隧道组合",
16: "设备/位置API及方法地址范围", 17: "改机/自动化/定位路径位图",
18: "设备/内核双来源差异", 21: "50槽HID异常评分",
22: "请求API探针", 23: "selector 8回复非零,用途未明确",
24: "调用栈分类", 27: "常量0", 28: "近期UT事件1013",
29: "近期订单URL的UT事件1013", 31: "常量状态2",
32: "常量状态2", 33: "常量0", 34: "probe解析/历史缓存",
38: "异步UI回复反向标志", 39: "5槽HID阈值",
40: "常量0", 43: "保存时间未设置/时钟回退",
46: "时间API归属/入口模式", 47: "版本门控页驻留探针,用途未明确",
48: "位置来源/数值模式", 49: "源字符串副本缺失",
50: "PID选择的标识CRC位", 51: "PID选择的标识CRC位",
52: "PID选择的标识CRC位", 53: "PID选择的标识CRC位",
54: "WUA原组常量2", 55: "WUA原组常量2",
57: "启动/文本事件/触摸历史", 59: "轨迹重复/直线特征",
61: "常量0", 64: "会话首次读取持久标记",
65: "端口采集常量返回值", 70: "eno配置缺失/负数",
}
SIGNALS = {1, 9, 23, 28, 38}
def require(condition, message):
if not condition:
raise ValueError(message)
def crypt(data, algorithm, key, decrypt=False, iv=None):
args = ["openssl", "enc", "-" + algorithm, "-K", key.hex()]
if algorithm == "des-ecb":
args.append("-nopad")
if decrypt:
args.append("-d")
if iv is not None:
args += ["-iv", iv.hex()]
result = subprocess.run(args, input=data, capture_output=True)
if result.returncode and algorithm == "des-ecb":
result = subprocess.run(
args + ["-provider", "default", "-provider", "legacy"],
input=data, capture_output=True,
)
require(result.returncode == 0, "OpenSSL执行失败,请检查密码材料及算法/provider支持")
if algorithm == "des-ecb":
require(len(result.stdout) == len(data), "DES输出长度错误")
return result.stdout
def mini_xor(body):
out = bytearray(body)
material = bytes(out[3:5])
for i in range(5, len(out)):
out[i] ^= material[(~i) & 1]
return bytes(out)
def mini_checksum(body):
crc = zlib.crc32(body[9:]) & 0xffffffff
# 这里继续更新原始CRC状态,不能用一次普通crc32拼接代替。
for value in body[3:5]:
crc ^= value
for _ in range(8):
crc = (crc >> 1) ^ (0xedb88320 if crc & 1 else 0)
return crc.to_bytes(4, "big")
def mini_parts(body):
require(len(body) >= 14, "mini头被截断")
require(body[2] == 0x41 and body[9:14] == bytes.fromhex("003a050000"),
"不符合本文新mini头格式")
parts, at = {}, 14
for name in ("eeid", "boolean", "nibble", "fixed", "extension"):
require(at < len(body), "缺少mini组件长度")
length, start = body[at], at + 1
require(start + length <= len(body), "mini组件被截断")
parts[name] = body[start:start + length]
at = start + length
require([len(parts[n]) for n in ("boolean", "nibble", "fixed")] == [7, 11, 32],
"mini状态/固定记录长度不符合本文分支")
return parts, at
def mini_decode(token, key):
require(len(key) == 8, "mini需要8字节DES密钥")
require(token.startswith("i"), "缺少mini前缀i")
out = bytearray(base64.b64decode(token[1:], validate=True))
require(len(out) > 2 and (len(out) - 2) % 8 == 0, "mini分组长度错误")
count = (len(out) - 2) // 8
special = out[0] % count
for j in range(count):
at = 2 + 8 * j
block = bytes(out[at:at + 8])
out[at:at + 8] = (bytes(x ^ out[0] for x in block) if j == special
else crypt(block, "des-ecb", key, decrypt=True))
# 先按5个长度字段找到正文终点,再验证原始填充,兼容已对齐不补的情况。
candidate = mini_xor(bytes(out))
parts, end = mini_parts(candidate)
pad = (-(end - 2)) % 8
require(bytes(out[end:]) == bytes([pad]) * pad, "mini对齐填充错误")
body = candidate[:end]
require(body[5:9] == mini_checksum(body), "mini CRC不匹配")
return body, parts, parts["boolean"] + parts["nibble"]
def mini_encode(body, key):
require(len(key) == 8, "mini需要8字节DES密钥")
parts, end = mini_parts(body)
require(end == len(body), "mini正文有多余内容")
out = bytearray(mini_xor(body))
pad = (-(len(out) - 2)) % 8
out.extend(bytes([pad]) * pad)
count = (len(out) - 2) // 8
special = out[0] % count
for j in range(count):
at = 2 + 8 * j
block = bytes(out[at:at + 8])
out[at:at + 8] = (bytes(x ^ out[0] for x in block) if j == special
else crypt(block, "des-ecb", key))
return "i" + base64.b64encode(out).decode()
def wua_xor(body):
require(len(body) >= 5, "WUA头被截断")
return body[:5] + bytes(x ^ body[(i - 5) & 1]
for i, x in enumerate(body[5:], 5))
def wua_key(config):
return hashlib.md5(config).hexdigest().encode("ascii")[:16]
def wua_material(config=None, aes_key=None, aes_iv=None):
require((config is not None) != (aes_key is not None), "请提供SDS配置值或直接AES密钥,两者选一")
if config is not None:
require(bool(config), "SDS配置值不能为空")
require(aes_iv is None, "配置派生模式不单独指定IV")
key = wua_key(config)
iv = key
else:
key = aes_key
iv = aes_iv if aes_iv is not None else key
require(len(key) == 16 and len(iv) == 16, "AES-128-CBC的K和IV都需要16字节")
return key, iv
def wua_decode(token, config=None, aes_key=None, aes_iv=None):
require("_" in token, "缺少WUA的index_前缀")
index, encoded = token.split("_", 1)
require(bool(index), "WUA的index不能为空")
key, iv = wua_material(config, aes_key, aes_iv)
ciphertext = base64.b64decode(encoded, validate=True)
require(bool(ciphertext) and len(ciphertext) % 16 == 0, "WUA AES密文长度错误")
framed = crypt(ciphertext,
"aes-128-cbc", key, decrypt=True, iv=iv)
head = framed[:4]
require(head in (b"\x00\x00\x00\x02", b"\x00\x00\x00\x03"), "WUA帧头错误")
payload = zlib.decompress(framed[4:]) if head[-1] == 2 else framed[4:]
body = wua_xor(payload)
require(body[3] == 0x7b, "WUA状态长度标记错误")
start = 5 + body[2]
end = start + 18
require(end < len(body) and body[-1] == 0, "WUA状态/字段被截断")
fields = body[end:].split(b"\0")[:-1]
require(len(fields) == body[4], "WUA辅助字段数量错误")
return body, body[start:end], body[5:start], fields, index, head[-1] == 2
def wua_encode(body, index, config=None, compress=True, aes_key=None, aes_iv=None):
payload = wua_xor(body)
framed = (b"\x00\x00\x00\x02" + zlib.compress(payload) if compress
else b"\x00\x00\x00\x03" + payload)
key, iv = wua_material(config, aes_key, aes_iv)
encrypted = crypt(framed, "aes-128-cbc", key, iv=iv)
return index + "_" + base64.b64encode(encrypted).decode()
def patch_pid(state, pid):
first, second = 1, 0
modulus1 = ((pid >> 8) & 127) | 128
modulus2 = (pid & 191) | 64
for value in state:
first = ((first & 255) + value) % modulus1
second = ((second & 255) + first) % modulus2
out = bytearray(state)
out[8] = ((out[8] & 240) | ((second & 240) >> 4)) ^ 10
out[9] = ((out[9] & 240) | (second & 15)) ^ 10
return bytes(out)
def state_fields(state, mini=False):
require(len(state) == 18, "需要18字节状态")
positions = [(i // 8, 7 - i % 8, 1, group)
for i, group in enumerate(BOOL_GROUPS)]
positions.append((6, 0, 1, None))
positions += [(7 + i // 2, 4 if i % 2 == 0 else 0, 4, group)
for i, group in enumerate(NIBBLE_GROUPS)]
occupied, rows = set(), []
for byte, shift, width, group in positions:
bits = {(byte, bit) for bit in range(shift, shift + width)}
require(not occupied.intersection(bits), "字段位重叠")
occupied.update(bits)
mask = ((1 << width) - 1) << shift
position = (f"byte{byte}.bit{shift}" if width == 1
else f"byte{byte}.bit{shift + 3}..{shift}")
name = GROUP_NAMES.get(group, "空组保留位置") if group is not None else "填充位"
if mini and group in (54, 55):
name = "PID双累加校验,覆盖原组常量"
rows.append((position, mask, group, (state[byte] & mask) >> shift, name))
require(len(rows) == 78 and occupied == {(b, k) for b in range(18) for k in range(8)},
"字段表没有完整覆盖144个位位置")
return rows
def show_state(kind, state, show_all=False):
rows = state_fields(state, kind == "mini")
print(f"{kind}: {state.hex()} (18 bytes, 78 fields, 144 positions)")
for position, mask, group, value, name in rows:
if show_all or group in SIGNALS:
print(f" {position} mask=0x{mask:02x} group={group} value={value} {name}")
def verify_demo(args):
mini_body, parts, mini_state = mini_decode(MINI_SAMPLE, DES_KEY)
wua_body, wua_state, eeid, fields, index, compressed = wua_decode(WUA_SAMPLE, aes_key=WUA_KEY, aes_iv=WUA_IV)
require(mini_state.hex() == EXPECTED_MINI and wua_state.hex() == EXPECTED_WUA,
"检测状态与文章预期不符")
require(parts["eeid"] == eeid == DEMO_EEID, "EEID占位内容不匹配")
require(len(mini_body) == 215 and len(wua_body) == 209 and len(fields) == 29,
"示例正文长度/字段数量不符")
require(int.from_bytes(parts["fixed"][16:20], "big") == DEMO_PID, "示例PID不符")
require(parts["fixed"][1] == 0, "示例机型表号应为占位值0")
require(patch_pid(wua_state, DEMO_PID) == mini_state, "mini PID回填不匹配")
require(fields[15:18] == [b"com.taobao.taobao4iphone", b"10.66.30", b"6.4.219"],
"示例应用版本字段不符")
require(mini_encode(mini_body, DES_KEY) == MINI_SAMPLE, "mini重加密不匹配")
require(wua_encode(wua_body, index, compress=compressed, aes_key=WUA_KEY, aes_iv=WUA_IV) == WUA_SAMPLE,
"WUA重加密不匹配")
state_fields(mini_state, True)
state_fields(wua_state)
print("PASS: 解密 / 分段 / CRC / EEID占位 / PID回填 / 144位覆盖 / 重加密")
for kind, state in (("mini", mini_state), ("wua", wua_state)):
show_state(kind, state, args.all)
if args.show_body:
print("mini body hex: " + mini_body.hex())
print("wua body hex: " + wua_body.hex())
def read_token(value):
if value.startswith("@"):
lines = Path(value[1:]).read_text(encoding="utf-8-sig").splitlines()
value = next((line.strip() for line in lines if line.strip()), "")
require(bool(value.strip()), "样本为空")
return value.strip()
def main():
parser = argparse.ArgumentParser(description="解密真实mini/WUA样本,或验证文章内置的脱敏样本")
parser.add_argument("mode", nargs="?", choices=("demo", "mini", "wua"), default="demo")
parser.add_argument("input", nargs="?", help="密文字符串或@文件(首个非空行)")
des = parser.add_mutually_exclusive_group()
des.add_argument("--des-key-hex", help="对应版本的8字节DES密钥,16个hex字符")
des.add_argument("--des-key-file", type=Path, help="保存8字节原始DES密钥的文件")
config = parser.add_mutually_exclusive_group()
config.add_argument("--config-value", help="该index对应的SDS配置值文本,按UTF-8编码")
config.add_argument("--config-value-file", type=Path, help="该index对应的配置值原始字节文件")
config.add_argument("--aes-key-hex", help="直接提供16字节AES密钥,32个hex字符")
parser.add_argument("--aes-iv-hex", help="直接AES模式的16字节IV,省略时使用K作为IV")
parser.add_argument("--config-index", help="可选:核对所供配置的index与密文前缀一致")
parser.add_argument("--url-encoded", action="store_true", help="输入是form URL编码结果时先还原")
parser.add_argument("--all", action="store_true", help="按位顺序打印全部78个字段")
parser.add_argument("--show-body", action="store_true", help="在本地显示完整正文hex,包含EEID等字段")
args = parser.parse_args()
try:
if args.mode == "demo":
require(not any((args.input, args.des_key_hex, args.des_key_file,
args.config_value, args.config_value_file,
args.aes_key_hex, args.aes_iv_hex,
args.config_index, args.url_encoded)), "外部样本请使用mini或wua模式")
verify_demo(args)
return
require(args.input is not None, "请提供密文字符串或@文件")
token = read_token(args.input)
if args.url_encoded:
token = unquote_plus(token)
if args.mode == "mini":
require(args.des_key_hex is not None or args.des_key_file is not None,
"真实mini需要--des-key-hex或--des-key-file")
require(args.config_value is None and args.config_value_file is None
and args.aes_key_hex is None and args.aes_iv_hex is None
and args.config_index is None, "mini模式不使用SDS配置或AES材料")
key = (bytes.fromhex(args.des_key_hex) if args.des_key_hex is not None
else args.des_key_file.read_bytes())
body, parts, state = mini_decode(token, key)
print("PASS: mini解密 / 分段 / CRC / 144位覆盖")
print(f"body={len(body)} bytes; " + "; ".join(
f"{name}={len(value)} bytes" for name, value in parts.items()))
else:
require(args.config_value is not None or args.config_value_file is not None
or args.aes_key_hex is not None,
"真实WUA需要--config-value、--config-value-file或--aes-key-hex")
require(args.des_key_hex is None and args.des_key_file is None, "wua模式不使用DES密钥")
if args.aes_key_hex is not None:
config_value = None
aes_key = bytes.fromhex(args.aes_key_hex)
aes_iv = bytes.fromhex(args.aes_iv_hex) if args.aes_iv_hex is not None else None
else:
require(args.aes_iv_hex is None, "--aes-iv-hex需要同时提供--aes-key-hex")
config_value = (args.config_value.encode("utf-8") if args.config_value is not None
else args.config_value_file.read_bytes())
aes_key = aes_iv = None
if args.config_index is not None:
require(token.split("_", 1)[0] == args.config_index, "配置index与密文前缀不一致")
body, state, eeid, fields, index, compressed = wua_decode(token, config_value, aes_key, aes_iv)
print("PASS: WUA解密 / 分帧 / XOR还原 / 144位覆盖")
print(f"body={len(body)} bytes; EEID={len(eeid)} bytes; fields={len(fields)}; "
f"frame={'zlib' if compressed else 'copy'}")
show_state(args.mode, state, args.all)
if args.show_body:
print("body hex: " + body.hex())
except (ValueError, OSError, zlib.error) as error:
parser.error(str(error))
if __name__ == "__main__":
main()
代码存好以后,先跑一遍演示样本:
python3 verify_wua.py
python3 verify_wua.py --all
第二条会把每份结果的78个字段全部列出来。正常情况下,开头会出现PASS,两段状态应该是:
mini: 80001420800000002a2f0000000000000000
wua: 800014208000000022220000000000000000
这一步除了取出状态,还会核对mini的CRC、五个组件的长度、EEID和机型占位、PID回填、WUA字段数量,以及整张字段表是否覆盖144个位位置。最后再把正文加密回去,看能不能逐字节得到脚本里的密文。都对上,至少说明这两份样本的包装和读取过程可以复现;各字段为什么有这些含义,依据仍然是前面的VM代码。
接下来就可以换成自己的样本。把密文放进mini_real.txt或wua_real.txt,如果密码材料与上表匹配,直接这样运行:
python3 verify_wua.py mini @mini_real.txt --des-key-hex 36333964377a3161 --all
python3 verify_wua.py wua @wua_real.txt --config-index pDU6 --aes-key-hex 39333433343631643766363734613835 --aes-iv-hex 39333433343631643766363734613835 --all
WUA这条命令会顺便检查前缀是不是pDU6。如果手里拿到的是另一份材料,mini可用--des-key-file des_key.bin读取8字节原始密钥;WUA则可用--config-value-file sds_value.bin或--config-value从配置值派生K/IV,和直接传--aes-key-hex两种方式选一。配置文件会按原始字节读入,不会替你删掉换行或空格。
还有两个参数用得上:抓到的是form URL编码结果,就加--url-encoded先还原;普通Base64里的+要保留原样。想在本地看完整正文,可以加--show-body,里面也会带出EEID等原始字段。
真实样本的位值当然可能和演示样本不同,脚本会按解出的正文显示,不会拿演示预期值去限制它。我也用这份代码重新解过保存的真实mini和WUA,以及另外三份真实mini样本。后三份的正文都是151字节,EEID为82字节,扩展为空,CRC和重新加密比较都通过了。这也说明,前面演示样本的215字节不是固定长度。
同理,状态偏移也要按长度字段走。本例mini取的是body[114:121] + body[122:133],中间那个长度字节不能算进去;WUA取的是还原XOR后的body[103:121]。代码已经按长度解析,换样本时不需要照抄这些偏移。这里覆盖的是核对过的新mini和WUA文本字段正文,遇到别的版本或正文分支,还得先对一下格式和位表。