)
案例地址QQ音乐热歌榜上一篇057我们验证了musics.fcg的sign服务端并不校验走明文入口就能拿数据。但那篇留了个尾巴如果就是要正面把 vmp 拆了呢这一篇就来还债。目标很明确给定任意data字符串本地算出和浏览器逐字符一致的sign并且用线上真实值验证不是“看着像”。一、先确认 sign 是纯函数先把__TmeWebSec_sign在初始化时截住。它是 vendor.chunk 里的一个模块页面启动时会被delete掉所以要在删除前把引用存下来// 在 __TmeWebSec_sign 被读取的那一行下断点暂停后执行 window.__SIGN_CAPTURED window.__TmeWebSec_sign;拿到模块后直接当 oracle 用await window.__SIGN_CAPTURED.getSecuritySign(abc) // zzc369981d08l6noj1uu5ylcp8kabaw6rnxos49c78ba06同一个输入连调两次结果一样换一个浏览器会话结果还是一样。说明它不依赖 cookie、时间戳、随机数是纯函数。这是能纯算的前提。顺手多取几组输入线上 signzzcee394bd8gx4qeiq5cfgdyqwu7sdqfsb5froaf95326babczzc369981d08l6noj1uu5ylcp8kabaw6rnxos49c78ba06111zzca716bbc0ozflmywhgchufgyc7cwsw7bh3e3f955fd5hellozzc1df4e8a989n1iwax0l5rimr0qwjexceiov4ae3bdac5热歌榜 payloadzzc0d695574jk4zm35be88xewyr59lrtvftc3b1a8dab输出长度 4446 位固定zzc开头后面的字符只来自0-9a-z。这很像“固定前缀 定长摘要编码”。二、插桩vmp 入口长什么样在 vendor.chunk 里搜_getSecuritySign找到调用点后再在 vmp 函数体第一行下断点f[o,a,n,this,arguments,u,r,0]断住时Scope 里能看到变量真实值t37075入口 PCn[]a[undefined, null, 3432918353, 3864292196, 2869860233, ...]this函数对象键为qt, d, Rt, w, e, level, $targuments[0]SignModule模块名不是数据注意a里那串常量3432918353, 3864292196, 2869860233...是 MurmurHash 家族的常量。别看到这里就以为 sign 是 MurmurHash——这是指纹模块用的签名主路径不是它。插桩要以实际调用链为准。getSecuritySign本身是个vmp 分发器arguments[0]是模块名SignModule、TMESec、FingerprintModule...真正的数据在后续参数里。字节码r有 48157 个 opcode靠switch一条条解释执行。三、从 mid 突破SHA-1 XOR Base64vmp 硬读很费劲换个思路先猜结构再用真实值验。公开的旧版分析给过一个方向SHA-1 → 20 字节 XOR → Base64 → 拼前后缀。我拿这个结构套当前版本h sha1(data.encode()).digest() # 20 字节 x bytes(h[i] ^ KEY[i] for i in range(20)) mid base64.b64encode(x).decode() mid mid.replace(, ).replace(/, ).replace(, )把算出来的mid拿到线上 sign 里找——它原样出现了。比如abc线上 signzzc 369981d0 8l6noj1uu5ylcp8kabaw6rnxos4 9c78ba06 ↑pre ↑ mid 原样命中 ↑suf这说明SHA-1、XOR KEY、Base64 去字符这三步是对的。KEY 是 20 字节固定盐当前版本是KEY [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]这里说明一下来源KEY 是先拿公开旧版分析的候选值验mid再用线上 12 组真实值全量确认的。公开资料只给了方向最终结论是真实值验出来的。四、反推 pre / suf 的下标mid 定位后前后各剩 8 个十六进制字符abc: pre 369981d0 suf 9c78ba06 : pre ee394bd8 suf af95326b它们不是随便取的而是从20 字节摘要里按字节下标挑出来的pre取digest[3], digest[1], digest[6], digest[17]suf取digest[16], digest[12], digest[8], digest[5]验证一下abc的摘要SHA1(abc) a9 99 3e 36 47 06 81 6a ba 3e 25 71 78 50 c2 6c 9c d0 d8 9d 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 pre d[3] d[1] d[6] d[17] 36 99 81 d0 ✓ suf d[16] d[12] d[8] d[5] 9c 78 ba 06 ✓这就是为什么前面按“hex 字符下标”怎么都对不上——它取的是字节一个字节对应两个 hex 字符。五、纯算代码把上面几步拼起来import hashlib, base64 KEY bytes([89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]) PRE_IDX [3, 1, 6, 17] SUF_IDX [16, 12, 8, 5] def qq_sign(data: str) - str: h hashlib.sha1(data.encode(utf-8)).digest() x bytes(b ^ k for b, k in zip(h, KEY)) mid (base64.b64encode(x).decode() .replace(, ).replace(/, ).replace(, )) pre .join(f{h[i]:02x} for i in PRE_IDX) suf .join(f{h[i]:02x} for i in SUF_IDX) return (zzc pre mid suf).lower()案例目录里落成了52_纯算sign.py带--verify自检。六、相互证明12 组全中真实请求也中--verify会做两件事。第一跑 12 组线上 oracle 输入/输出MATCH zzcee394bd8gx4qeiq5cfgdyqwu7sdqfsb5froaf95326b MATCH a zzc37f7a77639bxosd3nqbqaaenwaoyb7jxhgs37b9e1a5 MATCH abc zzc369981d08l6noj1uu5ylcp8kabaw6rnxos49c78ba06 MATCH 111 zzca716bbc0ozflmywhgchufgyc7cwsw7bh3e3f955fd5 MATCH hello zzc1df4e8a989n1iwax0l5rimr0qwjexceiov4ae3bdac5 ...12 组全部逐字符相等。第二拿真实请求的明文1084 个字符从musics.fcg断点里取出来的r.data本地重算本地重算zzc3f18dad0tjdqaqp4a9bauamorgx9jx8q45de24c5d 抓包原值zzc3f18dad0tjdqaqp4a9bauamorgx9jx8q45de24c5d MATCH: True这组zzc3f18...就是当时断点里浏览器算出来的 sign不是我事后编的。到这一步才能说“纯算还原完成”。七、版本与踩坑KEY 和下标是版本相关的。旧版52.js用另一套常量对abc算出来是zzc7a68a42...和线上zzc369...对不上。结构不变常量和下标会变。看到 MurmurHash 常量别急着下结论。vmp 里同时有指纹模块和签名模块a里的常量属于指纹那条线签名主路径是 SHA-1。先验 mid再推下标。mid 能在真实 sign 里原样找到说明哈希和 KEY 对了剩下的前后缀只是“取哪几个字节”的问题用多组输入很容易反推。至少要 3 组以上样本。只用一组前缀下标会有大量歧义12 组基本能锁死。站点更新后按同样的插桩流程重新取 10 组 oracle先验 mid 是否还命中再重推下标即可。还有一点别忘了057 已经验证过sign在当前接口不参与服务端判断。纯算更多是学习 vmp 和补全链路真要做数据明文入口更省事。如有问题请及时提出加油加油。