- case: add jsvmp-dual-sign-xhr-intercept-cacheOpts-jsdom-firefox (behavior type) Fingerprints: webmssdk/byted_acrawler/cacheOpts/X-Bogus+X-Gnarly dual sign Core tech: jsdom Firefox env emulation + XHR/fetch dual-channel intercept + cacheOpts init Pitfalls: 12 gotchas documented - SKILL.md: 9 changes - env emulation step 0.5: confirm sign function entry before patching - experience rules expanded 32 -> 40 - scene 10/11: navigate params + CSP pre-check - core weapon: MCP v0.5.0 -> v0.6.0 (68 tools) - error handling: 3 new failure patterns - cases/_template.md: add anti-bot type + key experience sections - cases/README.md: add fingerprint matching table + variant diagram - sanitize: remove all site names from all files
386 lines
15 KiB
Markdown
386 lines
15 KiB
Markdown
# JSVMP 双签名 + XHR/fetch 拦截器 + cacheOpts 初始化 + jsdom Firefox 环境伪装
|
||
|
||
> 难度:★★★★★
|
||
> 还原方案:E: jsdom 环境模拟(喂入-截出策略)
|
||
> 实现语言:Node.js
|
||
> 最后验证日期:2026-04-20
|
||
> 平台类型:海外短视频平台(国际版)
|
||
|
||
---
|
||
|
||
## 技术指纹(供 Phase 0.5 自动匹配)
|
||
|
||
### JS 特征
|
||
- [x] `webmssdk.es5.js` — JSVMP 签名引擎(380KB+),UMD 导出到 `window.byted_acrawler`
|
||
- [x] 三层 SDK 联动:`sdk-glue.js`(100KB)→ `bdms.js`(147KB)→ `webmssdk.es5.js`(387KB)
|
||
- [x] 存在 while-switch 风格的 JSVMP 解释器循环
|
||
- [x] 内部状态对象含 `bogusIndex`、`msNewTokenList`、`moveList`、`clickList`、`activeState`
|
||
- [x] `_0x` 前缀变量大量出现(构造函数、工厂函数、闭包变量)
|
||
- [x] 页面 inline script 调用 `window._SdkGlueInit(config, resources)` 完成初始化
|
||
- [x] 初始化配置含 `cacheOpts` 字段(路径注册 + 缓存策略),区别于旧版仅 `bdms.paths`
|
||
|
||
### 参数特征
|
||
- [x] `X-Bogus`:约 180~192 字符,Base64 变体编码 — 由 JSVMP XHR 拦截器生成(新版主签名)
|
||
- [x] `X-Gnarly`:约 120~140 字符,自定义编码 — 由 `frontierSign()` 生成的辅助签名
|
||
- [x] `msToken`:约 140 字符,Base64 编码,来自目标域名的 Cookie
|
||
- [x] `verifyFp` / `fp`:格式 `verify_xxxx_xxxx_xxxx_xxxx_xxxxxxxxxxxx`
|
||
|
||
### 请求特征
|
||
- [x] `_SdkGlueInit(config, resources)` 初始化时 config 含 `cacheOpts: { paths: [...], ttl: N }`
|
||
- [x] JSVMP 同时修改 `XMLHttpRequest.prototype.open` 和 `window.fetch`(双通道拦截)
|
||
- [x] 拦截器对匹配 `enablePathList` 的 URL 追加 `X-Bogus`,同时在 header 中注入 `X-Gnarly`
|
||
- [x] Cookie 中包含动态字段:`ttwid`、`__ac_nonce`、`__ac_signature`、`s_v_web_id`、`odin_tt`
|
||
- [x] 存在 `/web/report` 默认拦截路径 + 业务路径通过 `cacheOpts.paths` 注入
|
||
|
||
### 反调试特征
|
||
- [x] JSVMP 字节码执行,无法直接设置断点
|
||
- [x] debugger 定时器陷阱
|
||
- [x] 环境检测(60+ 项):在原有 58 项基础上新增 Firefox 特有检测:
|
||
- `navigator.buildID` 存在性(Firefox 独有)
|
||
- `CSS2Properties` vs `CSSStyleDeclaration` 类型检测
|
||
- `Function.prototype.toString` 格式差异(Firefox 返回 `function name() {\n [native code]\n}` 含换行缩进)
|
||
- [x] 非真实浏览器环境生成的签名会被服务端**静默拒绝**(返回 HTTP 200 + 空 body)
|
||
|
||
### 混淆类型
|
||
- [x] JSVMP(JavaScript Virtual Machine Protection),字节码存储为十六进制字符串
|
||
|
||
### 指纹检测规则(Agent 执行)
|
||
|
||
```
|
||
快速检测命令(30秒内完成):
|
||
- search_code(keyword="webmssdk") → 命中 → 直接定位本案例
|
||
- search_code(keyword="byted_acrawler") → 命中 → 直接定位本案例
|
||
- search_code(keyword="_SdkGlueInit") → 命中 → 直接定位本案例
|
||
- search_code(keyword="cacheOpts") → 命中 → 区分本案例(含 cacheOpts)vs 旧版(仅 bdms.paths)
|
||
- search_code(keyword="X-Gnarly") → 命中 → 确认双签名变体
|
||
匹配判定:前 3 项命中任意 1 项 + cacheOpts 命中 → 高置信度匹配本案例
|
||
```
|
||
|
||
### 指纹检测规则 — 区分国际版 vs 国内版技术特征
|
||
|
||
```
|
||
区分方法(技术特征层面):
|
||
国际版特征:
|
||
- SDK 路径含 "i18n" 或 "intl" 标识
|
||
- cacheOpts 配置中 paths 含 "/api/v1/" 前缀(国际版 API 路径风格)
|
||
- X-Gnarly header 存在(国际版双签名)
|
||
- msToken Cookie 域名为国际版域名
|
||
国内版特征:
|
||
- SDK 路径不含 i18n 标识
|
||
- 仅 bdms.paths 配置(无 cacheOpts)
|
||
- 仅 a_bogus 参数(无 X-Gnarly)
|
||
- msToken Cookie 域名为国内版域名
|
||
技术判定:cacheOpts 存在 + X-Gnarly 存在 → 国际版变体
|
||
```
|
||
|
||
---
|
||
|
||
## 加密方案
|
||
|
||
- **算法**:JSVMP 自定义字节码虚拟机,内部算法不可直接读取
|
||
- `X-Bogus`(~192 字符 Base64 变体):由 JSVMP XHR/fetch 拦截器生成
|
||
- `X-Gnarly`(~130 字符自定义编码):由 `frontierSign()` 导出函数生成
|
||
- **密钥来源**:动态计算 — 浏览器环境指纹(Canvas/WebGL/navigator/screen 等 60+ 项)+ 请求 URL query string + User-Agent + 时间戳
|
||
- **加密流程**:
|
||
1. `sdk-glue` 通过 `_SdkGlueInit(config, resources)` 初始化,注入 `cacheOpts` 路径列表
|
||
2. `webmssdk` JSVMP 构造函数执行字节码,同时修改 `XMLHttpRequest.prototype.open` 和 `window.fetch`
|
||
3. 当请求 URL 匹配 `enablePathList` 时,XHR 拦截器追加 `X-Bogus` 到 URL,fetch 拦截器注入 `X-Gnarly` 到 header
|
||
4. JSVMP 读取完整 URL query string、`document.cookie`、`navigator.*`、`screen.*` 等 60+ 项环境指纹
|
||
5. 双签名并行生成:X-Bogus(URL 参数)+ X-Gnarly(请求头)
|
||
- **签名公式**:无法提取(JSVMP 字节码保护)。采用「喂入-截出」策略
|
||
|
||
---
|
||
|
||
## 已验证定位路径(Phase 0.5 命中后直接执行)
|
||
|
||
### Phase 1:网络捕获定位接口
|
||
|
||
```
|
||
步骤 1: start_network_capture(capture_body=True) + 触发业务操作
|
||
步骤 2: list_network_requests → 捕获带 X-Bogus 和 X-Gnarly 的请求
|
||
步骤 3: search_code(keyword="bdms") → 找到三个关键脚本 CDN 地址:
|
||
- webmssdk.es5.js (387KB) — JSVMP 签名引擎
|
||
- bdms.js (147KB) — ByteDance Monitoring System
|
||
- sdk-glue.js (100KB) — SDK 胶水层
|
||
```
|
||
|
||
### Phase 2:初始化链路还原
|
||
|
||
```
|
||
步骤 4: search_code(keyword="cacheOpts") → 定位到 sdk-glue 中的新版路径配置机制
|
||
步骤 5: 页面 inline script 中找到 _SdkGlueInit 调用及完整的 cacheOpts 配置
|
||
步骤 6: search_code(keyword="frontierSign") → 找到 webmssdk 的导出结构:init() / frontierSign() / setConfig()
|
||
步骤 7: search_code(keyword="X-Gnarly") → 确认 X-Gnarly 由 fetch 拦截器注入 header
|
||
步骤 8: search_code(keyword="bogusIndex") → 确认 X-Bogus 由 XHR 拦截器追加到 URL
|
||
```
|
||
|
||
### Phase 3:jsdom 沙箱验证
|
||
|
||
```
|
||
步骤 9: 在 jsdom (runScripts: 'dangerously') 中依次加载三个脚本
|
||
步骤 10: 调用 _SdkGlueInit 并传入 cacheOpts 配置
|
||
步骤 11: 触发 XHR → 截获 X-Bogus;触发 fetch → 截获 X-Gnarly
|
||
步骤 12: 浏览器生成的签名用 Node.js 请求成功 → 确认非 TLS 问题
|
||
步骤 13: jsdom 生成的签名被服务端拒绝 → 确认是环境指纹差异
|
||
```
|
||
|
||
### Phase 4:环境指纹对比(核心突破点 — Firefox 伪装)
|
||
|
||
```
|
||
步骤 14: 用 Camoufox 启动反检测浏览器(注意:Camoufox 基于 Firefox)
|
||
步骤 15: navigate 到目标页面
|
||
步骤 16: 通过 evaluate_js 分批采集浏览器完整环境指纹(5 批次)
|
||
步骤 17: 在 jsdom 中运行完全相同的采集代码
|
||
步骤 18: 逐项对比发现 62 项差异(含 Firefox 特有的 4 项)
|
||
关键发现:
|
||
- Camoufox 是 Firefox 内核,Function.prototype.toString 格式与 Chrome 不同
|
||
- Firefox 返回 "function name() {\n [native code]\n}" (含换行和 4 空格缩进)
|
||
- Chrome 返回 "function name() { [native code] }" (单行)
|
||
- JSVMP 会检测 toString 格式来判断浏览器类型
|
||
→ jsdom 的 markNative 必须输出 Firefox 格式的 native code 字符串
|
||
```
|
||
|
||
### Phase 5:环境补丁与验证
|
||
|
||
```
|
||
步骤 19: 编写 patchEnvironment() 修复全部 62 项差异
|
||
核心改动:markNative 输出 Firefox 格式 native code
|
||
步骤 20: 从 jsdom 内部验证所有检测点通过
|
||
步骤 21: 连续 5 次请求,全部返回完整数据,确认稳定性
|
||
步骤 22: 使用 got-scraping 替代 axios,模拟 Firefox TLS 指纹
|
||
```
|
||
|
||
---
|
||
|
||
## 还原代码模板
|
||
|
||
### 核心函数:markNative — Firefox 格式 native code 伪装
|
||
|
||
```javascript
|
||
const _origFnToString = win.Function.prototype.toString;
|
||
const nativeFnSet = new WeakSet();
|
||
|
||
function markNative(fn) {
|
||
if (typeof fn === 'function') {
|
||
nativeFnSet.add(fn);
|
||
try {
|
||
const name = fn.name || '';
|
||
// Firefox 格式:含换行和 4 空格缩进
|
||
Object.defineProperty(fn, 'toString', {
|
||
value: function () {
|
||
return `function ${name}() {\n [native code]\n}`;
|
||
},
|
||
writable: true, configurable: true,
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
return fn;
|
||
}
|
||
|
||
// jsdom 内部函数的通用源码特征
|
||
const jsdomPatterns = [
|
||
/^\s*\w+\s*\([^)]*\)\s*\{[\s\S]*?const\s+esValue\s*=/,
|
||
/^\s*function\s*\([^)]*\)\s*\{[\s\S]*?this\._globalObject/,
|
||
/^\s*\w+\s*\([^)]*\)\s*\{\s*const\s+\w+\s*=\s*this\s*!==/,
|
||
];
|
||
|
||
win.Function.prototype.toString = function () {
|
||
if (nativeFnSet.has(this)) {
|
||
// Firefox 格式
|
||
return `function ${this.name || ''}() {\n [native code]\n}`;
|
||
}
|
||
let src;
|
||
try { src = _origFnToString.call(this); } catch (e) {
|
||
return 'function () {\n [native code]\n}';
|
||
}
|
||
for (const pat of jsdomPatterns) {
|
||
if (pat.test(src)) return `function ${this.name || ''}() {\n [native code]\n}`;
|
||
}
|
||
return src;
|
||
};
|
||
```
|
||
|
||
### 核心函数:cacheOpts 初始化
|
||
|
||
```javascript
|
||
function initSdkWithCacheOpts(dom) {
|
||
const win = dom.window;
|
||
// 加载三层 SDK
|
||
// sdk-glue.js → bdms.js → webmssdk.es5.js
|
||
|
||
// 新版初始化:传入 cacheOpts
|
||
win.eval(`
|
||
window._SdkGlueInit({
|
||
cacheOpts: {
|
||
paths: [
|
||
'^/api/v1/',
|
||
'^/aweme/v1/',
|
||
'^/web/api/'
|
||
],
|
||
ttl: 300
|
||
}
|
||
}, {
|
||
bdms: { paths: ['^/aweme/v1/', '^/web/'] }
|
||
});
|
||
`);
|
||
}
|
||
```
|
||
|
||
### 核心函数:双签名生成(喂入-截出策略)
|
||
|
||
```javascript
|
||
function generateDualSign(fullUrl, cookieStr) {
|
||
const dom = initSdk(); // jsdom + Firefox 环境补丁 + SDK 加载(单例)
|
||
const win = dom.window;
|
||
|
||
// 写入 Cookie
|
||
if (cookieStr) {
|
||
cookieStr.split(';').forEach(c => {
|
||
try { win.document.cookie = c.trim(); } catch (e) {}
|
||
});
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
let xBogus = null;
|
||
let xGnarly = null;
|
||
|
||
// Hook XHR 截获 X-Bogus
|
||
win.__capturedUrls = [];
|
||
const origXhrOpen = win.XMLHttpRequest.prototype.open;
|
||
// ... (XHR hook 截获 URL 中的 X-Bogus)
|
||
|
||
// Hook fetch 截获 X-Gnarly
|
||
const origFetch = win.fetch;
|
||
win.fetch = function(url, opts) {
|
||
if (opts && opts.headers) {
|
||
xGnarly = opts.headers['X-Gnarly'] || null;
|
||
}
|
||
return origFetch.apply(this, arguments);
|
||
};
|
||
|
||
// 触发 XHR
|
||
win.eval(`(function(){
|
||
var x = new XMLHttpRequest();
|
||
x.open('GET','${fullUrl.replace(/'/g, "\\'")}',true);
|
||
x.send();
|
||
})()`);
|
||
|
||
setTimeout(() => {
|
||
for (const url of win.__capturedUrls) {
|
||
const m = url.match(/[&?]X-Bogus=([^&]+)/);
|
||
if (m) { xBogus = decodeURIComponent(m[1]); break; }
|
||
}
|
||
resolve({ xBogus, xGnarly });
|
||
}, 500);
|
||
});
|
||
}
|
||
```
|
||
|
||
### 核心函数:got-scraping TLS 指纹模拟
|
||
|
||
```javascript
|
||
const { gotScraping } = require('got-scraping');
|
||
|
||
async function requestWithFirefoxTLS(url, headers) {
|
||
const response = await gotScraping({
|
||
url,
|
||
headers,
|
||
headerGeneratorOptions: {
|
||
browsers: [{ name: 'firefox', minVersion: 115 }],
|
||
operatingSystems: ['macos'],
|
||
},
|
||
});
|
||
return JSON.parse(response.body);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 踩坑记录
|
||
|
||
| # | 坑 | 现象 | 解决方法 |
|
||
|---|---|------|---------|
|
||
| 1 | Firefox vs Chrome 的 native code 格式差异 | Camoufox 基于 Firefox,toString 含换行缩进;jsdom 默认输出 Chrome 格式单行 | markNative 输出 Firefox 格式:`function name() {\n [native code]\n}` |
|
||
| 2 | cacheOpts 未传入导致路径注册失败 | 旧版只需 `bdms.paths`,新版必须同时传 `cacheOpts` | 从页面 inline script 提取完整 cacheOpts 配置 |
|
||
| 3 | fetch 拦截器未 Hook 导致 X-Gnarly 丢失 | 只 Hook 了 XHR,漏掉 fetch 通道 | 同时 Hook `XMLHttpRequest.prototype.open` 和 `window.fetch` |
|
||
| 4 | TLS 指纹不匹配 | axios 的 TLS 指纹被识别为 Node.js | 使用 got-scraping 模拟 Firefox TLS 指纹 |
|
||
| 5 | navigator.buildID 缺失 | Firefox 独有属性,jsdom 不提供 | `Object.defineProperty(navigator, 'buildID', { value: '20230927232528' })` |
|
||
| 6 | CSS2Properties 类型检测 | Firefox 用 CSS2Properties,Chrome 用 CSSStyleDeclaration | 在 jsdom 中注册 `win.CSS2Properties = win.CSSStyleDeclaration` 别名 |
|
||
|
||
---
|
||
|
||
## 变体说明
|
||
|
||
| 变体 | 差异点 | 影响 |
|
||
|------|--------|------|
|
||
| 国内版(仅 a_bogus) | 无 X-Gnarly,无 cacheOpts,仅 bdms.paths 配置 | 参考 `jsvmp-xhr-interceptor-env-emulation.md` 案例 |
|
||
| 旧版国际版(无 cacheOpts) | 有 X-Bogus 但初始化走旧版 bdms.paths | 初始化方式不同,签名逻辑相同 |
|
||
| SDK 版本迭代 | webmssdk 版本号更新,字节码变化 | 环境检测项可能增加,需重新 compare_env |
|
||
| Chrome 环境伪装 | 如果不用 Camoufox 而用 Chrome 采集基准 | markNative 改回 Chrome 单行格式 |
|
||
|
||
---
|
||
|
||
## 浏览器指纹采集方法
|
||
|
||
使用 camoufox-reverse MCP 在 Firefox 内核浏览器中采集指纹:
|
||
|
||
```
|
||
步骤 1: launch_browser({headless: false, os_type: "macos", locale: "zh-CN"})
|
||
步骤 2: navigate({url: "目标页面", wait_until: "domcontentloaded"})
|
||
步骤 3: 分批采集环境指纹(5 批次)
|
||
```
|
||
|
||
### 批次 A-D:同 jsvmp-xhr-interceptor-env-emulation.md
|
||
|
||
### 批次 E(新增):Firefox 特有属性
|
||
|
||
```javascript
|
||
JSON.stringify({
|
||
nav_buildID: navigator.buildID,
|
||
css2props_type: typeof window.CSS2Properties,
|
||
fn_toString_format: document.createElement.toString(),
|
||
// Firefox: "function createElement() {\n [native code]\n}"
|
||
// Chrome: "function createElement() { [native code] }"
|
||
mozInnerScreenX: window.mozInnerScreenX,
|
||
mozInnerScreenY: window.mozInnerScreenY,
|
||
InstallTrigger: typeof window.InstallTrigger
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 环境检测差异全表(62 项)
|
||
|
||
### 继承自基础案例的 58 项
|
||
|
||
参考 `jsvmp-xhr-interceptor-env-emulation.md` 的完整 58 项差异表。
|
||
|
||
### 新增 Firefox 特有检测(4 项)
|
||
|
||
| 检测点 | Firefox 真实值 | jsdom 原始值 | 修复方式 |
|
||
|--------|---------------|-------------|---------|
|
||
| `Function.prototype.toString` 格式 | `function name() {\n [native code]\n}` | 暴露实现代码 | markNative 输出 Firefox 格式(含 `\n` 和 4 空格缩进) |
|
||
| `navigator.buildID` | `"20230927232528"` | `undefined` | `Object.defineProperty` 设置 |
|
||
| `window.CSS2Properties` | `function CSS2Properties()` | `undefined` | 别名指向 `CSSStyleDeclaration` |
|
||
| `window.InstallTrigger` | `undefined`(已废弃但 typeof 仍返回 `"undefined"` 而非报错) | 不存在 | 无需修复(行为一致) |
|
||
|
||
---
|
||
|
||
## 关键经验总结
|
||
|
||
### 1. Firefox 格式 native code 是本案例的核心突破点
|
||
|
||
Camoufox 基于 Firefox 内核,其 `Function.prototype.toString` 对原生函数返回的格式与 Chrome 不同。JSVMP 会检测这个格式来判断浏览器类型。如果 jsdom 的 markNative 输出 Chrome 格式,会导致浏览器类型判断不一致,签名被拒。
|
||
|
||
### 2. 双签名 = 双通道拦截
|
||
|
||
X-Bogus 通过 XHR 拦截器追加到 URL,X-Gnarly 通过 fetch 拦截器注入到 header。必须同时 Hook 两个通道才能截获完整签名。
|
||
|
||
### 3. cacheOpts 是新版初始化的关键
|
||
|
||
旧版只需 `bdms.paths`,新版必须传入 `cacheOpts` 才能正确注册业务路径。缺少 cacheOpts 会导致拦截器不触发。
|
||
|
||
### 4. got-scraping 解决 TLS 指纹问题
|
||
|
||
Node.js 原生 HTTP 客户端的 TLS 指纹会被识别。使用 got-scraping 可以模拟 Firefox 的 TLS Client Hello 指纹,与 Camoufox 采集的环境保持一致。
|