QQ 音乐网页端的请求签名分析 (zzb)
QQ 音乐网页端的接口签名加密进过了数次迭代,本次分析的是 2021 年 7 月左右上线的 zzb
签名的脚本。
QQ 音乐网页端请求签名分析系列文章:
- QQ 音乐网页端的请求签名分析 (zza) - 2020.03
- QQ 音乐网页端的请求签名分析 (zzb) - 2021.07
- 对抗 QQ 音乐网页端的请求签名 (zzc) - 2024.07
相比 zza 签名代码,VM 架构上基本一致,部分操作码做了部分简化。
zzb 签名
zzb
的请求签名算法从 2021 年开始上线,一直沿用到 2024 年 7 月。
通过 WebArchive 的缓存记录来推断,是在 左右上线的。
脚本地址:
脚本结构
和上个版本相比,本次更新将签名算法塞到了 WebPack 的编译过程中(其实 zza
在这段期间也尝试一起编译进去了)。
不过 QQ 音乐的签名算法定位很容易,直接检索关键字 getSecuritySign
就能快速定位到虚拟机的附近了。
(window.webpackJsonp = window.webpackJsonp || []).push([
[0],
[ ... /* 模块列表 */ ],
]);
其中模块下标 350
就是我们的虚拟机了,结构如下:
function (e, t, n) {
function (world) {
var $world = world; // 获取全局对象
var r = (function () {
function runVM(...) { /* 执行虚拟机 */ }
runVM.v = 5;
return runVM(
0,
/* 解码 VM */
(function (e) { return []; /* 解码过程省略 */ })([
'MwgO...' /* VM 机器码 */,
[ 133, 2628, 156, 340, ... /* 超过 uint8_t 取值范围的插入数据。 */ ],
]),
$world,
);
})(); // 执行 VM
$world.__sign_hash_20200305 = md5;
var i = $world._getSecuritySign;
delete $world._getSecuritySign;
t.default = i;
}.call(this, window);
}
和上个版本相比,虚拟机的字节码使用了 Base64 编码 (MwgO...
),并在解码后向指定位置插入对应的大的常数:
// [ 133, 2628, 156, 340, ... ]
idx = 133, value = 2628
idx = 156, value = 340
...
了解了这个解码虚拟机的步骤后,就可以在 Node 重新实现一份:
const fs = require('node:fs');
/**
* Decode ZZB VM
* @author {@link https://jixun.uk Jixun}
* @param {string} VM_CODE_STR
* @param {number[]} VM_LARGE_CONST
* @returns {number[]}
*/
function decodeVM([VM_CODE_STR, VM_LARGE_CONST]) {
const vm_code = Array.from(Buffer.from(VM_CODE_STR, 'base64'));
// Insert large value
for (let i = 0; i < VM_LARGE_CONST.length; i += 2) {
const [idx, value] = VM_LARGE_CONST.slice(i, i + 2);
vm_code.splice(idx, 0, value);
}
return vm_code;
}
// TODO: 手动补充下方省略的内容
const decoded = decodeVM([ 'MwgO...', [ 133, 2628, 156, 340, ... ] ]);
fs.writeFileSync(__dirname + '/zzb_vm.json', JSON.stringify(decoded), 'utf-8');
解码后的文件校验值如下:
$ sha256sum ./zzb_vm.json
96107deeefa9dde648416f9e754772e010cad8d5587919b7de5d8ac8f3c93eee ./zzb_vm.json
虚拟机分析
虚拟机架构
虚拟机的本体执行和上个版本的堆栈机大差不差;而处理 OpCode 的 VM_HANDLER 做了一定的精简和打乱。
参考上个版本的处理,做个简单的反编译器就行。
分析入口
比上个版本的入口要简单不少。
fn_17(fn_135); // 注册 __getSecuritySign 方法
其中 fn_17 是一个简易的 AMD 加载器引导代码:
function fn_17(arg0) {
const loader = typeof $world.define === "function" && $world.define.amd;
if (loader) {
loader(arg0);
} else {
arg0($world);
}
}
fn_135
这个方法也没干多少事,单纯将指定的函数导出到世界环境。
loc_0349: # opcode=20, handler=const_number_array
| .init_stack[33] <- stack[8] # $world
| .arg[0] -> &stack[3] # arg.0
| .entrypoint = 354
| .initialStackLen = 1
| .argsIdxOrderLen = 1
push vm_fn, ^args
stack[9] = fn_354
$world._getSecuritySign = fn_354
fn_354
重头戏来啦!这个函数就是我们找了半天的 __getSecuritySign
方法了。
首先就是初始化虚拟机环境:
_getSecuritySign:
| .init_stack[33] <- stack[8] # global
| .arg[0] -> &stack[3] # arg.0()
loc_0354: # opcode=51, handler=set_stack_length
stack.resize 34
stack[2..=32] ??= []
然后初始化 HEX 反查和 base64 码表,分别储存到 stack[9]
和 stack[10]
:
loc_0418: # opcode=20, handler=const_number_array
push [9]
push {} # rev_hex_table
loc_0436: # opcode=43, handler=dup
rev_hex_table["0"] = 0
rev_hex_table["1"] = 1
...
rev_hex_table["F"] = 35 - 20 # 15
stack[9] = rev_hex_table
# stack balance = loc_0418
loc_0602: # opcode=20, handler=const_number_array
push [10]
push "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
stack[10] = base64_table
# .balance
进入正题,利用 __sign_hash_20200305
方法计算输入内容的 MD5 并大写,储存到 stack[11]
中:
loc_0738: # opcode=20, handler=const_number_array
push [11]
push $world.__sign_hash_20200305
if (*stack) goto loc_0790 # always true
drop
loc_0792: # opcode=20, handler=const_number_array
push [$world, "__sign_hash_20200305"]
loc_0836: # opcode=3, handler=pushIndirect
push $world.__sign_hash_20200305(.arg[0])
loc_0840: # opcode=4, handler=swapN
push "toUpperCase"
push $world.__sign_hash_20200305(.arg[0]).toUpperCase()
loc_0869: # opcode=39, handler=indirect_mov
stack[11] = result.toUpperCase() # hash_upper
# stack balance = loc_0738
环境检测。相比上个版本在找不到 location
时就跳过检测不同,本次若是未正确提供则是会导致验证失败:
loc_0872: # opcode=20, handler=const_number_array
push [12]
typeof $world.window === "object"
if (*stack) goto loc_0908 # always true
loc_0908: # opcode=38, handler=drop
drop
typeof $world.window.navigator === "object"
if (*stack) goto loc_0949
loc_0949: # opcode=38, handler=drop
drop
typeof $world.location === "object"
loc_0983: # opcode=39, handler=indirect_mov
stack[12] = true # isBrowser
loc_0984: # opcode=38, handler=drop
drop 2
# stack balance = loc_0872
新加的检测,看当前环境是不是无头浏览器:
loc_0986: # opcode=20, handler=const_number_array
push [13]
push *stack[12] # isBrowser = true
if (*stack) goto loc_0995 # always
loc_0995: # opcode=38, handler=drop
drop
push $world.RegExp
push "Headless"
push "i"
rHeadless = RegExp("Headless", "i") # drop 3
push "test"
push [/Headless/i, "test"] # drop 2
push $world.navigator.userAgent
isHeadlessBrowser = /Headless/i.test($world.navigator.userAgent) # drop 2
loc_1085: # opcode=39, handler=indirect_mov
stack[13] = isHeadlessBrowser
# stack.balance = loc_0986
看起来像是“跳过检测的后门”。若 __qmfe_sign_check
为 1
则跳过检测,否则检测域名是否包含 qq.com
。
当然,理论上我们也可以做一个 qq.com.jixun.uk
的域名来绕过这么个检测 :)
loc_1088: # opcode=20, handler=const_number_array
push [14]
$world.__qmfe_sign_check === 1 # Set to 1 to skip environment check?
loc_1132: # opcode=9, handler=jmpTrue
if (*stack) goto loc_1217 # never - this value was never set.
loc_1134: # opcode=38, handler=drop
drop
push *stack[12] # isBrowser = true
if (*stack) goto loc_1142
loc_1142: # opcode=38, handler=drop
drop
push !*stack[13] # !isHeadlessBrowser
loc_1146: # opcode=9, handler=jmpTrue
if (*stack) goto loc_1151 # always
loc_1151: # opcode=38, handler=drop
drop
push $world.location.host.indexOf("qq.com") > -1
loc_1217: # opcode=39, handler=indirect_mov
stack[14] = environmentOK
drop 2
# stack.balance = loc_1088
若是 12、13 的检测都通过了,且域名符合规则,那么就设置 stack[14]
为 true
(环境检测通过)。
目前为止,整理下已知的变量和含义:
地址 | 含义 |
---|---|
stack[9] | 十六进制反查码表 |
stack[10] | Base64 码表 |
stack[11] | input.md5().upper() |
stack[12] | 是否有 $world.location 对象 |
stack[13] | 是否为无头浏览器 |
stack[14] | 环境是否一切正常。 |
然后就是开始生成第一部分:
loc_1220: # opcode=20, handler=const_number_array
push [15]
push true # swap + drop
push [] # new $world.Array() # arr1
loc_1237: # opcode=43, handler=dup
arr1[0] = 46 - 25 # => 21
# loc_1237 ... loc_1324
arr1 = [21,4,9,26,16,20,27,30]
loc_1327: # opcode=23, handler=pushEmptyStr
| .init_stack[9] <- stack[11] # hash_upper
| .init_stack[10] <- stack[14] # environmentOK
| .arg[0] -> &stack[3] # idx
| .entrypoint = 1338
| .initialStackLen = 2
| .argsIdxOrderLen = 1
push vm_fn, ^args # fn_1338
stack[15] = arr1.map(fn_1338).join("") # part_1
drop 2
# stack.balance = loc_1220
在 loc_1237
到 loc_1324
之间,还尝试对常量进行一定的隐藏(如简单的减法),避免静态查看文件找到对应的值。
而 fn_1338
则是根据 arr1
的索引值来对哈希进行查表:
function fn_1338(idx) {
// define: hash_upper
// define: environmentOK
return hash_upper[environmentOK ? idx : idx + 1];
}
然后看第二段,类似的操作:
loc_1405: # opcode=20, handler=const_number_array
push [16]
loc_1419: # opcode=10, handler=invoke_n_ctor
arr2 = []
loc_1422: # opcode=68, handler=push
arr2[0] = 18
# loc_1422 ... loc_1498
arr2 = [18,11,3,2,1,7,6,25]
loc_1499: # opcode=23, handler=pushEmptyStr
part_2 = stack[16] = arr2.map(fn_1510).join("")
其中 fn_1510
也会在 environmentOK
的情况下将传入的索引增 1。
然后就是最后一段了:
loc_1574: # opcode=20, handler=const_number_array
push [17]
arr3 = []
loc_1590: # opcode=43, handler=dup
arr3[0] = 8 + 17 * 12 # 212
# loc_1590 ... loc_1806
arr3 = [212, 45, 80, 68, 195, 163, 163, 203, 157, 220, 254, 91, 204, 79, 104, 6]
stack[17] = arr3
loc_1809: # opcode=3, handler=pushIndirect
push *stack[14] # environmentOK
loc_1811: # opcode=16, handler=bNot
if (!environmentOK) goto loc_1818 # never
loc_1814: # opcode=38, handler=drop
drop
检测环境,若是环境不行则是重新生成一个数组来替换,这里就不展开了。
然后就是一个循环:
# for loop init
loc_2036: # opcode=20, handler=const_number_array
stack[18] = [] # part3
loc_2055: # opcode=20, handler=const_number_array
stack[19] = 0 # i
loc_2062: # opcode=20, handler=const_number_array
if (i < 19 - 3) goto loc_2078 (loop)
else: goto loc_2074 (loop end)
loc_2078: # opcode=38, handler=drop
drop
loc_2079: # opcode=20, handler=const_number_array
push [20]
push [9] # rev_hex_table
loc_2090: # opcode=27, handler=merge_arr_indirect
push hash_upper[i * 2] # t1
loc_2092: # opcode=27, handler=merge_arr_indirect
push rev_hex_table[t1] # t2
loc_2094: # opcode=68, handler=push
t2 = t2 * (54 - 38)
# t2 = t2 * 16
loc_2100: # opcode=20, handler=const_number_array
push rev_hex_table[hash_upper[i * 2 + 1]] # t3
t3 += t2
stack[20] = t3
drop 2
# stack.balance = loc_2079
loc_2120: # opcode=20, handler=const_number_array
stack[21] = arr3[i]
loc_2131: # opcode=20, handler=const_number_array
push [18] # part3
push [part3, "push"]
part3.push(t3 ^ arr3[i])
loc_2151: # opcode=20, handler=const_number_array
i = i + 1
jmp loc_2062
loc_2074: # opcode=38, handler=drop
drop
循环体中,将哈希两个字符一组,查表得到对应的数值,然后与刚才计算的码表依次 XOR 计算:
const scramble_table = [212, 45, 80, 68, 195, 163, 163, 203, 157, 220, 254, 91, 204, 79, 104, 6];
const hash = '20776c1797822117687715554f77ba60'
const hexValue = '0123456789ABCDEF';
const rev_hex_table = Object.fromEntries(Array.from(hexValue).entries().map(([i, digit]) => [digit, i]));
const part3 = [];
for(let i = 0; i < 16; i++) {
const hi = rev_hex_table[hash[i*2]];
const lo = rev_hex_table[hash[i*2 + 1]];
const temp = hi * 16 + lo; // 去除对应的值
const scrambled = temp ^ scramble_table[i];
part3.push(scrambled);
}
本质上可以用 parseInt(hash.slice(i*2, i*2+2), 16)
来代替。
随后是又臭又长的 base64 编码过程,此处快速略过:
loc_2174: # opcode=20, handler=const_number_array
push [17] # arr3
push [11] # hash_upper
push [9] # rev_hex_table
push false
arr3 = hash_upper = rev_hex_table = false
loc_2192: # opcode=20, handler=const_number_array
stack[29] = "" # b64_encoded
loc_2198: # opcode=20, handler=const_number_array
stack[30] = 0 # j
if (j < 5) goto loc_2218 # loop_b64_encode
else: loc_2214
loc_2218:
# base 64 encode loop, skip
loc_2214: # opcode=38, handler=drop
drop
因为 base64 编码的输入是没 3 个字符一组,剩下最后一个单独拎出来处理下:
# Finish off the last byte encoded to base64
loc_2192: # opcode=20, handler=const_number_array
stack[29] = "" # b64_encoded
push [10] # base64_table
push [18] # part3
# encode last char
b64_encoded += base64_table[part3[15] >> 2]
loc_2410: # opcode=20, handler=const_number_array
b64_encoded += base64_table[((part3[15] & 3) << 4)]
loc_2433: # opcode=20, handler=const_number_array
part3 = false
然后删除编码后字符串的特殊符号(\/+
):
loc_2439: # opcode=20, handler=const_number_array
part_3 = stack[31] = b64_encoded.replace(/[\\/+]/g, "")
接着将这三段以 1、3、2 的顺序拼接,并在最前方加上 zzb
字样:
loc_2495: # opcode=20, handler=const_number_array
push [32]
push "zzb"
push part_1 # *stack[15]
add
push part_3 # *stack[31]
add
push part_2 # *stack[16]
add
sign = push[32] = "zzb" + part_1 + part_3 + part_2
loc_2516: # opcode=20, handler=const_number_array
stack[15] = stack[16] = stack[31] = stack[29] = stack[10] = false
最后将签名转换为小写:
loc_2546: # opcode=20, handler=const_number_array
sign = stack[32] = sign.toLowerCase()
return sign
重新实现
使用 JavaScript 完整实现一遍:
const crypto = require('node:crypto');
/**
* @param {string} text
* @returns {Buffer}
*/
function md5(text) {
const hashInst = crypto.createHash('md5');
hashInst.update(text);
return hashInst.digest();
}
/**
* Sign payload (zzb)
* @param {string} payload
* @returns {string}
*/
function getSecuritySign(payload) {
const digest = md5(payload);
const hash = digest.toString('hex').toLowerCase();
const idx1 = [21, 4, 9, 26, 16, 20, 27, 30];
const idx2 = [18, 11, 3, 2, 1, 7, 6, 25];
const scramble = [212, 45, 80, 68, 195, 163, 163, 203, 157, 220, 254, 91, 204, 79, 104, 6];
const part1 = idx1.map((idx) => hash[idx]).join('');
const part2 = idx2.map((idx) => hash[idx]).join('');
const part3 = Buffer.from(scramble.map((value, i) => value ^ digest[i]))
.toString('base64')
.replace(/[\\/+=]/g, '')
.toLowerCase();
return 'zzb' + part1 + part3 + part2;
}
module.exports = getSecuritySign;
验证
因为无法在线进行调试验证,所以结束的时候只能借用其它人分析的过程 1,或模拟执行原始代码来检查我们的结果了。
不过这个版本没有随机数的参与,可以直接生成一堆测试案例来验证。
$ node emulate/run_zzb.js 123
zzb : zzb7bc290379ahpjm6pjalllo4wwjdg49c2026d
impl: zzb7bc290379ahpjm6pjalllo4wwjdg49c2026d
$ node emulate/run_zzb.js jixun.uk
zzb : zzb567761769fo8u1qhgtz1qsogzjszg7277071f
impl: zzb567761769fo8u1qhgtz1qsogzjszg7277071f
可以发现和我们分析的完全一致,验证通过。
结语
相比上个版本稍微尝试了一些相对更加“复杂”的代码来虚拟化,并稍微加强了环境检测。
整体难度提升了一点,但是架构相对并没有更换过,因此分析起来并不是特别复杂。
作为逆向分析人员,面对此类虚拟机有两个选项:
- 静态分析,尝试理解每一条指令并编写对应的反编译脚本。
- 每执行一行打印一遍堆栈来 trace 执行流程
本文则是尝试静态分析,部分繁琐的地方(例如获取这三个数组的值)也是透过调试器使其在对应的位置断下,方便后续分析。
附录
A.1 反编译器代码
在代码仓库 jixunmoe/qmweb-sign#zzb/decompiler 可以找到反编译器的代码。
@swearl 的代码片段,日期 2021.11.02。 ↩︎