Files
hello_js_reverse_skill/references/path-b-env-emulation.md
T
白夜 38b982fb1e feat: skill v3.2.0 — 硬约束Checklist五项压缩到三项,移除MCP session依赖,cases/成为唯一经验库
- SKILL.md 瘦身重写(核心层+按需加载references/)
- Checklist: CHECK-3/4(session)删除,五项→三项
- 新增6个references子文档(path-a/path-b/phase-details/experience-rules-full/mcp-tool-reference/common-pitfalls)
- 删除 references/domain-session-guide.md
- 工具引用对齐实际MCP(32工具),verify_signer_offline替代verify_against_session
- cases/_template.md 新增可验证事实清单段
- 经验法则session相关条目改写
- 红线四条、第一原则0-5、三分法、四板斧、六步法完整保留
2026-04-18 13:36:14 +08:00

20 KiB
Raw Blame History

路径 B:环境伪装 — jsdom/vm 沙箱六步法完整方法论

触发条件:Phase 2 选择路径 B(jsdom/sdenv/vm 沙箱环境伪装)时读此文档

前置要求:已完成反爬类型三分法识别 + 路径选择决策树(见 SKILL.md)

版本:v3.1.0(MCP v0.9.0 工具名迁移)


一、核心思想

不分析 JSVMP 内部算法,而是在 jsdom 中完整运行原始 JSVMP 字节码,通过「采集 → 对比 → 补丁」使 JSVMP 认为自己运行在真实浏览器中,直接截获生成的签名值。

适用条件

  • JSVMP 与浏览器环境深度绑定(环境指纹参与签名哈希)
  • JSVMP 劫持了请求链路(XHR/fetch 拦截器),签名在内部完成
  • 算法不可直接提取(字节码保护 + 自定义算法)
  • jsdom 可以成功加载并执行 JSVMP 脚本

典型用时

2-4 小时(对比路径 A 的 4-8 小时)


二、步骤 0.5:确认签名函数入口(v2.7.0 新增)

⚠️ 跳过此步骤可能导致:只 Hook 了 XHR 漏掉 fetch 通道的签名、未传 cacheOpts 导致拦截器不触发、误以为签名函数不存在而走了更复杂的路径。

MCP 操作

search_code(keyword="frontierSign", script_url='<VMP脚本URL>')
<!-- v3.1.0: migrated from search_code_in_script -->
→ 是否有导出签名函数

search_code(keyword="XMLHttpRequest.prototype.open", script_url='<VMP脚本URL>')
<!-- v3.1.0: migrated from search_code_in_script -->
→ 是否劫持 XHR

search_code(keyword="window.fetch", script_url='<VMP脚本URL>')
<!-- v3.1.0: migrated from search_code_in_script -->
→ 是否劫持 fetch

search_code(keyword="cacheOpts", script_url='<VMP脚本URL>')
<!-- v3.1.0: migrated from search_code_in_script -->
→ 是否使用新版初始化

判定结果与后续路径

判定 签名入口类型 后续策略
仅 XHR 拦截器 单通道截获 Hook XHR.open 即可
XHR + fetch 双通道拦截 双通道截获 需同时 Hook 两个通道
导出函数 + 拦截器 优先直接调用 优先调用导出函数,拦截器作为备选
含 cacheOpts 初始化 需配置初始化 必须传入 cacheOpts 配置才能注册业务路径

三、步骤 1:Camoufox 环境指纹采集

目标

用 Camoufox 反检测浏览器采集真实浏览器的完整环境指纹,作为 jsdom 补丁的基准。

MCP 操作

launch_browser({headless: false, os_type: "macos", locale: "zh-CN"})
navigate({url: "目标页面", wait_until: "domcontentloaded"})
compare_env()
→ 采集主流环境基准数据(navigator/screen/canvas/WebGL/Audio/timing)

分批采集细粒度环境值(4-5 批次)

⚠️ 单次 evaluate_js 代码太长会报错,必须分批采集

批次 A:navigator 属性(24 项)

evaluate_js(`JSON.stringify({
  userAgent: navigator.userAgent,
  platform: navigator.platform,
  language: navigator.language,
  languages: navigator.languages,
  cookieEnabled: navigator.cookieEnabled,
  doNotTrack: navigator.doNotTrack,
  hardwareConcurrency: navigator.hardwareConcurrency,
  maxTouchPoints: navigator.maxTouchPoints,
  vendor: navigator.vendor,
  vendorSub: navigator.vendorSub,
  productSub: navigator.productSub,
  appVersion: navigator.appVersion,
  appName: navigator.appName,
  appCodeName: navigator.appCodeName,
  onLine: navigator.onLine,
  webdriver: navigator.webdriver,
  pdfViewerEnabled: navigator.pdfViewerEnabled,
  deviceMemory: navigator.deviceMemory,
  plugins_length: navigator.plugins.length,
  mimeTypes_length: navigator.mimeTypes.length,
  connection: navigator.connection ? {
    effectiveType: navigator.connection.effectiveType,
    downlink: navigator.connection.downlink,
    rtt: navigator.connection.rtt
  } : null,
  userAgentData: navigator.userAgentData ? {
    brands: navigator.userAgentData.brands,
    mobile: navigator.userAgentData.mobile,
    platform: navigator.userAgentData.platform
  } : null,
  getBattery: typeof navigator.getBattery,
  mediaDevices: typeof navigator.mediaDevices
})`)

批次 B:screen + window 属性(25 项)

evaluate_js(`JSON.stringify({
  screen_width: screen.width,
  screen_height: screen.height,
  screen_availWidth: screen.availWidth,
  screen_availHeight: screen.availHeight,
  screen_colorDepth: screen.colorDepth,
  screen_pixelDepth: screen.pixelDepth,
  screen_orientation: screen.orientation ? screen.orientation.type : null,
  window_innerWidth: window.innerWidth,
  window_innerHeight: window.innerHeight,
  window_outerWidth: window.outerWidth,
  window_outerHeight: window.outerHeight,
  window_devicePixelRatio: window.devicePixelRatio,
  window_screenX: window.screenX,
  window_screenY: window.screenY,
  chrome_exists: typeof window.chrome,
  chrome_runtime: window.chrome ? typeof window.chrome.runtime : null,
  Notification: typeof window.Notification,
  Worker: typeof window.Worker,
  SharedWorker: typeof window.SharedWorker,
  RTCPeerConnection: typeof window.RTCPeerConnection,
  AudioContext: typeof window.AudioContext,
  OfflineAudioContext: typeof window.OfflineAudioContext,
  fetch_exists: typeof window.fetch,
  matchMedia: typeof window.matchMedia,
  indexedDB: typeof window.indexedDB
})`)

批次 C:document + performance + toString + Function.toString(28 项)

evaluate_js(`JSON.stringify({
  doc_hasFocus: document.hasFocus(),
  doc_readyState: document.readyState,
  doc_visibilityState: document.visibilityState,
  doc_hidden: document.hidden,
  doc_characterSet: document.characterSet,
  doc_compatMode: document.compatMode,
  doc_contentType: document.contentType,
  doc_designMode: document.designMode,
  doc_referrer: document.referrer,
  doc_title: document.title,
  doc_domain: document.domain,
  doc_cookie_type: typeof document.cookie,
  perf_timing: typeof performance.timing,
  perf_navigation: typeof performance.navigation,
  perf_now: typeof performance.now,
  perf_memory: typeof performance.memory,
  fn_toString_createElement: document.createElement.toString(),
  fn_toString_getElementById: document.getElementById.toString(),
  fn_toString_querySelector: document.querySelector.toString(),
  fn_toString_addEventListener: EventTarget.prototype.addEventListener.toString(),
  fn_toString_fetch: typeof window.fetch === 'function' ? window.fetch.toString() : null,
  fn_toString_setTimeout: window.setTimeout.toString(),
  obj_toString_document: Object.prototype.toString.call(document),
  obj_toString_window: Object.prototype.toString.call(window),
  obj_toString_navigator: Object.prototype.toString.call(navigator),
  obj_toString_screen: Object.prototype.toString.call(screen),
  symbol_toStringTag_doc: document[Symbol.toStringTag],
  symbol_toStringTag_screen: screen[Symbol.toStringTag]
})`)

批次 D:DOM 布局 + Canvas + WebGL + Audio(指纹检测类)

evaluate_js(`(function(){
  var d = document.createElement('div');
  d.style.cssText = 'width:100px;height:100px;position:absolute;left:-9999px';
  document.body.appendChild(d);
  var layout = {
    offsetHeight: d.offsetHeight,
    offsetWidth: d.offsetWidth,
    clientHeight: d.clientHeight,
    clientWidth: d.clientWidth,
    rect: d.getBoundingClientRect()
  };
  document.body.removeChild(d);

  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  ctx.textBaseline = 'top';
  ctx.font = '14px Arial';
  ctx.fillText('test', 2, 2);
  var canvasData = canvas.toDataURL();

  var gl = document.createElement('canvas').getContext('webgl');
  var webglInfo = gl ? {
    vendor: gl.getParameter(gl.VENDOR),
    renderer: gl.getParameter(gl.RENDERER),
    version: gl.getParameter(gl.VERSION)
  } : null;

  return JSON.stringify({
    layout: layout,
    canvas_hash: canvasData.length,
    webgl: webglInfo,
    audioContext: typeof AudioContext,
    offlineAudioContext: typeof OfflineAudioContext
  });
})()`)

注意事项

  • Camoufox 的 C++ 引擎级指纹伪装提供「干净基准」,比普通 Chrome 更可靠
  • compare_env 不覆盖 Function.prototype.toString / Symbol.toStringTag / DOM 布局等细粒度检测,这些必须通过 evaluate_js 手动采集
  • 采集代码模板参考 cases/ 经验案例中的「浏览器指纹采集方法」段

四、步骤 2:jsdom 环境采集

目标

在本地 Node.js 中创建 jsdom 实例,执行与步骤 1 完全相同的采集代码。

实现

const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
  url: '目标URL',
  pretendToBeVisual: true,
  runScripts: 'dangerously'
});
const win = dom.window;

// 在 win.eval() 中运行与步骤 1 完全相同的采集脚本
// 批次 A
const navResult = win.eval(`JSON.stringify({
  userAgent: navigator.userAgent,
  platform: navigator.platform,
  // ... 与步骤 1 批次 A 完全相同
})`);

// 批次 B、C、D 同理

关键点

  • jsdom 实例的 url 参数必须设为目标 URL(影响 document.URL / document.domain)
  • pretendToBeVisual: true 使 requestAnimationFrame 可用
  • runScripts: 'dangerously' 允许执行脚本(环境伪装必需)

五、步骤 3:Diff 与严重性分级

差异分级标准

致命级 — 缺失即被服务端拒绝

检测项 浏览器值 jsdom 默认值 影响
Function.prototype.toString function createElement() { [native code] } 暴露 jsdom 实际 JS 代码 直接暴露非浏览器环境
navigator.plugins.length 5(典型值) 0 插件数为 0 = 非浏览器
navigator.webdriver false undefined webdriver 检测
document.hasFocus() true false 焦点检测
DOM offsetHeight/Width 非零值 0 布局检测

高危级 — 可能参与指纹哈希

检测项 浏览器值 jsdom 默认值
Object.prototype.toString.call(document) [object HTMLDocument] [object Document]
window.chrome {runtime: {...}, ...} undefined
navigator.userAgentData {brands: [...], ...} undefined
performance.timing/navigation 完整对象 缺失
Symbol.toStringTag 正确标签 不正确

中危级 — API 存在性检测(30+ 缺失 API)

window.Notification / Worker / SharedWorker / RTCPeerConnection
window.AudioContext / OfflineAudioContext / fetch
window.matchMedia / indexedDB / caches / visualViewport
navigator.connection / getBattery / deviceMemory

参考

完整的差异表和修复代码模板见 references/jsdom-env-patches.md。


六、步骤 4:patchEnvironment() 实现

修复优先级排序

按以下优先级逐项修复,每修一项立即验证:

① markNative 三层防御(最高优先级)

核心问题:jsdom 所有 DOM 方法的 toString() 会暴露实际 JS 代码
解决方案:WeakSet + 源码正则 + 实例覆写 + 50+ 原型链扫描

三层防御:
  Layer 1: WeakSet 记录所有需要伪装的函数引用
  Layer 2: 源码模式正则匹配(function X() { [native code] })
  Layer 3: 实例级覆写 toString(兜底)

⚠️ Firefox vs Chrome 格式差异:
  Chrome: "function createElement() { [native code] }"
  Firefox: "function createElement() {\n    [native code]\n}"
  → Camoufox 基于 Firefox 内核,必须使用 Firefox 格式

② navigator 补丁

修复项:
  - plugins: 完整 PluginArray/Plugin/MimeType 对象树(length=5)
  - webdriver: false
  - userAgentData: {brands: [...], mobile: false, platform: "macOS"}
  - connection: {effectiveType: "4g", downlink: 10, rtt: 50}
  - 每个属性经 markNative 处理

③ window 补丁

修复项:
  - chrome 对象: {runtime: {}, app: {}, ...}
  - 30+ API 存根: Notification / Worker / SharedWorker / RTCPeerConnection /
    AudioContext / OfflineAudioContext / fetch / matchMedia / indexedDB / caches /
    visualViewport 等
  - 每个存根经 markNative 处理

④ document + performance 补丁

修复项:
  - hasFocus(): 始终返回 true
  - readyState: "complete"
  - visibilityState: "visible"
  - hidden: false
  - performance.timing: 完整 PerformanceTiming 对象
  - performance.navigation: {type: 0, redirectCount: 0}

⑤ DOM 布局属性

修复项:
  - offsetHeight/offsetWidth: 返回非零值
  - clientHeight/clientWidth: 返回非零值
  - getBoundingClientRect(): 返回合理的 DOMRect
  - scrollHeight/scrollWidth: 返回非零值

⑥ Symbol.toStringTag 全面修复

修复项:
  - document → "HTMLDocument"(而非 "Document")
  - screen → "Screen"
  - navigator → "Navigator"
  - window → "Window"
  - 通过 Object.defineProperty 设置 Symbol.toStringTag

完整代码模板

参考 references/jsdom-env-patches.md 获取每个修复模块的完整实现代码。


七、步骤 5:内部验证(win.eval)

目标

从 jsdom 内部(win.eval)验证所有检测点通过。

关键

验证代码必须在 jsdom 的 window 上下文中执行(win.eval),因为 JSVMP 看到的就是这个上下文。

验证代码

const result = win.eval(`JSON.stringify({
  fnToString: document.createElement.toString(),
  plugins: navigator.plugins.length,
  webdriver: navigator.webdriver,
  hasFocus: document.hasFocus(),
  chromeExists: typeof window.chrome,
  docTag: Object.prototype.toString.call(document),
  offsetTest: (function(){
    var d = document.createElement('div');
    d.style.cssText = 'width:100px;height:100px';
    document.body.appendChild(d);
    var h = d.offsetHeight;
    document.body.removeChild(d);
    return h;
  })()
})`);

// 预期结果:
// fnToString 包含 [native code](Firefox 格式含换行)
// plugins = 5
// webdriver = false
// hasFocus = true
// chromeExists = 'object'
// docTag = '[object HTMLDocument]'
// offsetTest > 0

验证清单

检测项 预期值 不通过时的修复模块
document.createElement.toString() 含 [native code] markNative 三层防御
navigator.plugins.length 5 navigator 补丁
navigator.webdriver false navigator 补丁
document.hasFocus() true document 补丁
typeof window.chrome 'object' window 补丁
Object.prototype.toString.call(document) '[object HTMLDocument]' Symbol.toStringTag 修复
div.offsetHeight > 0 DOM 布局属性

八、步骤 6:端到端验证

目标

在 jsdom 中加载完整 JSVMP 脚本,触发签名生成,用截获的签名值发起真实接口请求。

验证流程

1. 在 jsdom 中加载完整 JSVMP 脚本并触发签名生成
2. 用截获的签名值发起真实接口请求
3. 确认返回有效数据(非空 body / 非错误码)
4. 连续多次请求验证稳定性(至少 5 次)

注意事项

  • ⚠️ 服务端可能静默拒绝(返回 HTTP 200 + 空 body,不报错)— 这是环境检测失败的信号
  • 连续 5 次请求全部返回有效数据才算通过
  • 如果间歇性失败,检查是否有时间戳相关的环境值未补齐

九、环境伪装还原策略表

情况 策略 实现方式
JSVMP 劫持 XHR,在拦截器中追加签名 「喂入-截出」(feed-intercept) jsdom + XHR Hook → 在 jsdom 内发 XHR,拦截器自动追加签名,Hook 截获
JSVMP 导出签名函数到 window 直接调用导出函数 jsdom 加载 JSVMP → win.签名函数(参数) → 获取签名
JSVMP + 预热初始化(如 SdkGlueInit) 完整初始化链路 jsdom 依次加载所有脚本 → 调用初始化函数 → 再触发签名生成
JSVMP + cacheOpts 配置 配置驱动初始化 传入 cacheOpts 配置 → 注册业务路径 → 拦截器才会触发
双签名(URL 参数 + header) 双通道截获 同时 Hook XHR.open(URL 签名)+ XHR.setRequestHeader(header 签名)

十、Firefox vs Chrome 变体差异

Camoufox 基于 Firefox 内核,与 Chrome 在多个环境值上存在差异。jsdom 补丁必须匹配采集基准浏览器的格式。

关键差异

检测项 Chrome 格式 Firefox (Camoufox) 格式
Function.prototype.toString (native) "function name() { [native code] }" "function name() {\n [native code]\n}"
navigator.vendor "Google Inc." ""
navigator.vendorSub "" ""
navigator.productSub "20030107" "20100101"
window.chrome 完整对象 undefined(需补丁)
navigator.userAgentData 完整对象 undefined(Firefox 不支持)

修复策略

  • 如果目标站点的 JSVMP 是在 Chrome 环境下开发的,可能期望 Chrome 格式
  • 但 Camoufox 采集的基准是 Firefox 格式
  • 原则:以 Camoufox 采集的基准为准,因为 Camoufox 的 C++ 引擎级伪装能通过反检测
  • 如果服务端明确校验 Chrome 特征(如 window.chrome 必须存在),则需要额外补丁

十一、常见踩坑与解决

坑 1:Canvas 存根不够真实

问题:jsdom 的 canvas 返回空白数据,指纹哈希不匹配
解决:使用 node-canvas 或 @napi-rs/canvas 提供真实 Canvas 实现
      或者从 Camoufox 采集 canvas 指纹后硬编码返回值

坑 2:native code 多行格式

问题:markNative 输出了 Chrome 单行格式,但 Camoufox 基准是 Firefox 多行格式
解决:将 markNative 的返回值从
      "function name() { [native code] }"
      改为
      "function name() {\n    [native code]\n}"

坑 3:Symbol.toStringTag 遗漏

问题:Object.prototype.toString.call(document) 返回 [object Document] 而非 [object HTMLDocument]
解决:Object.defineProperty(Document.prototype, Symbol.toStringTag, {
        get: () => 'HTMLDocument', configurable: true
      })

坑 4:jsdom 环境补丁必须在 JSVMP 脚本加载前完成

问题:JSVMP 在加载时就读取环境值,补丁打晚了无效
解决:确保 patchEnvironment() 在任何 JSVMP 脚本 eval 之前执行
      XHR Hook 的安装顺序也决定能否截获最终 URL

坑 5:服务端静默拒绝

问题:返回 HTTP 200 + 空 body(不报错),看起来请求成功但没数据
信号:环境检测失败,签名格式正确但环境指纹不匹配
解决:回到步骤 3 重新 diff,检查是否有遗漏的致命级差异

坑 6:cacheOpts 未传入

问题:拦截器不触发,签名不生成
信号:旧版只需 bdms.paths,新版必须同时传入 cacheOpts
解决:检查 _SdkGlueInit 调用是否包含 cacheOpts 字段
      确认 cacheOpts.paths 覆盖目标 API 路径

十二、JSVMP 核心经验(路径 B 专项)

  1. 环境伪装优先于算法追踪 — 路径 B 通常比路径 A 快 10 倍
  2. Function.prototype.toString 是 jsdom 环境伪装的第一杀手 — 三层防御必须到位
  3. 环境对比要分批采集 — 单次 evaluate_js 代码太长会报错
  4. jsdom 环境补丁必须在 JSVMP 脚本加载前完成 — 加载顺序决定成败
  5. 服务端静默拒绝是环境检测失败的信号 — HTTP 200 + 空 body
  6. Firefox 格式 native code 与 Chrome 不同 — 必须匹配采集基准浏览器的格式
  7. cacheOpts 是新版 SDK 初始化的必传项 — 缺少会导致拦截器不触发
  8. 环境伪装前先确认签名函数入口 — 步骤 0.5 不可跳过

环境补丁代码模板:references/jsdom-env-patches.md 经验案例:cases/jsvmp-xhr-interceptor-env-emulation.md、cases/jsvmp-dual-sign-xhr-intercept-cacheOpts-jsdom-firefox.md 完整工作流:SKILL.md「路径 B:环境伪装」段