Files
hello_js_reverse_skill/references/jsvmp-source-instrumentation.md
T

16 KiB
Raw Blame History

JSVMP 源码级插桩专项指南(第四板斧)

v2.5.0 新增文档,v3.4.1 更新健康诊断。当前执行接口以 camoufox-reverse MCP v1.1.1 的 instrumentation(action=...) 为准;文中的旧工具名只用于解释能力来源。

与 jsvmp-analysis.md 的第一/二/三板斧互补——前三板斧诊断"VM 看外界 / 外界看 VM",本文档讲解"VM 看自己"。


1. 为什么需要源码级插桩

1.1 传统 hook 的盲区

hook_jsvmp_interpreter 即使升级到 v0.4.0 的多路径版本(apply/call/bind + Reflect.*/Proxy 全局对象 + timing/random),仍然只能看到 VMP 路由到可 hook JS API 的部分。但RS 5/6、Akamai sensor_data v2/v3、webmssdk(短视频平台)、obfuscator.io 这类 VMP 的典型结构是:

// 典型的自包含 VMP 字节码分发循环
function _vm(bytecode) {
  var stack = [], pc = 0, env = window;
  while (pc < bytecode.length) {
    var op = bytecode[pc++];
    switch (op) {
      case 1: stack.push(env[bytecode[pc++]]); break;    // ← GET 操作:env[key] 直接访问,不经过 apply/Reflect
      case 2: var k = stack.pop(), o = stack.pop(); stack.push(o[k]); break;  // ← 对象属性读取
      case 3: var args = stack.splice(-bytecode[pc++]);
              var fn = stack.pop(); stack.push(fn.apply(null, args)); break;  // ← 这种才被 apply hook 看到
      case 4: stack.push(bytecode[pc++] + stack.pop()); break;                // ← 纯字符串拼接,hook 完全抓不到
      // ... 数十到数百个 case
    }
  }
}
  • case 1 直接访问 env[key],hook navigator/screen 的 Proxy 能看到(如果 env 是被 Proxy 的全局),但嵌套多层后 Proxy 链会断。
  • case 2 读任意对象的任意属性——hook 不可能覆盖所有对象。
  • case 4 纯内存里的字符串拼接,任何 hook 都看不到。

结果:VMP 生成签名需要的关键中间值在 dispatch 循环内部全部丢失,传统 hook 返回的日志里只有零散的 API 调用,无法拼出算法。

1.2 源码级插桩的原理

instrument_jsvmp_source 在 HTTP 层拦截目标脚本,在脚本抵达浏览器前把源码改写成等价的"带 tap 的版本":

// 改写前
stack.push(env[key]);

// 改写后(regex 模式)
stack.push(__mcp_tap_get(env, key, 'vmp1'));

// 改写前
fn.apply(null, args);

// 改写后(AST 模式)
__mcp_tap_call(fn, null, [args[0], args[1], ...], 'vmp1');

__mcp_tap_get / __mcp_tap_call / __mcp_tap_method 三个运行时函数会把每次访问记录到 window.__mcp_vmp_log,包括:

  • tap_get: 读取属性——{type, tag, key, objType, value (preview)}
  • tap_method: obj.method(args) 调用——{type, tag, objType, method, argc, arg0, ret}
  • tap_call: fn(args) 直接调用——{type, tag, name, argc, arg0, ret}
  • tap_call_err: 调用抛错——{type, tag, name, err}

改写后的源码继续正常执行——VMP 照跑不误,但每一次对宿主环境或对象的交互都被记录。


2. 两种改写模式:regex vs AST

instrument_jsvmp_source(mode=...) 支持两种模式:

2.1 mode="regex"(轻量兜底,零依赖)

  • 原理:纯正则匹配 <identifier>[<expr>],替换为 __mcp_tap_get(identifier, expr, tag)
  • 优点:无外部 CDN 依赖,页面离线也能用;速度极快;大文件安全(10MB+ VMP 也能秒级改写)
  • 覆盖率:~80% 的 member access;不覆盖函数调用 tap(只改写 obj[key],不改写 fn(args))
  • 局限:对带模板字符串、正则字面量、嵌套方括号的代码可能误改写;只改 member access,不改 call
  • 使用场景:
    • 极限大文件(AST 模式内存超限)
    • 快速扫一遍看看 hot_keys 分布

2.2 mode="ast"(默认,MCP 侧 esprima)

  • 原理:在 MCP 进程中用 esprima-python 解析源码为 AST,识别 MemberExpression / CallExpression 后插入 tap
  • 优点:同时改写 member access 和 call;不会把字符串字面量或正则内容当作代码节点
  • 依赖:不依赖页面 CDN;esprima 仅覆盖其支持的 JavaScript 语法版本
  • 使用场景:
    • 绝大多数场景(默认推荐)
    • 需要 hot_methods / hot_functions 数据(只有 AST 模式会生成)
  • 失败回退:原始源码解析失败时可自动回落 regex;last_mode_used="ast" 不代表改写产物已经在浏览器成功执行

v0.5.0 变化:mode="ast" 的实现从 v0.4.x 的"页面内加载 acorn CDN"改为 MCP 侧 esprima-python 解析。这意味着:

  • 挑战页(RS 412、Akamai sensor_data 挑战等)上 AST 模式能用了(旧实现因 CDN 被挑战拦住会静默失败)
  • 零 JS 依赖,instrumentation(action="install") 的 AST 模式现在应成为默认选择
  • 新增 fallback_on_error=True,esprima 解析失败时自动回落 regex

2.3 选择建议

情况 选 原因
首次分析某个 VMP AST hot_methods + hot_functions 能快速画出 VMP 行为画像
只需快速观察 bracket member access regex 改写轻、无需 AST
VMP 文件 > 5 MB regex AST 改写内存开销大
关心 VMP 调了哪些方法 AST regex 不生成 tap_method / tap_call
关心 VMP 读了哪些属性 任一 两种模式都生成 tap_get

AST 模式健康诊断(v2.6.0 新增)

启用 instrumentation(action="install", mode="ast") 后,先通过 instrumentation(action="status") 的 active_patterns[i].last_mode_used 判断改写路径:

last_mode_used 含义 动作
"ast" 原始源码解析成功并完成 AST 改写 继续做运行时健康检查
"regex" 你显式指定了 mode="regex" 正常,继续
"regex (fallback)" mode="ast" 但 esprima 解析失败,自动回落 regex 警告:看下文

"regex (fallback)" 持续出现时:

  1. 先 get_console_logs 里搜 [INSTRUMENT] AST parse failed,看具体报错
  2. 如果是 ES2022+ 的 private field / static block / top-level await 等新语法 → 手动 mode="regex"(~80% 覆盖)或 mode="transparent"(runtime 观察)兜底
  3. 如果是 SyntaxError: Unexpected token 开头 → 可能 JS 被加了 BOM 或非标准前缀,可以本地用 curl 下来手动检查首 100 字节

一般来说,RS sdenv*.js / Akamai acmescripts*.js / webmssdk 都能被 esprima 解析。如果出现持续 fallback,报告给 MCP 仓库 issue 区。

改写产物运行时健康检查

files_rewritten > 0 和 last_mode_used="ast" 只证明 MCP 生成并下发了改写文本, 不能证明浏览器成功解析和执行了它。每次 reload 后继续检查:

instrumentation(action="status")
evaluate_js(expression="(() => ({
  tapInstalled: window.__mcp_tap_installed === true,
  logReady: Array.isArray(window.__mcp_vmp_log)
}))()")
instrumentation(action="log", tag_filter="vmp1", limit=10)

判定:

  • files_rewritten > 0 且 tapInstalled=false:优先怀疑改写产物语法损坏或执行前异常。常见触发结构是嵌套 CallExpression / MemberExpression / NewExpression,不是原始源码 parse 失败。
  • tapInstalled=true 但日志为空:runtime 已执行,继续检查 VMP 是否触发、tag/filter 是否正确,不要把空日志直接归因于语法错误。
  • 确认改写产物失败后,先停止原 AST route,再按 ast → regex → transparent 梯度重装;不要通过持续增大 max_rewrites 反复下发同一份失败产物。

3. 黄金 8 步流程

这是面向RS 5/6、Akamai sensor_data、webmssdk、obfuscator.io 的通用流程。照抄 Actions 段即可,执行方需要替换的只有 url_pattern 和 script_url。

Step 1 — 启动 + 网络捕获

Actions:
  launch_browser(headless=False)   # 有头调试,看浏览器行为
  start_network_capture(capture_body=True)   # 抓响应体,后续 analyze_cookie_sources 需要

Step 2 — 第一次导航定位 VMP 脚本 URL

Actions:
  navigate(url="https://target.com/", wait_until="load")
  list_network_requests(resource_type="script")
  → 找 size 最大的 JS(通常 100KB+,名字像 sdenv-*.js / FuckCookie_*.js / webmssdk.es5.js / akam/xxx.js)
  → 记下 url,下面称为 <VMP_URL>

Step 3 — 确认是 VMP(而不是普通混淆)

Actions:
  find_dispatch_loops(script_url="<VMP_URL>", min_case_count=20)
  → 返回:
    {
      "candidates": [
        {"fn_name": "_0xabc", "case_count": 87, "char_range": [45321, 182400], "preview": "..."}
      ],
      "total": 1,
      "source_length": 382112
    }
  → case_count > 50 基本确认是 VMP
  → 记下 fn_name 和 char_range(后续定位方便)

Step 4 — 装源码级插桩(核心)

Actions:
  instrument_jsvmp_source(
    url_pattern="**/sdenv-*.js",       # glob 模式,匹配所有 CDN hash 变种
    mode="ast",                         # 联网优先 ast
    tag="vmp1",                         # 一次插桩多 VMP 时区分
    rewrite_member_access=True,
    rewrite_calls=True,
    max_rewrites=5000
  )
  → 返回:{"status": "instrumenting", "pattern": "...", "mode": "ast", "tag": "vmp1"}

Step 5 — 装兜底 hook(与源码插桩互补)

Actions:
  inject_hook_preset(preset="cookie", persistent=True)       # 原型链级 cookie hook
  inject_hook_preset(preset="xhr", persistent=True)          # 请求出口
  inject_hook_preset(preset="fetch", persistent=True)        # 请求出口
  inject_hook_preset(preset="crypto", persistent=True)       # btoa/atob/JSON.stringify
  hook_jsvmp_interpreter(script_url="<VMP basename>",        # 多路径运行时探针
                         track_calls=True,
                         track_reflect=True,
                         track_props=True)
  bypass_debugger_trap()                                      # 避免 VMP 的 debugger 陷阱

Step 6 — reload_with_hooks 让探针先于 VMP 生效

Actions:
  reload_with_hooks(clear_log=True, wait_until="networkidle")
  → 注意 final_status:
    · 200   → 正常
    · 412   → RS挑战页未过,需要等 challenge 完成
    · 403   → Akamai 拦截,检查指纹或 UA

特殊场景:若目标就是首屏挑战页(RS 412),用:
  navigate(
    url="https://target.com/",
    pre_inject_hooks=["xhr", "fetch", "cookie", "jsvmp_probe"],
    via_blank=True,
    wait_until="networkidle"
  )

Step 7 — 触发业务操作

Actions:
  click(selector=".some-btn")
  # 或
  type_text(selector="#search", text="test")
  click(selector=".submit")
  # 或
  evaluate_js(expression="document.querySelector('.nextPage').click()")

  → VMP 会在这一步生成签名并发起请求

Step 8 — 读日志,分析 hot_keys / hot_methods / hot_functions

Actions (hot_keys — VMP 读取了哪些属性):
  get_instrumentation_log(
    tag_filter="vmp1",
    type_filter="tap_get",
    limit=200
  )
  → summary.hot_keys:
    {
      "userAgent": 120,
      "plugins": 98,
      "webdriver": 77,
      "platform": 65,
      "language": 54,
      "cookie": 43,
      ...
    }
  → 这就是 VMP 参与签名哈希的完整环境指纹集

Actions (hot_methods — VMP 调用了哪些方法):
  get_instrumentation_log(
    tag_filter="vmp1",
    type_filter="tap_method",
    limit=200
  )
  → summary.hot_methods:
    {
      "Object.defineProperty": 45,
      "Array.prototype.join": 38,
      "String.prototype.charCodeAt": 30,
      "CryptoJS.MD5": 12,
      ...
    }
  → 这告诉你算法是否用标准加密

Actions (hot_functions — VMP 调用了哪些函数):
  get_instrumentation_log(
    tag_filter="vmp1",
    type_filter="tap_call",
    limit=200
  )
  → summary.hot_functions:
    {
      "_0xabc": 300,
      "_0xdef": 150,
      "encodeURIComponent": 8,
      "parseInt": 6,
      ...
    }

4. 进阶技巧

4.1 多 VMP 场景(一次分析两个脚本)

instrument_jsvmp_source(url_pattern="**/webmssdk.es5.js", mode="ast", tag="webmssdk")
instrument_jsvmp_source(url_pattern="**/a_bogus.js",       mode="ast", tag="bogus")

# 分别看
get_instrumentation_log(tag_filter="webmssdk", type_filter="tap_get")
get_instrumentation_log(tag_filter="bogus",    type_filter="tap_get")

4.2 key_filter 锁定某个具体属性

# 只看 VMP 读取 navigator.webdriver 的记录
get_instrumentation_log(tag_filter="vmp1", key_filter="webdriver")

# 只看 VMP 调用 CryptoJS.MD5 的记录(method_name 过滤)
get_instrumentation_log(tag_filter="vmp1", key_filter="MD5")

4.3 管理插桩 route

# 查看所有激活的 route
get_instrumentation_status()

# 停止单个
stop_instrumentation(url_pattern="**/sdenv-*.js")

# 全部停止
stop_instrumentation()

4.4 与 runtime_probe 互补

runtime_probe 是低开销广谱探针,不改写源码,只 override 几个热点 API。源码级插桩改写 VMP 源码;runtime_probe 覆盖浏览器原生 API。两者叠加可以看到:

  • VMP 内部 obj[key] 调用 → get_instrumentation_log
  • VMP 最终落到 XMLHttpRequest.open / canvas.toDataURL / navigator.userAgent 的 getter 调用 → get_runtime_probe_log
inject_hook_preset(preset="runtime_probe", persistent=True)
instrument_jsvmp_source(url_pattern="**/sdenv-*.js", mode="ast", tag="vmp1")
reload_with_hooks()
触发操作
# 两路都读
get_instrumentation_log(tag_filter="vmp1", limit=300)
get_runtime_probe_log(type_filter="xhr_send", limit=100)
get_runtime_probe_log(type_filter="canvas_toDataURL", limit=50)

5. 常见问题与陷阱

Q1:files_rewritten=0,没改写到

可能原因:

  1. url_pattern 没命中——确认 VMP 实际 URL(list_network_requests 里看),glob 模式要用 **/ 前缀才能匹配任意路径
  2. VMP 从缓存加载——强制 reload_with_hooks(clear_log=True) 或清浏览器缓存
  3. pattern 注册太晚——要在 navigate / reload_with_hooks 之前调用 instrument_jsvmp_source

Q2:AST 模式报 parse_error

可能原因:原始脚本包含 esprima-python 不支持的语法。

对策:

  • 降级为 mode="regex"(零依赖,不会 parse)
  • 或先 save_script 把脚本存本地,人工看看是不是有奇怪的语法,再考虑局部 patch

Q3:改写后页面崩溃

可能原因:

  1. files_rewritten > 0 但 runtime 标记不存在——AST 改写产物可能因嵌套节点区间冲突而无法解析;停止 AST route 后降级 regex/transparent
  2. VMP 自己对源码做完整性校验——用 regex 模式做轻改写,只改 member access
  3. Tag 变量名冲突——改个 tag 再试
  4. 改写后代码过大——把 max_rewrites 调小

Q4:hot_keys 没出现预期的环境指纹

可能原因:

  1. VMP 还没真正执行——加大 wait_until="networkidle",或 wait_for 某个元素后再读日志
  2. 改写或执行没成功——先看 files_rewritten,再检查 window.__mcp_tap_installed
  3. tag 搞混了——检查 tag_filter 是否写对

Q5:日志爆炸到上限 20000 条

对策:

  • get_instrumentation_log(..., clear=True) 读完清
  • 收紧 url_pattern,只插桩必要的一个脚本
  • 第一次跑用 limit=300,看 top hot_keys 就够,不需要全部 entries

6. 源码级插桩的还原决策

拿到 hot_keys / hot_methods / hot_functions 三个摘要后,按下面的表格选策略:

hot_methods 特征 hot_keys 环境属性数 cookie 来源(analyze_cookie_sources) 策略
包含 CryptoJS.MD5 / SubtleCrypto.digest / HMAC 少(< 10) 全部 js_document_cookie 纯算法还原(crypto/hashlib)
包含 CryptoJS 但环境读取多 中(10-30) 混合或 js_document_cookie 提取 JS 签名函数 + vm/execjs 沙箱
全是自定义 fn 名,无可识别加密 多(30+) 任意 路径 B:jsdom 环境伪装(见 jsdom-env-patches.md)
全是自定义 fn 名 中-多 主要 http_set_cookie 路径 B + 重点关注发 token 的 POST 接口

7. 参考

  • references/jsvmp-analysis.md:四板斧方法论总纲
  • references/jsdom-env-patches.md:环境伪装补丁库(路径 B)
  • references/mcp-cookbook.md 场景 6:源码级插桩场景化操作
  • cases/universal-vmp-source-instrumentation.md:骨架案例模板
  • MCP 上游:https://github.com/WhiteNightShadow/camoufox-reverse-mcp(当前基线 v1.1.1)