Files
白夜 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

580 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 路径 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 完全相同的采集代码。
### 实现
```javascript
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 看到的就是这个上下文。
### 验证代码
```javascript
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:环境伪装」段