JS VMP 原理详解:从虚拟机到代码保护
什么是VMP
VM即Virtual Machine,翻译为虚拟机。它的工作方式是:接受源代码编译生成的字节码,然后将字节码转换成对应平台CPU能够识别的机器码。
为什么要有VM,而不是直接把源代码编译成机器码?因为通过字节码加VM的运行方式,可以实现多平台运行,程序不再和平台强绑定。
JS VMP,指在JavaScript代码里,用软件手写一个虚拟的CPU,专门用来执行一套加密过的私有指令,从而保护核心逻辑。
创建VMP的原理
第一步:编译
将源代码编译成字节码。因为是自己的虚拟机,所以可以自定义每个操作对应的字节码,只要不产生冲突即可。
例如:
0x12 代表加法运算
0x55 代表取余
0x99 代表减法运算
第二步:解释器/执行器
浏览器在获取字节码之后无法正常执行,因此需要虚拟机的CPU进行指令识别和指令执行。解释器和执行器一般同时出现在一个巨大的死循环中。解释器每拿到一条指令,就会通过switch来进行指令匹配,匹配到对应的case块之后执行case块内的语句。
常见VMP结构
// 此处 data 是源代码编译后得到的字节码
function virtual_machine(data) {
var program_counter = 0; // 指令指针,记录当前执行到第几条指令
var stack = []; // 栈,用来临时存放数据
// 巨大的死循环,模拟机器持续运转
while (true) {
// 1. 取出一条指令
var opcode = data[program_counter];
program_counter++;
// 2. 解释器解释指令
switch (opcode) {
case 0x01: // 加法
var a = stack.pop();
var b = stack.pop();
stack.push(a + b);
break;
case 0x02: // 输出
console.log(stack.pop());
break;
case 0x03: // 结束
return;
// 实际应用中这里会有成百上千个case
}
}
}实战创建VMP
假设要完成一个加法操作 c = a + b,写出对应的指令集(栈模式写法):
PUSH 10
PUSH 20
ADD
POP ECX栈模式对应的JS代码如下:
// 指令集定义:代表一系列待执行的操作
const instructionSequence = [
{ action: "LOAD", parameter: 2000 },
{ action: "LOAD", parameter: 210 },
{ action: "ADD" },
{ action: "RETURN" },
];一、创建编译器
构建指令集:确定要完成的操作后,可以自定义每个指令对应的操作码。要实现加法一共有三个操作:LOAD、ADD、RETURN。定义如下:
// 映射表:将可读的指令动作映射为混淆后的操作码
const operationCodeMapping = {
"LOAD": "_0x04de2",
"ADD": "_0x04de3",
"RETURN": "_0x04de4"
};构建编译程序:将操作翻译为对应的操作码
/**
* 编译器函数:将高层指令转换为虚拟机可识别的字节码字符串
* @param {Array} instructions - 指令数组
* @returns {string} - 格式化后的字节码
*/
function compileToBytecode(instructions) {
const rawBytecodes = [];
for (const instruction of instructions) {
// 提取混淆后的指令名称
const mappedCode = operationCodeMapping[instruction.action];
rawBytecodes.push(mappedCode);
// 如果存在参数,将其转换为ASCII字符编码
if (instruction.parameter !== undefined) {
// 将数字转化为对应的Unicode字符
const charCode = String.fromCharCode(instruction.parameter);
rawBytecodes.push(charCode);
// 注意:此处默认参数只有一位
// 如果参数是字符串类型,则需要先把字符串长度压入栈中,再处理字符串
}
}
// 使用管道符|连接,形成最终的指令流
return rawBytecodes.join("|");
}二、编译源代码
完成编译器的操作后,编译源代码,最终得到:
_0x04de2|97|_0x04de2|98|_0x04de3|_0x04de4
三、创建解释器
如果提供上面这串莫名其妙的字符串作为输入,浏览器不知道要做什么,因此需要创建自己的解释器来解释输入,明确要执行哪些动作。
(1)初始化VM
初始化一个空栈用于运算,初始化操作码映射表用于将操作码翻译为指令:
function VirtualMachine() {
// 数据栈:用来存放运算过程中的临时数据
this.dataStack = [];
// 指令映射表:将混淆后的字符串映射回逻辑动作
this.operationCodeMapping = {
"LOAD": "_0x04de2",
"ADD": "_0x04de3",
"RETURN": "_0x04de4"
};
}(2)定义核心运算方法
核心运算操作一般直接定义在VM的原型链上。此处相当于add被混淆成了_0x00af:
/**
* 核心运算方法(原add)
* 执行具体的数学加法逻辑
*/
VirtualMachine.prototype._0x00af = function(_0x00bf, _0x00cf) {
return _0x00bf + _0x00cf;
};(3)创建解释器
// 解释器和执行器
VirtualMachine.prototype.executer = function(rawOPCode) {
let OPCodeSeq = rawOPCode.split("|");
let pc = 0;
while (pc < OPCodeSeq.length) {
switch (OPCodeSeq[pc++]) {
case this.operationCodeMapping.LOAD:
this.dataStack.push(OPCodeSeq[pc++].charCodeAt());
break;
case this.operationCodeMapping.ADD:
console.log(this.dataStack);
this.dataStack.push(this._0x00af.apply(
this, [this.dataStack.pop(), this.dataStack.pop()]
));
break;
case this.operationCodeMapping.RETURN:
return this.dataStack.pop();
}
}
};四、调用并执行
const JSVMP = new VirtualMachine();
const res = JSVMP.executer("_0x04de2|ߐ|_0x04de2|Ò|_0x04de3|_0x04de4");
console.log(res);总结
VMP的核心思想是将原始程序逻辑编译为自定义字节码,然后由虚拟机解释器来执行,而不是由宿主CPU或原生执行环境直接运行真实语义。
其本质结构通常包括:
字节码
虚拟CPU(包含程序计数器、数据栈或寄存器)
指令分发机制
状态驱动执行模型
解释器通过while循环不断读取opcode,根据调度机制(如switch或函数表)分发到对应的handler执行,从而将原本具有清晰结构的控制流(if、for、call等)转化为统一的状态机循环结构,实现控制流平坦化。
对于逆向分析来说,由于程序结构被抹平、语义被封装进自定义指令处理函数中,分析者难以通过控制流图还原真实逻辑,同时自动化分析、符号执行和模式匹配等工具也难以直接适配自定义指令集。
VMP的优势不仅在于混淆控制流,还在于隐藏指令语义、提高逆向成本、支持动态解密与环境绑定,并具备良好的跨平台封装能力。虽然理论上解释器存在就意味着语义可以被还原,但VMP的核心目标并非绝对不可破解,而是显著提高攻击成本,使破解变得复杂、耗时且难以规模化。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!