JavaScript VMP 逆向工程

JavaScript VMP 逆向工程

📚 前置知识

本配方涉及以下核心技术,建议先阅读相关章节:

  • JS OB 混淆分析 - 理解 JavaScript 混淆的基本原理
  • AST 与编译原理 - 理解抽象语法树和字节码执行

JavaScript VMP(虚拟机保护)是一种高级的代码保护技术,它将原始的 JavaScript 代码转换成一种自定义的、基于虚拟机的字节码。然后,在运行时,一个内置的解释器(或虚拟机)会执行这些字节码。这种方式极大地增加了逆向工程的难度,因为它隐藏了原始的代码逻辑和结构。


JSVMP 原理

什么是 JavaScript 虚拟机保护

JSVMP 的核心思想是 “把 JavaScript 当作汇编来写”。传统的 JavaScript 混淆(如 OB 混淆)只是对源码做变形——变量重命名、控制流平坦化、字符串加密等,但代码结构仍然是标准 JavaScript,可以用 AST 工具还原。JSVMP 则彻底改变了执行模型:它将原始 JS 编译为一套 私有字节码,再用一个 JavaScript 编写的 虚拟机解释器 在运行时逐条解释执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
原始 JavaScript 代码
        |
        v
  +-----------+
  | JS 编译器  |   (离线阶段, 在构建/发布时执行)
  +-----------+
        |
        v
  +-------------------+
  | 私有字节码 (opcode)|   一个数字/字符串数组
  +-------------------+
        |
        v
  +-----------------+
  | JS 虚拟机解释器  |   (运行时, 在浏览器中执行)
  +-----------------+
        |
        v
     执行结果

与传统混淆的对比

特性OB 混淆JSVMP
执行模型标准 JS 引擎直接执行自定义 VM 解释执行
代码结构仍是合法 JS 语法字节码 + 解释器
AST 还原可行(Babel 插件)几乎不可行
逆向难度中等非常高
性能影响较小较大(解释执行开销)
代码体积中等增长较大增长
典型代表javascript-obfuscator某数、瑞数、Jsvmp.com

编译流程详解

JSVMP 的编译过程模拟了真实编译器的工作方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
源码: function sign(data) { return md5(data + secret); }
         |
         v
    [1] 词法分析 (Lexer)
         |
         v
    Token 流: [FUNCTION, IDENT("sign"), LPAREN, ...]
         |
         v
    [2] 语法分析 (Parser)
         |
         v
    AST (抽象语法树)
         |
         v
    [3] 字节码生成 (Code Generator)
         |
         v
    字节码: [1, 0, 5, 2, 0, 1, 12, 3, 0, 7, 2, 1, 14, ...]
         |
         v
    [4] 打包 (Bundler)
         |
         v
    最终产物: VM 解释器代码 + 字节码数组

生成的字节码通常被编码为一个大型数组:

1
2
3
4
5
6
7
// 编译后的字节码示例 (已简化)
var _bytecode = [1,0,5,2,0,1,12,3,0,7,2,1,14,0,3,6,15];
// 每个数字代表一条指令或操作数
// 1 = PUSH_LOCAL, 0 = 变量索引0
// 5 = PUSH_CONST, 2 = 常量索引2
// 12 = BINARY_ADD
// ...

JSVMP 架构

JSVMP 虚拟机在架构上与真实 CPU 非常相似,包含以下核心组件:

整体架构图

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
+------------------------------------------------------------------+
|                        JSVMP 虚拟机                               |
|                                                                  |
|  +------------------+    +-----------------------------+         |
|  |   字节码数组      |    |        调度器 (Dispatcher)   |         |
|  |  [1,0,5,2,12,..] |    |                             |         |
|  +--------+---------+    |   while (true) {            |         |
|           |              |     opcode = fetch(PC++);   |         |
|           v              |     switch (opcode) {       |         |
|  +------------------+    |       case 1: handler1();   |         |
|  |  程序计数器 (PC)  |--->|       case 2: handler2();   |         |
|  |     PC = 0       |    |       case 3: handler3();   |         |
|  +------------------+    |       ...                   |         |
|                          |     }                       |         |
|  +------------------+    |   }                         |         |
|  |   虚拟寄存器      |    +-----------------------------+         |
|  |  r0, r1, r2, ... |                                            |
|  +------------------+    +-----------------------------+         |
|                          |    Opcode Handlers           |         |
|  +------------------+    |                             |         |
|  |   虚拟栈 (Stack) |    |  handler1: PUSH(val)       |         |
|  |  [..., val, val] |    |  handler2: POP() -> reg    |         |
|  +------------------+    |  handler3: ADD(a, b)       |         |
|                          |  handler4: CALL(fn, args)  |         |
|  +------------------+    |  handler5: JMP(addr)       |         |
|  |   作用域链        |    |  ...                       |         |
|  |  [global, local] |    +-----------------------------+         |
|  +------------------+                                            |
+------------------------------------------------------------------+

核心组件说明

1. 字节码数组(Bytecode Array)

字节码数组是 VM 的 “程序”。它以紧凑的数字序列存储所有指令和操作数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 典型的字节码数组结构
var G = [
    // 指令区
    1, 0,       // PUSH_LOCAL 0
    5, 3,       // PUSH_CONST 3
    12,         // ADD
    2, 1,       // STORE_LOCAL 1
    // 常量池 (有时混在一起, 有时独立存储)
    // ...
];

2. 程序计数器(PC)

PC 指向当前要执行的字节码位置。每执行一条指令后,PC 递增到下一条指令的起始位置。跳转指令会直接修改 PC。

3. 虚拟栈(Virtual Stack)

VM 使用栈来传递操作数和存储中间结果。这是一个典型的 基于栈的虚拟机 架构(类似 JVM、Python VM)。

1
2
3
4
5
// 栈操作示例
// 计算 3 + 5:
// PUSH 3    -> stack: [3]
// PUSH 5    -> stack: [3, 5]
// ADD       -> pop 5, pop 3, push 8 -> stack: [8]

4. 虚拟寄存器(Virtual Registers)

部分 JSVMP 实现采用寄存器式或混合式架构,使用一组变量模拟寄存器来存储局部变量和临时值。

1
2
3
4
5
// 寄存器式示例
var regs = new Array(16);  // 16 个虚拟寄存器
regs[0] = localVar0;
regs[1] = localVar1;
// ...

5. 调度器(Dispatcher Loop)

调度器是 VM 的心脏,负责取指令、解码、派发到对应的 handler。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 最典型的调度器结构
function vm_exec(bytecode) {
    var stack = [];
    var pc = 0;
    var regs = {};

    while (true) {
        var opcode = bytecode[pc++];
        switch (opcode) {
            case 0: // HALT
                return stack.pop();
            case 1: // PUSH_CONST
                var idx = bytecode[pc++];
                stack.push(constants[idx]);
                break;
            case 2: // PUSH_LOCAL
                var slot = bytecode[pc++];
                stack.push(regs[slot]);
                break;
            case 3: // STORE_LOCAL
                var slot = bytecode[pc++];
                regs[slot] = stack.pop();
                break;
            case 4: // ADD
                var b = stack.pop();
                var a = stack.pop();
                stack.push(a + b);
                break;
            // ... 更多 handler
        }
    }
}

识别 JSVMP

在分析一段混淆代码时,识别它是否使用了 JSVMP 是第一步。以下是典型的识别特征:

特征 1:巨大的 while-true + switch 结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// JSVMP 的标志性模式
while (true) {
    switch (_0x3a7b[_0x1c++]) {
        case 0:
            // ...
            break;
        case 1:
            // ...
            break;
        // 几十甚至上百个 case
        case 47:
            // ...
            break;
    }
}

注意:控制流平坦化(CFF)也有类似结构,但 CFF 的 case 数通常较少(与原始代码块数一致),且 case 内代码量较大。JSVMP 的 case 数非常多,每个 case 内代码非常短(通常只有 1-3 行),因为它们只执行一个原子操作。

特征 2:大型数字/字符串数组

1
2
3
4
5
// 字节码数组 — 大量数字
var _0xab3f = [1,0,5,2,0,1,12,3,0,7,2,1,14,0,3,6,15,1,0,5,
    2,0,1,12,3,0,7,2,1,14,0,3,6,15,1,0,5,2,0,1,12,3,0,7,2,
    // ... 几千甚至上万个数字
];

特征 3:栈操作模式

代码中频繁出现 pushpop 操作或数组索引递增模式:

1
2
3
4
5
// 常见的栈操作变体
_0x4f2a.push(_0x1b3c);          // 直接 push
_0x1b3c = _0x4f2a.pop();        // 直接 pop
_0x4f2a[++_0x5e7d] = _0x1b3c;  // 通过索引模拟 push
_0x1b3c = _0x4f2a[_0x5e7d--];  // 通过索引模拟 pop

特征 4:PC 递增模式

程序计数器变量不断递增来读取操作数:

1
2
var _op = _0xab3f[_pc++];   // 取 opcode
var _arg = _0xab3f[_pc++];  // 取操作数

快速判别流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
发现大型混淆 JS 文件
        |
        v
是否有大型数字数组 (>100 个元素)?  --否--> 可能是 OB 混淆或 CFF
        |
        v
是否有 while(true) + switch?  --否--> 可能是其他保护
        |
        v
switch 的 case 数量多且每个 case 很短?  --否--> 可能是 CFF
        |
        v
是否有明显的栈操作 (push/pop)?  --否--> 可能是寄存器式 VMP
        |
        v
    确认为 JSVMP (基于栈)

分析方法论

逆向 JSVMP 是一个系统性工程,需要结合静态分析和动态分析。以下是推荐的分步方法论:

总体流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[1] 定位 VM 入口
      |
      v
[2] 识别核心组件 (字节码数组, 调度器, 栈, PC)
      |
      v
[3] 标注所有 Opcode Handler
      |
      v
[4] 动态跟踪: 记录执行轨迹 (PC, opcode, stack)
      |
      v
[5] 逐个分析 Handler 功能
      |
      v
[6] 编写反汇编器 / 反编译器
      |
      v
[7] 还原关键逻辑

步骤 1:定位 VM 入口

VM 入口通常是一个接收字节码数组(或其索引)并启动解释循环的函数。搜索以下模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 模式 A: 直接传入字节码
function _0x2b3c(_bytecodes) {
    var _stack = [];
    var _pc = 0;
    while (true) { /* ... */ }
}

// 模式 B: 从全局数组中取字节码
function _0x2b3c(_startPC) {
    _pc = _startPC;
    while (true) {
        switch (_G[_pc++]) { /* ... */ }
    }
}

步骤 2:识别核心组件

定位到 VM 入口后,在闭包或函数作用域内寻找以下变量:

组件识别特征示例
字节码数组大型数组,被 PC 索引访问_G[_pc++]
程序计数器不断递增的数值变量_pc++, _idx++
虚拟栈有 push/pop 操作的数组_s.push(...), _s.pop()
寄存器/局部变量通过索引访问的数组或对象_r[0], _locals[n]
作用域存储外部引用的对象_scope, _env

步骤 3:标注 Opcode Handler

对调度器 switch 中的每个 case 添加注释,记录其功能。这是最耗时但也最关键的步骤。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
switch (opcode) {
    case 0:  // HALT - 终止执行, 返回栈顶
        return _stack[_sp];
    case 1:  // PUSH_CONST - 压入常量
        _stack[++_sp] = _G[_pc++];
        break;
    case 2:  // PUSH_LOCAL - 压入局部变量
        _stack[++_sp] = _regs[_G[_pc++]];
        break;
    case 3:  // STORE_LOCAL - 存储到局部变量
        _regs[_G[_pc++]] = _stack[_sp--];
        break;
    case 4:  // ADD - 加法
        _sp--;
        _stack[_sp] = _stack[_sp] + _stack[_sp + 1];
        break;
    // ... 逐个标注
}

步骤 4:动态跟踪

在调度器循环中插入日志代码,记录每条指令的执行情况:

1
2
3
4
5
6
7
// 在 switch 之前插入
console.log(
    'PC:', _pc - 1,
    'OP:', opcode,
    'Stack:', JSON.stringify(_stack.slice(0, _sp + 1)),
    'Regs:', JSON.stringify(_regs)
);

Opcode Handler 分析

以下是 JSVMP 中最常见的 Opcode 类型及其识别方法:

常见 Opcode 分类表

类别Opcode描述典型实现
栈操作PUSH_CONST压入常量s[++sp] = G[pc++]
PUSH_LOCAL压入局部变量s[++sp] = r[G[pc++]]
POP弹出栈顶sp--
DUP复制栈顶s[sp+1] = s[sp]; sp++
存储STORE_LOCAL保存到局部变量r[G[pc++]] = s[sp--]
STORE_GLOBAL保存到全局global[k] = s[sp--]
算术ADD加法s[sp-1] = s[sp-1] + s[sp]; sp--
SUB减法s[sp-1] = s[sp-1] - s[sp]; sp--
MUL乘法s[sp-1] = s[sp-1] * s[sp]; sp--
MOD取模s[sp-1] = s[sp-1] % s[sp]; sp--
BITAND按位与s[sp-1] = s[sp-1] & s[sp]; sp--
BITOR按位或s[sp-1] = s[sp-1] | s[sp]; sp--
BITXOR按位异或s[sp-1] = s[sp-1] ^ s[sp]; sp--
SHL左移s[sp-1] = s[sp-1] << s[sp]; sp--
SHR右移s[sp-1] = s[sp-1] >> s[sp]; sp--
USHR无符号右移s[sp-1] = s[sp-1] >>> s[sp]; sp--
比较EQ等于s[sp-1] = s[sp-1] == s[sp]; sp--
SEQ严格等于s[sp-1] = s[sp-1] === s[sp]; sp--
LT小于s[sp-1] = s[sp-1] < s[sp]; sp--
GT大于s[sp-1] = s[sp-1] > s[sp]; sp--
逻辑NOT逻辑非s[sp] = !s[sp]
BITNOT按位取反s[sp] = ~s[sp]
跳转JMP无条件跳转pc = G[pc]
JMP_IF条件跳转if(s[sp--]) pc=G[pc]; else pc++
JMP_IFNOT条件不跳if(!s[sp--]) pc=G[pc]; else pc++
函数CALL函数调用s[sp-n] = s[sp-n-1](...args)
NEW构造对象s[sp-n] = new s[sp-n-1](...args)
RETURN返回return s[sp]
对象GET_PROP获取属性s[sp-1] = s[sp-1][s[sp]]; sp--
SET_PROP设置属性s[sp-2][s[sp-1]] = s[sp]; sp-=3
TYPEOF类型判断s[sp] = typeof s[sp]

识别 Handler 的技巧

技巧 1:观察栈指针变化

1
2
3
4
弹出 2 个, 压入 1 个 (sp--)  ->  二元运算 (ADD, SUB, EQ, ...)
弹出 0 个, 压入 1 个 (sp++)  ->  PUSH 类操作
弹出 1 个, 压入 0 个 (sp--)  ->  STORE 或 POP 操作
弹出 1 个, 压入 1 个 (sp不变) ->  一元运算 (NOT, TYPEOF, ...)

技巧 2:观察 PC 变化

1
2
3
pc 正常递增 (+1 或 +2)  ->  普通指令
pc 被直接赋值          ->  跳转指令 (JMP, JMP_IF)
pc 不再使用            ->  RETURN 或 HALT

技巧 3:观察外部调用

1
2
3
4
5
6
7
// 如果 handler 中出现 apply / call, 大概率是 CALL opcode
case 28:
    var args = _stack.splice(_sp - _argc, _argc);
    var fn = _stack[--_sp];
    var thisArg = _stack[--_sp];
    _stack[++_sp] = fn.apply(thisArg, args);
    break;

浏览器 Hook 辅助分析

使用 Chrome DevTools

方法 1:条件断点记录

在调度器的 switch 语句处设置条件断点,使其永远不暂停,但打印日志:

1
2
3
// 在 switch(opcode) 行设置条件断点, 表达式为:
(console.log('PC:', _pc, 'OP:', opcode, 'ST:', JSON.stringify(_stack.slice(0, _sp+1))), false)
// 返回 false 使断点不暂停, 但 console.log 已经执行

方法 2:Overrides 注入

使用 Chrome DevTools 的 Local Overrides 功能,直接修改 JS 文件,在调度器中注入跟踪代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 在 fetch-decode 之后, execute 之前注入
var __opName = {0:'HALT',1:'PUSH_C',2:'PUSH_L',3:'STORE',4:'ADD',/*...*/};
console.log(
    '[VM]',
    'PC=' + (_pc-1).toString().padStart(5),
    'OP=' + opcode.toString().padStart(3),
    '(' + (__opName[opcode] || 'UNK') + ')',
    'SP=' + _sp,
    'TOP=' + JSON.stringify(_stack[_sp])
);

使用 Frida Hook

当 JSVMP 运行在 WebView 或 Node.js 环境中时,可以使用 Frida 进行 Hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Frida 脚本: Hook WebView 中的 evaluateJavascript
Java.perform(function() {
    var WebView = Java.use('android.webkit.WebView');

    // 注入跟踪脚本到 WebView
    WebView.evaluateJavascript.overload('java.lang.String', 'android.webkit.ValueCallback')
        .implementation = function(script, callback) {

        // 在目标脚本执行前注入 Hook
        var hookScript = `
            (function() {
                // 保存原始 Array.prototype.push
                var origPush = Array.prototype.push;
                var logBuffer = [];

                // Hook Array.push 来捕获栈操作
                // 注意: 需要精确定位 VM 的栈对象, 避免干扰其他数组
                // 这里仅作示意
            })();
        `;
        this.evaluateJavascript(hookScript, null);
        this.evaluateJavascript(script, callback);
    };
});

关键 API Hook 策略

通过 Hook 浏览器 API,可以捕获 VM 与外部环境的交互,从而快速定位关键逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Hook XMLHttpRequest 来捕获签名参数
(function() {
    var origOpen = XMLHttpRequest.prototype.open;
    var origSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function(method, url) {
        this._url = url;
        console.log('[XHR] open:', method, url);
        return origOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function(data) {
        console.log('[XHR] send:', this._url, data);
        // 在这里下断点, 然后查看调用栈
        // 可以找到 VM 中哪个 CALL handler 发起了请求
        // debugger;
        return origSend.apply(this, arguments);
    };
})();

// Hook document.cookie 的读取
(function() {
    var cookieDesc = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')
        || Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie');
    if (cookieDesc && cookieDesc.get) {
        Object.defineProperty(document, 'cookie', {
            get: function() {
                var val = cookieDesc.get.call(this);
                console.log('[Cookie] get:', val);
                console.trace();  // 打印调用栈, 定位 VM 中的读取位置
                return val;
            },
            set: function(val) {
                console.log('[Cookie] set:', val);
                console.trace();
                return cookieDesc.set.call(this, val);
            }
        });
    }
})();

补环境执行

“补环境” 是指在 Node.js 中模拟浏览器环境,使 JSVMP 代码能够在浏览器之外运行。这样做的目的是:

  1. 更方便地添加日志和调试
  2. 批量执行签名生成函数
  3. 避免浏览器反调试检测

基本环境框架

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// env.js - 浏览器环境模拟框架

// 1. 基础对象
var window = global;
var document = {};
var navigator = {};
var location = {};
var screen = {};
var history = {};

// 2. Navigator 模拟
navigator.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
    'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
navigator.platform = 'Win32';
navigator.language = 'zh-CN';
navigator.languages = ['zh-CN', 'zh', 'en'];
navigator.cookieEnabled = true;
navigator.webdriver = false;  // 重要: 很多检测会查这个属性

// 3. Document 模拟
document.cookie = '';
document.referrer = 'https://www.example.com/';
document.title = 'Example Page';
document.createElement = function(tag) {
    var elem = {
        tagName: tag.toUpperCase(),
        style: {},
        setAttribute: function(k, v) { this[k] = v; },
        getAttribute: function(k) { return this[k]; },
        appendChild: function(child) { return child; },
        addEventListener: function() {},
    };
    if (tag === 'canvas') {
        elem.getContext = function(type) {
            return {
                fillText: function() {},
                measureText: function() { return { width: 10 }; },
                canvas: { toDataURL: function() { return 'data:image/png;base64,...'; } }
            };
        };
    }
    return elem;
};
document.getElementById = function() { return null; };
document.querySelector = function() { return null; };
document.querySelectorAll = function() { return []; };

// 4. Location 模拟
location.href = 'https://www.example.com/page';
location.protocol = 'https:';
location.host = 'www.example.com';
location.hostname = 'www.example.com';
location.pathname = '/page';
location.search = '';
location.hash = '';

// 5. Screen 模拟
screen.width = 1920;
screen.height = 1080;
screen.availWidth = 1920;
screen.availHeight = 1040;
screen.colorDepth = 24;

// 6. 常用 API
window.btoa = function(s) { return Buffer.from(s, 'binary').toString('base64'); };
window.atob = function(s) { return Buffer.from(s, 'base64').toString('binary'); };

window.setTimeout = setTimeout;
window.setInterval = setInterval;
window.clearTimeout = clearTimeout;
window.clearInterval = clearInterval;

// 7. XMLHttpRequest 简单模拟 (仅记录, 不真正发送)
function XMLHttpRequest() {
    this.readyState = 0;
    this.status = 200;
    this.responseText = '{}';
}
XMLHttpRequest.prototype.open = function(method, url) {
    console.log('[XHR Mock] open:', method, url);
};
XMLHttpRequest.prototype.send = function(data) {
    console.log('[XHR Mock] send:', data);
    if (this.onreadystatechange) {
        this.readyState = 4;
        this.onreadystatechange();
    }
};
XMLHttpRequest.prototype.setRequestHeader = function() {};

环境检测对抗

JSVMP 通常会检测运行环境是否是真实浏览器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 常见检测点及对策

// 检测 1: window === this (全局上下文)
// 对策: 确保 window 指向 global
global.window = global;
global.self = global;
global.top = global;
global.parent = global;

// 检测 2: toString 检测 (检查原生函数)
// 很多 VMP 会检查 document.createElement.toString() 是否返回 "function createElement() { [native code] }"
// 对策: 修改 Function.prototype.toString
(function() {
    var _origToString = Function.prototype.toString;
    var _nativeMap = new Map();

    function patchNative(obj, prop) {
        if (obj[prop]) {
            _nativeMap.set(obj[prop], 'function ' + prop + '() { [native code] }');
        }
    }

    patchNative(document, 'createElement');
    patchNative(document, 'getElementById');
    // ... 其他需要伪装的函数

    Function.prototype.toString = function() {
        if (_nativeMap.has(this)) {
            return _nativeMap.get(this);
        }
        return _origToString.call(this);
    };
    // 也要伪装 toString 自身
    _nativeMap.set(Function.prototype.toString,
        'function toString() { [native code] }');
})();

// 检测 3: Proxy 检测
// 一些高级 VMP 会尝试检测对象是否被 Proxy 包装
// 对策: 尽量避免使用 Proxy, 直接定义属性

使用 Proxy 记录环境访问

在调试阶段,可以用 Proxy 来自动发现 VMP 需要哪些环境属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// proxy_logger.js - 自动检测 VMP 需要的环境属性
function createLoggingProxy(name, target) {
    return new Proxy(target || {}, {
        get: function(obj, prop) {
            if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) {
                return undefined;
            }
            var val = obj[prop];
            console.log('[ENV GET] ' + name + '.' + prop + ' =', val);
            if (val === undefined) {
                console.log('[ENV MISS] ' + name + '.' + prop + ' 需要补充!');
            }
            return val;
        },
        set: function(obj, prop, val) {
            console.log('[ENV SET] ' + name + '.' + prop + ' =', val);
            obj[prop] = val;
            return true;
        }
    });
}

// 使用方式
var document = createLoggingProxy('document', { /* 已知属性 */ });
var navigator = createLoggingProxy('navigator', { /* 已知属性 */ });
// 运行 VMP 代码后, 查看日志中的 [ENV MISS], 逐个补充

实战案例

💡 思路一句话: 定位 JS VM 解释器入口 → 插桩记录 opcode 执行序列 → 分析每个 handler 语义 → 编写反编译器将虚拟指令还原为原始 JS 代码。

以下以一个典型的 JSVMP 签名生成函数为案例,演示完整的分析流程。

场景描述

某网站的请求中携带 _signature 参数,该参数由 JavaScript 动态生成。经初步分析,生成逻辑被 JSVMP 保护。

第一步:定位签名生成入口

1
2
3
4
5
6
7
8
9
// 通过 Hook XMLHttpRequest 或 fetch, 找到 _signature 的生成位置
// 在 Network 面板中找到携带 _signature 的请求
// 搜索 "_signature" 关键字, 或在 Initiator 中追溯调用栈

// 假设定位到以下代码:
var _signature = _0x2f3a(_0x4b7c, _0x1d5e);
// _0x2f3a 就是 VM 入口函数
// _0x4b7c 是字节码起始位置或参数
// _0x1d5e 是待签名的数据

第二步:分析 VM 结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 格式化代码后, 找到 VM 调度器
function _0x2f3a(_startIdx, _input) {
    var _G = [3,1,0,5,...];  // 字节码, 数千个数字
    var _s = [];             // 虚拟栈
    var _pc = _startIdx;     // 程序计数器
    var _r = [_input];       // 寄存器, _r[0] = 输入参数
    var _sp = -1;            // 栈指针

    while (true) {
        var _op = _G[_pc++];
        switch (_op) {
            case 0: return _s[_sp];                              // HALT
            case 1: _s[++_sp] = _G[_pc++]; break;               // PUSH_CONST
            case 2: _s[++_sp] = _r[_G[_pc++]]; break;           // PUSH_REG
            case 3: _r[_G[_pc++]] = _s[_sp--]; break;           // STORE_REG
            case 4: _sp--; _s[_sp] = _s[_sp] + _s[_sp+1]; break; // ADD
            case 5: _sp--; _s[_sp] = _s[_sp] - _s[_sp+1]; break; // SUB
            case 6: _sp--; _s[_sp] = _s[_sp] ^ _s[_sp+1]; break; // XOR
            case 7: _sp--; _s[_sp] = _s[_sp] & _s[_sp+1]; break; // AND
            case 8: _sp--; _s[_sp] = _s[_sp] | _s[_sp+1]; break; // OR
            case 9: _sp--; _s[_sp] = _s[_sp] << _s[_sp+1]; break;// SHL
            case 10: _sp--; _s[_sp] = _s[_sp] >>> _s[_sp+1]; break;// USHR
            case 11: _pc = _G[_pc]; break;                       // JMP
            case 12:                                              // JMP_IF
                if (_s[_sp--]) _pc = _G[_pc]; else _pc++;
                break;
            case 13:                                              // CALL
                var _n = _G[_pc++];  // 参数数量
                var _args = _s.splice(_sp - _n + 1, _n); _sp -= _n;
                var _fn = _s[_sp--];
                var _this = _s[_sp--];
                _s[++_sp] = _fn.apply(_this, _args);
                break;
            case 14:                                              // GET_PROP
                _sp--;
                _s[_sp] = _s[_sp][_s[_sp+1]];
                break;
            case 15:                                              // PUSH_THIS
                _s[++_sp] = this;
                break;
            // ... 更多 handler
        }
    }
}

第三步:编写反汇编器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// disasm.js - 简易反汇编器
function disassemble(G, startPC, endPC) {
    var pc = startPC;
    var output = [];

    var opNames = {
        0: ['HALT', 0],
        1: ['PUSH_CONST', 1],
        2: ['PUSH_REG', 1],
        3: ['STORE_REG', 1],
        4: ['ADD', 0],
        5: ['SUB', 0],
        6: ['XOR', 0],
        7: ['AND', 0],
        8: ['OR', 0],
        9: ['SHL', 0],
        10: ['USHR', 0],
        11: ['JMP', 1],
        12: ['JMP_IF', 1],
        13: ['CALL', 1],
        14: ['GET_PROP', 0],
        15: ['PUSH_THIS', 0],
    };

    while (pc < endPC) {
        var addr = pc;
        var op = G[pc++];
        var info = opNames[op];

        if (!info) {
            output.push(addr + ': UNKNOWN_' + op);
            continue;
        }

        var line = addr.toString().padStart(6, '0') + ': ' + info[0].padEnd(12);

        // 读取操作数
        for (var i = 0; i < info[1]; i++) {
            line += ' ' + G[pc++];
        }

        output.push(line);
    }

    return output.join('\n');
}

// 使用示例
// console.log(disassemble(_G, 0, 100));
//
// 输出:
// 000000: PUSH_REG     0
// 000002: PUSH_CONST   10
// 000004: AND
// 000005: STORE_REG    1
// 000007: PUSH_REG     0
// 000009: PUSH_CONST   8
// 000011: USHR
// ...

第四步:分析执行轨迹

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
PC     | OP          | Stack (top)          | 说明
-------|-------------|----------------------|------------------
000000 | PUSH_REG 0  | [input_data]         | 加载输入
000002 | PUSH_CONST  | [input_data, 0xFF]   | 加载掩码
000004 | AND         | [input_data & 0xFF]  | 取低8位
000005 | STORE_REG 1 | []                   | 存入 r1
000007 | PUSH_REG 0  | [input_data]         | 再次加载输入
000009 | PUSH_CONST  | [input_data, 8]      | 加载移位量
000011 | USHR        | [input_data >>> 8]   | 右移8位
000012 | STORE_REG 2 | []                   | 存入 r2
  ...  |    ...      |       ...            | ...

=> 还原逻辑: 这段代码在对输入做逐字节拆分, 类似:
   r1 = input & 0xFF;
   r2 = (input >>> 8) & 0xFF;
   r3 = (input >>> 16) & 0xFF;
   r4 = (input >>> 24) & 0xFF;
   => 这是一个常见的 32 位整数拆字节操作

自动化工具

现有工具与项目

工具说明链接/备注
AST Explorer在线查看 JS AST 结构astexplorer.net
BabelJS 编译器框架,可用于 AST 分析和代码变换babeljs.io
estools/escodegenAST 代码生成库npm: escodegen
de4js在线 JS 反混淆工具de4js.com
jsvmp-decompiler社区 JSVMP 反编译项目GitHub 搜索
Chrome DevTools断点、条件日志、覆盖内置
Frida动态 Hook 框架frida.re
mitmproxy中间人代理,可修改 JS 响应mitmproxy.org

自定义跟踪脚本模板

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// trace_template.js - 可复用的 VMP 跟踪模板
(function() {
    'use strict';

    // ========== 配置区 ==========
    var VM_DISPATCHER_FUNC = '_0x2f3a';   // VM 调度器函数名
    var BYTECODE_VAR = '_G';               // 字节码数组变量名
    var PC_VAR = '_pc';                    // PC 变量名
    var STACK_VAR = '_s';                  // 栈变量名
    var MAX_LOG_ENTRIES = 10000;            // 最大记录条数

    // ========== 跟踪逻辑 ==========
    var traceLog = [];
    var traceCount = 0;

    function logInstruction(pc, opcode, stack, sp) {
        if (traceCount >= MAX_LOG_ENTRIES) return;
        traceLog.push({
            pc: pc,
            op: opcode,
            stackTop: stack[sp],
            stackSize: sp + 1,
            timestamp: Date.now()
        });
        traceCount++;
    }

    // 导出分析结果
    function exportTrace() {
        var blob = new Blob(
            [JSON.stringify(traceLog, null, 2)],
            { type: 'application/json' }
        );
        var url = URL.createObjectURL(blob);
        var a = document.createElement('a');
        a.href = url;
        a.download = 'vm_trace_' + Date.now() + '.json';
        a.click();
        console.log('Trace exported:', traceLog.length, 'entries');
    }

    // 统计 opcode 使用频率
    function analyzeTrace() {
        var freq = {};
        traceLog.forEach(function(entry) {
            freq[entry.op] = (freq[entry.op] || 0) + 1;
        });
        console.table(
            Object.keys(freq)
                .sort(function(a, b) { return freq[b] - freq[a]; })
                .map(function(op) {
                    return { opcode: op, count: freq[op] };
                })
        );
    }

    // 挂载到 window 供手动调用
    window.__vmTrace = {
        log: traceLog,
        export: exportTrace,
        analyze: analyzeTrace
    };

    console.log('[VMP Tracer] Ready. Use __vmTrace.export() to save, __vmTrace.analyze() for stats.');
})();

使用 Babel 自动标注 Handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// label_handlers.js - 使用 Babel 自动分析 switch case
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
const fs = require('fs');

const code = fs.readFileSync('vm_code.js', 'utf8');
const ast = parser.parse(code);

traverse(ast, {
    SwitchCase(path) {
        const testVal = path.node.test;
        if (!testVal || !t.isNumericLiteral(testVal)) return;

        const caseNum = testVal.value;
        const body = path.node.consequent;

        // 分析 case body 中的操作
        var label = 'UNKNOWN';
        const bodyCode = body.map(n => generate(n).code).join('; ');

        // 简单的模式匹配来猜测 opcode 功能
        if (/\.push\(/.test(bodyCode)) label = 'PUSH_*';
        if (/\.pop\(/.test(bodyCode)) label = 'POP / STORE_*';
        if (/\+\s/.test(bodyCode) && /\[\w+\s*-/.test(bodyCode)) label = 'ADD';
        if (/\-\s/.test(bodyCode) && /\[\w+\s*-/.test(bodyCode)) label = 'SUB';
        if (/\^\s/.test(bodyCode)) label = 'XOR';
        if (/return/.test(bodyCode)) label = 'HALT / RETURN';
        if (/apply|call/.test(bodyCode)) label = 'CALL';
        if (/\=\s*\w+\[\w+\]/.test(bodyCode) && !/pc|PC/.test(bodyCode)) label = 'GET_PROP';

        // 添加注释
        path.node.leadingComments = path.node.leadingComments || [];
        path.node.leadingComments.push({
            type: 'CommentLine',
            value: ' OP ' + caseNum + ': ' + label
        });

        console.log('Case', caseNum, '->', label);
    }
});

const output = generate(ast, { comments: true }).code;
fs.writeFileSync('vm_code_annotated.js', output);
console.log('Done! Annotated code saved to vm_code_annotated.js');

防御与对抗

VMP 作者常用的加强手段

1. 多层嵌套 VM

将 VM 解释器本身也用另一个 VM 保护,形成嵌套结构:

1
外层 VM 执行 -> 内层 VM 字节码 -> 内层 VM 执行 -> 目标逻辑字节码

这使得分析者不仅要还原目标逻辑,还要先还原内层 VM 本身。

2. 动态 Opcode 映射

每次生成的字节码使用不同的 opcode 编号映射,使得固定的反汇编器失效:

1
2
3
// 版本 A: case 1 = ADD, case 2 = SUB
// 版本 B: case 1 = JMP, case 2 = PUSH
// 每次构建都会随机打乱映射关系

3. Opcode 融合

将多个简单操作合并为一个复合 opcode,增加 handler 数量和分析难度:

1
2
3
4
5
6
7
// 普通: PUSH_LOCAL 0; PUSH_CONST 5; ADD;  (3条指令)
// 融合: ADD_LOCAL_CONST 0, 5;              (1条指令)
case 47:  // ADD_LOCAL_CONST (融合指令)
    var slot = _G[_pc++];
    var constVal = _G[_pc++];
    _s[++_sp] = _r[slot] + constVal;
    break;

4. 环境指纹绑定

将字节码的解密密钥绑定到浏览器环境指纹(如 Canvas 指纹、WebGL 信息等),使得在非目标环境中无法正确解码字节码。

5. 运行时完整性校验

VM 在执行过程中定期校验自身代码的完整性(如计算 hash),检测是否被篡改或注入了跟踪代码:

1
2
3
4
5
6
7
8
9
// 简化示例
case 99: // SELF_CHECK
    var vmCode = _0x2f3a.toString();
    var hash = crc32(vmCode);
    if (hash !== EXPECTED_HASH) {
        // 检测到篡改, 走错误逻辑或直接崩溃
        _pc = TRAP_ADDRESS;
    }
    break;

6. 反调试指令

在字节码中嵌入检测调试器的指令:

1
2
3
4
5
6
7
8
9
case 98: // ANTI_DEBUG
    var start = performance.now();
    // 执行一些操作
    var elapsed = performance.now() - start;
    if (elapsed > 100) {
        // 时间异常, 可能在单步调试
        _pc = TRAP_ADDRESS;
    }
    break;

对抗策略总结

加强手段对抗方法
多层嵌套 VM逐层分析,从外到内;优先用动态分析定位关键逻辑
动态 Opcode 映射动态跟踪而非依赖静态 opcode 编号;按 handler 行为分类
Opcode 融合将融合指令拆解为基本操作序列来理解
环境指纹绑定完善补环境,或直接在真实浏览器中分析
完整性校验Hook Function.prototype.toString 返回原始代码
反调试指令Hook performance.now / Date.now 返回稳定值
debugger 陷阱Chrome DevTools 中禁用 “Pause on debugger statements”
代码自修改在修改点下断点,记录修改前后的差异

通用反反调试脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// anti_anti_debug.js - 在目标页面加载前注入
(function() {
    'use strict';

    // 1. 禁用 debugger 语句
    // 通过 hook Function 构造器来拦截动态生成的 debugger
    var _origFunction = Function;
    Function = function() {
        var args = Array.from(arguments);
        var body = args[args.length - 1];
        if (typeof body === 'string' && body.indexOf('debugger') !== -1) {
            args[args.length - 1] = body.replace(/debugger/g, '');
        }
        return _origFunction.apply(this, args);
    };
    Function.prototype = _origFunction.prototype;
    Object.defineProperty(Function, 'name', { value: 'Function' });

    // 2. 稳定化时间函数 (防止时间检测)
    var _startTime = Date.now();
    var _fakeElapsed = 0;
    var _origDateNow = Date.now;
    var _origPerfNow = performance.now.bind(performance);

    Date.now = function() {
        _fakeElapsed += Math.random() * 2 + 0.5;  // 模拟正常耗时
        return _startTime + Math.floor(_fakeElapsed);
    };

    performance.now = function() {
        return _fakeElapsed;
    };

    // 3. 保护 console 对象
    // 有些 VMP 会重写 console 来阻止日志输出
    var _console = {};
    ['log', 'warn', 'error', 'info', 'debug', 'trace', 'table'].forEach(function(method) {
        _console[method] = console[method].bind(console);
    });
    Object.defineProperty(window, '__console', {
        value: _console,
        writable: false,
        configurable: false
    });

    console.log('[Anti-Anti-Debug] Protections active');
})();

常用工具

  • 浏览器开发者工具:用于下断点、单步调试和观察变量。

  • Frida:用于 Hook 关键函数,实现动态跟踪。

  • Babel:用于将 JavaScript 代码解析成 AST,辅助静态分析。

  • AST Explorer:一个在线工具,可以方便地查看代码对应的 AST 结构。

  • IDA Pro / Ghidra:虽然主要用于原生代码,但它们强大的反汇编和反编译功能可以为理解复杂的 JavaScript 虚拟机逻辑提供借鉴。

  • mitmproxy / Charles:HTTP 代理工具,用于拦截和修改 JS 文件,注入跟踪代码。

  • Node.js:用于在浏览器外执行 VMP 代码,配合补环境技术。

  • Overrides (Chrome):DevTools 本地覆盖功能,可持久化修改线上 JS 文件。


相关内容

如果这篇文章对你有帮助,请我喝杯咖啡吧~
+5 支付宝支付宝
+5 微信微信