Files
hello_js_reverse_skill/references/jsvmp-analysis.md
T
白夜 b5cf8b121a feat(v2.4.0): JSVMP 双路径攻克体系 + jsdom 环境补丁知识库
基于实战经验重构 JSVMP 分析框架:

- SKILL.md: 新增路径选择决策树(路径 A 算法追踪 vs 路径 B 环境伪装)
- SKILL.md: 路径 B「环境伪装六步法」完整方法论(采集→对比→分级→补丁→验证→端到端)
- SKILL.md: 新增场景 10「JSVMP + jsdom 环境伪装」、解法模式 E
- SKILL.md: 标注三板斧适用边界与局限,经验法则扩充至 22 条
- references/jsdom-env-patches.md: 新增 jsdom 环境补丁知识库(Function.toString 三层防御 / plugins 完整模拟 / DOM 布局属性 / 30+ API 存根)
- cases/: 新增经验库系统(模板 + 首个 JSVMP 实战案例含 58 项环境检测差异全表)
- references/jsvmp-analysis.md: JSVMP 专项分析详细指南
- references/troubleshooting.md: 常见问题排查指南
- templates/python-request/: Python 项目模板
- 各 references 文档增强、Python 技术栈支持、MCP v1.1 适配(44→52 工具)

Made-with: Cursor
2026-04-02 18:10:05 +08:00

16 KiB
Raw Blame History

JSVMP 虚拟机保护分析指南

什么是 JSVMP

JSVMP(JavaScript Virtual Machine Protection)是一种高级 JS 代码保护技术,将原始 JS 源码编译为自定义字节码,运行时由内嵌的虚拟机解释器逐条执行。这意味着:

  • 原始代码逻辑不再以 JS AST 形式存在,无法通过传统反混淆手段还原
  • 代码以字节码数组 + 解释器循环的形式运行
  • 常见于瑞数、极验、某数等商业级反爬方案

核心原则:不反编译字节码,用行为追踪法(Hook / 插桩 / 日志分析)从 I/O 两端夹逼定位签名逻辑。


识别 JSVMP

文件特征

特征 描述
文件大小 200KB+ 的单文件,通常 500KB~2MB
变量命名 完全无意义:单字母(a, b, c)或 _0x 前缀
超大数组 包含数千个数字元素的数组(字节码)
解释器循环 while(true) { switch(opcode) { case 0: ... case 1: ... } }
栈操作 频繁出现 push、pop、shift 操作
API 劫持 重写 XMLHttpRequest、fetch、document.cookie 等原生 API

代码模式示例

// 典型的 JSVMP 解释器结构
var _0x1234 = [3, 15, 7, 22, ...]; // 字节码数组
var _0x5678 = [];                   // 操作栈
var _0xabcd = 0;                    // 指令指针

function _0xefgh() {
  while (true) {
    var _0x9999 = _0x1234[_0xabcd++];
    switch (_0x9999) {
      case 0: _0x5678.push(_0x1234[_0xabcd++]); break;  // PUSH
      case 1: var a = _0x5678.pop(), b = _0x5678.pop(); _0x5678.push(a + b); break; // ADD
      case 2: /* ... */ break;
      // 数十到数百个 case
    }
  }
}

如何确认是 JSVMP

MCP 操作:
  - list_scripts → 找到异常大的 JS 文件
  - get_script_source(script_id=N, start_line=1, end_line=50) → 查看文件头部结构
  - search_code(keyword="while") → 搜索解释器循环
  - search_code_in_script(script_id=N, keyword="switch") → 在指定大文件中精确搜索(前后 3 行上下文)
  - search_code(keyword="push|pop|shift") → 搜索栈操作
  - dump_jsvmp_strings → 提取字符串数组,识别 API 名称和混淆模式(快速判断)

三板斧方法论

快速路径:一键 JSVMP 分析(推荐先试)

对于典型 JSVMP,可以跳过手动 Hook 直接使用专项工具:

MCP 操作:
  - hook_jsvmp_interpreter → 自动 Hook Function.prototype.apply + 追踪 30+ 敏感属性读取
  - dump_jsvmp_strings → 提取字符串数组,识别 API 名称,检测混淆模式
  - 触发目标操作(翻页、提交等)
  - get_jsvmp_log → 获取结构化分析结果:
    · API 调用统计(哪些原生函数被 VM 调用)
    · 属性读取摘要(哪些环境属性被访问)
    · 调用时序(帮助定位签名生成节点)
  - compare_env → 收集完整浏览器环境(navigator/screen/canvas/WebGL/Audio/timing)
    · 用于后续 Node.js/Python 补环境时对照

快速路径无法解决时,再进入手动三板斧流程 ↓

第一板斧:Hook 出入口(确定 I/O 边界)

目标:不看 VM 内部实现,只关心"什么进去了"和"什么出来了"。

1.1 Hook 出口 — 签名值去了哪里

MCP 操作:
  - inject_hook_preset(preset="xhr", persistent=True)   → 持久化拦截 XHR(跨导航不丢失)
  - inject_hook_preset(preset="fetch", persistent=True) → 持久化拦截 Fetch
  - hook_function(
      function_path="Document.prototype.cookie",
      hook_code="console.log('[COOKIE_SET]', arguments[0], new Error().stack)",
      position="before", non_overridable=True
    ) → 拦截 Cookie 写入(防覆盖)
  - freeze_prototype(className="XMLHttpRequest", methodName="send")
    → 冻结 XHR.send 防止 VM 覆盖 Hook
  - reload() → 刷新页面触发 Hook
  - get_console_logs → 查看捕获的签名值和调用栈

关键信息提取:

  • 签名参数名(如 sign、m、_signature)
  • 签名值的格式(长度、字符集、编码方式)
  • 签名值出现在请求的哪个位置(URL params / Body / Header / Cookie)

1.2 Hook 入口 — 明文从哪里来

MCP 操作:
  - inject_hook_preset(preset="crypto", persistent=True)
    → 持久化 Hook btoa/atob/JSON.stringify/JSON.parse

  - hook_function(function_path="String.fromCharCode",
      hook_code="if(arguments.length > 1) console.log('[fromCharCode]', Array.from(arguments).map(Number), String.fromCharCode(...arguments))",
      position="before")
    → JSVMP 高频使用 fromCharCode 构造字符串

  - add_init_script(script=`
      // Hook 常见加密库入口
      const _origMD5 = window.CryptoJS?.MD5;
      if (_origMD5) {
        window.CryptoJS.MD5 = function() {
          console.log('[CryptoJS.MD5] input:', arguments[0]?.toString());
          const result = _origMD5.apply(this, arguments);
          console.log('[CryptoJS.MD5] output:', result.toString());
          return result;
        };
      }
    `)

1.3 I/O 关联分析

拿到出口和入口的数据后,进行关联:

已知信息:
  出口:sign=a1b2c3d4e5f6... (32位hex → 疑似MD5)
  入口:CryptoJS.MD5 输入 = "page=1&ts=1680000000&key=secret123"

关联结论:
  sign = MD5("page=" + page + "&ts=" + timestamp + "&key=" + secret_key)

第二板斧:插桩解释器(追踪执行链路)

当出入口 Hook 无法直接关联(例如 VM 使用自实现的 MD5 而非 CryptoJS)时,需要插桩解释器。

2.1 定位解释器核心函数

MCP 操作:
  - search_code(keyword="while.*true") → 找到解释器主循环
  - search_code_in_script(script_id=N, keyword="case 0:|case 1:|case 2:")
    → 在指定大文件中搜索 opcode 分发表(前后 3 行上下文,更精确)
  - get_script_source → 读取解释器函数体

判断标准:
  - 函数体超过 500 行
  - 包含 20+ 个 case 分支
  - 有数组索引递增操作(指令指针移动)

2.2 分层插桩策略

原则:由粗到细,逐步缩小范围。

第一轮 — 分发器级别(粗粒度)

MCP 操作:
  - trace_function(
      function_path="解释器分发函数名",
      log_args=true, log_return=true, max_captures=500,
      persistent=True  → 持久化追踪,跨导航不丢失
    )
  - 触发一次包含签名参数的请求
  - get_trace_data → 获取 500 条执行记录(自动合并页面数据 + Python 端持久化数据)

分析要点:
  - 观察每次调用的参数模式
  - 找到签名值首次出现的调用序号
  - 缩小时间窗口

第二轮 — 子函数级别(中粒度)

从第一轮日志中识别被频繁调用的子函数(通常 3-5 个核心函数):

MCP 操作:
  - trace_function(function_path="子函数A", log_args=true, log_return=true, log_stack=true)
  - trace_function(function_path="子函数B", log_args=true, log_return=true, log_stack=true)
  - 再次触发请求
  - get_trace_data → 分析子函数的输入输出

第三轮 — 字符串操作级别(细粒度)

JSVMP 中字符串操作是签名生成的关键信号:

MCP 操作:
  - hook_function(function_path="String.prototype.charAt",
      hook_code="console.log('[charAt]', this.substring(0,50), arguments[0])",
      position="before")
  - hook_function(function_path="String.prototype.charCodeAt", ...)
  - hook_function(function_path="String.prototype.substring", ...)
  - hook_function(function_path="String.prototype.concat", ...)
  - hook_function(function_path="Array.prototype.join", ...)

分析要点:
  - 追踪字符串如何被逐步拼接
  - 观察 charCodeAt 的输入字符和输出编码
  - 关注 join('') 调用 —— 通常是最终拼接点

2.3 关键变量监控

方法 1:使用 MCP 专用工具(推荐)

MCP 操作:
  - trace_property_access(target_expression="疑似签名容器对象")
    → Proxy 级别属性访问追踪,自动记录读写操作
  - get_property_access_log → 获取属性访问记录
  - compare_env → 收集完整浏览器环境信息
    → 用于后续补环境时精确对照 Node.js/Python 差异

方法 2:手动注入监控脚本

// 通过 add_init_script(persistent=True) 注入

(function() {
  // 方法1:监控全局变量写入
  const watched = ['sign', 'm', '_signature', 'token'];
  const origDefineProperty = Object.defineProperty;
  watched.forEach(key => {
    let val;
    try {
      origDefineProperty(window, key, {
        get() { return val; },
        set(v) {
          console.log(`[GLOBAL_SET] window.${key} =`, v);
          console.trace();
          val = v;
        },
        configurable: true
      });
    } catch(e) {}
  });

  // 方法2:监控对象属性赋值
  const _origAssign = Object.assign;
  Object.assign = function() {
    const result = _origAssign.apply(this, arguments);
    for (let i = 1; i < arguments.length; i++) {
      const src = arguments[i];
      if (src && typeof src === 'object') {
        Object.keys(src).forEach(k => {
          if (watched.includes(k.toLowerCase())) {
            console.log(`[ASSIGN] ${k} =`, src[k]);
            console.trace();
          }
        });
      }
    }
    return result;
  };
})();

第三板斧:日志分析(从海量数据中提取签名链路)

JSVMP 的 trace 日志通常有数百甚至数千条,需要系统化的分析方法。

3.1 日志采集

MCP 操作:
  - get_trace_data → 获取函数追踪数据(自动合并页面数据 + Python 端持久化数据)
  - get_jsvmp_log → 获取 JSVMP 专项日志(含 API 调用统计 + 属性读取摘要)
  - get_console_logs → 获取 Hook 输出
  - get_breakpoint_data → 获取伪断点捕获数据
  - get_property_access_log → 获取属性访问记录

3.2 过滤策略

过滤维度 方法 说明
关键词过滤 搜索签名值片段 在日志中搜索已知签名值的前8位
时间窗口过滤 只看请求前 1-2 秒 签名通常在请求发送前即时生成
类型过滤 只看字符串参数 数值型操作大多是 VM 内部调度
长度过滤 关注长度 > 8 的字符串 排除短字符串噪声
变化过滤 对比多次请求日志 每次都变的是动态参数,不变的是密钥

3.3 反向追踪法(核心技巧)

从已知的签名值反向追踪到原始明文:

步骤 1:在日志中搜索签名值
  → 搜索 "a1b2c3d4" (签名值的前几位)
  → 找到:[trace#247] return "a1b2c3d4e5f6..."

步骤 2:查看该次调用的输入
  → [trace#247] args: ["page=1&ts=1680000000&key=secret123"]
  → 这是签名函数的输入明文

步骤 3:继续追踪输入来源
  → 搜索 "page=1&ts=1680000000"
  → [trace#245] return "page=1&ts=1680000000&key=secret123"
  → [trace#245] args: ["page=1", "ts=1680000000", "key=secret123"]
  → 这是参数拼接函数

步骤 4:确认各参数来源
  → "page=1" → 用户输入
  → "ts=1680000000" → Date.now() / 1000
  → "key=secret123" → 硬编码密钥

结论:sign = MD5("page=" + page + "&ts=" + Math.floor(Date.now()/1000) + "&key=secret123")

3.4 多次请求对比法

请求 1: sign=aaa111, ts=1680000000, page=1
请求 2: sign=bbb222, ts=1680000005, page=2
请求 3: sign=ccc333, ts=1680000010, page=1

对比分析:
  - page 相同但 sign 不同(请求1 vs 请求3)→ ts 参与签名
  - ts 不同且 sign 不同 → ts 是变化因子之一
  - 请求1 和 请求3 page 相同但 sign 不同 → 不仅仅是 page 参与

结论:sign 至少由 page + ts 共同决定

3.5 算法指纹识别

根据 I/O 特征反推算法类型:

输出特征 可能算法
32 位 hex MD5
40 位 hex SHA-1
64 位 hex SHA-256
44 位 Base64(含 = 填充) HMAC-SHA256 + Base64
24/32/44 位 Base64 AES 加密(CBC/ECB)
输出长度随输入变化 非固定哈希,可能是加密或自定义编码
输出包含特殊分隔符 自定义拼接格式

常见 JSVMP 变体及应对

变体 1:VM 劫持 XHR/Fetch

特征:VM 重写了 XMLHttpRequest 或 fetch,请求在 VM 内部完成,外部 Hook 抓不到。

应对:

- Hook 更底层的接口:
  · hook_function(function_path="XMLHttpRequest.prototype.send", ...)
  · 在 add_init_script 中保存原始引用,在 VM 重写之前

- 或 Hook 网络层:
  · start_network_capture → 不依赖 JS 层 Hook,直接在协议层捕获
  · list_network_requests → get_network_request → 获取完整请求详情

变体 2:VM 动态生成加密函数

特征:加密算法不是硬编码在字节码中,而是 VM 运行时通过 eval 或 new Function 动态构造。

应对:

- inject_hook_preset(preset="crypto") → 包含 eval/Function Hook
- hook_function(function_path="Function",
    hook_code="console.log('[new Function]', arguments[arguments.length-1].substring(0,200))",
    position="before")
- 从 Hook 日志中提取动态生成的函数源码

变体 3:多层 VM 嵌套

特征:外层 VM 解密出内层 VM 的字节码,再由内层 VM 执行签名逻辑。

应对:

- 不要试图理解嵌套关系
- 依然从 I/O 两端入手,Hook 最终的出口和最初的入口
- 增加 trace_function 的 max_captures 到 1000+
- 用时间戳过滤法缩小日志范围

变体 4:VM + WASM 混合

特征:VM 负责流程控制,核心加密算法调用 WASM 导出函数。

应对:

- search_code(keyword="WebAssembly|wasm|instantiate")
- Hook WASM 导出函数:
  evaluate_js(expression=`
    // 等 WASM 实例化后 Hook 导出函数
    const origInstantiate = WebAssembly.instantiate;
    WebAssembly.instantiate = async function() {
      const result = await origInstantiate.apply(this, arguments);
      const exports = result.instance?.exports || result.exports;
      Object.keys(exports).forEach(name => {
        if (typeof exports[name] === 'function') {
          const orig = exports[name];
          exports[name] = function() {
            console.log('[WASM]', name, 'args:', Array.from(arguments));
            const ret = orig.apply(this, arguments);
            console.log('[WASM]', name, 'return:', ret);
            return ret;
          };
        }
      });
      return result;
    };
  `)
- 确认 WASM 函数的 I/O 后,下载 .wasm 文件用 Node.js 直接加载调用

还原策略决策

JSVMP 签名还原决策树:
  ├─ Hook 确认使用标准算法(MD5/HMAC/AES)?
  │   ├─ YES → 纯算法还原(Node.js crypto / Python hashlib+pycryptodome)
  │   │         · 还原拼接顺序和密钥即可
  │   │         · 这是最理想的结果
  │   └─ NO → 继续分析
  │
  ├─ 能提取出独立的签名函数?
  │   ├─ YES → 沙箱执行(Node.js vm / Python execjs)
  │   │         · 提取函数及其依赖
  │   │         · 构造最小执行环境
  │   └─ NO → 继续分析
  │
  ├─ VM 劫持了整个请求链路?
  │   ├─ YES → 加载完整 VM(仅调用签名入口)
  │   │         · 下载完整 JS 文件
  │   │         · 在 Node.js vm 中加载
  │   │         · 补最小浏览器环境
  │   │         · 调用签名函数获取结果
  │   └─ NO → 浏览器自动化兜底
  │
  └─ 无法脱离浏览器
      → 使用 Camoufox 浏览器自动化(最后手段)

实战检查清单

在 JSVMP 分析过程中,确保以下每项都已完成:

  • 确认文件是 JSVMP(解释器循环 + 字节码数组 + 栈操作)
  • Hook XHR/Fetch 出口,捕获签名值的最终形态
  • Hook Cookie setter,确认是否有动态 Cookie
  • Hook 加密原语入口(btoa/atob/CryptoJS/MD5/SHA)
  • Hook String.fromCharCode(JSVMP 高频信号)
  • 定位解释器核心分发函数
  • 分层插桩(粗 → 中 → 细)
  • 日志反向追踪,找到签名值首次出现的位置
  • 多次请求对比,确认变化因子和固定因子
  • 根据输出特征判断算法类型
  • 选择还原策略(纯算法 / 沙箱 / 完整 VM / 浏览器)
  • 实现并验证签名计算结果