QQ 音乐网页端的请求签名分析 (zza)
QQ 音乐网页端的接口签名加密进过了数次迭代(特征为签名前缀是 zz
,第三个字符为版本,如 zza
为第一个版本)。
QQ 音乐网页端请求签名分析系列文章:
- QQ 音乐网页端的请求签名分析 (zza) - 2020.03
- QQ 音乐网页端的请求签名分析 (zzb) - 2021.07
- 对抗 QQ 音乐网页端的请求签名 (zzc) - 2024.07
zza 签名
zza
是相对比较老的一套算法,网上还能找到部分当时的分析。
通过 WebArchive 的缓存记录来推断,是在 左右上线的。
脚本地址:
QQ 音乐的签名算法定位很容易,直接检索关键字 getSecuritySign
就能快速定位到虚拟机的附近了。
不过本次通过缓存找到的这个链接是初版,可能最初设计是作为独立的模块运行而没有利用 WebPack 一同打包,整个文件就这一个功能,也不需要去手动拆包了。
虚拟机分析
虚拟机架构
文件大概是这么个结构:
(function (ctx /*n*/, init /*t*/) {
// 兼容各个环境的脚本,浏览器下等价于
window.getSecuritySign = init();
})(this, function () {
'use strict';
var n = window; // 全局环境
n.__sign_hash_20200305 = md5; // md5 (小写 hex 输出)
(function r() { /* 虚拟机实现 */ })(120731, 0 /* entrypoint */, [21, 34, 50, ... /* 虚拟机字节码 */], n);
var { __getSecuritySign } = n;
delete n.__getSecuritySign;
return __getSecuritySign;
})();
题外话:__sign_hash_20200305
这个 MD5 实现在三个算法都有包含(zzc
未调用它)。
然后观察虚拟机执行代码,大概的架构如下:
function runVM/* r */(unknown, pc, vm_code, $world, stack) {
stack = stack || [[this], [{}]];
// 支持错误处理
const errorHandlers /* t */ = [];
let errorCaptured /* o */ = null;
// 解释执行虚拟机的代码
const vm_handlers /* n */ = [function (){ /* Opcode: 0 */ }, ...];
while(true) {
try {
let halt = false;
while(!halt) {
halt = vm_handlers[vm_code[pc++]];
}
// 抛出错误
if (errorCaptured) throw errorCaptured;
// 返回栈顶内容并结束
return stack.pop();
} catch (err) {
// 模拟错误处理,如果没有注册 try ... catch 则继续抛出。
}
}
try {
for (; !n[c[h++]](); );
if (o) throw o;
return g.pop();
} catch (n) {
var e = t.pop();
if (void 0 === e) throw n;
(o = n), (h = e[0]), (g.length = e[1]), e[2] && (g[e[2]][0] = o);
}
}
观察 vm_handlers
的部分代码,可以发现虚拟机是个非常传统的堆栈机,主要体现在每条指令的参数都是基于之前的堆栈的内容来操作。
大概摸清楚代码的作用后,就可以尝试写个反编译器。
反编译器从给定入口开始一条一条给出对应的解释,遇到指令会造成停机(halt)状态时停止。
反编译的代码因为篇幅原因就不放到文章内了,可以参考文章附录的仓库链接。
分析入口
loc_0000: # opcode=21, handler=jmp
jmp loc_0034
loc_0034: # opcode=2, handler=setStackLen
stack.length = 10
stack[2..=9] ??= []
loc_0069: # opcode=21, handler=jmp
jmp loc_0427
loc_0427: # opcode=3, handler=pushValue
push [9]
| .arg[0] -> &stack[3]
| .arg[1] -> &stack[4]
| .entrypoint = 72
| .initialStackLen = 0
| .argsIdxOrderLen = 2
push vm_fn, ^args
loc_0436: # opcode=13, handler=setLoc
stack[9] = [fn_72]
drop # drop vm_fn
loc_0438: # opcode=61, handler=setConst
*stack = 8 # [9] -> [8]
| .entrypoint = 444
| .initialStackLen = 0
| .argsIdxOrderLen = 0
push vm_fn, ^args
result = call fn_444
stack[8] = [ result ]
# => find global object
loc_0727: # opcode=13, handler=setLoc
push [global, "__getSecuritySign"]
loc_1126: # opcode=49, handler=makeVmFunction
| .init_stack[12] <- stack[9]} # [fn_72 (randChar)]
| .init_stack[13] <- stack[8]} # [global]
| .arg[0] -> &stack[3]
| .entrypoint = 771
| .initialStackLen = 2
| .argsIdxOrderLen = 1
push fn_771
global.__getSecuritySign = fn_771
loc_1139: # opcode=0, handler=halt
halt
相对传统的堆栈架构,一开始就设置了当前 scope 的堆栈大小。
随后注册函数 fn_72
,到 stack[9]
内,供之后的函数调用;调用 fn_444
来获取当前的全局对象(浏览器下为 window
)。
随后继续注册函数 fn_771
,导出到 $world.__getSecuritySign
。
入口做的事情相对比较少,只是将另一个位置的函数导出到当前环境。
fn_771
fn_771
也就是 __getSecuritySign
是本次研究的重点。可能是刚开始尝试 JS-VMP 保护,流程是相对简单的:
| .init_stack[12] <- stack[9]} # [fn_72]
| .init_stack[13] <- stack[8]} # [$world]
| .arg[0] -> &stack[3] # payload
# 初始化
loc_0771: # opcode=2, handler=setStackLen
stack.length = 14
stack[2..=11] ??= []
loc_0813: # opcode=61, handler=setConst
push [9]
push typeof $world.location
push "undefined"
loc_0857: # opcode=22, handler=jmpTrue
if (typeof $world.location !== "undefined") goto loc_0862 # good
jmp loc_0932 # bad
# 环境检测
loc_0862: # opcode=4, handler=drop
push [$world.location.host, "indexOf"]
push "y.qq.com"
$world.location.host.indexOf("y.qq.com") === -1
loc_0932: # opcode=22, handler=jmpTrue
if (*stack) goto loc_0963 # bad
# else:
loc_0935: # opcode=55, handler=pushEmptyStr
push 'CJBPACrRuNy7'
jmp loc_0974
loc_0974: # opcode=13, handler=setLoc
*stack[9] = 'CJBPACrRuNy7' # salt1
loc_0976: # opcode=61, handler=setConst
push [10]
if ($world.__sign_hash_20200305) goto loc_1030 # always, function exists
loc_1030: # opcode=4, handler=drop
drop
push [$world, "__sign_hash_20200305"]
push *stack[9] + *stack[3] # tmp = salt1 + arg[0]
$world.__sign_hash_20200305(tmp) # hash = md5(tmp)
*stack[10] = hash
drop
# Generate random string
push [11]
push *stack[12] # fn_72
push 10
push 53 - 37 # 16
loc_1103: # opcode=24, handler=callN
rand_str = fn_72(10, 16)
*stack[11] = rand_str
drop
drop
# Build sign
loc_1108: # opcode=55, handler=pushEmptyStr
push 'zza'
push *stack[11] # rand_str
add
push *stack[10] # hash
add
loc_1125: # opcode=0, handler=halt
halt
一番简化后,大概如下:
function fn_771(payload) {
// define: $world
// define: fn_72
const environmentOK = typeof $world.location !== "undefined" && $world.location.host.indexOf("y.qq.com") !== 1;
const salt = environmentOK ? 'CJBPACrRuNy7' : fn_72(8, 10);
const sign = $world.__sign_hash_20200305 && $world.__sign_hash_20200305(salt + payload);
const rand_str = fn_72(10, 16);
return "zza" + rand_str + sign;
}
可以看出在这个阶段,撰写这段原始代码的开发者可能只是想着防御跨域调用,而非防止其它 JS 执行引擎。
只要执行环境不提供 location
属性就能轻松绕过。
fn_72
这个函数是个简单的随机字符串生成方法,反编译整理后的对应 JavaScript 实现如下:
/**
* Generate random string of given length.
* @param {number} min
* @param {number} max
* @returns {string} Generated string.
*/
function fn_72(min, max) {
// import: $world.Math
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const len = Math.floor(min + (max - min + 1) * Math.random());
let result = '';
for (let i = 0; i < len; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
重新实现
使用 JavaScript 完整实现一遍:
const crypto = require('node:crypto');
/**
* @param {string} text
* @returns {string}
*/
function md5(text) {
const hashInst = crypto.createHash('md5');
hashInst.update(text);
return hashInst.digest('hex');
}
/**
* Generate random string of given length.
* @param {number} min
* @param {number} max
* @returns {string} Generated string.
*/
function makeRandomString(min, max) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const len = Math.floor(min + (max - min + 1) * Math.random());
let result = '';
for (let i = 0; i < len; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
/**
* Sign payload (zza)
* @param {string} payload
* @returns {string}
*/
function getSecuritySign(payload) {
const hash = md5('CJBPACrRuNy7' + payload);
const random = makeRandomString(10, 16);
return 'zza' + random + hash;
}
exports.getSecuritySign = getSecuritySign;
验证
因为无法在线进行调试验证,所以只能借用其它人分析的算法流程来验证我们重新实现的内容了。参考当时其它人的分析12,可以发现对方分析的逻辑与本文基本一致。
结语
应该是 QQ 音乐网页端第一次尝试添加请求签名 + 代码混淆,相对来说签名代码并不是特别的复杂。
不过感觉也有可能和他们使用的 JS-VMP 工具并不能很好支持部分 JavaScript 特性有关?
可以看到虚拟机实现了逻辑左移等一系列没用到的处理块,但最终却是尝试调用外部的 __sign_hash_20200305
方法来计算 MD5。
附录
A.1 反编译器代码
在代码仓库 jixunmoe/qmweb-sign#zza/decompiler 可以找到反编译器的代码。
《关于QQ音乐sign参数的获取》发布日期:2020-05-22 作者:蒟蒻… ↩︎
《QQ音乐API分析之-加密参数分析(sign计算)》发布日期:2020-12-21 亦泽同学 ↩︎