跳至内容

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

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

QQ 音乐网页端的接口签名加密进过了数次迭代(特征为签名前缀是 zz,第三个字符为版本,如 zza 为第一个版本)。

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

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 可以找到反编译器的代码。


  1. 关于QQ音乐sign参数的获取》发布日期:2020-05-22 作者:蒟蒻… ↩︎

  2. QQ音乐API分析之-加密参数分析(sign计算)》发布日期:2020-12-21 亦泽同学 ↩︎

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

评论区