跳至内容

Jixun's Blog 填坑还是开坑,这是个好问题。

QQ 音乐网页端的请求签名分析 (zzb)

QQ 音乐网页端的接口签名加密进过了数次迭代,本次分析的是 2021 年 7 月左右上线的 zzb 签名的脚本。

QQ 音乐网页端请求签名分析系列文章:

相比 zza 签名代码,VM 架构上基本一致,部分操作码(opcode)做了部分简化。

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

新加的检测,看当前环境是不是无头浏览器(Headless browser)

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_check1 则跳过检测,否则检测域名是否包含 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]是否为无头浏览器(Headless browser)
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_1237loc_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

可以发现和我们分析的完全一致,验证通过。

结语

相比上个版本稍微尝试了一些相对更加“复杂”的代码来虚拟化,并稍微加强了环境检测。

整体难度提升了一点,但是架构相对并没有更换过,因此分析起来并不是特别复杂。

作为逆向分析人员,面对此类虚拟机有两个选项:

  1. 静态分析,尝试理解每一条指令并编写对应的反编译脚本。
  2. 每执行一行打印一遍堆栈来 trace 执行流程

本文则是尝试静态分析,部分繁琐的地方(例如获取这三个数组的值)也是透过调试器使其在对应的位置断下,方便后续分析。

附录

A.1 反编译器代码

在代码仓库 jixunmoe/qmweb-sign#zzb/decompiler 可以找到反编译器的代码。


  1. @swearl 的代码片段,日期 2021.11.02。 ↩︎

知识共享许可协议 本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。

评论区