参考这位师傅的文章https://jixun.uk/posts/2024/qqmusic-zzc-sign/, 将虚拟机架构代码重新命名了一下
该虚拟机是基于寄存器的虚拟框架,所有临时变量、结果都保存在一个寄存器列表中
function getVariableType(e) {
return e && "undefined" != typeof Symbol && e.constructor === Symbol ? "symbol" : typeof e
}
// 这俩虚拟机指令都相同
function VM() {
function decodeVM() {}
return function (encod_code, isVM1) {
var vm_code = decodeVM(encod_code);
function createVM1(entrypoint, params, world, initialData, errorReportCallback) {
return function vm_runtime() {
var tempParams;
var tempParamsCount;
var regs = [world, initialData, params, this, arguments, vm_runtime, vm_code, 0];
var fnCtx = undefined;
var pc = entrypoint;
var tryCatchHandlers = [];
try {
for (;;) {
switch (vm_code[++pc]) {
case 2:
for (tempParams = [], tempParamsCount = vm_code[++pc]; tempParamsCount > 0; tempParamsCount--) {
tempParams.push(regs[vm_code[++pc]]);
}
regs[vm_code[++pc]] = createVM2(pc + vm_code[++pc], tempParams, world, initialData, errorReportCallback);
try {
Object.defineProperty(regs[vm_code[pc - 1]], "length", {
value: vm_code[++pc],
configurable: true,
writable: false,
enumerable: false
});
} catch (v) {}
break;
case 46:
// 46 paramsCount param_1 param_2 ... param_n vmFunc offset func_length
for (tempParams = [], tempParamsCount = vm_code[++pc]; tempParamsCount > 0; tempParamsCount--) {
tempParams.push(regs[vm_code[++pc]]);
}
regs[vm_code[++pc]] = createVM1(pc + vm_code[++pc], tempParams, world, initialData, errorReportCallback);
try {
Object.defineProperty(regs[vm_code[pc - 1]], "length", {
value: vm_code[++pc],
configurable: true,
writable: false,
enumerable: false
});
} catch (v) {}
break;
case 65:
regs[vm_code[++pc]] += String.fromCharCode(vm_code[++pc]);
for (tempParams = [], tempParamsCount = vm_code[++pc]; tempParamsCount > 0; tempParamsCount--) {
tempParams.push(regs[vm_code[++pc]]);
}
regs[vm_code[++pc]] = createVM1(pc + vm_code[++pc], tempParams, world, initialData, errorReportCallback);
try {
Object.defineProperty(regs[vm_code[pc - 1]], "length", {
value: vm_code[++pc],
configurable: true,
writable: false,
enumerable: false
});
} catch (A) {}
regs[vm_code[++pc]][regs[vm_code[++pc]]] = regs[vm_code[++pc]];
break;
}
}
} catch (e) {
}
}
}
function createVM2(entrypoint, params, world, initialData, errorReportCallback) {
return function vm_runtime() {
var tempParams;
var tempParamsCount;
var regs = [world, initialData, params, this, arguments, vm_runtime, vm_code, 0];
var fnCtx = undefined;
var pc = entrypoint;
var tryCatchHandlers = [];
try {
for (;;) {
switch (vm_code[++pc]) {
}
}
} catch (e) {
}
}
}
return isVM1 ? createVM1 : createVM2;
}
}
const createVM = VM(encod_code, false);
const vm_func = createVM(entrypoint, params, world, initialData, errorReportCallback);
vm_func();
首先调用createVM函数传入入口点、初始化参数、全局变量、初始数据等等,返回一个构造好的虚拟函数
在该函数中会从入口点开始读取操作码,根据不同的操作码找到对应的handler,执行完handler后又返回分发器继续获取下一个操作码,循环往复,直到遇到return,结束运行该虚拟函数,整个执行流程如下所示:

虚拟机架构.png (131 KB, 下载次数: 0)
下载附件
2025-6-28 03:02 上传
该虚拟机共有82个handler
举几个例子:
case 0:
regs[vm_code[++pc]] = new regs[vm_code[++pc]](regs[vm_code[++pc]]);
break;
case 1:
return regs[vm_code[++pc]];
case 2:
for (tempParams = [], tempParamsCount = vm_code[++pc]; tempParamsCount > 0; tempParamsCount--) {
tempParams.push(regs[vm_code[++pc]]);
}
regs[vm_code[++pc]] = createVM2(pc + vm_code[++pc], tempParams, world, initialData, errorReportCallback);
try {
Object.defineProperty(regs[vm_code[pc - 1]], "length", {
value: vm_code[++pc],
configurable: true,
writable: false,
enumerable: false
});
} catch (v) {}
break;
case 6:
regs[vm_code[++pc]] = regs[vm_code[++pc]] >> vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]][regs[vm_code[++pc]]];
break;
case 13:
regs[vm_code[++pc]] = regs[vm_code[++pc]] | regs[vm_code[++pc]];
regs[vm_code[++pc]][regs[vm_code[++pc]]] = regs[vm_code[++pc]];
pc += regs[vm_code[++pc]] ? vm_code[++pc] : vm_code[(++pc, ++pc)];
break;
每个handler中又包含了多个操作(可以称之为一条指令)
二、虚拟指令
在看下面指令介绍之前,可以先了解一下js中自增运算的特性,可以参考这位师傅的文章https://www.resourch.com/archives/129.html
以下内容均引用该师傅的文章
可以发现,第一次输出的PC为3,第二次则为4
var PC = 1
PC += ++PC
// PC = 3
var PC = 1
var value = ++PC
PC += value
// PC = 4
我们将这段代码编译为v8字节码
var PC = 0;
PC +=1;
0x63e081d5b7a @ 0 : 0c LdaZero
0x63e081d5b7b @ 1 : 25 02 StaCurrentContextSlot [2]
0x63e081d5b7d @ 3 : 17 02 LdaImmutableCurrentContextSlot [2]
0x63e081d5b7f @ 5 : 45 01 00 AddSmi [1], [0]
0x63e081d5b87 @ 13 : c4 Star0
0x63e081d5b88 @ 14 : a9 Return
看出什么端倪了吗?在第一条字节码中,就已经获取了PC的值,并将其存入累加器,这意味着累加时,赋值语句左侧的PC一直是最初的值
PC += ++PC 实际上等同于 PC = (PC)+(++value),变成了简单的覆盖操作,而不是在右侧表达式执行完毕后再进行累加
(一) 基础指令
1、Mov
将寄存器赋值给寄存器
regs[vm_code[++pc]] = regs[vm_code[++pc]];
Ra = Rb
2、MovCall
将源寄存器传入某个函数中,得到执行结果后赋值给目标寄存器
regs[vm_code[++pc]] = Y(regs[vm_code[++pc]])
Ra = Y(Rb)
3、LoadImm
将一个立即数赋值给寄存器,这个立即数是从vm_code中获取的
regs[vm_code[++pc]] = vm_code[++pc];
Ra = b
4、LoadConstant
将一个常量赋值给寄存器,这个常量并不在vm_code,而是直接给定的,不会影响pc,如下:
regs[vm_code[++pc]] = "";
regs[vm_code[++pc]] = {};
regs[vm_code[++pc]] = true;
Ra = "";
Ra = {};
Ra = true;
(二) 运算指令
1、算数运算
(1) Add
目的操作数 = 源操作数1 + 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] + vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] + regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb + c;
Ra = Rb + Rc;
(2) Sub
目的操作数 = 源操作数1 - 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] - vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] - regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb - c;
Ra = Rb - Rc;
(3) Mul
目的操作数 = 源操作数1 * 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] * vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] * regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb * c;
Ra = Rb * Rc;
(4) Div
目的操作数 = 源操作数1 / 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] / vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] / regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb / c;
Ra = Rb / Rc;
(5) Mod
目的操作数 = 源操作数1 % 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] % vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] % regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb % c;
Ra = Rb % Rc;
(6) Neg
目的操作数 = -源操作数
这里的源操作数一般就是寄存器,该虚拟机中没有出现立即数的情况
regs[vm_code[++pc]] = -regs[vm_code[++pc]];
Ra = -Rb;
(7) PreIncrementAssign
目的操作数 = ++源操作数
这里的源操作数肯定是寄存器,一个立即数怎么可能去自增呢
regs[vm_code[++pc]] = ++regs[vm_code[++pc]];
Ra = ++Rb;
(8) PostIncrementAssign
目的操作数 = 源操作数++
这里的源操作数肯定是寄存器,一个立即数怎么可能去自增呢
regs[vm_code[++pc]] = regs[vm_code[++pc]]++;
Ra = Rb++;
2、逻辑运算
(1) LogicalAnd
目的操作数 = 源操作数1 && 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] && vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] && regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb && c;
Ra = Rb && Rc;
(2) LogicalOr
目的操作数 = 源操作数1 || 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] || vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] || regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb || c;
Ra = Rb || Rc;
(3) LogicalNot
目的操作数 = !源操作数
regs[vm_code[++pc]] = !regs[vm_code[++pc]];
源操作数一般为寄存器,如果是一个立即数,那在生成vm_code的时候就直接取非么,不会留到虚拟指令运行时去取反的
Ra = !Rb
3、位运算
(1) BitwiseAnd
目的操作数 = 源操作数1 & 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] & vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] & regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb & c;
Ra = Rb & Rc;
(2) BitwiseOr
目的操作数 = 源操作数1 | 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] | vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] | regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb | c;
Ra = Rb | Rc;
(3) BitwiseXor
目的操作数 = 源操作数1 ^ 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] ^ vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] ^ regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb ^ c;
Ra = Rb ^ Rc;
(4) BitwiseNot
目的操作数 = !源操作数
regs[vm_code[++pc]] = ~regs[vm_code[++pc]];
源操作数一般为寄存器,如果是一个立即数,那在生成vm_code的时候就直接取反么,不会留到虚拟指令运行时去取反的
Ra = ~Rb
(5) BitwiseSal
目的操作数 = 源操作数1
regs[vm_code[++pc]] = regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb
(6) BitwiseShr
目的操作数 = 源操作数1 >>> 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] >>> vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] >>> regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb >>> c;
Ra = Rb >>> Rc;
(7) BitwiseSar
目的操作数 = 源操作数1 >> 源操作数2
regs[vm_code[++pc]] = regs[vm_code[++pc]] >> regs[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] >> regs[vm_code[++pc]]
源操作数可能为寄存器也可能为立即数
Ra = Rb >> c;
Ra = Rb >> Rc;
4、比较指令
(1) Cmp
比较的条件有, , >, >=, ===, ==, !===, !==
regs[vm_code[++pc]] = regs[vm_code[++pc]] cond vm_code[++pc];
regs[vm_code[++pc]] = regs[vm_code[++pc]] cond regs[vm_code[++pc]];
Ra = Rb cond c;
Ra = Rb cond Rc;
(三) 控制流指令
1、Jz
分支语句
pc += regs[vm_code[++pc]] ? vm_code[++pc] : vm_code[++pc, ++pc];
Jz b: c

jz.png (120.41 KB, 下载次数: 0)
下载附件
2025-6-28 03:02 上传
2、Ret
return语句,会退出虚拟函数
return regs[vm_code[++pc]];
包含该指令的handler就是退出handler
3、Call
顾名思义,就是调用其他函数,这里使用的是js中Function的call方法
regs[vm_code[++pc]] = regs[vm_code[++pc]].call(fnCtx);
regs[vm_code[++pc]] = regs[vm_code[++pc]].call(fnCtx, regs[vm_code[++pc]], regs[vm_code[++pc]]);
regs[vm_code[++pc]] = regs[vm_code[++pc]].call(regs[vm_code[++pc]]);
regs[vm_code[++pc]] = regs[vm_code[++pc]].call(regs[vm_code[++pc]], regs[vm_code[++pc]]);
这里的fnCtx会被初始化为undefined
这个在处理的时候需要关注一下传入参数的个数以及this指针
(四) 对象操作指令
1、NewObj
就是new一个新的对象
regs[vm_code[++pc]] = new regs[vm_code[++pc]](regs[vm_code[++pc]]);
Ra = new Rb(Rc, ...)
这里也需要注意传入参数的个数
2、PropSet
给某个对象的某个属性赋值
regs[vm_code[++pc]][regs[vm_code[++pc]]] = regs[vm_code[++pc]];
regs[vm_code[++pc]][vm_code[++pc]] = regs[vm_code[++pc]];
regs[vm_code[++pc]][regs[vm_code[++pc]]] = vm_code[++pc];
regs[vm_code[++pc]][vm_code[++pc]] = vm_code[++pc];
Ra[Rb] = Rc;
Ra = Rc;
Ra[Rb] = c;
Ra = c;
这里除了被修改的对象操作数以外,其他操作数有可能是寄存器也有可能是立即数
3、PropGet
regs[vm_code[++pc]] = regs[vm_code[++pc]][regs[vm_code[++pc]]];
regs[vm_code[++pc]] = regs[vm_code[++pc]][vm_code[++pc]];
Ra = Rb[Rc];
Ra = Rb[c];
(五) 字符串操作指令
1、StrConcat
regs[vm_code[++pc]] += String.fromCharCode(vm_code[++pc]);
Ra += String.fromCharCode(b);
(六) 特殊指令
1、ArrayCreate
regs[vm_code[++pc]] = Array(vm_code[++pc]);
Ra = Array(b);
创建容量为b的数组
2、ConvertToNumber
regs[vm_code[++pc]] = regs[vm_code[++pc]] - 0;
Ra = Rb - 0;
将一个寄存器变成一个数字
3、CreateVmFunction
这个是重中之重,用来调用createVM1或者createVM2来创建一个新的虚拟函数,以便后续使用Call指令进行调用
换句话来说,其实这个虚拟机就是一个又一个的虚拟函数组成,给定一个入口函数,开始执行,函数之间互相调用完成对应的功能,最终返回运算结果
for (tempParams = [], tempParamsCount = vm_code[++pc]; tempParamsCount > 0; tempParamsCount--) {
tempParams.push(regs[vm_code[++pc]]);
}
regs[vm_code[++pc]] = createVM2(pc + vm_code[++pc], tempParams, world, initialData, errorReportCallback);
try {
Object.defineProperty(regs[vm_code[pc - 1]], "length", {
value: vm_code[++pc],
configurable: true,
writable: false,
enumerable: false
});
} catch (v) {}

CreateVmFunction.png (56.63 KB, 下载次数: 0)
下载附件
2025-6-28 03:02 上传
(七) 中间指令集抽象定义
由于是jsvmp,所以使用js代码来处理
1、指令类
定义IRInstruction基类类,用来存放基本的属性,例如ir_name, address, ins_len
接着根据不同的指令定义对应的类,均继承该基类,我定义了如下指令类,其继承关系如下图:

IRInstruction.png (349.17 KB, 下载次数: 0)
下载附件
2025-6-28 03:02 上传
2、操作数类
上面的分析可知,操作数分为两类,一类是寄存器,另一类是立即数

IROperand.png (27.19 KB, 下载次数: 0)
下载附件
2025-6-28 03:02 上传
三、构建控制流图
在构建控制流图前,先定义一下基本块类
class BasicBlock {
static blockId = 0;
static exitAddr = -1;
constructor() {
this.id = BasicBlock.blockId++;
this.startAddr = BasicBlock.exitAddr;
this.endAddr = BasicBlock.exitAddr;
this.instructions = [];
this.prevBlocks = new Set(); // 前驱基本块
this.nextBlocks = new Map(); // 后继基本块: true 或者 false分支
this.isExitBlock = false;
}
}
(一) 创建初始基本块
此步骤为每个handler创建一个基本块对象,然后将handler中的多条虚拟指令添加到基本块中
在创建初始基本块时,可能会遇到虚拟字节码自更改的情况,这个时候需要打上补丁,详细分析可以参考https://jixun.uk/posts/2024/qqmusic-zzc-sign/
(二) 填充基本块前驱节点
此步骤会将基本块的前驱节点字段填充
遍历所有基本块,将其后驱节点的前驱节点设置为当前基本块
(三) 合并基本块
如果一个节点的后驱节点只有一个, 并且这个唯一的后驱节点的前驱节点也只有一个, 那么后驱节点就可以与该节点合并
(四) 删除花指令
该虚拟机架构主要有两种花指令
Jz条件恒真或恒假,此时就不需要递归处理false或者true分支
Jz的条件进行二次判断

花指令.png (114.44 KB, 下载次数: 0)
下载附件
2025-6-28 03:02 上传
可以看到当Rx为假时会跳转到Block off2基本块,那么此时Block off2中关于Rx的判断一定也是假,那么就不会跳转到Block off3,而是跳转到Block off4,所以从Block off2到Block off3这条边需要删掉
(五) 生成控制流图
遍历所有基本块,根据其前驱节点和后驱节点,生成节点和边,最终得到dot文件,使用Graphviz工具将dot文件转换成png
四、qqyysign分析
(一) f_3945
从虚拟机入口开始分析,最开始的入口函数地址偏移为3945
其控制流图如下所示:

3945.png (21.54 KB, 下载次数: 0)
下载附件
2025-6-28 03:03 上传
手动转换成js代码如下:
function f_3945() {
f_4(f_4157)
return undefined;
}
(二) f_4
接着需要分析偏移为4的函数,其控制流图如下:

4.png (75.59 KB, 下载次数: 0)
下载附件
2025-6-28 03:02 上传
转换成js代码如下:
function f_4(func) {
if (typeof window["define"] === "function" && window["define"]["amd"]) {
window["define"].call(undefined, func);
} else {
func.call(undefined);
}
return undefined;
}
就是将传入的参数当作函数调用
(三) f_4157

4157.png (249.21 KB, 下载次数: 0)
下载附件
2025-6-28 03:03 上传
function f_4157() {a
// createVM1(6777, length=1, paramsCount=1, param=[R26])
R24 = [f_6777];
// createVM1(5770, length=1, paramsCount=1, param=[R24]);
R43 = [f_5770];
R33 = [getGlobalContext()];
R34 = [window];
R25 = [["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f"]];
R20 = [[-2147483648, 8388608, 32768, 128]];
R27 = [[24, 16, 8, 0]];
R26 = [[]];
// createVM1(3902, length=1, paramsCount=2, param=[R34,R27]);
f_6777.prototype.update = f_3902;
// createVM1(6860, length=0, paramsCount=1, param=[R20]);
f_6777.prototype.finalize = f_6860;
// createVM1(4633, length=0, paramsCount=0, param=[]);
f_6777.prototype.hash = f_4633;
// createVM1(2555, length=0, paramsCount=1, param=[R25]);
f_6777.prototype.hex = f_2555;
f_6777.prototype.toString = f_6777.prototype.hex;
// createVM1(5329, length=1, paramsCount=2, param=[R43,R33]);
window._getSecuritySign = f_5329;
return undefined;
}
这里就发现了关键的函数_getSecuritySign,其偏移为5329,这个就是用来生成sign的函数
结合网上公开的分析以及这里的函数名update, finalize, hash, hex,可以知道这里其实是一个sha1的类
https://github.com/emn178/js-sha1/blob/5c5ec87/src/sha1.js#L145
(四) f_5329

5329.png (1.1 MB, 下载次数: 0)
下载附件
2025-6-28 03:03 上传
function f_5329(data) {
let params = [
[f_5770],
[getGlobalContext()]
];
R88 = data;
sha1 = f_5770(data).toUpperCase();
R43 = [sha1];
R67 = Array(0);
R128 = [f_5770];
R160 = [getGlobalContext()];
R94 = vm_runtime;
R85 = {"0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "A": 10, "B": 11, "C": 12, "D": 13, "E": 14, "F": 15};
R116 = "ABCDEDGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
// R135 = getVariableType(window) === "object";
// R135 = getVariableType(window.navigator) === "object";
R135 = getVariableType(window.location) === "object";
R104 = getVariableType(window.location) === "object";
R71 = /Headless/i.test(navigator.userAgent);
R154 = /Headless/i.test(navigator.userAgent);
R78 = ["qq.com", "joox.com", "tencentmusic.com", "wavecommittee.com", "kugou.com", "kuwo.cn"];
R98 = R78.some((ele) => {
return location.host.indexOf(ele) !== -1;
}); // R98 = true
R78 = false;
R95 = [23, 14, 6, 36, 16, 40, 7, 19];
// createVM1(3488, length=1, paramsCount=2, param=[R67,R43]);
R139 = R95.map((ele) => {
return R43[0][ele];
});
R8 = R139.join("");
R153 = [16, 1, 32, 12, 19, 27, 8, 5];
R186 = R153.map((ele) => {
return R43[0][ele];
});
R162 = R186.join("");
R87 = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179];
R48 = [];
R181 = 0;
while (R181 > 2] + R116[R48[R61 * 3] & 3 > 4] + R116[R48[R61 * 3 + 1] & 15 > 6] + R116[R48[R61 * 3 + 2] & 63];
R61++;
}
R80 += R116[R48[18] >> 2] + R116[R48[18] & 3 > 4] + R116[R48[19] & 15
可以看到sign由三部分构成
四、下步改进方向
本方法只是去除了最简单的花指令,然后生成控制流图,最后手动优化得到js代码,属于半自动半手动
下一步将继续学习中间代码优化相关知识,进行常量折叠、常量传播等优化,优化完毕后将控制流图转换成对应的js代码
参考资料
对抗QQ音乐网页端的请求签名(zzc + ag-1)
jsvmp编译与反编译详解 (3)——某讯新版vmp反编译