把 mini-wua 和 WUA 解开,看一眼里面的检测位

这次整理淘宝的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文本字段正文,遇到别的版本或正文分支,还得先对一下格式和位表。