某书shield算法记录
本文仅用于安全研究、逆向学习和自有测试环境分析,请勿用于未授权的数据抓取、攻击目标应用、绕过他人系统保护或窃取用户隐私
1. 背景
在分析某红书 App(build 9334801)的关键接口时,请求头里有一个 shield 字段。没有正确的 shield,服务端直接拒绝请求。
抓包和 Java 层 Hook 能看到 shield 的样子,但生成逻辑全部在 Native 库 libxyass.so 里:
1. shield 是一段 "XY" 开头的 base64,解出来是二进制包。
2. 包体经过 RC4 加密,密钥是固定字符串。
3. 真正的核心是一段哈希,由 libxyass.so 的导出函数计算。
4. 哈希的 key 不是明文,而是请求里 hmac_key 经过一层 AES 解密得到的。
也就是说,想离线复现 shield,至少要还原三件事:AES 密钥派生、核心哈希算法、外层组包。
最初的方案是用 unicorn 把 libxyass.so 加载起来,模拟执行哈希函数。这能跑通,但有两个问题:
1. 依赖 so 文件 + unicorn,部署重,跨平台麻烦。
2. so 内部是控制流扁平化 + 指令乱序的魔改算法,黑盒调用看不懂在算什么。
本文记录把 libxyass.so 的核心算法完全还原成纯 Python(不再需要 so 和模拟器)的全过程。重点不是“调用 so 拿结果”,而是说明这段魔改 MD5 到底改了什么、怎么一步步把它从乱序汇编里抠出来。
2. shield 的整体结构
先把 shield 的生成链路拆清楚。从外到内是三段流水线:
device_id + hmac_key
-> AES-128-CBC 解密 hmac_key 得到 raw_key (64 字节)
-> 拼接签名串 sign_str
-> 魔改 HMAC-MD5(raw_key, sign_str) 得到 16 字节哈希
-> 组装 header + build + device_id + 哈希
-> RC4 加密
-> base64,前面加 "XY" 得到最终 shield
签名串的拼法是固定模板:
sign_str = path + query + xy_common_params + xy_direction
+ "platform=android&build=<build>&deviceId=<device_id>"
+ xy_scene + body
其中只有“魔改 HMAC-MD5”这一段在 so 里,其余(AES、RC4、组包)虽然 so 也实现了,但都是标准/可读算法,能直接用 Python 重写。难点集中在哈希。
3. 核心哈希:先确认它是 HMAC-MD5
用 unicorn 跑通 so 后,先做黑盒观察,确认整体框架。
hook 哈希入口,对同一 key 喂不同长度的 data,会看到压缩函数 sub_81CC4 被调用多次,并且观察到典型的 HMAC 双哈希结构:
1. 内层块的第一块全是 0x36 (ipad)。
2. 外层块的第一块全是 0x5c (opad)。
3. 即 H(opad || H(ipad || data)),标准 HMAC 结构。
所以框架是 HMAC-MD5,ipad/opad 标准。真正被魔改的是底层 MD5 的压缩函数。剩下的工作就是还原这个压缩函数。
一个值得记的坑:标准 HMAC 对超长 key(>64 字节)会先哈希再用,但这里实测是直接截断到 64 字节,不预哈希。这种细节只能靠对拍发现。
4. 在 IDA 里定位压缩函数
不要直接读那几千行扁平化汇编,先用关键锚点缩小范围。
4.1 关键地址
sub_81710 = MD5 update(管理 64 字节缓冲 + 比特计数)
sub_81CC4 = MD5 压缩函数(控制流扁平化混淆,重点)
0x94710 = 一张完整的标准 MD5 K 表(诱饵!)
0x94910 = 魔改 K 表(大部分真,个别是诱饵)
0x15ca0 = seed / IV 存储
4.2 第一个陷阱:诱饵 K 表
so 里同时存在两张 K 表。0x94710 是标准 MD5 的 K 表,看起来很正确,但它根本不参与计算——是用来误导静态分析的诱饵。真正用的是 0x94910,而且这张表里个别项也是假的。
结论:不要相信静态读出来的常量表,要用动态执行时实际加载的值为准。
4.3 第二个陷阱:执行顺序 ≠ 数据顺序
sub_81CC4 被控制流扁平化(BR X9 派发器)+ 指令乱序处理。直接按汇编顺序读,会得到错误的轮次对应关系:
静态看到的 "第 40 条 rotate 指令" 实际是数据上的第 42 轮。
所以必须建立动态追踪,而不是肉眼读汇编。
5. 魔改 MD5 的还原(核心)
5.1 单累加器模型
标准 MD5 每轮维护 4 个工作变量 (a,b,c,d) 并轮转。把它展开成单序列 x[] 后,递推是:
x[d] = x[d-1] + ROL( x[d-4] + F(x[d-1],x[d-2],x[d-3]) + M[g[d]] + K[d], SH[d] )
魔改点集中在四处:
1. seed(IV) 非标准排列。
2. K 常量表整表替换。
3. 每轮循环左移量 SH 部分自定义(F1 区、H 区)。
4. H 区指令乱序,导致少数轮的 base/a 取自非相邻的中间结果。
5.2 动态抓取每一轮
在 unicorn 里 hook 每条指令,按 ror / extr 指令抓出每一轮的三要素:
tv = rotate 的输入值(旋转前的 32 位数)
sh = 旋转量
res = rotate 之后那条加法的结果(即 x[d])
抓 res 有个坑:unicorn 的 MEM_READ hook 里 value 参数对读操作是无意义的,必须用 uc.mem_read(addr,size) 真正读内存,否则 K 全读成 0。
5.3 反推数据顺序
拿到 64 个 rotate 的 (tv, sh, res) 后,它们是执行序、被打乱的。要先排回数据序。
利用两个约束反推:
1. 递推关系 x[d] = x[d-1] + ROL(tv, sh),用 res 值做链式匹配。
2. Davies-Meyer feed-forward:最后 4 个 x 与输出 OUT 直接相关,
可反算出 x[60..63],作为链尾锚点。
用回溯 DFS(base = x[d-1] 严格匹配 + 末轮反算)能把绝大多数轮排回去。但会发现 H 区(数据序 39~47)排不干净——base 偏移异常,几轮无解。
5.4 关键转折:切回执行序
数据序排不动,是因为 H 区指令被乱序,某轮的 a/F 参数其实引用了“执行序里更晚”的中间结果,在数据序里是“向前引用”,看起来不合因果。
解法是直接在执行序里建模:每一轮的 a/F 参数取自“更早执行过的结果”。这样因果就顺了。
求每一轮参数 (F函数, a偏移, b/c/d偏移, g, K) 的方法是多 key 联立:
1. 用很多个不同的 key(改变消息 M)跑 so。
2. 对每一轮,枚举 (F, 各偏移, g),要求反解出的 K 在所有样本里恒定。
3. K 恒定的那组参数就是正确解。
执行序一上,64 轮全部干净解出,K 值也和动态地址锚点一致(如 K[0]=E9C9B756)。
5.5 最后的坑:种子位置 + 变 state 标定
前面所有推导都用同一个初始 state(SEED 恒定),导致两类引用无法区分:
1. 前几轮 a/F 引用初始 4 个 state 槽位时,因 SEED 不变而无法确定具体是哪一个。
2. feed-forward 里加的是哪个 seed 分量也无法确定。
解决办法是引入变 state 的样本:HMAC 内层第二块,state = 第一块的输出(随 key 变化),块 = 固定 padding。用这种样本重新标定,前几轮和 feed-forward 的种子位置全部确定。
5.6 最终模型
还原后的压缩函数是一个“执行序单累加器”:维护一个不断增长的结果序列 avail,初值是 4 个 seed 字;每一轮按该轮的参数取若干历史结果做运算,把新结果追加进去。
seed(内存序) = [0x10325476, 0x98BADCFE, 0xEFCDAB89, 0x67452301]
avail = seed 的副本
对每一轮 e (0..63):
取该轮参数:布尔函数选择 + 4 个“回看偏移” + 消息字下标 g + 常量 K
a = avail[末尾 - a偏移]
f = 布尔函数(avail[末尾 - b偏移], [- c偏移], [- d偏移])
tv = (a + f + 消息字M[g] + K) mod 2^32
avail 追加 (avail[末尾 - base偏移] + 循环左移(tv, 移位量)) mod 2^32
输出 = 4 个 seed 字分别加上 avail 末尾附近的 4 个结果(Davies-Meyer 前馈)
每一轮需要确定的参数和它们的取值规律:
布尔函数:仍是标准 MD5 的 F/G/H/I 四个,只是分组边界被改过。
消息字下标 g:本质就是标准 MD5 的消息调度,只是映射到了执行序。
常量 K:整表被替换成魔改值(如第 0 轮 K=0xE9C9B756)。
移位量:F1 区和 H 区是自定义的,其余沿用标准。
回看偏移:绝大多数轮 base 偏移=1(即上一轮结果),
只有第 0 轮和 H 区被乱序的 5 轮是例外。
到这里,64 轮的全部参数都被一张表固定下来,压缩函数即可纯算复现。
6. AES 密钥派生其实是标准 AES
raw_key 由 hmac_key 经一层“白盒 AES”解密得到。原始实现里这部分是几千行查找表,看着吓人。
把这些表逐一和标准 AES 常量比对后发现,它就是标准 AES-128:
逆 S 盒表 == 标准 AES 逆 S 盒
解密 T 表(4 张) == 标准 AES 解密 T 表 Td0..Td3
加密 T 表 == 标准 AES 加密 T 表 Te
密钥扩展里的运算 == 标准 SubWord(RotWord(w))
唯一非标准的只有三个小常量:
1. 轮常量 rcon(10 个,被定制过,不是标准的 01 02 04 08 ...)
2. 初始密钥异或常量(4 个 32 位)
3. CBC 的 IV(16 字节)
所以那几千行表可以全部删掉,用运行时 GF(2^8) 现算 S 盒 + 标准逆 cipher 代替,只保留上面三个小常量。模式是 AES-128-CBC,解密后丢弃首尾各 16 字节,取中间 64 字节作为 raw_key。
这一步是“精简”的关键:先确认白盒是不是标准密码,是的话整表都能扔。
7. 纯算实现与校验
把上面三段拼起来,得到一个零依赖(仅标准库)的实现,核心约 200 行:
1. AES-128-CBC:device_id + hmac_key -> raw_key
2. 魔改 HMAC-MD5:raw_key + sign_str -> 16 字节哈希
3. RC4 + base64 组包 -> shield
校验必须和 so 做 bit-exact 对拍,而不是“看着像就行”:
1. 压缩函数:固定用例 11/11 + 随机 120/120,与 so 模拟器逐字节一致。
2. raw_key 派生:随机 device_id 与原白盒 AES 对拍一致。
3. 端到端 shield:用真实 hmac_key + 真实参数,
纯算 == so 模拟器 == 线上 shield API 三方一致。
三方一致才算真正还原成功。
8. 常见坑排查
8.1 K 全是 0 或对不上
优先怀疑两点:
1. 读到的是诱饵 K 表(0x94710),换成动态实际加载的值。
2. unicorn MEM_READ hook 里直接用 value,对读操作是无效的,
要 uc.mem_read(addr, size) 真正读。
8.2 单块对得上、多块/HMAC 对不上
1. 块间 state 传递的字节序/排列没对齐(feed-forward 输出序 ≠ 下一块输入序)。
2. 末轮 res 抓成了“输出写回”的值而不是该轮真实结果,需要反算。
3. 长 key 处理:这里是直接截断 64 字节,不是标准 HMAC 的预哈希。
8.3 数据序怎么都排不齐
1. 别在数据序硬排,切到执行序建模。
2. H 区有指令乱序,某些轮 base/a 取自非相邻中间结果,属正常。
3. 用多 key 让 K 恒定来定参数,比纯靠链式匹配稳。
8.4 前几轮/feed-forward 参数有歧义
初始 state 恒定时无法区分种子槽位,
引入“变 state 样本”(HMAC 内层第二块)重新标定。
9. 小结
shield 的还原不能停留在“用 unicorn 跑通 so 拿结果”。要真正离线复现并精简,至少要分清:
外层 HMAC 结构(ipad/opad,长 key 截断)
底层魔改 MD5 压缩函数(seed/K/SH/乱序)
hmac_key 的 AES-128-CBC 派生(其实是标准 AES + 定制常量)
RC4 + base64 组包
还原魔改算法的核心经验是:
1. 不信静态常量表(有诱饵),以动态实际加载值为准。
2. 执行序 ≠ 数据序,乱序混淆下优先在执行序建模。
3. 多 key / 变 state 联立反解参数,让未知量被“恒定约束”逼出来。
4. 白盒密码先比对是不是标准算法,是的话整表都能删。
5. 任何还原都要和原实现 bit-exact 对拍,多 key、多 block、长 key 全覆盖。
最后一句话:
不要满足于"能调用 so",要让算法在没有 so 的情况下逐字节复现。
只要把执行序模型和参数恒定约束建立起来,再深的控制流扁平化和指令乱序,也能一轮一轮抠回来。
文章作者:Linso
文章链接:https://linso.pro//archives/shield
版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0 许可协议,转载请注明出处!
评论