前言
我一直使用 AI 辅助 JS 逆向分析。从简单的 OB 混淆、sojson,到单 VM 保护,AI 都能很好地完成任务。
但这次遇到了一个新挑战:多态虚拟机 (Polymorphic VM) 保护。
与单 VM 不同,多态 VM 有 30+ 个独立的 dispatcher,同一个 opcode 在不同 VM 中执行完全不同的操作。如果不使用 AI,这种多态 VM 几乎无法静态分析——光是建立 30 套 opcode 映射表就是噩梦。
但 AI 让多 VM 的静态还原成为可能。本文分享三个核心干货:
一、挑战:多态 VM
1.1 什么是多态 VM?
普通 VM 保护:
|
1 2 |
case 2 → 永远是 PUSH 操作 case 5 → 永远是 POP 操作 |
多态 VM 保护:
|
1 2 3 |
VM_A: case 2 → PUSH VM_B: case 2 → STORE VM_C: case 2 → CALL |
同一个数字,在不同 VM 中含义完全不同。
1.2 难度对比
| 字符串混淆 | 低 | 高 |
| 控制流平坦化 | 中 | 中 |
| 单 VM 保护 | 中高 | 高 |
| 多态 VM | 极高 | 极低 |
1.3 具体挑战
二、方法:AI 辅助分析
2.1 整体流程
|
1 |
AST 解混淆 → 提取结构化数据 → AI 分析模式 → 人工验证 → AI 生成代码 |
2.2 AST 解混淆
工作流程:Gemini 识别混淆类型 → Claude Code 编写 AST 脚本 → 迭代优化
拿到 450KB 的混淆代码后,先让 Gemini 识别混淆类型,然后让 Claude Code 编写 Babel AST 脚本。整个脚本分 9 个 Pass:
| 1 | 字符串解密函数还原 | _4o5l4("xvvw") → "test" |
| 2 | 字符串数组还原 | _1w8l4[42] → "appid" |
| 3 | 常量折叠 | 0x1a2b + 0x3c4d → 数值 |
| 4 | 逗号表达式展开 | (a=1, b=2, c()) → 多行 |
| 5 | 变量语义重命名 | _2n8l4 → Bytecode |
| 6 | 对象字典展开 | _$wR.add(x,y) → x + y |
| 7-9 | 清理 | 死代码移除、未使用变量删除 |
调试技巧:分 Pass 执行,每个 Pass 单独测试,报错信息直接发给 AI 修复。
2.3 AI 分析 VM
解混淆后,用 AST 工具提取:字节码数组、字符串表、VM dispatcher 结构。
然后让 AI 识别:
- 每个 VM 的 opcode 语义
- VM 之间的调用关系
- 关键算法的位置
关键点:用浏览器实际执行结果验证 AI 的分析,发现不一致时针对性重新分析。
2.4 AI + 人工协作模式(重要干货)
这是我在分析过程中摸索出的高效协作模式:
|
1 2 3 4 5 |
┌─────────────┐ 分析代码,定位关键点 ┌─────────────┐ │ │ ──────────────────────────→ │ │ │ AI │ │ 人工 │ │ │ ←────────────────────────── │ │ └─────────────┘ 执行结果、变量值 └─────────────┘ |
具体流程:
AI 分析代码,找到断点位置
- AI 阅读解混淆后的代码
- 识别关键函数入口、可疑的魔改点
- 告诉人工:「在第 X 行设置断点,观察变量 Y 的值」
人工在浏览器中操作
- 打开 DevTools,设置断点
- 触发签名请求
- 记录断点处的变量值、调用栈
把结果反馈给 AI
- 「断点触发了,变量 X = "abc123",调用栈是 A → B → C」
- AI 根据实际值分析逻辑
AI 调整分析方向
- 如果结果符合预期,继续下一个断点
- 如果不符合,AI 重新分析,找新的断点位置
为什么这种模式有效?
- AI 擅长:阅读大量代码、识别模式、推理逻辑
- 人工擅长:操作浏览器、获取运行时数据
- 互补:AI 无法执行 JS,人工不想读混淆代码
实际案例:
发现 _append 魔改就是这样找到的:
|
1 2 3 4 5 6 |
AI: 「SHA256 结果不对,在 _append 函数入口设断点,看看传入的参数」 人: 「参数是 "test",但 this.buffer 变成了 "testXXXXXX"」 AI: 「说明 _append 内部有额外处理,让我分析这个函数…」 AI: 「找到了,_append 调用了 _eData,在 _eData 设断点」 人: 「_eData 返回值是原字符串 + 固定后缀」 AI: 「这就是第二层魔改!」 |
这种「AI 指挥 + 人工执行」的模式,比纯静态分析效率高很多。
三、干货:SHA256 魔改还原
在所有算法还原中,魔改 SHA256 是最难的部分。不是因为 SHA256 本身复杂,而是魔改点极其隐蔽。
3.1 发现过程
最初,我以为只需要找到 SHA256 的调用点,直接用标准库就行。但对比浏览器输出后发现结果不一致:
|
1 2 |
标准 SHA256("test") = 9f86d08… 京东 SHA256("test") = 完全不同的值 |
于是开始追踪 SHA256 的实现。
3.2 第一层魔改:_seData1 预处理
输入数据在进入 SHA256 前,会先经过 _seData1 函数处理,追加 10 个字符的动态后缀:
|
1 |
"test" → "test" + [10字符动态后缀] |
这 10 个字符是根据输入内容计算的,算法是将输入分成 10 块,每块计算字符码累加值,映射到自定义字符表。
3.3 第二层魔改:_eData 预处理(最隐蔽)
找到 _seData1 后,我以为已经完成了。但验证时发现结果还是不对。
问题出在 _append 函数被魔改了。
在标准 SHA256 实现中,_append 只是简单地追加数据。但京东的实现中,_append 内部调用了 _eData,会额外追加一个固定后缀:
|
1 2 3 4 5 6 7 8 9 |
// 标准实现 _append(data) { this.buffer += data; } // 京东魔改 _append(data) { this.buffer += _eData(data); // _eData 会追加固定后缀 } |
这个魔改非常隐蔽,因为:
3.4 第三层魔改:字节交换
最后,SHA256 的输出还会进行字节交换——交换第 0 字节和第 2 字节:
|
1 |
[a, b, c, d, …] → [c, b, a, d, …] |
3.5 完整调用链
|
1 2 3 4 5 6 7 8 9 10 11 |
jd_sha256(input) ↓ _seData1(input) ← 追加 10 字符动态后缀 ↓ _eData(result) ← 追加固定后缀 (隐藏在 _append 中) ↓ 标准 SHA256 ↓ swap(byte[0], byte[2]) ← 字节交换 ↓ 输出 64 位 hex |
3.6 教训
这个案例说明:魔改可能发生在任何地方,包括看起来无害的辅助函数。
如果不是 AI 帮我快速分析了所有相关 VM 的字节码,手动追踪这个调用链可能需要很长时间。
四、VM 架构分析
4.1 整体架构
通过 AI 分析,我梳理出了签名的完整 VM 调用链:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
signSync() 入口 ↓ ┌─────────────────────────────────────────────────────────┐ │ l34 (签名入口 VM) │ │ ↓ │ │ l31 (h5st 组装 VM) ─────────────────────────────────┐ │ │ │ │ │ │ ├─→ l24 (密钥生成 VM) │ │ │ │ └─→ l25 (辅助函数) │ │ │ │ │ │ │ ├─→ l28 (签名计算 VM) ──→ jd_sha256 │ │ │ │ └─→ l29 (辅助函数) │ │ │ │ │ │ │ ├─→ l30 (签名数据 VM) │ │ │ │ │ │ │ ├─→ wm_encode (自定义 Base64 VM) │ │ │ │ │ │ │ └─→ l27 (最终组装 VM) ←─────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ |
4.2 VM 分类
分析了 17 个核心 VM,按功能分类:
签名核心 VM(6 个):
| l34 | 签名入口 | signSync() 的实际实现 |
| l31 | h5st 组装 | 协调各子 VM,组装最终结果 |
| l24 | 密钥生成 | 根据 token、fingerprint 等生成 key |
| l28 | 签名计算 | 调用 jd_sha256 计算签名 |
| l30 | 签名数据 | 生成 signData 字段 |
| l27 | 最终组装 | 拼接 10 个字段生成 h5st |
加密算法 VM(6 个):
| wm_encode | 自定义 Base64 | 5 步变换的编码算法 |
| seData1 | SHA256 预处理 | 追加 10 字符动态后缀 |
| eData | 数据编码 | 追加固定后缀(隐藏在 _append 中) |
| sha256_seData | SHA256 专用预处理 | 配合 seData1 使用 |
| append | 数据追加 | 被魔改的辅助函数 |
| eKey | HMAC 密钥处理 | 密钥相关处理 |
辅助 VM(5 个):l25、l29、l32、l33、l23(环境检测)
4.3 多态 opcode 示例
同一个 opcode 数字在不同 VM 中的含义:
| 2 | PUSH_IMM | – | STORE_wK |
| 5 | DUP_GET_PROP | PUSH_IMM | NE |
| 7 | STORE_wR | SET_PROP_CHAIN | PUSH_STR |
| 12 | POP | PUSH_OBJ | STORE_wF |
这就是为什么叫「多态」——同一个数字,在不同 VM 中执行完全不同的操作。
五、环境检测分析
5.1 检测目的
签名过程中会收集大量浏览器指纹,用于:
5.2 检测项目
通过分析 l23(环境检测 VM),发现以下检测项:
运行时检测:
| typeof Deno | Deno 运行时 | 类型检查 |
| typeof Bun | Bun 运行时 | 类型检查 |
| typeof process | Node.js | 类型检查 |
| navigator.webdriver | Selenium/Puppeteer | 属性检查 |
| window.phantom | PhantomJS | 属性检查 |
| window._phantom | PhantomJS | 属性检查 |
| window.callPhantom | PhantomJS | 属性检查 |
浏览器指纹:
5.3 envData 结构
这些检测结果会被编码到 h5st 的第 8 个字段(envData)中:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
{ "sua": "Mozilla/5.0…", // userAgent "pp": {}, // 插件信息 "extend": { "wd": false, // webdriver "l": 0, // 语言数量 "ls": "zh-CN", // 语言 "ml": 8, // 最大触摸点 "pl": 5, // 插件数量 "av": "5.0…", // appVersion "ua": "Mozilla/5.0…", // userAgent "pf": "MacIntel", // platform "canvas": "…", // canvas 指纹 "webglFp": "…" // webgl 指纹 }, "random": "xxxxxxxx" // 随机值 } |
5.4 绕过策略
如果要在非浏览器环境运行,需要:
或者更简单的方式:直接固定 envData,因为这个字段主要用于风控,不影响签名验证。
六、总结
6.1 AI 辅助逆向的优势
6.2 方法论
|
1 2 3 |
1. 先整体后局部:理解架构再深入细节 2. 先静态后动态:AST 分析 + 运行时验证 3. 持续验证:每还原一部分就验证一部分 |
6.3 AI 的局限性
AI 不是万能的,以下场景仍需人工介入:
- 动态值提取:盐值、模板字符串等需要从字节码中定位
- 最终验证:确保还原结果与原始实现一致




