16 KiB
JSVMP 源码级插桩专项指南(第四板斧)
v2.5.0 新增文档。本指南讲解 camoufox-reverse MCP v0.4.0+ 提供的源码级插桩能力(
instrument_jsvmp_source/get_instrumentation_log/find_dispatch_loops)的使用方法论,是 JSVMP 四板斧中"第四板斧"的完整说明。与
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],hooknavigator/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
- 使用场景:
- 目标环境无法访问 cdnjs.cloudflare.com
- 极限大文件(AST 模式内存超限)
- 快速扫一遍看看 hot_keys 分布
2.2 mode="ast"(高保真,需 CDN)
- 原理:在页面内加载 Acorn(
cdnjs.cloudflare.com/ajax/libs/acorn/8.11.3/acorn.min.js)解析源码为 AST,精确识别MemberExpression/CallExpression,只对应该改写的节点插入 tap - 优点:99% 覆盖率,同时改写 member access 和 call;不会误改写字符串字面量/正则
- 依赖:页面必须能访问 cdnjs CDN
- 使用场景:
- 绝大多数联网场景(默认推荐)
- 需要
hot_methods/hot_functions数据(只有 AST 模式会生成)
- 失败回退:AST 解析失败时(例如脚本有语法错误或 Acorn 版本不支持的语法),
instrument_jsvmp_source会自动 pass-through 源码不变,并在 console 记 warn
v0.5.0 变化:
mode="ast"的实现从 v0.4.x 的"页面内加载 acorn CDN"改为 MCP 侧 esprima-python 解析。这意味着:
- 挑战页(RS 412、Akamai sensor_data 挑战等)上 AST 模式能用了(旧实现因 CDN 被挑战拦住会静默失败)
- 零 JS 依赖,
instrument_jsvmp_source的 AST 模式现在应成为默认选择- 新增
fallback_on_error=True,esprima 解析失败时自动回落 regex- 旧的 "ast_page" 模式保留但标注为 deprecated,仅做 A/B 对比用
2.3 选择建议
| 情况 | 选 | 原因 |
|---|---|---|
| 首次分析某个 VMP,联网环境 | AST | hot_methods + hot_functions 能 30 秒内画出 VMP 行为画像 |
| 离线/受限网络 | regex | 零依赖 |
| VMP 文件 > 5 MB | regex | AST 改写内存开销大 |
| 关心 VMP 调了哪些方法 | AST | regex 不生成 tap_method / tap_call |
| 关心 VMP 读了哪些属性 | 任一 | 两种模式都生成 tap_get |
AST 模式健康诊断(v2.6.0 新增)
启用 instrument_jsvmp_source(mode="ast") 后,通过 get_instrumentation_status() 的 active_patterns[i].last_mode_used 字段判断实际走了哪条路径:
last_mode_used |
含义 | 动作 |
|---|---|---|
"ast" |
MCP 侧 esprima 成功,全量 AST 改写 | 正常,继续 |
"regex" |
你显式指定了 mode="regex" |
正常,继续 |
"regex (fallback)" |
mode="ast" 但 esprima 解析失败,自动回落 regex |
警告:看下文 |
"ast_page" |
走的是 deprecated 的页面内 Acorn 实现 | 迁到 "ast",除非你在做 A/B |
"regex (fallback)" 持续出现时:
- 先
get_console_logs里搜[INSTRUMENT] AST parse failed,看具体报错 - 如果是 ES2022+ 的 private field / static block / top-level await 等新语法 → 手动
mode="regex"(~80% 覆盖)或mode="transparent"(runtime 观察)兜底 - 如果是
SyntaxError: Unexpected token开头 → 可能 JS 被加了 BOM 或非标准前缀,可以本地用curl下来手动检查首 100 字节
一般来说,RS sdenv*.js / Akamai acmescripts*.js / webmssdk 都能被 esprima 解析。如果出现持续 fallback,报告给 MCP 仓库 issue 区。
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,
cache_rewritten=True # 避免重复改写
)
→ 返回:{"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,没改写到
可能原因:
url_pattern没命中——确认 VMP 实际 URL(list_network_requests里看),glob 模式要用**/前缀才能匹配任意路径- VMP 从缓存加载——强制
reload_with_hooks(clear_log=True)或清浏览器缓存 - pattern 注册太晚——要在
navigate/reload_with_hooks之前调用instrument_jsvmp_source
Q2:AST 模式报 parse_error
可能原因:脚本有 Acorn 不支持的语法(如极老的 ES3 hack 或极新的 stage-0 提案)。
对策:
- 降级为
mode="regex"(零依赖,不会 parse) - 或先
save_script把脚本存本地,人工看看是不是有奇怪的语法,再考虑局部 patch
Q3:改写后页面崩溃
可能原因:
- VMP 自己对自己的源码做了完整性校验(少见,但存在)——对策:用 regex 模式做轻改写,只改 member access
- Tag 变量名冲突——改个
tag再试 - 改写后代码超过 JS 引擎 token 上限——把
max_rewrites调小
Q4:hot_keys 没出现预期的环境指纹
可能原因:
- VMP 还没真正执行——加大
wait_until="networkidle",或wait_for某个元素后再读日志 - 改写没成功——先
get_instrumentation_status看files_rewritten是否 > 0 - 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(v0.4.0+)