feat(v2.4.0): JSVMP 双路径攻克体系 + jsdom 环境补丁知识库
基于实战经验重构 JSVMP 分析框架: - SKILL.md: 新增路径选择决策树(路径 A 算法追踪 vs 路径 B 环境伪装) - SKILL.md: 路径 B「环境伪装六步法」完整方法论(采集→对比→分级→补丁→验证→端到端) - SKILL.md: 新增场景 10「JSVMP + jsdom 环境伪装」、解法模式 E - SKILL.md: 标注三板斧适用边界与局限,经验法则扩充至 22 条 - references/jsdom-env-patches.md: 新增 jsdom 环境补丁知识库(Function.toString 三层防御 / plugins 完整模拟 / DOM 布局属性 / 30+ API 存根) - cases/: 新增经验库系统(模板 + 首个 JSVMP 实战案例含 58 项环境检测差异全表) - references/jsvmp-analysis.md: JSVMP 专项分析详细指南 - references/troubleshooting.md: 常见问题排查指南 - templates/python-request/: Python 项目模板 - 各 references 文档增强、Python 技术栈支持、MCP v1.1 适配(44→52 工具) Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# 逆向经验库(Cases)
|
||||
|
||||
本目录存放已验证的逆向分析经验案例,供 Phase 0.5 指纹匹配阶段自动检索。
|
||||
|
||||
## 使用方式
|
||||
|
||||
- Agent 在 Phase 0.5 采集目标站点的技术指纹后,遍历本目录下的 `.md` 文件,匹配「指纹检测规则」段
|
||||
- 命中后按案例中的「已验证定位路径」和「还原代码模板」快速执行
|
||||
- 每个案例以**技术特征**命名,不以具体域名命名
|
||||
|
||||
## 案例索引
|
||||
|
||||
| 案例文件 | 技术特征 | 难度 | 核心方案 |
|
||||
|---------|---------|------|---------|
|
||||
| [jsvmp-xhr-interceptor-env-emulation.md](jsvmp-xhr-interceptor-env-emulation.md) | JSVMP 字节码虚拟机 + XHR 拦截器 + 多层 SDK 联动 + jsdom 全量环境伪装 | ★★★★★ | jsdom 沙箱 + 58 项环境补丁 + XHR Hook 截出 a_bogus |
|
||||
|
||||
## 私有映射
|
||||
|
||||
`_private_mapping.json` 可用于本地私有标注域名与案例的对应关系(已加入 `.gitignore`,不会被提交):
|
||||
|
||||
```json
|
||||
{
|
||||
"某短视频平台": {
|
||||
"pattern": "jsvmp-xhr-interceptor-env-emulation",
|
||||
"domain_hint": "短视频",
|
||||
"notes": "Cookie 字段 ttwid/__ac_nonce/__ac_signature"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 新增案例
|
||||
|
||||
1. 复制 `_template.md` 为新文件,以技术特征命名
|
||||
2. 按模板格式填写各段
|
||||
3. 更新本文件的案例索引表
|
||||
4. 可选:在 `_private_mapping.json` 中添加私有域名映射
|
||||
|
||||
### 跨端知识提取提示词
|
||||
|
||||
当你在其他大模型端完成了一个站点的逆向分析,可以使用以下提示词让它输出可沉淀的结构化信息:
|
||||
|
||||
```
|
||||
请将本次逆向分析的经验总结为结构化的技术案例,按以下格式输出。注意:不要包含具体域名、URL、真实密钥等敏感信息,用抽象描述代替。
|
||||
|
||||
---
|
||||
|
||||
## 案例名称
|
||||
(用技术特征命名,如"OB混淆+AES-CBC+动态密钥"、"JSVMP+Cookie生成")
|
||||
|
||||
## 技术指纹
|
||||
(列出可用于自动检测的稳定特征,每条写成可搜索的模式)
|
||||
- JS特征: (如"_0x前缀变量大量出现"、"单文件200KB+"、"存在while-switch解释器循环")
|
||||
- 参数特征: (如"sign参数,32位hex,疑似MD5"、"token参数,Base64格式")
|
||||
- 请求特征: (如"存在/api/init预热请求"、"Cookie中有动态字段__ac_xxx")
|
||||
- 反调试特征: (如"debugger定时器"、"console.log检测")
|
||||
- 混淆类型: (如"OB混淆v2"、"JSVMP"、"webpack打包+变量混淆")
|
||||
|
||||
## 加密方案
|
||||
- 算法: (如AES-CBC、MD5、HMAC-SHA256、RSA等)
|
||||
- 密钥来源: (硬编码/接口下发/动态计算/从页面DOM提取)
|
||||
- 加密流程: (明文如何组装 → 如何加密 → 如何拼接到请求中)
|
||||
- 签名公式: (如 sign = MD5(path + timestamp + nonce + secret).toLowerCase())
|
||||
|
||||
## 定位路径
|
||||
(还原过程中最高效的定位方法,按执行顺序)
|
||||
1. 第一步做了什么,搜索了什么关键词
|
||||
2. 第二步怎么找到的关键函数
|
||||
3. 第三步怎么确认的算法
|
||||
|
||||
## 还原代码
|
||||
(脱敏后的核心还原函数,可直接复用)
|
||||
|
||||
## 踩坑记录
|
||||
(遇到的坑和解决方法,每条一个)
|
||||
|
||||
## 变体说明
|
||||
(同类站点的已知变体差异)
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
将输出内容发给本 Skill 的使用者,即可按 `_template.md` 格式沉淀到本目录。
|
||||
@@ -0,0 +1,120 @@
|
||||
# [案例名称:用技术特征命名,如 OB混淆+AES-CBC+动态密钥]
|
||||
|
||||
> 难度:★☆☆☆☆ ~ ★★★★★
|
||||
> 还原方案:[A: 纯算法还原 / B: 沙箱执行 / C: WASM 加载 / D: 浏览器自动化 / E: jsdom 环境模拟]
|
||||
> 实现语言:[Node.js / Python / 双语言]
|
||||
> 最后验证日期:YYYY-MM-DD
|
||||
|
||||
---
|
||||
|
||||
## 技术指纹(供 Phase 0.5 自动匹配)
|
||||
|
||||
### JS 特征
|
||||
- [ ] 特征1:描述 + 可搜索的关键词/正则
|
||||
- [ ] 特征2:...
|
||||
|
||||
### 参数特征
|
||||
- [ ] 参数名: [名称] | 长度: [N] | 字符集: [hex/base64/自定义] | 编码: [描述]
|
||||
- [ ] ...
|
||||
|
||||
### 请求特征
|
||||
- [ ] 特征1:如预热接口、XHR 拦截、多层 SDK 加载顺序
|
||||
- [ ] ...
|
||||
|
||||
### 反调试特征
|
||||
- [ ] 特征1:如 debugger 陷阱、Function.toString 检测
|
||||
- [ ] ...
|
||||
|
||||
### 混淆类型
|
||||
- [ ] 类型:OB混淆 / JSVMP / 控制流平坦化 / eval打包 / 其他
|
||||
|
||||
### 指纹检测规则(Agent 执行)
|
||||
|
||||
```
|
||||
快速检测命令(30秒内完成):
|
||||
- search_code(keyword="关键词1") → 命中条件
|
||||
- search_code(keyword="关键词2") → 命中条件
|
||||
- list_scripts → 文件体积条件
|
||||
匹配判定:以上 N 项中命中 M 项以上 → 高置信度匹配
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 加密方案
|
||||
|
||||
- **算法**:[具体算法名称和模式]
|
||||
- **密钥来源**:[硬编码 / 接口下发 / 动态计算 / 环境指纹]
|
||||
- **加密流程**:
|
||||
1. [步骤1]
|
||||
2. [步骤2]
|
||||
3. ...
|
||||
- **签名公式**:`sign = 算法(参数1 + 参数2 + ... + 密钥)`(如可提取)
|
||||
|
||||
---
|
||||
|
||||
## 已验证定位路径(Phase 0.5 命中后直接执行)
|
||||
|
||||
```
|
||||
步骤 1: [具体 MCP 操作和关键词]
|
||||
步骤 2: [...]
|
||||
步骤 3: [...]
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 还原代码模板
|
||||
|
||||
### 核心函数
|
||||
|
||||
```javascript
|
||||
// Node.js 还原代码
|
||||
```
|
||||
|
||||
```python
|
||||
# Python 还原代码(如适用)
|
||||
```
|
||||
|
||||
### 环境补丁(如需要)
|
||||
|
||||
```javascript
|
||||
// 环境模拟代码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
| # | 坑 | 现象 | 解决方法 |
|
||||
|---|---|------|---------|
|
||||
| 1 | | | |
|
||||
| 2 | | | |
|
||||
|
||||
---
|
||||
|
||||
## 变体说明
|
||||
|
||||
| 变体 | 差异点 | 影响 |
|
||||
|------|--------|------|
|
||||
| A | | |
|
||||
| B | | |
|
||||
|
||||
---
|
||||
|
||||
## 浏览器指纹采集方法(如涉及环境伪装)
|
||||
|
||||
> 仅当还原方案涉及环境模拟(jsdom / vm 沙箱)时需要此段
|
||||
|
||||
```javascript
|
||||
// 使用 camoufox-reverse MCP 在真实浏览器中采集指纹的代码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境检测差异表(如涉及环境伪装)
|
||||
|
||||
> 仅当还原方案涉及环境模拟时需要此段
|
||||
|
||||
| 检测点 | 真实浏览器值 | 模拟环境原始值 | 修复方式 |
|
||||
|--------|-------------|---------------|---------|
|
||||
| | | | |
|
||||
@@ -0,0 +1,515 @@
|
||||
# JSVMP 字节码虚拟机 + XHR 拦截器 + 多层 SDK 联动 + jsdom 全量环境伪装
|
||||
|
||||
> 难度:★★★★★
|
||||
> 还原方案:E: jsdom 环境模拟(喂入-截出策略)
|
||||
> 实现语言:Node.js
|
||||
> 最后验证日期:2026-04-01
|
||||
> 平台类型:短视频平台
|
||||
|
||||
---
|
||||
|
||||
## 技术指纹(供 Phase 0.5 自动匹配)
|
||||
|
||||
### JS 特征
|
||||
- [x] `window['_$webrt_1668687510']` — JSVMP 解释器入口函数,接受十六进制字节码字符串
|
||||
- [x] 单文件 380KB+,包含 `'HNOJ@?RC'` 魔数校验
|
||||
- [x] `_0x` 前缀变量大量出现(构造函数、工厂函数、闭包变量)
|
||||
- [x] UMD 模式导出到 `window.byted_acrawler`(浏览器)或 `module.exports`(Node.js)
|
||||
- [x] 存在 while-switch 风格的 JSVMP 解释器循环(`_0x1218ef` / `_0x5f1fc4` 函数)
|
||||
- [x] 内部状态对象含 `bogusIndex`、`msNewTokenList`、`moveList`、`clickList`、`activeState`
|
||||
- [x] 三层 SDK 脚本联动:`sdk-glue.js`(100KB)→ `bdms.js`(147KB)→ `webmssdk.es5.js`(387KB)
|
||||
|
||||
### 参数特征
|
||||
- [x] `a_bogus`:约 180~192 字符,Base64 变体编码(含 `+`、`/`、`=`)
|
||||
- [x] `X-Bogus`(旧版):16 字符,字母数字(由 `frontierSign()` 生成,非 JSVMP 拦截器)
|
||||
- [x] `msToken`:约 140 字符,Base64 编码,来自目标域名的 Cookie
|
||||
- [x] `verifyFp` / `fp`:格式 `verify_xxxx_xxxx_xxxx_xxxx_xxxxxxxxxxxx`
|
||||
- [x] `UIFID`:约 256 字符 hex 编码,浏览器指纹标识
|
||||
|
||||
### 请求特征
|
||||
- [x] 页面 inline script 调用 `window._SdkGlueInit(config, resources)` 完成初始化
|
||||
- [x] 初始化配置含 `bdms.paths` 数组(正则路径列表,如 `'^/aweme/v1/'`)
|
||||
- [x] JSVMP 通过修改 `XMLHttpRequest.prototype.open` 实现请求拦截
|
||||
- [x] 拦截器仅对匹配 `enablePathList` 的 URL 追加 `a_bogus`
|
||||
- [x] 默认仅拦截 `/web/report`,需通过 `_SdkGlueInit` 注入业务路径
|
||||
- [x] Cookie 中包含动态字段:`ttwid`、`__ac_nonce`、`__ac_signature`、`s_v_web_id`、`odin_tt`
|
||||
|
||||
### 反调试特征
|
||||
- [x] JSVMP 字节码执行,无法直接设置断点
|
||||
- [x] debugger 定时器陷阱(可通过 `bypass_debugger_trap` 绕过)
|
||||
- [x] 环境检测(58 项):`Function.prototype.toString` 原生性检查、`navigator.webdriver`、`navigator.plugins.length`、`document.hasFocus()`、DOM `offsetHeight/Width` 零值检测、`Object.prototype.toString` 标签、`window.chrome` 存在性、`Performance.timing` 对象、Canvas/WebGL 指纹、AudioContext 指纹等
|
||||
- [x] 非真实浏览器环境生成的签名会被服务端**静默拒绝**(返回 HTTP 200 + 空 body,不报错)
|
||||
|
||||
### 混淆类型
|
||||
- [x] JSVMP(JavaScript Virtual Machine Protection),字节码存储为十六进制字符串,由解释器 `_$webrt_1668687510` 运行时执行
|
||||
|
||||
### 指纹检测规则(Agent 执行)
|
||||
|
||||
```
|
||||
快速检测命令(30秒内完成):
|
||||
- search_code(keyword="webmssdk") → 命中 → 直接定位本案例
|
||||
- search_code(keyword="byted_acrawler") → 命中 → 直接定位本案例
|
||||
- search_code(keyword="_SdkGlueInit") → 命中 → 直接定位本案例
|
||||
- search_code(keyword="a_bogus") → 命中 + 参数长度 180-192 → 高置信度
|
||||
- list_scripts → 存在 380KB+ 文件 → 辅助确认
|
||||
匹配判定:以上前 3 项命中任意 1 项 → 高置信度匹配
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 加密方案
|
||||
|
||||
- **算法**:JSVMP 自定义字节码虚拟机,内部算法不可直接读取。签名结果为 `a_bogus`(~192 字符 Base64 变体)或 `X-Bogus`(16 字符)
|
||||
- **密钥来源**:动态计算 — 由浏览器环境指纹(Canvas/WebGL/navigator/screen 等数十项数据)+ 请求 URL query string + User-Agent + 时间戳共同生成
|
||||
- **加密流程**:
|
||||
1. `sdk-glue` 通过 `_SdkGlueInit(config, resources)` 初始化,注入拦截路径列表
|
||||
2. `webmssdk` JSVMP 构造函数执行字节码,修改 `XMLHttpRequest.prototype.open`
|
||||
3. 当 XHR 请求 URL 匹配 `enablePathList` 中的正则时,拦截器触发
|
||||
4. JSVMP 读取完整 URL query string、`document.cookie`、`navigator.*` 属性、`screen.*` 属性等数十项环境指纹
|
||||
5. JSVMP 内部通过字节码计算生成 `a_bogus` 值(含环境指纹哈希)
|
||||
6. 拦截器将 `&a_bogus=xxx` 追加到 URL 末尾,再调用原始 `open`
|
||||
- **签名公式**:无法提取(JSVMP 字节码保护,算法封装在虚拟机内部)。采用「喂入-截出」策略:在 jsdom 中执行原始 JSVMP 字节码,通过 XHR Hook 截获最终签名值
|
||||
|
||||
---
|
||||
|
||||
## 已验证定位路径(Phase 0.5 命中后直接执行)
|
||||
|
||||
### Phase 1:网络捕获定位接口
|
||||
|
||||
```
|
||||
步骤 1: start_network_capture + list_network_requests → 捕获带 a_bogus 的完整请求 URL
|
||||
步骤 2: search_code(keyword="bdms") → 找到三个关键脚本的 CDN 地址:
|
||||
- webmssdk.es5.js (v1.0.0.20, 387KB) — JSVMP 签名引擎
|
||||
- bdms.js (v1.0.1.19, 147KB) — ByteDance Monitoring System
|
||||
- sdk-glue.js (v1.0.0.64, 100KB) — SDK 胶水层
|
||||
```
|
||||
|
||||
### Phase 2:初始化链路还原
|
||||
|
||||
```
|
||||
步骤 3: search_code(keyword="enablePathList") → 定位到 sdk-glue 中的路径配置机制
|
||||
步骤 4: 页面 inline script 中找到 _SdkGlueInit 调用及完整的 paths 配置
|
||||
步骤 5: search_code(keyword="frontierSign") → 找到 webmssdk 的导出结构:init() / frontierSign() / setConfig()
|
||||
步骤 6: search_code(keyword="bogusIndex") → 确认 a_bogus 由 JSVMP 的 XHR 拦截器生成(不同于 frontierSign 生成的 X-Bogus)
|
||||
```
|
||||
|
||||
### Phase 3:jsdom 沙箱验证
|
||||
|
||||
```
|
||||
步骤 7: 在 jsdom (runScripts: 'dangerously') 中依次加载三个脚本并调用 _SdkGlueInit,
|
||||
成功触发 JSVMP 的 XHR 拦截器生成 192 字符的 a_bogus
|
||||
步骤 8: 浏览器生成的 a_bogus 用 Node.js axios 请求成功 → 确认非 TLS 问题
|
||||
步骤 9: jsdom 生成的 a_bogus 被服务端拒绝(返回空 body)→ 确认是环境指纹差异
|
||||
```
|
||||
|
||||
### Phase 4:环境指纹对比(核心突破点)
|
||||
|
||||
```
|
||||
步骤 10: 用 camoufox-reverse MCP 启动反检测浏览器,navigate 到目标页面
|
||||
步骤 11: 通过 evaluate_js 分批采集浏览器完整环境指纹
|
||||
(navigator 24项 / screen 10项 / window 40项 / document 16项 /
|
||||
performance 12项 / toString 7项 / Function.toString 6项 /
|
||||
DOM布局 6项 / Canvas+WebGL+Audio 各项)
|
||||
步骤 12: 在 jsdom 中运行完全相同的采集代码
|
||||
步骤 13: 逐项对比发现 58 项差异,按影响分级修复
|
||||
```
|
||||
|
||||
### Phase 5:环境补丁与验证
|
||||
|
||||
```
|
||||
步骤 14: 编写 patchEnvironment() 函数修复全部 58 项差异
|
||||
步骤 15: 从 jsdom 内部验证所有检测点通过(win.eval() 内执行 Function.prototype.toString 等)
|
||||
步骤 16: 连续 5 次请求同一视频 ID,全部返回完整数据(35KB+),确认稳定性
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 还原代码模板
|
||||
|
||||
### 核心函数:markNative — 将 jsdom 函数伪装为浏览器原生函数
|
||||
|
||||
```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 || '';
|
||||
Object.defineProperty(fn, 'toString', {
|
||||
value: function () { return `function ${name}() { [native code] }`; },
|
||||
writable: true, configurable: true,
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
// jsdom 内部函数的通用源码特征(不在 WeakSet 中的漏网之鱼)
|
||||
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)) {
|
||||
return `function ${this.name || ''}() { [native code] }`;
|
||||
}
|
||||
let src;
|
||||
try { src = _origFnToString.call(this); } catch (e) {
|
||||
return 'function () { [native code] }';
|
||||
}
|
||||
for (const pat of jsdomPatterns) {
|
||||
if (pat.test(src)) return `function ${this.name || ''}() { [native code] }`;
|
||||
}
|
||||
return src;
|
||||
};
|
||||
```
|
||||
|
||||
### 核心函数:深度扫描原型链标记所有 jsdom 内置方法
|
||||
|
||||
```javascript
|
||||
function scanPrototypeChain(obj, maxDepth) {
|
||||
let proto = obj;
|
||||
for (let d = 0; d < (maxDepth || 10) && proto; d++) {
|
||||
for (const name of Object.getOwnPropertyNames(proto)) {
|
||||
try {
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, name);
|
||||
if (desc && typeof desc.value === 'function') markNative(desc.value);
|
||||
if (desc && typeof desc.get === 'function') markNative(desc.get);
|
||||
if (desc && typeof desc.set === 'function') markNative(desc.set);
|
||||
} catch (e) {}
|
||||
}
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
if (proto === Object.prototype || proto === null) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描 50+ 原型链,覆盖 jsdom 所有内置类
|
||||
const protoTargets = [
|
||||
win.Document?.prototype, win.HTMLDocument?.prototype,
|
||||
win.Element?.prototype, win.HTMLElement?.prototype,
|
||||
win.Node?.prototype, win.EventTarget?.prototype,
|
||||
win.XMLHttpRequest?.prototype, win.HTMLCanvasElement?.prototype,
|
||||
// ... 扫描 50+ 个 DOM 类的原型链
|
||||
];
|
||||
for (const proto of protoTargets) {
|
||||
if (proto) scanPrototypeChain(proto, 3);
|
||||
}
|
||||
scanPrototypeChain(doc, 5); // document 实例自身
|
||||
scanPrototypeChain(nav, 5); // navigator 实例自身
|
||||
```
|
||||
|
||||
### 核心函数:navigator.plugins 完整模拟
|
||||
|
||||
```javascript
|
||||
const pluginData = [
|
||||
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
];
|
||||
const mimeData = [
|
||||
{ type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf' },
|
||||
{ type: 'text/pdf', description: 'Portable Document Format', suffixes: 'pdf' },
|
||||
];
|
||||
|
||||
// 构建完整的 PluginArray → Plugin → MimeType 对象树
|
||||
// 包含 item()/namedItem()/refresh() 方法 + Symbol.toStringTag
|
||||
```
|
||||
|
||||
### 核心函数:a_bogus 生成(喂入-截出策略)
|
||||
|
||||
```javascript
|
||||
function generateABogus(fullUrl, cookieStr) {
|
||||
const dom = initSdk(); // jsdom + 环境补丁 + SDK 加载(单例)
|
||||
const win = dom.window;
|
||||
|
||||
// 写入 Cookie 供 JSVMP 读取
|
||||
if (cookieStr) {
|
||||
cookieStr.split(';').forEach(c => {
|
||||
try { win.document.cookie = c.trim(); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
win.__capturedUrls = [];
|
||||
// 在 jsdom 内发起 XHR,触发 JSVMP 拦截器自动追加 a_bogus
|
||||
win.eval(`(function(){
|
||||
var x = new XMLHttpRequest();
|
||||
x.open('GET','${fullUrl.replace(/'/g, "\\'")}',true);
|
||||
x.send();
|
||||
})()`);
|
||||
|
||||
setTimeout(() => {
|
||||
let aBogus = null;
|
||||
for (const url of win.__capturedUrls) {
|
||||
const m = url.match(/[&?]a_bogus=([^&]+)/);
|
||||
if (m) { aBogus = decodeURIComponent(m[1]); break; }
|
||||
}
|
||||
resolve(aBogus);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
| # | 坑 | 现象 | 解决方法 |
|
||||
|---|---|------|---------|
|
||||
| 1 | Function.prototype.toString 暴露 jsdom | JSVMP 检测到非原生函数,生成无效签名 | 三层防御:WeakSet 精确标记 + jsdom 源码模式正则 + 实例级 Object.defineProperty |
|
||||
| 2 | navigator.plugins 结构不完整 | 不是简单的 `length=5`,JSVMP 遍历 `item()`/`namedItem()` 并检查 `Symbol.toStringTag` | 构建完整 PluginArray→Plugin→MimeType 对象树 |
|
||||
| 3 | DOM 布局属性全为 0 | jsdom 无渲染引擎,`offsetHeight`/`Width`/`getBoundingClientRect` 返回 0 | HTMLElement.prototype getter 覆写,解析 style 或返回默认非零值 |
|
||||
| 4 | Object.prototype.toString 标签错误 | jsdom 的 document 原型链是 `Document` 而非 `HTMLDocument`,screen 是普通对象 | `Symbol.toStringTag` 修复 + 原型链 `constructor.name` 修改 |
|
||||
| 5 | XHR Hook 顺序导致截获失败 | webmssdk 保存的是 Hook 之前的原始 `open`,我方 Hook 截获不到最终 URL | 必须在 webmssdk 加载前完成 XHR Hook 安装 |
|
||||
| 6 | 服务端静默拒绝 | 环境不完整时返回 HTTP 200 + 空 body,不返回错误码 | 通过对比 Camoufox 真实环境逐项排查 58 项差异 |
|
||||
|
||||
---
|
||||
|
||||
## 变体说明
|
||||
|
||||
| 变体 | 差异点 | 影响 |
|
||||
|------|--------|------|
|
||||
| X-Bogus(旧版) | 由 `frontierSign()` 生成的 16 字符签名,非 JSVMP 拦截器 | 较简单,可通过 Hook `frontierSign` 入参直接还原 |
|
||||
| 不同业务线 | `enablePathList` 配置不同,不同业务接口使用不同路径正则 | 需从 `_SdkGlueInit` 调用中提取完整 paths 配置 |
|
||||
| SDK 版本迭代 | `webmssdk` 版本号更新,字节码变化 | 环境检测项可能增加,需重新对比 `compare_env` |
|
||||
|
||||
---
|
||||
|
||||
## 浏览器指纹采集方法
|
||||
|
||||
使用 camoufox-reverse MCP 在真实浏览器中采集指纹的完整流程:
|
||||
|
||||
```
|
||||
步骤 1: launch_browser({headless: false, os_type: "macos", locale: "zh-CN"})
|
||||
步骤 2: navigate({url: "目标页面", wait_until: "domcontentloaded"})
|
||||
步骤 3: 分批采集环境指纹(通过 evaluate_js 在页面上下文中执行)
|
||||
```
|
||||
|
||||
### 批次 A: navigator 属性
|
||||
|
||||
```javascript
|
||||
JSON.stringify({
|
||||
nav_userAgent: navigator.userAgent,
|
||||
nav_platform: navigator.platform,
|
||||
nav_language: navigator.language,
|
||||
nav_languages: navigator.languages,
|
||||
nav_cookieEnabled: navigator.cookieEnabled,
|
||||
nav_doNotTrack: navigator.doNotTrack,
|
||||
nav_onLine: navigator.onLine,
|
||||
nav_hardwareConcurrency: navigator.hardwareConcurrency,
|
||||
nav_maxTouchPoints: navigator.maxTouchPoints,
|
||||
nav_webdriver: navigator.webdriver,
|
||||
nav_vendor: navigator.vendor,
|
||||
nav_productSub: navigator.productSub,
|
||||
nav_appVersion: navigator.appVersion,
|
||||
nav_deviceMemory: navigator.deviceMemory,
|
||||
nav_plugins_length: navigator.plugins ? navigator.plugins.length : -1,
|
||||
nav_mimeTypes_length: navigator.mimeTypes ? navigator.mimeTypes.length : -1,
|
||||
nav_pdfViewerEnabled: navigator.pdfViewerEnabled,
|
||||
nav_connection_type: typeof navigator.connection,
|
||||
nav_getBattery_type: typeof navigator.getBattery,
|
||||
nav_userAgentData_type: typeof navigator.userAgentData
|
||||
})
|
||||
```
|
||||
|
||||
### 批次 B: screen + window 属性
|
||||
|
||||
```javascript
|
||||
JSON.stringify({
|
||||
scr_width: screen.width, scr_height: screen.height,
|
||||
scr_availWidth: screen.availWidth, scr_availHeight: screen.availHeight,
|
||||
scr_colorDepth: screen.colorDepth, scr_pixelDepth: screen.pixelDepth,
|
||||
scr_availLeft: screen.availLeft, scr_availTop: screen.availTop,
|
||||
scr_orientation_type: screen.orientation ? screen.orientation.type : undefined,
|
||||
win_innerWidth: window.innerWidth, win_innerHeight: window.innerHeight,
|
||||
win_outerWidth: window.outerWidth, win_outerHeight: window.outerHeight,
|
||||
win_devicePixelRatio: window.devicePixelRatio,
|
||||
win_chrome: typeof window.chrome,
|
||||
win_Notification: typeof window.Notification,
|
||||
win_SharedWorker: typeof window.SharedWorker,
|
||||
win_WebSocket: typeof window.WebSocket,
|
||||
win_RTCPeerConnection: typeof window.RTCPeerConnection,
|
||||
win_AudioContext: typeof window.AudioContext,
|
||||
win_OfflineAudioContext: typeof window.OfflineAudioContext,
|
||||
win_fetch: typeof window.fetch,
|
||||
win_Worker: typeof window.Worker,
|
||||
win_isSecureContext: window.isSecureContext,
|
||||
win_matchMedia: typeof window.matchMedia,
|
||||
win_indexedDB: typeof window.indexedDB,
|
||||
win_visualViewport: typeof window.visualViewport,
|
||||
win_clientInformation: typeof window.clientInformation
|
||||
})
|
||||
```
|
||||
|
||||
### 批次 C: document + performance + toString + Function.toString
|
||||
|
||||
```javascript
|
||||
JSON.stringify({
|
||||
doc_hidden: document.hidden,
|
||||
doc_visibilityState: document.visibilityState,
|
||||
doc_hasFocus: document.hasFocus(),
|
||||
doc_characterSet: document.characterSet,
|
||||
doc_compatMode: document.compatMode,
|
||||
doc_readyState: document.readyState,
|
||||
doc_fullscreenEnabled: document.fullscreenEnabled,
|
||||
doc_fonts_type: typeof document.fonts,
|
||||
doc_timeline_type: typeof document.timeline,
|
||||
perf_timing_type: typeof performance.timing,
|
||||
perf_navigation_type: typeof performance.navigation,
|
||||
perf_memory_type: typeof performance.memory,
|
||||
toString_nav: Object.prototype.toString.call(navigator),
|
||||
toString_doc: Object.prototype.toString.call(document),
|
||||
toString_win: Object.prototype.toString.call(window),
|
||||
toString_scr: Object.prototype.toString.call(screen),
|
||||
toString_perf: Object.prototype.toString.call(performance),
|
||||
fn_eval: eval.toString().substring(0, 60),
|
||||
fn_setTimeout: setTimeout.toString().substring(0, 60),
|
||||
fn_createElement: document.createElement.toString().substring(0, 80),
|
||||
FnProto_toString: Function.prototype.toString.call(Function.prototype.toString).substring(0, 80),
|
||||
nav_proto: Object.getPrototypeOf(navigator).constructor.name,
|
||||
doc_proto: Object.getPrototypeOf(document).constructor.name,
|
||||
nav_toStringTag: navigator[Symbol.toStringTag],
|
||||
doc_toStringTag: document[Symbol.toStringTag]
|
||||
})
|
||||
```
|
||||
|
||||
### 批次 D: DOM 布局 + Canvas + WebGL + Audio
|
||||
|
||||
```javascript
|
||||
JSON.stringify({
|
||||
dom_test: (function(){
|
||||
var d = document.createElement('div');
|
||||
d.style.cssText = 'width:100px;height:100px;position:absolute;left:-9999px';
|
||||
document.body.appendChild(d);
|
||||
var r = {offsetHeight: d.offsetHeight, offsetWidth: d.offsetWidth,
|
||||
clientHeight: d.clientHeight, clientWidth: d.clientWidth};
|
||||
var rect = d.getBoundingClientRect();
|
||||
r.rect_width = rect.width; r.rect_height = rect.height;
|
||||
document.body.removeChild(d);
|
||||
return r;
|
||||
})(),
|
||||
canvas_test: (function(){
|
||||
var c = document.createElement('canvas'); c.width = 200; c.height = 50;
|
||||
var ctx = c.getContext('2d');
|
||||
ctx.textBaseline = 'top'; ctx.font = '14px Arial';
|
||||
ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20);
|
||||
ctx.fillStyle = '#069'; ctx.fillText('abcdef', 2, 15);
|
||||
return {dataURL_len: c.toDataURL().length};
|
||||
})(),
|
||||
webgl_test: (function(){
|
||||
try {
|
||||
var c = document.createElement('canvas');
|
||||
var gl = c.getContext('webgl');
|
||||
var ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
vendor: gl.getParameter(gl.VENDOR),
|
||||
renderer: gl.getParameter(gl.RENDERER),
|
||||
unmasked_vendor: ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : 'N/A',
|
||||
unmasked_renderer: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : 'N/A'
|
||||
};
|
||||
} catch(e) { return {error: e.message}; }
|
||||
})(),
|
||||
audio_test: (function(){
|
||||
try { var ctx = new OfflineAudioContext(1,44100,44100);
|
||||
return {sampleRate: ctx.sampleRate, available: true};
|
||||
} catch(e) { return {error: e.message}; }
|
||||
})()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境检测差异全表(58 项)
|
||||
|
||||
### 致命级(缺失即被拒)
|
||||
|
||||
| 检测点 | 真实浏览器值 | jsdom 原始值 | 修复方式 |
|
||||
|--------|-------------|-------------|---------|
|
||||
| `Function.prototype.toString` | `function X() { [native code] }` | 暴露实际实现代码(如 `createElement(localName) { const esValue = ...`) | WeakSet 精确标记 + jsdom 源码模式正则 + 实例级 `Object.defineProperty(fn, 'toString')` |
|
||||
| `navigator.webdriver` | `false` | `undefined` | `Object.defineProperty` 强制设为 `false` |
|
||||
| `navigator.plugins.length` | `5` | `0` | 模拟 Chrome 5 个 PDF 插件(含完整 PluginArray/Plugin/MimeType 结构) |
|
||||
| `document.hasFocus()` | `true` | `false` | 方法覆写,始终返回 `true` |
|
||||
| DOM `offsetHeight/Width` | 非零值(如 100) | `0` | `HTMLElement.prototype` getter 覆写,解析 style 或返回默认值 |
|
||||
|
||||
### 高危级(可能参与指纹哈希)
|
||||
|
||||
| 检测点 | 真实浏览器值 | jsdom 原始值 | 修复方式 |
|
||||
|--------|-------------|-------------|---------|
|
||||
| `Object.prototype.toString(document)` | `[object HTMLDocument]` | `[object Document]` | `Symbol.toStringTag` 修复 + 原型链 `constructor.name` 修改 |
|
||||
| `Object.prototype.toString(screen)` | `[object Screen]` | `[object Object]` | 重建 screen 对象并设置 `Symbol.toStringTag` |
|
||||
| `window.chrome` | `{app:{...}, runtime:{...}, csi, loadTimes}` | `undefined` | 完整 Chrome 对象模拟(含 `runtime.OnInstalledReason` 等枚举) |
|
||||
| `navigator.userAgentData` | `NavigatorUAData` 对象 | `undefined` | 模拟 Chrome brands/mobile/platform + `getHighEntropyValues()` |
|
||||
| `navigator.platform` | `MacIntel` | `""` | `Object.defineProperty` 覆盖 |
|
||||
| `navigator.language` | `zh-CN` | `en-US` | `Object.defineProperty` 覆盖 |
|
||||
| `navigator.languages` | `["zh-CN", "zh"]` | `["en-US", "en"]` | `Object.defineProperty` 覆盖 |
|
||||
| `performance.timing` | 完整 `PerformanceTiming` 对象(20+ 字段) | `undefined` | 构造合理的 timing 对象(时间戳递增) |
|
||||
| `performance.navigation` | `PerformanceNavigation` 对象 | `undefined` | 构造 `{type:0, redirectCount:0}` 对象 |
|
||||
| `document.readyState` | `complete` | `loading` | getter 覆写返回 `'complete'` |
|
||||
| `screen.colorDepth/pixelDepth` | `30` | `24` | 重建 screen 对象 |
|
||||
| `screen.orientation` | `{type: 'landscape-primary', angle: 0}` | `undefined` | 重建 screen 对象含 orientation |
|
||||
|
||||
### 中危级(API 存在性检测)
|
||||
|
||||
| 检测点 | jsdom 原始值 | 修复方式 |
|
||||
|--------|-------------|---------|
|
||||
| `window.Notification` | `undefined` | 构造函数存根 + `permission`/`requestPermission` |
|
||||
| `window.Worker` / `SharedWorker` | `undefined` | 构造函数存根 |
|
||||
| `window.RTCPeerConnection` | `undefined` | 构造函数存根 |
|
||||
| `window.AudioContext` / `OfflineAudioContext` | `undefined` | 完整 Web Audio API 存根(含 `createOscillator`/`createDynamicsCompressor`/`startRendering`) |
|
||||
| `window.fetch` | `undefined` | 函数存根 + `Request`/`Response`/`Headers`/`AbortController` |
|
||||
| `window.matchMedia` | `undefined` | 返回 `{matches:false, media:query}` 存根 |
|
||||
| `window.requestIdleCallback` | `undefined` | `setTimeout` 包装 |
|
||||
| `window.indexedDB` | `undefined` | `IDBFactory` 存根 |
|
||||
| `window.caches` | `undefined` | `CacheStorage` 存根 |
|
||||
| `window.speechSynthesis` | `undefined` | `SpeechSynthesis` 存根 |
|
||||
| `window.customElements` | `undefined` | `CustomElementRegistry` 存根 |
|
||||
| `window.visualViewport` | `undefined` | `VisualViewport` 存根 |
|
||||
| `window.isSecureContext` | `undefined` | 设为 `true` |
|
||||
| `window.clientInformation` | `undefined` | 指向 `navigator` |
|
||||
| `navigator.connection` | `undefined` | `NetworkInformation` 存根 |
|
||||
| `navigator.getBattery` | `undefined` | 返回 `BatteryManager` Promise |
|
||||
| `navigator.deviceMemory` | `undefined` | 设为 `8` |
|
||||
| `navigator.maxTouchPoints` | `undefined` | 设为 `0` |
|
||||
| `navigator.pdfViewerEnabled` | `undefined` | 设为 `true` |
|
||||
| `navigator.mimeTypes.length` | `0` | 模拟 2 个 MIME 类型 |
|
||||
| `document.fonts` | `undefined` | `FontFaceSet` 存根 |
|
||||
| `document.timeline` | `undefined` | `DocumentTimeline` 存根 |
|
||||
| `document.fullscreenEnabled` | `undefined` | 设为 `true` |
|
||||
|
||||
---
|
||||
|
||||
## 关键经验总结
|
||||
|
||||
### 1. 为什么不反编译 JSVMP 字节码
|
||||
|
||||
JSVMP 用自定义指令集执行,反编译成本极高且版本迭代频繁。「喂入-截出」策略(在模拟环境中运行原始字节码,只截获 I/O)是性价比最高的方案。
|
||||
|
||||
### 2. jsdom 环境伪装的核心难度排序
|
||||
|
||||
| 排名 | 难度 | 检测项 | 为什么难 |
|
||||
|------|------|--------|---------|
|
||||
| 1 | ★★★★★ | `Function.prototype.toString` | jsdom 所有 DOM 方法都是 JS 实现,`toString()` 会暴露完整源码。需要三层防御:WeakSet 精确匹配 + jsdom 源码正则模式 + 实例级 toString 覆写 |
|
||||
| 2 | ★★★★ | `navigator.plugins` 结构 | 不是简单的 `length=5`,JSVMP 可能遍历 `item()`/`namedItem()` 并检查 `Symbol.toStringTag` |
|
||||
| 3 | ★★★ | DOM 布局属性 | jsdom 无渲染引擎,所有 `offsetHeight`/`Width`/`getBoundingClientRect` 返回 0 |
|
||||
| 4 | ★★★ | `Object.prototype.toString` 标签 | jsdom 的 document 原型链是 `Document` 而非 `HTMLDocument`,screen 是普通对象 |
|
||||
| 5 | ★★ | 30+ 缺失 API | 需要逐个补全,且每个都要 `markNative` 处理 toString |
|
||||
|
||||
### 3. XHR Hook 顺序至关重要
|
||||
|
||||
```
|
||||
我方 Hook → webmssdk 加载(保存我方 Hook 后的 open 引用)→ JSVMP 执行时调用保存的 open
|
||||
↓
|
||||
我方 Hook 拦截到带 a_bogus 的 URL
|
||||
```
|
||||
|
||||
如果 webmssdk 先加载再 Hook,JSVMP 保存的是原始 `open`,我方 Hook 无法截获最终 URL。
|
||||
|
||||
### 4. 对比方法论
|
||||
|
||||
用 Camoufox 反检测浏览器(C++ 引擎级指纹伪装)作为「干净基准」,在目标页面上下文中执行环境采集代码,与 jsdom 输出逐项 diff。分批采集(navigator / screen+window / document+performance+toString / DOM+Canvas+WebGL+Audio)避免单次执行过长。
|
||||
Reference in New Issue
Block a user