本文记录小红书 Android 9.43.1(build 9431801)请求签名的逆向结果,涵盖 deviceId、MUA、SIG、S1、Shield、main_hmac、SSK、注册报文及设备画像。MUA 使用 X25519、AES-CBC、zlib 和 JSON;SIG 基于 canonical 的 SHA-256 与固定 GF(2) 仿射变换;S1 使用摘要、定制 AES-like CBC、时间戳和 CRC;Shield 使用定制 HMAC-MD5、RC4 及可选 SSK 证明;SSK 通过 X25519 和 AES-256-GCM 解密。文章还说明会话状态、计数器、随机材料和设备采集字段的关系,并给出验证样本与实现细节。结论仅适用于指定版本和环境,不能代表后续版本、真实 OEM 采集或服务端风控策略。
本文分析小红书 Android 9.43.1(build 9431801)的请求签名,包括设备标识、MUA、SIG、S1、Shield,以及 Shield 所需的密钥协商。文中的常量和二进制布局均以这一版本为范围。
MUA、SIG 和 S1 对应 libtiny 的处理链,Shield 及 main_hmac 解密对应 libxyass。以下按输入构造、密码变换和输出格式展开。文中 native 指这一版本的原生库,模拟器对照仅代表给定环境输入下的库输出。
下文使用 || 表示字节拼接,XOR 表示按位异或;u32le、u32be 分别表示 32 位整数的小端和大端编码。整数运算中的截断会单独标出。
本文是对客户端签名实现的逆向记录,范围限于 Android 9.43.1(build 9431801)的本地算法与报文格式。公式和常量只说明该版本如何构造请求头,不构成未授权访问、绕过风控或批量抓取的方法。服务端策略、设备环境和后续版本都可以使本文结论失效;未覆盖的采集分支与真机状态,不以一次成功响应补足。
0. 整体签名流程
内容请求涉及以下字段:
字段
内容与用途
x-mini-mua
会话公钥、随机材料、计数器及加密设备画像
x-mini-sig
请求规范串的 SHA-256 摘要及其前半段仿射变换
x-mini-s1
规范串、计数器和时间戳生成的 62 字节结构
shield
请求 HMAC,以及可选的 SSK 关联证明
x-mini-gid
服务端注册返回的身份标识
SIG 和 S1 使用同一份规范串,以下记为 canonical:
其中 METHOD 使用大写;PATH 不含域名;QUERY 是问号后未经再次解码、排序的原始字符串,不含问号;body 是实际发送的字节;SHA-256 使用 64 字符小写 hex;MUA 保留完整格式,包括末尾的点。五项之间各有一个 LF 字节(0x0A),末尾不额外换行。上式的 \n 表示换行,不是反斜杠和字母 n;query 为空时保留对应的空行。
GET 请求的 body 为空,其 SHA-256 为:
Shield 使用另一份输入:path、query、指定请求头和 body 按固定顺序直接拼接。它不把 SIG、S1、MUA 这三个头直接纳入 HMAC 原文。
一条已验证的冷启动客户端流程如下:
这是客户端的调用顺序,并非所有步骤都存在密码学依赖。例如,注册请求不使用 Shield,本身不依赖 key64。各阶段使用的字段如下:
阶段
MUA / SIG / S1
Shield
响应材料
获取 vfc_code
当前引导路径不带
首次调用为无 main_hmac 的 70B 结构
xy-ter-str
register/android
使用注册 body 计算
不带
data.g
activate
使用表单 body 计算
有 main_hmac、无 SSK,70B
SSK 密文、session
cold_start_config
使用最终请求计算
main_hmac 与 SSK 均存在,118B
id_token
内容请求
使用最终请求计算
通常为 118B
业务数据
activate 响应也可能包含 token。这里描述的客户端即使已有 token,仍会请求 cold_start_config 更新它。sid 和 id_token 经 xy-common-params 进入 Shield;它们不是 canonical 的独立字段。
各响应更新的状态也不同:注册保存 data.g;activate 解出 main_ssk,并将 data.session 加上 session. 前缀保存为 sid;cold_start_config 从 data.user_info.user_tags.id_token 读取 token。只有相应响应处理成功,后续请求才能使用这些新值。
同一套请求头与签名还覆盖推荐、搜索和作者主页接口。已在匿名会话下整链跑通的接口包括:edith.xiaohongshu.com 的 imagefeed、user/info、user/posted,rec.xiaohongshu.com 的 homefeed,so.xiaohongshu.com 的 search/notes。canonical 只取 PATH,不含域名;跨域名时只有 host 与 query 变化,签名公式不变。
服务端已观察到两类会话级失败:code=-100,msg 为「请登录账号」;code=300011,msg 为「当前账号存在异常,请切换账号后重试」。两者都在连续请求过程中出现过,同一设备上重试同一请求通常仍失败,换一套设备身份重新引导后恢复。它们是服务端会话与风控状态的表达,本文不把它们归因于某个签名输入的错误。
1. android_id 与 deviceId
deviceId 使用标准 MD5 和 UUID v3 的版本、变体位规则。输入为 android_id 文本的 UTF-8 字节;例如 16 字符 hex 文本仍是 16 字节 ASCII,不先解成 8 字节。
这里直接哈希 name bytes,没有额外拼接 namespace,因此不能用一个任意 namespace 的 UUID v3 接口替代。
一种等价写法是 (h[6] & 0x3F) | 0x30 和 (h[8] & 0xBF) | 0x80。前者保留的 bit 4、5 随后都会置 1,后者保留的 bit 7 随后也会置 1,最终结果相同。
例:
示例:
2. x-mini-mua:会话材料与设备画像
MUA 的格式为:
拼接时不插入空格。两个 Base64URL 段都去掉末尾的 =,最后的点保留。Part1 可以直接解码为 JSON;Part2 需要先得到会话共享秘密才能解密。
会话初始化先取得 64 字节随机材料 s,再取得 32 字节 X25519 原始 scalar。会话内复用这两份材料;请求计数器和设备运行态可以继续变化,所以复用会话材料不等于复用整个 MUA。
2.1 X25519 与 AES 材料
X25519 在素数域 p = 2^255 - 19 上计算。scalar 进入曲线运算前执行 clamp:
原始 scalar 可以保持原样存储,clamp 只作用于曲线运算的输入副本。
基点 9 编码为小端 32 字节,即 09 后跟 31 个 00。MUA 服务器公钥为:
SSK 使用另一把服务器公钥,见第 7 节。共享秘密为全零时应判定失败。
识别这层关系的依据,是将同一 native 会话中的原始 scalar、公钥 k 和 AES 材料分别对照:基点乘法得到 k,与固定 peer key 做 X25519 得到 slotA。两组不同 scalar 的会话均满足该关系,再用派生的 key/IV 重建 Part2,排除只对上一条中间值的情况。
native 使用五个 51-bit limb 表示域元素。其序列化结果对应标准 X25519 的 32 字节小端输出,协议复现不需要保留内部 limb 表示。
若直接对照 native 的中间缓冲区,五个 limb 各占一个小端 u64,合计 40 字节。设其数值为 h0…h4,序列化公式为:
这里对完整值取模,而不是先把每个 limb 截断到 51 位;中间 limb 可能携带尚未归一化的进位。k 和 slotA 在此边界采用同一规则。
曲线部分的最小实现:
会话材料与 slotA 的拆分:
2.2 Part1 的字段
当前请求构造中的键序为:
字段
含义
a
固定字符串 ECFAAF01
c
本次 MUA 的整数计数器
k
X25519 公钥,64 字符小写 hex
p
固定字符串 a
s
64 字节随机材料,128 字符小写 hex
t
运行态对象,例如 {"c":0,"d":0,"f":0,"s":4098,"t":0,"tt":[]},内部 t 可传入毫秒单位的 uptime
u
非注册请求且 OAID 非空时,为 "00000000" + oaid
v
固定字符串 2.9.99
序列化采用紧凑 JSON,Unicode 不转为 ASCII 转义,并保留字段顺序。注册路径省略 u。历史完整重放样本还存在不含 t 的 Part1,因此重放时需保持原始字段集合,补键会改变 Part1 和完整 MUA。
同次注册的 body.c、MUA.c 和 S1 帧中的 counter 使用同一个值。冷启动客户端的首次注册采用 c=2,但计数器必须由会话状态管理,不能把 2 写成所有请求的常量。
Part1 的字段集合随平台与版本变化。iOS 9.47.2 观察到的 Part1 使用 a=ECFAAF02,并出现 i、m(毫秒时间戳)、n 等本文未覆盖的字段,请求头还带 x-mini-nsig。跨版本复用本文公式前需先核对字段集合。
Part1 的组装:
dict 按插入序输出,正好对应上表的键序;内容请求在 OAID 非空时插入 u('00000000' + oaid,位于 t 与 v 之间),注册路径省略 u。
2.3 Part2 的编码与加密
Part2 的明文是一份设备画像 JSON,观察到的基线包含 95 个字段,涉及包版本、构建信息、安装时间、电池、网络、存储和进程状态等。
键序是 x0,x1,x10,…,不是数值序。这里仅重排顶层键;嵌套对象按已有顺序序列化。浮点数字面形式和 Unicode 转义方式也属于输入字节,跨语言复现时需保持一致。
PKCS#7 的补齐规则:
即使压缩结果已对齐 16 字节,也要追加 16 个 0x10。
反向核对时按 Base64URL 解码、CBC 解密、校验并移除 PKCS#7、zlib 解压的顺序处理。压缩数据带 zlib 封装,不能直接按 raw DEFLATE 解压。相同 JSON 不保证不同压缩器生成完全相同的压缩字节,要求逐字节重放时还需对照压缩流。
字段值必须保留 JSON 类型。例如 x2 为版本号整数,x17 为 SDK 整数,x43 为网络字符串,x242 为空数组;"0" 和 0 也不可互换。x5 的 "JTdCJTdE" 是文本 %7B%7D 的 Base64,不是 {} 的 Base64。这里的画像与注册 snapshot 是两份独立对象。
这一层的验证应同时比较明文 JSON、zlib 字节、CBC 密文和最终 MUA。只验证“自己加密后能自己解密”无法证明与 native 一致。固定会话材料和原始画像后,已有完整 MUA 与 native 输出逐字节相同的对照。
加密还原与设备采集还原是两个问题。使用合成画像可以验证编码链,但不能据此推导真实 Android 设备上的所有字段来源。
外层管线:
sorted(profile) 是字符串排序,直接得到 x0, x1, x10, … 的顶层键序;嵌套对象保持传入顺序。
2.4 95 字段画像的完整映射
下表记录已实现的画像构造。包、构建、硬件字段取自设备参数;时间和运行态字段可显式传入。固定值、时间偏移和 seed 派生值是离线合成策略,不等于真机的采集公式。带引号的值为 JSON 字符串,其余数值为整数,x247 中的数值为浮点。
真机确认(小米 MI 6 / Lineage 15 / 9.43.1):登录进程的 MUA Part2 明文正好是这 95 个键,与合成器集合一致。包名、版本、board、product、ABI、build id/host/incremental 与参数表一致。同一次启动里后两次 95 键 dump 只在时间、亮度、x258、x293 以及 x302/x303 上变化。观察到但未改合成默认值的项:x70 为 25 而非 Wi-Fi=3;x258 可为 1;x235 为 3348494;x302/x303 非空且同进程会变。尚无第二家厂商样本。
记 N 为当前 Unix 毫秒,I 为保存的安装时间,U 为 max(1, uptime_ms)。若未传 uptime,则使用 N 减保存的启动时间。运行态显式传值优先于下表默认值;固定设备参数需在同一档案内保持一致。
字段
来源或默认值
x0
包名 "com.xingin.xhs"
x1
版本 "9.43.1"
x2
版本号 9431801
x3, x4
I
x5
"JTdCJTdE"
x6
launch_time_ms,默认 N−1300
x7
hardware
x8
build_time_ms
x9
"{product}-{build_type} {android_release} {build_id} {build_incremental}"
x10
build_fingerprint
x11
build_host
x12
build_id
x13
build_tags
x14
build_type
x15
build_incremental
x16
android_release 字符串,例如 "15"
x17
sdk_int,例如 35
x18
security_patch
x19
board
x20
manufacturer
x21
supported_abis,逗号连接的字符串
x22
device
x23
brand
x24
model
x25
product
x26
machine
x27
kernel_release
x28
kernel_version
x29
baseband
x30
分辨率与密度字符串,例如 "1080,1920,480"
x31
55
x32
1
x33, x34, x35, x36
电池 status、scale、level、plugged;默认 2、100、99、2
x37
0
x38
network 为 "wifi" 时为 1,否则 0
x39
usb_config,默认 "adb"
x40
5
x41, x42
SIM 运营商名称、数字编码,均为字符串
x43
network,默认 "wifi"
x44
N
x45
brightness,默认 21
x70
Wi-Fi 为 3,否则 1
x72
Wi-Fi 连接时间,默认 N−min(U,145000);非 Wi-Fi 强制为 −1
x73
Wi-Fi DHCP 时间,默认 N−min(U,55000);非 Wi-Fi 强制为 −1
x78
保存的 UID
x79, x80
PID、TID,可用本次运行态覆盖
x87
max(0,N−1027)
x92
sdk_int
x93
3
x98, x120, x131
"0"
x146
seed 派生的 28 字节小写 hex,56 字符
x185
"IiGgSsKkCVvEeP"
x186, x187
−1
x194, x202, x203
"1"
x206, x207
0
x231, x232
total_storage,字节
x234
file_times 对象,默认见下文
x235
3275987
x236, x237
free_storage,字节
x238
locale_country 字符串
x242
[]
x243
max(0,N−1063)
x247
下方浮点对象
x258, x259
0
x260
N
x261
1
x263, x264
0
x267
1
x269
保存的值,默认 1230768000000
x272
0
x289
N+19
x290
1
x293
max(0,min(free_storage,available_memory));available_memory 默认 132146402
x296
""
x301
"LOADED,ABSENT"
x302, x303
""
x304
2
x305
−3
展开合并项后,共 95 个键。构建时可覆盖已存在的槽位,但新增或缺少键会偏离这套基线。x234 的默认键序为 "1"、"2"、"3",值分别为 I−67648、I−70281、I−70435。x247 为:
2.5 seed 派生与持久化策略
设备 seed 用来重复生成同一份合成身份,不是 native 的密钥派生输入,也不替代 MUA 的随机 s 和 scalar。字符串 seed 直接取 UTF-8;bytes seed 先变成小写 hex 文本再取 UTF-8;没有 seed 时生成 32 字节随机值的 hex 文本并保存。
定义 D(label,L) 为如下字节流的前 L 字节,计数器 j 从 0 开始:
这里的 j 是派生流计数器,与 MUA.c 无关。持久字段按以下规则初始化,已保存值优先:
label
派生结果
install-age-days
R(label,7,120);I = created_at_ms − 该值×86400000
boot-age-minutes
R(label,25,4320);boot_time_ms = created_at_ms − 该值×60000
x146
hex(D(label,28))
process-id
R(label,12000,29999)
thread-delta
R(label,1,64);TID = PID + 该值
android-uid
R(label,10000,19999)
free-storage-percent
R(label,31,72);free_storage = total_storage×该值整除100
created_at_ms、安装时间、启动时间和 x146 随档案保存。只保留 seed 却每次重设 created_at_ms,会改变默认安装时间和启动时间;x146 本身不依赖 created_at_ms。
同一派生规则还用于注册身份:
label
结果或用途
android-id
hex(D(label,8)),16 字符
oaid
hex(D(label,16)),32 字符
soc-serial
hex(D(label,8)),可选 SoC serial
boot-id
将 D(label,16) 直接按 UUID 格式显示,不修改版本位
apk-dir, apk-id
D(label,16) 的无 padding Base64URL,各 22 字符
sdcard-ctime-sec
R(label,1600000000,1900000000)
sdcard-ctime-ns
R(label,0,999999999)
sdcard-ino
R(label,1,1000000)
sdcard-dev
R(label,1,65535)
sdcard-fsid-lo, sdcard-fsid-hi
分别读取 D(label,4) 的小端 u32
合成安装目录为 /data/app/~~{apk-dir}/com.xingin.xhs-{apk-id}。合成文件系统状态只声明第 12 项成功,fstype 取 61267,其余 11 项双失败。SoC serial、boot_id 可以一起省略以重放空文件树基线;MMC、UFS 和额外 Settings 没有默认虚构值。
模拟 JNI ID 使用完整描述符 java/util/Map->方法签名 的 Java String.hashCode:按 UTF-16 码元执行 h=(31*h+unit) mod 2^32,再代入第 9.2 节的 MD5。模拟 attestation 使用 SHA256(UTF8(deviceId)),空身份则是空字节串的 SHA-256。这些替代值只重现模拟环境,不是 ART 或 TEE 的真实公式。
合成策略保持 attestation 分支存在、x148 为空串、x128 为缺文件文本;x170 根据设备参数的 manufacturer 是否等于 Xiaomi 选择已观察分支。实际设备上决定分支的是类加载和采集结果,不能用这套选择策略代替真实检测。
3. x-mini-sig:SHA-256 与 GF(2) 仿射变换
输出为 64 字符小写 hex。后 16 字节直接保留 SHA-256 摘要,前 16 字节经过固定的 GF(2) 仿射变换。
3.1 仿射关系如何求解
在 GF(2) 中,加法就是 XOR,乘法是单 bit 的 AND。对每个输出 bit,可以写成 128 个输入 bit 的线性组合再异或一个常数。
如果能在摘要后的变换入口自由注入输入,先求 f(0),就得到常量 b;再对 128 个单比特基向量 e_j 求值:
如果只能取得请求级输入输出,则对每组摘要前半段 x、签名前半段 y,建立:
通过 GF(2) 高斯消元求解。增广输入矩阵的秩达到 129,才能唯一确定全部系数;训练样本之外还需独立验证。
这是恢复仿射变换的方法。现有材料保留了最终矩阵和独立抓包向量,但没有足够的原始求解记录可在本文重现当时的全部采样、消元过程。11 组独立 GET 样本支持该矩阵的输出一致性,不能单凭这 11 组反推出 128×129 个系数。
3.2 位序与求值
把矩阵写成 128 行、129 列,最后一列保存常数项:
输入列 0 对应大端整数的最低位,输出行 0 却对应结果最高位,二者方向不同。完整矩阵的紧凑表示放在附录。
一组输入的中间结果为:
POST 使用同一公式,差别在 body 摘要。只有在 canonical 完全一致时,比较签名结果才有意义。
把 canonical、矩阵和位序组合起来:
rows 的第 j 列对应输入串的 bit j(见 3.2 的列序),输出第 i bit 写入第 i 个字节的 bit 7 - i % 8。
4. x-mini-s1:摘要变换、CBC 与双 CRC
S1 不使用 SIG 的输出。它对 canonical 追加 counter 后重新计算 SHA-256,再加入时间戳形成待加密结构,最终输出标准 Base64。
定位这条链路时,最初观察到的是 Base64 前的 62 字节结构。固定随机数后,跨进程结果仍可能变化:一类变化来自 canonical 内尚未固定的 MUA,另一类来自之后写入的秒级时间戳。只有固定完整 canonical、counter 和时间,才能判断剩余差异是否来自算法。
继续追踪中间缓冲区写入后,可以将结果拆成 32 字节 digest lane、44 字节 seed、48 字节密文和两个 CRC。固定完整 canonical 后仍存在的变化,最终对应到 time(NULL) 的返回值;固定这个秒级值后,跨进程输出一致。
lane 曾尝试用固定 XOR 仿射模型描述,但新增样本与模型矛盾。随后根据 VM 指令和内存写入分别恢复四段变换。CBC 部分则将查表层拆为 AES S-box 与输入、输出 XOR 掩码,得到第 4.4 节的显式轮函数。这里的掩码是从表中还原的常量,不是由 K0 执行标准 AES key schedule 推出的轮密钥。
4.1 SHA-256 与字重排
每个 4 字节块内部不反转字节序。
counter 按无符号 32 位编码,必须与 MUA.c 一致。它既参与 nested 的摘要,也出现在最终帧头;只改帧头不能得到对应新 counter 的完整 S1。
4.2 四段 lane 变换
state 分成四段,每段 8 字节,分别处理后拼接。
使用 IEEE 反射 CRC-32,多项式为 0xEDB88320,初值为 0xFFFFFFFF:
第一段先交换每个字节的高低 nibble,再以内部 CRC 的低 8 位作为统一 XOR mask:
第二段:
第三段使用 nibble S-box(下标 0..15):
CRC 的输入是展开后的 16 字节,最后异或的仍是原始 8 字节。
第四段:
四段输出拼成 lane32。第一段的 CRC 递推曾逐字节与 native 中间状态对照;其余段使用独立的变换,不能把第一段公式套用到全部 32 字节。
4.3 seed 与帧结构
偏移区间
长度
内容
[0,1)
1
marker 00
[1,5)
4
counter,小端
[5,6)
1
version 01
[6,54)
48
CBC 密文
[54,58)
4
seed CRC,大端
[58,62)
4
source CRC,大端
第二个 CRC 覆盖 53 字节,不包含最外层 marker 和 counter。若把它也拼入临时 source 缓冲区,该缓冲区长度是 57 字节;这与 CRC 输入长度是两个概念。
62 字节经过标准 Base64 后为 84 字符,末尾带一个 =。
timestamp_seconds 是 Unix 时间秒数,按 u32 写入。它与 Part1.t.t 的 uptime 毫秒不同,也不参与 nested 的 SHA-256;时间变化发生在 seed、CBC 密文和后续校验层。
4.4 fused AES-like CBC
CBC 的 IV:
先将当前明文块与 previous 异或,previous 初值为 IV。块变换使用 AES 的标准 S-box 和 MixColumns,但运算排列与轮密钥表按下面的形式组织:
Transpose 将列优先的 16 字节输入转成行优先状态;ShiftRows 将第 r 行左旋 r 格。轮密钥按上面行优先状态逐字节对应,不能再对密钥自行转置。
九轮键依次为:
K0 对应 ASCII xhs0oh2iu4an7og2。最后一层只有一次 S-box,没有 MixColumns。
AES S-box 的构造是先在 GF(2^8) 中求乘法逆元,再做仿射变换。不可约多项式为 0x11B,0 的逆按 0 处理:
MixColumns 对每列 [a,b,c,d] 计算:
乘法在同一 GF(2^8) 中完成。44 字节 seed 补四个 0x04 后正好是三个块。上述变换不能直接用标准 AES-CBC 加上一把初始 key 替代。
当前有 23 组固定时间、counter=1 的 native 完整帧对照,以及 5 组固定时间、counter=2 的完整帧对照。后者覆盖五个不同的 Unix 秒值,其中包括与 counter=1 语料相同的秒值、相邻秒、一小时偏移,以及 1 与 1700000000。这五组 raw62 与公式在相同 canonical、counter=2 和对应秒值下逐字节一致。51244a19 与 170e 在这些对照中保持不变。
另取 Android 9.47.0(build 9470809)的 arm64 libtiny,其内容与 9.43.1 样本逐字节相同。因此九轮键、FA、C、51244a19 与 170e 在这两个应用版本之间没有漂移。这些常量不以连续明文出现在 so 中,漂移判定依据的是整个库镜像,而不是字面量检索。
为了把前文公式落到可执行的检查,下面给出一个不依赖第三方库的最小实现;SBOX 由 GF(2^8) 求逆直接生成,与标准 AES S-box 一致:
5. main_hmac:解出 Shield 的 key64
main_hmac 是服务端返回的加密材料,不是当前请求的 HMAC 结果。客户端从 /api/sns/v1/system_service/vfc_code 的响应头 xy-ter-str 取得 Base64 字符串。
固定 header:
解密结果必须为 96 字节,且前 16 字节等于 header。Shield 使用中间 64 字节,footer 不参与请求 HMAC。此处不做 PKCS#7 去填充。
96 字节恰好是六个 AES 块,但长度本身不能证明 AES。依据还包括块变换中的标准 S-box、行移位和列混合,以及由 deviceId 驱动的轮密钥扩展。与标准 AES 的差异集中在下面的 Rcon,而不是另一个自定义 S-box。
5.1 定制 AES 密钥扩展
deviceId 使用带横线的 36 字节 UUID ASCII:
随后执行 AES-128 密钥扩展,但 Rcon 换成以下 10 个大端 u32:
RotWord 是将 4 字节左旋一个字节,SubWord 对每个字节应用标准 AES S-box。共得到 44 个字,即 176 字节轮密钥。
CBC 解密时,P_i = D_round_keys(C_i) XOR C_(i-1),首块的前驱取 16 个零字节。对明文切片 [0:16]、[16:80]、[80:96] 分别得到 header、key64、footer。
块密码轮函数使用标准 AES:初始 AddRoundKey,九轮 SubBytes、ShiftRows、MixColumns、AddRoundKey,最后一轮省略 MixColumns。修改的是 key schedule,直接调用标准 AES 库并只传 key16 会得到不同结果。
例:
判断这层还原是否成立,需要跨 deviceId 检查明文 header 和 key64。已有 18 组对应材料的解密结果一致;只做加解密往返不能替代该对照。
不存在仅凭 deviceId 离线推出 key64 的本地公式。libxyass 只在收到 xy-ter-str 后把它写入 prefs s/main_hmac,再用 deviceId 做 AES 包装密钥解出 64 字节;.so 里没有 f_key 字面量。空 prefs 冷启动保持 70 字节 Shield。正常协议取得 main_hmac 后即可解出 key64。
5.2 反向加密
给定同一 deviceId、已知 key64 和 footer,可以反向重建 main_hmac:
E 使用第 5.1 节的定制轮密钥,明文固定六块,不额外 padding。footer 必须作为输入保留,不能因为 Shield 不用它就省略。反向加密仅证明封装可重建,不意味着能从 deviceId 生成未知 key64,或自行得到与服务端下发相同的新材料。
key64 的提取同样可以只依赖标准库。SBOX 与 4.4 节示例相同;解密用标准 AES 块函数配本节轮密钥:
6. Shield:请求 HMAC 与 SSK 扩展
外层格式为:
这里使用标准 Base64,保留 padding。raw 有两种长度:基础结构 70 字节;同时具备 main_hmac 和 main_ssk 时为 118 字节。
6.1 HMAC 原文
各项按下面顺序拼接,无换行、问号或额外分隔符:
缺少的头按空串处理,最后按 UTF-8 得到字节。host、scheme、METHOD 不在这条原文中。platform 段按版本常量和 deviceId 重建,不直接读取可能不同的 xy-platform-info 值。
body 必须与实际发送内容一致。对于表单请求,应签已经完成百分号编码的表单文本;对于 JSON,应签最终序列化字节,而不是重新生成一个“语义相同”的 JSON。
6.2 定制 HMAC-MD5
HMAC 的外部结构不变:
key64 已经恰好是 64 字节,XOR 0x36 和 XOR 0x5c 表示对其每个字节异或相同常量。inner 和 outer 都是 16 字节原始摘要;计算 outer 时不能把 inner 转成 hex 文本。
native 的初始化和 64 轮压缩仍保留 MD5 的结构,因此可以逐项比较初始状态、旋转表、加法常量、消息下标和寄存器更新。下面列出的修改共同组成 MD5_mod。
初始状态相对标准 MD5 反序:
T 表:
先复制 T 为 K,再修改:
旋转位数先取标准 MD5 的四组,每组重复四次:
再覆盖 10 个位置:
轮函数和消息下标:
轮 39 起按 PIN 重排输入寄存器:
单块 64 字节按小端拆成 16 个 u32,记为 M:
Padding 保持 MD5 规则:追加 0x80,补零到长度模 64 等于 56,再写入原始 bit 长的小端 u64。所有块处理完后,将 state 按四个小端 u32 输出。
这类变体需要分别确认初始状态、表常量、轮函数、消息下标和寄存器更新顺序。只替换 MD5 的 T 表不够;旋转前的 32 位截断也不能省略。
一个摘要检查值:
6.3 70 字节基础结构
常量如下:
RC4_KEY 是 72 字节 UTF-8。使用标准 RC4 KSA 和 PRGA:
hf 根据 main_hmac 是否存在选择 EMPTY_HF 或 NONEMPTY_HF,它不是 HMAC 的截断值。基础结构长度为 16 + 1 + 36 + 1 + 16 = 70 字节。
无 main_hmac 时不会计算请求 HMAC,此时 raw70 仅随 deviceId 变化;改变 path、query 或 body 不改变它。取得 main_hmac 后,才通过 tail 将请求内容绑定到 Shield。70B 和 118B 分别编码为 96 和 160 字符 Base64,加上 XY 后总长为 98 和 162 字符。
6.4 48 字节 SSK 扩展
将有无 SSK 的输出对比,可以看到基础部分保持为 70 字节,额外尾段为 48 字节。继续对序列化前的内容拆分,可识别出两个 TLV:tag 4 的 20 字节证明和 tag 5 的 24 字节证明。
native 使用 main_ssk 的标准 Base64 ASCII,而非直接使用其 32 字节原值:
长度字节 14、18 是十六进制,分别代表 20、24。
扩展使用 RC4 密钥流的指定区间:
偏移是 68 而不是 70,因为最终 raw 的前两个字节由后处理插入。基础结构的等价构造和扩展的流偏移应分别保留,不能把 plain54 与 plain48 直接拼起来重新做一次 RC4。
当 main_hmac 为空时,即使有 SSK,也不进入此扩展分支。改变 query 会改变 request_hmac,再改变 req_proof;改变 SSK 则先改变 ssk_proof。
三种 SSK 与三种 URL 的九组固定 nonce 样本,完整 118 字节均能复现。4 字节 nonce 来自 libxyass 扩展分支中的生成函数,不是 getrandom 或 /dev/urandom。该函数对进程内原子计数器执行加一,再与当前栈上 sp+0x20 这个槽位地址的低 32 位异或,按小端写出:
随后用 SHA1(ASCII(main_ssk_b64) || nonce4) 填入 tag 5。sp+0x20 是运行时栈布局,不是协议常量;模拟器里栈地址稳定,因此连续请求的 nonce 低位随计数器变化,并在计数器越过 16 的幂次时出现异或翻转,而不是单调加一。真机 ASLR 会让这一项表现为每进程不同的 4 字节值。可移植实现在不知道栈地址时仍应把 nonce 当作每请求输入,而不是写成跨进程常数表。32 组同一进程的连续 native 输出满足上述公式。
无 main_hmac 的 70B 形态不需要定制 MD5,可独立复现:
7. SSK:X25519 与 AES-256-GCM
客户端向 /api/sns/v1/user/activate 发送临时 X25519 公钥,字段名为 client_public_key_base64。公钥先做标准 Base64,再按表单规则百分号编码。
SSK 服务器公钥为:
解码层也可以兼容 URL-safe Base64。shared 直接作为 256 位 AES key,中间没有 HKDF;全零 shared 应拒绝,GCM tag 必须验证,明文必须为 32 字节。SSK 的 GCM nonce 是 12 字节,与 Shield 扩展中的 nonce4 无关。
该过程不能和 MUA 的 key/IV 拆分混用:MUA 将共享秘密拆成 AES-128-CBC key 与 IV;SSK 则把完整共享秘密用于 AES-256-GCM,并从响应读取 IV。
这组请求、响应必须共享同一对临时密钥:请求发送公钥,处理响应时保留对应私钥。重新生成私钥再解密原响应,会改变 shared 并导致 GCM tag 校验失败。MUA 的 scalar 与 activate 的临时私钥也应作为独立会话材料管理。
RFC 7748 向量可检查 X25519 标准原语,合成 GCM 报文可检查解密和 tag 拒绝行为,但这两者均不能单独证明某次真实 activate 响应使用了同样的材料。
main_ssk 的解出:
shared 使用第 2.1 节的 x25519(),peer 为本节的 SSK 服务器公钥,不是 MUA 的 MUA_SERVER_PUBLIC_KEY。得到的 32 字节直接作为 AES-256-GCM 密钥;不要复用第 2.1 节的 slotA。
8. 注册报文:GID 与加密 snapshot
注册接口是 POST /api/v1/register/android。请求携带 MUA、SIG、S1,不带 Shield。body 是按以下键序生成的紧凑 JSON:
a、c、k、p、s、v 与同次 MUA Part1 保持一致。e 是明文身份对象,首次启动的 empty-identity 样本中为:
d 的编码过程为:
d 与 MUA Part2 共用同次会话的 slotA 和外层算法,明文却是不同的数据结构。不能拿 MUA Part2 的字符串直接当 d。
一组注册样本中,snapshot 明文 960 字节,zlib 结果 448 字节,补齐后为 464 字节密文。长度由压缩内容决定,不是固定协议值。
注册签名的依赖顺序是:
签名完成后不再重排或修改 body。服务端响应的 data.g 在观察样本中为 56 字符 hex,后续原样写入 x-mini-gid 和 common params 的 gid。
字段 d 与 body 的组装:
这里的 key、iv 与同次注册 MUA Part2 共用 slotA;body 组装完成后,再按第 10 节的顺序计算 SIG 与 S1。
9. snapshot 中的哈希与编码字段
snapshot 的字段存在条件分支,24 键只是一个基线。例如,其他条件相同的情况下,SDK 28 起增加 x252、x253;可选采集结果还会继续增加或移除键。下面只讨论会影响复现的哈希与编码关系。
9.1 x86:XXH3-128 与 CRC32
XXH3 使用标准 seeded 128-bit 算法,结果按 high64 << 64 | low64 格式化。CRC32 为标准 IEEE 反射形式,输出固定 8 个 hex 字符。
输入由成功采集的内容按顺序拼接,没有分隔符:
SoC/MMC/UFS 和上述 Settings 值的规范化为去除两端 ASCII 空白,再截取前 200 字节。cpuinfo 和 boot_id 不在这条输入链中。
这类字段适合用单变量差分确认:固定其他采集项,只改变一个输入,同时观察拼接缓冲区和最终哈希。固定 seed 的依据还包括 native 中的立即数装载,而不是仅依赖输出拟合。XXH3 的短、中、长输入分支应使用标准实现,不应将两个 64-bit hash 简单拼成 128-bit 替代。
x86 的组合只有一行,xxh3_128 的完整实现见附录 B:
9.2 x137:JNI method ID 的 MD5
将以下七个 JNI method ID 按顺序编码成小端 u32,拼接为 28 字节,计算标准 MD5,输出大写 hex:
哈希公式与 ID 的来源需要分开。
真机路径已定性。native 对 java/util/Map 做 FindClass,再按上表顺序各调用一次 GetMethodID,用 str x0 把 64 位 jmethodID 写入全局表。随后 MD5 只读每个槽起始的 4 字节,即小端低 32 位。本机样本的指针都在 4GB 以下,高 32 位为 0,trunc32 等于完整指针;不能外推到所有 64 位地址布局。
这些 ID 是 ART 方法对象的地址,落在一块约 2.8MiB、无文件后端的 rw- 映射里。同一次开机内进程重启后七个值不变;重启设备后映射基址随机化,七个值一起平移,相对偏移不变。因此 x137 是开机期运行时变量,服务端不可能按固定表严格校验。模拟环境仍可用描述符的 Java hashCode 代替 ID,只作为降级 oracle,不是 ART 的分配公式。
x137:
9.3 attestation 与环境分支
x147 对 attestation 字节做 UTF-8 解码,非法序列替换成 U+FFFD。x86 使用原始字节,不能改用解码后再编码的 x147。
输入来源已定性。native 通过 Class.forName 加载 com.huawei.attestation.HwAttestationManager,再反射调用 getDeviceID(I)[B。该类不在 APK 里,是华为系统类。类存在时 x148 为空串,原始字节进入 x86;类加载失败则 x147、x148 都不出现,attestation 字节也不进入 x86。失败后还会探测 attestation_service 与 IHwTelephony$Stub,它们不能代替 getDeviceID 的返回值。
在小米 MI 6(Lineage,无华为包)上,三次进程重启都走省略分支:注册 snapshot 的 x147 路径不产出字节。同进程里 AMediaDrm_getPropertyByteArray("deviceUniqueId") 稳定返回 32 字节,但那是 Widevine 采集;隐藏 HwAttestationManager 时 x147 仍会消失,因此它不是 x147 的输入。华为 TEE 的 getDeviceID 载荷仍是设备特定未覆盖。
模拟环境默认仍用 SHA256(UTF8(deviceId)) 作为降级;也可注入原始字节,或显式走省略分支。
同样,OAID 提供者、文件可读性和 JNI 环境会改变 snapshot。可以复现某个固定环境下的序列化与加密,不等于已经复现所有设备厂商的采集行为。
9.4 x165:文件系统信息的字符串编码
x165 将 12 个固定目标的文件状态与文件系统状态编码为对象。成功项的键是从 1 开始的十进制序号字符串,/sdcard 对应 "12"。每项格式为:
固定序号与目标如下;这些是 Android 采集目标,不是文章读者计算机上的目录:
序号
采集目标
1
/data/system
2
/data/data/
3
/data/data/com.android.shell
4
/data/system/install_sessions
5
/data/data/com.google.android.webview
6
/data/data/com.google.android.gms
7
/dev/__properties__/u:object_r:radio_prop:s0
8
/dev/__properties__/u:object_r:ffs_prop:s0
9
/dev/__properties__/u:object_r:debuggerd_prop:s0
10
/dev/fd/1
11
/dev/fd/0
12
/sdcard
在这台 MI 6 上,12 个目标里存在 /data/system、/data/data/、/data/data/com.android.shell、/data/system/install_sessions、/sdcard、/dev/fd/0、/dev/fd/1 以及 radio/debuggerd 两处 property 节点;不存在 webview、GMS 数据目录和 ffs_prop。/system/bin/getprop 与 /system/bin/sh 都在,按第 9.5 节的 helper 规则,这台设备的 x128 应走省略键。尚未用清除数据后的 register snapshot 直接核对。
ctime 的秒与纳秒之间没有分隔符,纳秒至少 9 位,不足补零;二者按有符号 64 位整数输出。inode、device、fstype 按无符号 64 位输出。fsid 为两个 u32 各自编码成 8 位小写 hex,再按 low、high 顺序拼接。
当 stat 失败、statfs 成功时,ctime、inode、device 取零,保留 fsid 和 fstype;stat 成功而 statfs 失败时,fsid 为字面量 *,fstype 为 0;两者都失败则省略该项。所有目标均失败时,x165 的值为 null,而不是空对象。
例如只保留第 12 项,stat 值全零,fsid 的两个字为 0xd3609fe8、0x04970d6b,fstype 为十进制 61267,则得到:
9.5 x170 与 x128 的条件分支
已观察的类加载实验中,存在 com.android.id.impl.IdProviderImpl 时,x170 为 "id_provider",OAID 同时写入 x173、x174、x175、x181,并在 x86 中追加一次。隐藏该类后,x170 回退为 "vivo",四个副本消失,x86 不再追加 OAID;x171 仍保留 OAID。这是该模拟环境中的分支结果,不能单凭 manufacturer 字符串推导真实设备的提供者。
x128 对应文件打开失败后的 errno=2 文本 "No such file or directory"。在已观察实验中,同时提供 getprop 和 sh 两个系统 helper 后该键消失;只提供 getprop 时仍保留。boot_id 另走 x47,样本中去尾换行后保留前 35 字符,不参与 x86。native 对异常长度和编码的完整读取行为仍未覆盖。
9.6 snapshot 字段与出现条件
以下为已映射的字段。字符串按 UTF-8 序列化,容量字段为字节数整数;“存在”指采集分支成功或调用方显式提供输入。
字段
值或来源
出现条件
x137
七个 Map method ID(trunc32)的 MD5,大写 hex
基线
x165
文件系统对象或 null
基线
x170
OAID 提供者标签
基线
x171
OAID 字符串
基线
x20
manufacturer
基线
x21
CPU ABI 列表字符串
基线
x25
product
基线
x46
android_id
基线
x49
ApplicationInfo.sourceDir
基线
x77
boot hardware
基线
x86
XXH3-128 与 CRC32 的 40 字符拼接
基线
x231, x232
总存储字节数
基线
x236, x237
剩余存储字节数
基线
x128
缺文件错误文本
第 9.5 节的失败分支
x147
attestation 原始字节的 UTF-8 replace 结果
attestation 分支存在
x148
已观察样本为空字符串
attestation 分支存在
x173, x174, x175, x181
OAID 的四个副本
IdProviderImpl 分支
x252, x253
ApplicationInfo.publicSourceDir
SDK≥28
x149
规范化的 SoC serial
存在且规范化后非空
x151
规范化的 MMC cid
存在且规范化后非空
x153
规范化的 UFS id
存在且规范化后非空
x47
去尾 CR/LF 后前 35 字符的 boot_id
输入存在
x110
与 boot_id 同样截断的 kernel uuid
输入存在
x76
cpuinfo 差分样本中的整数 0
对应采集样本存在
SoC serial、MMC cid、UFS id 分别对应 /sys/devices/soc0/serial_number、/sys/block/mmcblk0/device/cid、/sys/ufs/ufsid;规范化字节进入 x86,字段值则将这些字节按 UTF-8 replace 解码。两者在非法 UTF-8 输入下不可互换。publicSourceDir 未单独提供时,当前重建策略使用 sourceDir。
以下 Settings 值在读取到非空字符串时写入对应槽位,先去两端 ASCII 空白、截取 200 字节,再按 UTF-8 replace 解码。是否出现键按规范化前的非空条件判断,因此全空白输入可以产生空字符串字段。
Settings 名
字段
规范化字节是否进入 x86
gcbooster_uuid
x133
是,在 android_id 之后
uuid
x158
否
pps_oaid
x169
是,在 gcbooster_uuid 之后
bluetooth_address
x63
否
key_mqs_uuid
x132
否
ad_aaid
x134
否
mi_health_id
x157
否
persist.sys.oppo.opmuuid
x159
否
com.vivo.pushservice.client_id
x160
否
iRoamingKey
x161
否
com.vivo.pushservice.back_up
x162
否
ZHVzY2Lk
x163
否
这张表覆盖当前已映射分支,不把尚未成功采集的其他 native 分支当作不存在。MUA Part2 中同名的存储和硬件槽位来自另一份画像对象,不能据此合并两个 JSON。
10. 把各层组合成一条请求
完成会话引导后,一次请求按以下顺序处理:
这里有三份容易混淆的状态:MUA 的会话随机材料、每次请求的 counter,以及 Shield 的 nonce4。它们的生命周期不同,也不能互相替代。
同一个请求对象应同时提供 canonical 和 Shield source 所需的字节。若 HTTP 库在签名后重新排序 query、改变百分号编码,或重新序列化 body,就会产生“本地公式正确、实际发送内容不一致”的问题。注册尤其需要先生成最终 body,再计算 SIG 和 S1。
10.1 一组可复算的 SIG / S1 输入
下面是检查公式的合成向量,MUA 使用字面量 part1.part2.,用于隔离 SIG/S1,不代表可提交给服务端的真实 MUA。
依照本文的 LF 拼接规则构造 canonical,结果应为:
该向量经过两份实现对照,作用是检查位序、counter、时间单位和编码。固定时间下 counter=2 的 native 完整帧已另行对照,见第 4.4 节。
现有固定输入对照支持本文的 MUA 编码、SIG、S1、main_hmac 解密和 Shield 公式。在公式对照之外,纯 Python 客户端已在匿名会话下完成整链验收:注册取得 GID、解出 main_hmac 与 main_ssk,homefeed、imagefeed、search/notes、user/info、user/posted 均返回 code=0,并跨越多套合成设备与会话。这一层验证的是请求头构造与状态衔接能被服务端接受。x137 的真机输入已定性为开机期 ART 地址(见第 9.2 节);尚未覆盖的部分包括真实 TEE attestation 字节,以及完整 OEM 采集分支。它们限制的是结论范围,不能用一次服务端成功响应补足。
附录:SIG 仿射矩阵
每行 129 个 bit 按“列 0 到列 128”的顺序放入 17 字节的高 129 位,低 7 位补零。以下 hex 共 2176 字节、128 行;为了排版,每个文本行包含四个矩阵行。
解码方法:
这里的打包位序只用于存储矩阵。解码得到 rows 后,按第 3.2 节的输入列位序计算,不能把打包时的高位优先误当成 digest 的取 bit 顺序。
附录 B:XXH3-128 的完整分支
x86 使用 xxHash 0.8.3 的 seeded XXH3-128。这里展开的是标准哈希的实现;本协议特有的是 seed 0x48FA5412、输入拼接顺序,以及末尾追加 CRC32 的规则。
实现将 seed 归一到无符号 64 位。读入整数使用小端,64×64 乘法保留完整 128 位结果;需要折叠时,将乘积高、低 64 位异或。加法、乘法与取负后的截断以代码中的 MASK 为准。
输入长度
处理方式
0
seed 与 secret 的两组 64-bit XOR,分别做 XXH64 avalanche
1–3
组合首、中、尾字节和长度,构造两路值,再 avalanche
4–8
组合头尾 u32,混入 seed 和 secret,做 128-bit 乘法及两路终混
9–16
头尾 u64 混入两组 bitflip,做两次乘法并得到 high/low
17–128
从输入两端配对取 16 字节,用 mix32 累积两路状态
129–240
先处理前 128 字节并 avalanche,再处理余下整组和尾部重叠窗口
≥241
使用 8 个累加器处理 64 字节 stripe,每 1024 字节扰乱状态,最后分别合并 high/low
中等长度的 mix16 使用 (secret_low + seed) 与 (secret_high - seed) 掩蔽两个输入 u64,再将乘积折叠为 64 位。mix32 同时更新 low/high,并交叉异或另一半输入的两字之和。129–240 字节分支的最终窗口使用负 seed,位置和方向不能与前面的整组互换。
长输入在 seed 非零时先派生 192 字节 secret:每个 16 字节单元的前 u64 加 seed,后 u64 减 seed。每个 stripe 分成八路,原值加入相邻累加器,掩码后值的两个 u32 相乘加入当前累加器。完整 1024 字节块处理后进行 scramble,最后一个 64 字节窗口单独处理,允许与前一窗口重叠。最终以不同 secret 偏移合并出 high 和 low,因此不是两次独立的 64-bit 哈希。
以下代码可独立运行,仅使用 Python 内建功能。源自 xxHash 0.8.3 的 Python 改写,原算法版权归 Yann Collet,按 BSD-2-Clause 保留许可。文末附完整许可文本。
返回值为 high64 << 64 | low64。生成 x86 时,对该整数使用固定 32 位大写 hex,再拼接同一输入的固定 8 位大写 CRC32:
许可: