feat(collect): 按 docs/tdc-案例.md 重做主链路 —— 四段加密 + 字符串指纹识别 37/37

做的什么:
  1. 判决并修正 collect 结构: 不是「整体一次加密」, 而是
     chunk1 / trajectory / chunk2 / sd **四段独立加密**, 前 3 段
     空格 pad 到 24 字节对齐、sd 不 pad, 4 段 base64 直接拼接。
  2. 按案例 §5.3 实现字符串+结构指纹的模块识别器, 把 37 个采集器
     全部识别且跨构建唯一。
  3. 修 reorder 的恒等映射 bug, 并补强断言。
  4. 在线抓取全新样本作为唯一数据源, 清理探索性脚本。

为什么:
  * 旧「整体加密 + \x00 补齐」与真机数据矛盾 —— 导致多轮实验
    建立在结构错误的 collect 上(这正是此前反复"乱碰"的根因)。
  * 模块 id 每份构建重排, 不能当跨构建锚点; 但模块的**代码**
    (字符串集合、结构)不变 —— 这是案例指明的不变量。
  * reorder 旧实现用「目标槽下标」切「源数组」, 顺序不同即全盘错位;
    原测试只断言"是置换", 恒等函数也满足, 所以 bug 被放过了。

怎么验证:
  * 四段结构(3 条独立证据, 其中一条不需密钥):
      - 真机明文 3 处连续空格串 [+5,+10,+24], 且 pad == 24-len%24 全中
      - 密文长度算术 480+96+416+56 = 1048 == 真实 1048 字符
      - '=' 只出现在 1046/1048, 对应 sd 段 33B→补 7→40B
  * 指纹: 4 份全新在线构建 37/37 识别、0 unknown、跨构建语义名集合
    完全一致、单构建内 0 重名
  * 配对正确性: 独立证据 37/37 一致; 负对照(清单下标 / 模块 id /
    随机配对)均只有 2-3/37 —— 证明归一化没有过拟合
  * 端到端重排 3/3 正确且非恒等
  * selftest 全绿; 强断言自查: 旧实现过不了新断言

新增:
  tools/tdc_modules.py            字符串+结构指纹识别(案例 §5.3)
  scripts/fetch_online_samples.py 在线抓取全新 tdc.js
  scripts/verify/*.py             7 个可复现的验证脚本
  docs/tdc-案例.md                案例原文(看雪, 作者执着的猫)

修改:
  tcaptcha/crypto.py          +build_collect / split_collect_segments(四段)
  tools/tdc_collectgen.py     reorder 改为跨构建锚点对齐 + spec_from_seq
  tools/tdc_layout.py         +static_fingerprint / opcode_histogram
  tools/tdc_program.py        修符号求值插值类型崩溃
  tcaptcha/tests/test_collectgen.py  补「非恒等」强断言 + 在线新样本端到端
  tcaptcha/client.py          debug 目录改到系统临时目录(不再写仓库内 scratch/)
  har/1ms_dianxuan/PROGRESS.md      新增已知 #41~#49, 显式修正被推翻的旧结论
  .gitignore                  +scratch/  +har/online/

删除:
  scratch/(含 10-11 遗留的一次性脚本, 已确认不可恢复后清理)

注意:
  * 账本记录了一次自我纠错: 曾据案例 §5.5 误判 kind=2「不进 cd」,
    实测(cd 38 槽、轨迹在第 10 槽)否定后已撤回。
  * 仍未打通: 纯离线生成需要每个语义名的取值来源(已知 #38)。
This commit is contained in:
yml2213
2026-10-12 02:28:08 +08:00
parent 50734a854b
commit 80a122245a
18 changed files with 2512 additions and 40 deletions
+760
View File
@@ -0,0 +1,760 @@
# 使用 AI 还原腾讯点选验证码算法——动态 jsvmp
> **原文来源**:看雪论坛(https://bbs.kanxue.com/thread-290124.htm)
> **作者**:执着的猫
> **发布时间**:2026-2-28 15:17
---
## 一、背景与目标
### 1.1 腾讯防水墙(TCaptcha)简介
腾讯防水墙是腾讯自研的验证码服务,广泛应用于 QQ、微信生态以及大量第三方接入站点。它提供多种验证类型:滑动拼图、文字点选、空间推理等。
本文以腾讯防水墙的**文字点选验证码**为目标。当用户触发需要验证的操作后,页面会弹出一个验证码 iframe,要求用户"请依次点击"指定的三个汉字。验证通过后返回 ticket,前端携带 ticket 完成业务请求。
验证码运行在跨域 iframe `turing.captcha.gtimg.com` 中,核心逻辑由一个名为 `tdc.js` 的脚本驱动。这个脚本负责采集用户的浏览器指纹、鼠标轨迹等行为数据,加密后生成一个 `collect` 字段提交给服务端验证。
### 1.2 为什么难:一切都在变
`tdc.js` 只有几行代码——但这几行代码定义了一个完整的栈式字节码虚拟机,腾讯称之为 **CHAOS VM**。所有真正的业务逻辑(指纹采集、加密算法、数据组装)都编译成了字节码,运行在这个 VM 内部。看不到任何业务代码——没有 `function encrypt()`,没有 `key = [...]`,一切都藏在一个巨大的 Base64 字符串里。
但 VM 保护本身不是最难的部分。真正的核心难点是:**`tdc.js` 每次加载都会变。**
每次请求 `https://turing.captcha.qcloud.com/tdc.js`,服务端都会动态生成一份全新的 `tdc.js`。变化的内容包括:
| 变化项 | 说明 | 影响 |
|---|---|---|
| XTEA 加密密钥 | 4 个 int32 的 key,每次完全不同 | 无法硬编码 key |
| key 的运行时偏移 | 加密时给特定 key 索引加的偏移值,每次不同 | 必须动态提取 |
| 操作码索引 | VM 的 58 个操作码在 handler 数组中的位置打乱 | 无法硬编码 opcode 映射 |
| 37 个模块的 ID | 每个采集器模块的注册 ID 随机重新分配 | 无法按 ID 判断模块类型 |
| 模块的调用顺序 | cd 数组中模块的排列顺序随机打乱 | 无法硬编码字段位置 |
| key 构建函数的 scope | 真实/诱饵 key buffer 的索引每次不同 | 无法硬编码判别规则 |
这意味着不能做一次分析然后硬编码结果——上一秒分析出来的 key、模块顺序、opcode 映射,下一秒全部作废。
### 1.3 为什么"补环境"不够
面对 `tdc.js` 的 VM 保护,一个常见的思路是"补环境":用 Node.js 或 jsdom 搭建一个模拟的浏览器环境,直接执行 `tdc.js`,调用 `TDC.getData(true)` 拿到结果。
这个思路确实能跑通——但它是一个**黑盒方案**:
- **指纹不可控**。`tdc.js` 内部有 37 个模块采集浏览器指纹(Canvas 指纹、WebGL 渲染器、屏幕分辨率、CPU 核心数等)。补环境方案中,这些值要么是 Node.js 的默认值(明显异常),要么需要逐一伪造——但不知道它到底采集了哪些项。
- **检测风险高**。`tdc.js` 有 27 项自动化检测(`navigator.webdriver`、ChromeDriver 标记等)。补环境稍有遗漏就会被标记。
- **维护成本高**。腾讯一旦更新 `tdc.js` 的采集项或检测逻辑,补环境方案就可能失效,而你无法快速定位问题。
最终选择了另一条路:**白盒还原**。完全理解 `tdc.js` 的内部逻辑,用 Python 从零构造 `collect`。这条路更难,但一旦走通,每个参数都在掌控之中。
### 1.4 最终目标
用纯 Python 还原 `TDC.getData(true)` 生成的 `collect` 加密字段,实现端到端的自动化流程:
```
prehandle 获取会话 → 动态下载并解析 tdc.js → OCR 识别验证码文字
→ 生成鼠标轨迹 → 构造 collect → 求解 PoW → 提交 verify → 获取 ticket
```
整个过程不需要运行任何 JavaScript,不需要浏览器环境,完全在 Python 中完成。
---
## 二、请求流程分析
### 2.1 验证码触发与交互流程
用户触发需要验证的操作后,服务端返回验证码弹出指令,前端随即加载腾讯验证码 SDK(`TCaptcha.js`),弹出一个跨域 iframe。
这个 iframe 的域名是 `turing.captcha.gtimg.com`,与宿主页面跨域隔离。验证码的所有逻辑——图片展示、用户交互、行为采集、数据加密——都在这个 iframe 内部完成。这也给逆向分析带来了额外的挑战:不能在主页面的 console 中直接访问验证码的变量和函数,必须切换到 iframe 上下文才行。
### 2.2 五阶段请求流程
**阶段 1:预处理(prehandle)**
```
GET https://turing.captcha.qcloud.com/cap_union_prehandle
参数: aid=xxx, ua=Base64(UserAgent), ...
```
返回内容包括:
- `sess`:会话标识,后续所有请求都要携带
- `pow_cfg`:PoW(工作量证明)配置,包含 prefix 和目标 MD5
- `dyn_show_info`:验证码展示信息,包含文字指令("请依次点击:X X X")和图片 URL
**阶段 2:tdc.js 加载**
```
GET https://turing.captcha.qcloud.com/tdc.js?app_data={sid}&t={random}
```
`tdc.js` 下载后通过 blob URL 加载到 iframe 中。加载完成后,iframe 的 `window` 上会出现一个 `TDC` 对象,暴露 4 个方法:
```javascript
window.TDC = {
setData: core.mSet, // 设置额外数据(如 ft 字段)
getData: core.mGetData, // 获取加密后的 collect ← 核心入口
getInfo: getInfoFunc, // 获取 eks 环境信息
clearTc: core.mClear // 清除采集数据
}
```
`tdc.js` 加载后立即调用 `core.mInit()` 开始采集行为数据(鼠标移动、键盘事件等)。
**阶段 3:用户交互**
用户看到验证码图片,依次点击指定的汉字。每次点击的坐标被记录为 `ans` 参数。与此同时,TDC 持续采集鼠标移动轨迹。
**阶段 4:提交验证**
用户点击完成后,前端脚本 `dy-ele.js` 执行以下操作:
```javascript
TDC.setData({ft: "qf_7Pf__H"}); // 设置额外数据
collect = decodeURIComponent(TDC.getData(true)); // 获取加密行为数据
eks = TDC.getInfo().info; // 获取环境信息
pow_answer = solvePow(pow_cfg); // 求解 PoW
```
```
POST /cap_union_new_verify
{
collect, eks, tlg: collect.length, ans: JSON.stringify(clicks),
sess, pow_answer, pow_calc_time, ...
}
```
**阶段 5:响应处理**
- 成功:返回 `ticket` + `randstr`,前端携带这两个值重新发起登录请求
- 失败:返回 `errorCode`(如 "50" 表示验证失败),同时返回新的 `sess`
### 2.3 参数分类
提交验证时有 8 个参数,但需要逆向的实际上只有 2 个:
| 参数 | 难度 | 说明 |
|---|---|---|
| collect | 核心难点 | `TDC.getData(true)` 生成的加密行为数据 |
| eks | 简单提取 | `tdc.js` 源码中的硬编码常量,正则提取即可 |
| ans | OCR 问题 | 用户点击坐标,明文 JSON |
| pow_answer | 简单 | MD5 碰撞,几行代码 |
| sess | 透传 | 从上一次响应中获取 |
| tlg | 无需逆向 | collect 字符串长度 |
| pow_calc_time | 无需逆向 | PoW 计算耗时 |
| aid | 固定值 | 接入方分配的 ID |
### 2.4 无状态架构
一个有意思的设计:服务端如何知道 XTEA 加密的密钥?
答案藏在 `eks` 字段里。`eks` 是一个 Base64 编码的 176 字节数据,实质上是 **XTEA key + offsets + 元数据的加密信封**。服务端生成 `tdc.js` 时,随机生成 XTEA key,用服务端主密钥加密后写入 `tdc.js` 源码中的一个全局变量。客户端原样回传 `eks`,服务端用主密钥解密即可还原 key。
这种设计实现了完全无状态的验证架构——服务端不需要存储任何 key-session 映射关系。
key 的生命周期与 `tdc.js` 绑定:页面加载时生成新 key,整个验证会话内不变(包括多次重试和刷新题目)。只有页面刷新重新加载 `tdc.js` 才会产生新 key。
---
## 三、CHAOS VM 逆向
### 3.1 VM 架构概览
打开 `tdc.js`,格式化后只有约 226 行。前几行是普通 JS 赋值(Date 代理函数、`eks` 常量),核心是一个名为 `__TENCENT_CHAOS_VM` 的函数,它定义了一个完整的栈式字节码虚拟机。
VM 的主循环极其简洁:
```javascript
for (var E = !1; !E;) E = U[k[N++]]();
```
三个核心变量:
- `N`:程序计数器(PC)
- `k`:指令流数组(操作码 + 操作数)
- `U`:操作码处理器表(handler 数组,约 70 个 slot,58 个有效)
每次循环取出 `k[N]` 作为操作码索引,调用 `U` 中对应的 handler 函数执行操作。handler 返回 `true` 时虚拟机停止。
字节码和字符串表的编码方式也很巧妙:
```javascript
// tdc.js 最后的调用
__TENCENT_CHAOS_VM(0, function(Q) {
var B = Q[0], // Base64 编码的字节码字符串
F = Q[1], // 字符串/常量表(数组)
U = P(B), // Base64 解码
...
// 将字节码 charCode 和字符串表合并成指令流 k
})
```
`Q` 参数是一个大数组:`["NgMBAjQ0NgYB...", 偏移1, 值1, 偏移2, 值2, ...]`。Base64 解码后,每个字符的 charCode 作为操作码或操作数,字符串表按偏移位置插入指令流。最终产出一个约 **45000 个元素**的指令数组。
### 3.2 操作码自动识别
VM 的操作码表 `U` 是一个数组,每个元素是一个 handler 函数。问题是:**每次加载 `tdc.js`,操作码在数组中的索引位置会变**。比如"加法"操作这次在 `U[19]`,下次可能在 `U[37]`。
但 handler 函数的代码本身不变。通过分析每个 handler 的代码特征来识别它的语义:
```python
# opcode_mapper.py 中的部分匹配规则
# 每个 handler 的 JS 代码有唯一的特征签名
patterns = {
'ADD': lambda code: 'b+a' in code or 'a+b' in code,
'SUB': lambda code: 'b-a' in code,
'MUL': lambda code: 'b*a' in code,
'SHL': lambda code: '<<' in code and '>>>' not in code,
'USHR': lambda code: '>>>' in code,
'PUSH_IMM': lambda code: 'k[N++]' in code and len(code) < 50,
...
}
```
58 个操作码涵盖了一个完整语言的基本操作:算术运算、位运算、比较、跳转、函数定义与调用、属性访问、数组操作等。每次拿到新的 `tdc.js`,自动识别一遍即可。
### 3.3 反汇编器实现
有了操作码映射,就可以将指令数组反汇编成可读的汇编代码。
反汇编器采用**递归下降**策略:
- **字节码解码**:解析 `tdc.js` 源码,提取 Base64 字节码和字符串表,合并为指令数组
- **函数边界识别**:`FUNC_DEF` 指令标记函数的起始 PC 和参数个数,据此拆分函数
- **跳转目标标注**:`GOTO`、`JMP_IF`、`JMP_UNLESS` 指令的目标地址标注为 label
- **按函数输出**:每个函数输出为独立的 `.asm` 文件
以 demo01 版本为例,反汇编产出 **214 个函数、24000+ 条指令**。一个典型的反汇编片段:
```asm
; func_15662.asm — module 13 的 .get 函数(18 条指令)
15662 PUSH_STR "Array" ; 压入 "Array"
15664 LOAD_GLOBAL ; window.Array
15665 CALL_NEW 0 ; new Array()
15667 STORE_VAR 5 ; M[5] = arr
15669 INIT_SLOT 5 ;
15671 PUSH_IMM 0 ;
15673 VAR_PROP ; arr[0]
15674 PUSH_IMM 537691106 ; 立即数
15676 SET_PROP ; arr[0] = 537691106
15677 PUSH_ARRAY 5 ;
15679 RETURN ; return arr
```
### 3.4 AI 在 VM 逆向中的角色
反汇编只是第一步。24000 条指令中,大部分是 37 个采集器模块的代码,真正的加密核心只有几百条。AI 在这一步的价值是快速定位和理解:
- 给 AI 一个函数的反汇编代码,它能直接还原为伪代码。比如看到 `PUSH_STR "charCodeAt"` → `VAR_PROP` → `LOAD_VAR 4` → `CALL_METHOD 1`,AI 可以立即识别这是 `string.charCodeAt(i)` 调用
- 面对 214 个函数,AI 能通过关键字搜索(如搜索包含 `2654435769` 的函数)快速定位 XTEA 加密核心
- 逐层深入:先理解顶层调用链(`mGetData` → `get_collected` → `encrypt`),再逐个分析子函数
整个 VM 逆向过程是一个 **"AI 精读 + 人工验证"** 的循环:AI 提出伪代码假设,人在浏览器中打断点验证。
---
## 四、XTEA 加密还原
### 4.1 算法定位
在 45000 个指令元素中,有一个数字极具辨识度:**2654435769**,即 `0x9E3779B9`——这是 TEA 系列加密算法的标志性 delta 常量,来源于黄金分割比。
在指令数组中搜索这个值,直接定位到 XTEA 核心函数(demo01 中是 `func_9181`,约 255 条指令)。从这个函数向上追溯调用链:
```
func_9181 (XTEA 核心)
← 被 func_7313 (主加密函数) 调用
← 被 func_10005 (导出包装) 调用
← 被 func_6186 (mGetData) 调用
func_6186 = TDC.getData 的实际实现
```
### 4.2 变化中的不变:Key 提取规则
Key 每次都变,但**提取 key 的方法是不变的**。这是"变化中找不变"这条主线在加密层面的体现。
XTEA 的 key 是 4 个 int32,由 4 个字符串通过小端序打包生成。每次 `tdc.js` 变化的是字符串内容和偏移值,但打包公式的代码结构不变:
```python
# 每个 key 元素的生成公式(不变)
key[index] = Σ (charCodeAt(string[i]) - offset) << (8 * i) # i = 0..3
# 变化的是具体的 string、offset、index
```
难点在于**真假混淆**。主加密函数有 14 个子函数参与 key 构建,其中只有 4 个写入真实 key buffer(`buf_A`),其余 10 个写入诱饵 buffer(`buf_B`)。真实和诱饵使用完全相同的字符串(如"SUGU"同时出现在 `buf_A` 和 `buf_B`),仅凭字符串无法区分。
而且,真实 buffer 的 scope 索引每个版本都不同——版本 A 中 `M[9]` 是真实 key,版本 B 中 `M[8]` 才是。不能硬编码。
**不变的判别规则**:虽然索引在变,但 XTEA 函数必须引用真实的 key buffer 才能加密。所以从 XTEA 函数的 scope 声明出发,逆向追踪它引用的是哪个 buffer,再映射到父函数中找到写入该 buffer 的子函数——这条 **scope chain 追踪逻辑是跨版本稳定的**。
用同样的思路处理 XTEA 的运行时偏移值(每次加密时给特定 key 索引加的额外偏移):偏移值本身每次不同,但它在字节码中的**位置模式不变**——总是作为 `PUSH_IMM` 指令嵌入在 XTEA 循环的条件分支中。搜索 XTEA 函数范围内符合特征的立即数,即可自动提取。
用 6 个不同版本的 `tdc.js` 验证了这套自动提取方案的正确性。
### 4.3 互联网调研
在动手实现之前,搜索了中英文社区(看雪、CSDN、知乎、GitHub、cn-sec.com 等),确认没有人公开过从字节码中自动静态提取 key 的方法。现有方案要么需要手动断点抓取(每次操作浏览器),要么走补环境路线(不提取 key,直接跑 `tdc.js`)。
本方案是:**纯静态分析字节码,自动提取 key**。不需要浏览器,不需要 Node.js,从 `tdc.js` 文本直接计算出 key 值。
---
## 五、collect 动态构造
### 5.1 collect 整体结构
`collect` 不是简单地"加密一段 JSON"。它由 **4 段密文直接拼接**而成:
```
collect = base64(xtea(chunk1_padded)) + base64(xtea(trajectory_padded))
+ base64(xtea(chunk2_padded)) + base64(xtea(sd_str))
```
- `chunk1`:`{"cd":[` + 轨迹前的指纹值(逗号分隔)+ `,`
- `trajectory`:鼠标轨迹数据 `[[x,y,t],[dx,dy,dt],...]`
- `chunk2`:`,` + 轨迹后的指纹值 + `],`
- `sd`:`"sd":{"od":"C","ft":"xxx"}}`
`chunk1`、`trajectory`、`chunk2` 在加密前用空格 pad 到 **24 字节对齐**(确保 Base64 编码后没有 `=` 填充符),`sd` 不 pad。4 段密文的 Base64 之间没有分隔符,直接拼接。
这个设计很聪明:因为每段都 pad 到 24 字节对齐,Base64 编码后没有 `=`,4 段拼起来看起来就是一个完整的 Base64 字符串。但解密时也可以整体 decode + decrypt,因为 XTEA 是 ECB 模式(每 8 字节独立加密)。
### 5.2 cd 数组与 37 个模块
`cd` 数组是 `collect` 的主体,包含了 37 个采集器模块的输出。这些模块采集各种浏览器指纹和环境信息:
| 类型 | 采集内容 | 值示例 |
|---|---|---|
| screen_fingerprint | 屏幕综合信息 | `"1512-982-900-30-*-*-|-*"` |
| user_agent | `navigator.userAgent` | `"Mozilla/5.0..."` |
| canvas_fingerprint | Canvas 2D 指纹 | Base64 字符串 |
| webgl_renderer | WebGL 渲染器 | `"ANGLE..."` |
| hardwareConcurrency | CPU 核心数 | 14 |
| timezone | 时区偏移 | `"+08"` |
| webdriver_detect | 自动化检测 | 0 |
| trajectory | 鼠标轨迹 | 轨迹数组(加密后的密文) |
| ... | ... | ... |
37 个模块中:
- 31 个有标准 `.get` 导出,通过 `module.get()` 返回 `[value, secondary, type_flag]`
- 6 个无 `.get`(`webdriver_detect`、`template_url`、`webrtc_ip`、`console_detect`、`timezone`、`dom_compliance`),但仍产生 `cd` token
- `guid_token` 是 RAW 模式,一次产生多个逗号分隔的 token
### 5.3 核心挑战:模块 ID 每次都变
这是整个项目中最核心也最曲折的部分——它完美体现了"变化中找不变"的主题。
**问题**:`tdc.js` 每次加载时,37 个模块的 ID 随机重新分配。上一秒"模块 13"是 `user_agent`,下一秒"模块 13"可能变成 `timezone`。要正确构造 `cd` 数组,必须知道每个模块 ID 这次对应什么类型——而这个映射关系每次都不同。
这是补环境方案完全无法解决的问题。补环境可以跑出一个 `collect`,但它不知道 `cd` 数组中第 5 个位置是 `user_agent` 还是 `screen_width`。一旦需要控制指纹值(比如随机化 UA 或屏幕分辨率来避免被检测),就必须知道每个位置填什么。
**失败的尝试 v1-v3**
尝试了 3 个版本的静态分析器,思路是"追踪每个模块 `.get` 函数的返回值来源"。从字节码中确实能找到正确的 JS API 字符串(如 `"userAgent"`、`"characterSet"`),但位置映射全部错误——类型识别是对的,分配给了错误的模块。
根因是 VM 的**动态语义**(变量自增、共享引用、运行时 scope 修改)无法通过纯静态分析推断。
**突破:代码变了位置,但没变内容**
失败三次后,重新审视了问题,发现了一个关键洞察:
**`tdc.js` 的"动态化"是重新排列,不是重新生成。**
模块的 ID 变了、位置变了,但每个模块的**代码逻辑完全不变**。采集 `userAgent` 的模块,不管被分配到 ID 13 还是 ID 57,它的字节码里一定包含字符串 `"userAgent"`。采集 `timezone` 的模块,一定包含 `"getTimezoneOffset"`。
这就是**不变量**。
基于这个洞察,放弃了"追踪返回值"的思路,转而用**字符串集合 + 结构特征**作为指纹来识别模块类型:
```python
# module_fingerprint.py — 每个模块类型有唯一的字符串指纹
fingerprint_rules = {
'user_agent': {'userAgent'},
'canvas_fingerprint': {'getContext', '2d'},
'webgl_renderer': {'UNMASKED_RENDERER_WEBGL'},
'timezone': {'getTimezoneOffset'},
'webdriver_detect': {'$cdc_asdjflasutopfhvcZLmcfl_'},
'webrtc_ip': {'RTCPeerConnection'},
...
}
```
对于没有区分性字符串的通用模块(如两个都只有简单赋值的模块),用 `.get` 函数的指令数 + entry 函数的指令数 + 是否包含特定指令来区分。
**结果:3 个 `tdc.js` 版本、37 个模块、全部正确识别,无一 unknown。** 从 v1 的全部错位到指纹方案的全部正确,关键不是更复杂的分析,而是换了一个更好的问题——不再问"这个模块返回什么值",而是问"这个模块的代码长什么样"。
### 5.4 模块顺序还原
知道了每个模块 ID 对应的类型后,还需要知道它们在 `cd` 数组中的排列顺序。
这个顺序由**编排函数**决定。编排函数的字节码结构是固定的:按 `require_seq` 顺序依次调用每个模块,将模块对象 push 到数组中。从编排函数的字节码中提取所有 `PUSH_IMM mid` 指令,就得到了完整的模块调用顺序。
```python
# 从编排函数提取 require_seq
# demo01: [8,9,10,13,14,15,16,19,20,22,23(traj),1,28,29,32,35,36,37,38,39,40,41,48,49,50,51,53,55,56,57,58]
```
结合指纹识别结果,每个位置的类型就确定了。再根据类型查表填入对应的指纹值,就能生成正确的 `cd` JSON。
### 5.5 func_4310 构建逻辑
`func_4310` 是将 37 个模块的输出组装成 JSON 字符串并分段加密的核心函数,逻辑如下:
```python
output = '{"cd":['
first = True
for module in cd_array:
if not module:
continue
value, secondary, type_flag = module.get()
if not first:
output += ","
first = False
if type_flag == 2:
# 轨迹分界:加密当前累积的 chunk1
encrypted = base64(xtea(pad_to_24(output)))
traj_output = encrypted + value # value 是模块内部已加密的轨迹
output = "" # 重置,开始 chunk2
elif value is None:
output += "null"
elif isinstance(value, (int, float)):
output += str(value or 0) # falsy 数字变 0
elif type_flag == 1:
output += value # RAW:不加引号
else:
output += '"' + value + '"' # 字符串:加引号
if isinstance(secondary, (int, float)):
output += "," + str(secondary or 0)
output += "],"
traj_output += base64(xtea(pad_to_24(output))) # 加密 chunk2
```
几个细节:
- `type_flag == 2` 只有 trajectory 模块会触发,是分段加密的边界标记
- `type_flag == 1` 表示 RAW 输出(如 `[360,360]`),不加引号直接拼接
- `value || 0`:JS 中 `0 || 0` 结果为 0,但 `undefined || 0` 也是 0
- `secondary` 是部分模块的第二个返回值,作为额外的逗号分隔值输出
---
## 六、全流程打通
### 6.1 轨迹生成
验证码要求用户依次点击 3 个汉字,过程中 TDC 会采集完整的鼠标移动轨迹。轨迹格式:
```
[[x, y, t], // 第一个点:绝对坐标 + 时间偏移(毫秒)
[dx, dy, dt], // 后续点:相对上一个点的偏移
[dx, dy, dt],
...
[1, 1, 12]] // 固定结尾标记
```
坐标系是相对于验证码容器 `tCaptchaDyContent`(360×360 像素),使用 `event.offsetX/Y`。注意这与 `ans` 参数的坐标系不同——`ans` 使用的是原始图片坐标(672×480)。
轨迹生成用贝塞尔曲线模拟真实鼠标移动:
```python
def generate_trajectory(click_points):
trajectory = []
last_x, last_y, last_t = 0, 0, 0
for i, (target_x, target_y) in enumerate(click_points):
# 贝塞尔曲线生成从当前位置到目标的移动路径
points = bezier_move(last_x, last_y, target_x, target_y)
for x, y, t in points:
if not trajectory:
trajectory.append([x, y, t]) # 第一个点用绝对值
else:
trajectory.append([x - last_x, y - last_y, t - last_t])
last_x, last_y, last_t = x, y, t
trajectory.append([1, 1, 12]) # 结尾标记
return trajectory
```
### 6.2 OCR 文字识别
验证码图片中,需要点击的汉字以黄色高亮显示在背景图上。识别流程:
- **HSV 颜色空间分割**:将图片转为 HSV,提取黄色区域(H: 20-40, S: 100-255, V: 200-255)
- **连通域分析**:找到独立的黄色文字区域,按面积过滤噪点
- **PaddleOCR 识别**:对每个区域裁剪后送入 PaddleOCR v5 进行汉字识别
- **匹配排序**:将识别结果与指令中的目标汉字匹配,按要求顺序排列
当前瓶颈是形近字的误识别。例如"边"和"芭"、"笆"在低分辨率下容易混淆。PaddleOCR 的识别准确率在 85% 左右,这直接影响了整体验证通过率。后续可以换用更强的 OCR 模型(如基于 Transformer 的方案)来优化。
### 6.3 指纹随机化
如果每次请求都用相同的浏览器指纹,很容易被服务端检测到异常。实现了指纹随机化:
```json
// fingerprint/profiles.json 定义了 6 款 Mac 设备画像
{
"MacBook Pro 14 M3 Pro": {
"screen": {"width": 1512, "height": 982, "colorDepth": 30},
"webgl_renderer": "ANGLE (Apple, Apple M3 Pro, OpenGL 4.1)",
"webgl_vendor": "Google Inc. (Apple)",
"hardwareConcurrency": 12,
"canvas_hash": "GgoAAAA...base64..."
},
"MacBook Air 13 M2": { ... },
"iMac 24 M3": { ... },
...
}
```
每次请求随机选择一款设备画像,再随机组合 Chrome 版本(130-145)和 macOS 版本,生成一致的 User-Agent、`sec-ch-ua` headers 和环境变量。所有相关的指纹值(屏幕尺寸、WebGL 渲染器、CPU 核心数等)都从同一个画像中取,确保自洽。
### 6.4 PoW 求解
验证码提交前需要解决一个 PoW(工作量证明):给定 `prefix` 和目标 MD5 前缀,找到一个 `nonce` 使得 `MD5(prefix + nonce)` 以目标前缀开头。
```python
import hashlib
def solve_pow(prefix, target):
nonce = 0
while True:
candidate = f"{prefix}{nonce}"
if hashlib.md5(candidate.encode()).hexdigest().startswith(target):
return f"{prefix}#{nonce}"
nonce += 1
```
通常几千到几万次尝试即可完成,耗时几十毫秒。
### 6.5 端到端编排
`full_flow.py` 是整个流程的编排器:
```python
def main():
# 1. 生成随机指纹
profile = random_profile()
# 2. prehandle 获取会话
sess, pow_cfg, captcha_info = prehandle(profile)
# 3. 下载并解析 tdc.js
tdc_js = download_tdc(sess)
key, offsets, eks, module_types, require_seq, traj_index = parse_tdc(tdc_js)
# 4. 下载验证码图片 + OCR
image = download_image(captcha_info)
chars = locate_chars(image) # HSV 分割 + PaddleOCR
click_points = match_click_points(chars, captcha_info['instruction'])
# 5. 生成轨迹
trajectory = generate_trajectory(click_points)
# 6. 构建 collect
collect = build_collect(
key, offsets, module_types, require_seq,
traj_index, trajectory, profile, eks
)
# 7. 求解 PoW
pow_answer = solve_pow(pow_cfg)
# 8. 生成 ans
ans = generate_ans(click_points)
# 9. 提交 verify
result = verify(sess, collect, eks, ans, pow_answer)
if result.get('ticket'):
print(f"验证通过!ticket: {result['ticket']}")
```
整个流程纯 Python 实现,不依赖浏览器环境,单次执行约 **2-3 秒**(主要耗时在网络请求和 OCR)。
---
## 七、工具介绍:JS Reverse MCP
### 7.1 为什么需要这个工具
传统 JS 逆向的工作流是:打开 DevTools → 搜索代码 → 手动打断点 → 触发操作 → 观察变量 → 记录到笔记。这个过程效率低、容易遗漏,且完全依赖人的经验。
AI 编码助手(如 Claude Code、Cursor、Copilot)擅长分析代码、识别模式、快速迭代方案。但它们有一个致命短板:**无法直接操控浏览器调试器**。AI 可以读反汇编代码并还原伪代码,但无法自己打个断点去验证。
市面上也有一些浏览器自动化的 MCP 工具(如 Playwright MCP、Puppeteer MCP),但它们的设计目标是 **UI 自动化测试,不是逆向分析**。它们能点击按钮、填写表单、截取截图,但不能设断点、不能查看调用栈、不能 Hook 函数、不能在断点处求值表达式——而这些恰恰是 JS 逆向最核心的操作。
**JS Reverse MCP** 是专门为 JS 逆向分析设计的 MCP 服务器。它将 Chrome DevTools Protocol(CDP)的完整调试能力暴露为标准化的 MCP 工具,让 AI 能像经验丰富的逆向工程师一样操作 DevTools。
### 7.2 核心能力
JS Reverse MCP 提供了 **30+ 个工具**,覆盖 JS 逆向分析的完整链路:
- **脚本分析**:列出页面所有 JS 脚本、在所有源码中搜索关键字、获取脚本源码(支持压缩代码的字符偏移定位)
- **断点调试**:设置行断点、条件断点、XHR/Fetch 断点。支持 `set_breakpoint_on_text`——直接搜索代码文本自动设置断点,特别适合压缩代码(整个文件在一行上)
- **执行控制**:暂停/恢复执行、step over / step into / step out。在断点处用 `evaluate_on_callframe` 求值任意表达式
- **函数追踪**:`hook_function` 拦截全局函数或对象方法,记录每次调用的参数和返回值。`trace_function` 更强大——它能追踪 webpack/rollup 打包的模块内部函数,使用条件断点(logpoint)实现零侵入式日志
- **网络分析**:查看请求详情和响应内容、获取请求发起的 JavaScript 调用栈(快速定位哪段代码触发了某个 API 请求)
- **运行时检查**:深度检查对象结构和原型链、获取 `cookies`/`localStorage`/`sessionStorage`、监控 DOM 事件、截取页面截图
### 7.3 一般 AI 工具做不到的事
JS Reverse MCP 有几个在逆向场景中至关重要、但其他 AI 工具完全不具备的能力:
**跨域 iframe 切换**
这是本项目中最关键的能力之一。腾讯验证码运行在跨域 iframe `turing.captcha.gtimg.com` 中,与宿主页面完全隔离。普通的浏览器自动化工具(Playwright MCP、Puppeteer MCP)只能操作主页面上下文,无法进入跨域 iframe 的 JS 执行环境。
JS Reverse MCP 通过 CDP 的 `Runtime.executionContextCreated` 事件追踪所有 frame 的执行上下文,支持自由切换:
```
AI: list_frames
→ [0] main frame: https://login.example.com
→ [1] tcaptcha_iframe_dy: https://turing.captcha.gtimg.com/...
AI: select_frame(1) ← 切换到验证码 iframe
AI: evaluate_script(() => window.TDC)
→ {getData: ƒ, getInfo: ƒ, setData: ƒ, clearTc: ƒ} ← 直接访问 TDC 对象!
AI: evaluate_script(() => TDC.getData(true))
→ "b4lpdxdUNOU90e5D..." ← 拿到加密后的 collect
```
如果没有这个能力,AI 根本无法接触到验证码的核心对象 `TDC`,更无法验证加密逻辑的正确性。在整个项目的动态验证阶段,几乎每一次浏览器操作都需要先 `select_frame(1)` 切换到 iframe。
**过检测能力**
现代网站普遍使用 `navigator.webdriver`、`window.chrome` 等属性检测自动化浏览器。Puppeteer 和 Playwright 默认会被检测到,导致页面行为异常(验证码无限弹出、数据采集结果异常等)。
JS Reverse MCP 支持**连接到用户已运行的 Chrome 实例**,而不是自己启动一个受控的浏览器:
```bash
# 用户自己启动 Chrome(完全正常的浏览器环境)
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
# MCP 连接到这个已运行的 Chrome
claude mcp add js-reverse node /路径/build/src/index.js \
-- --browser-url=http://127.0.0.1:9222
```
这意味着:
- **零检测风险**:连接的是用户正常使用的 Chrome,没有任何自动化标记。`navigator.webdriver` 为 false,`window.chrome` 正常存在,所有指纹都是真实的
- **保留登录状态**:已登录的 cookie、localStorage 都在,不需要重新登录
- **真实环境调试**:在真实的、未被篡改的浏览器环境中调试,观察到的行为和数据与用户完全一致
这对于分析验证码这类场景至关重要——腾讯防水墙的 `tdc.js` 有 27 项自动化检测(包括 `navigator.webdriver`、`document.$cdc_asdjflasutopfhvcZLmcfl_` ChromeDriver 标记等),如果被检测到,采集到的行为数据会包含异常标记,服务端直接拒绝。
**断点处执行任意表达式**
Playwright/Puppeteer MCP 只能执行页面级脚本(相当于在 console 中输入代码)。但 JS 逆向中最有价值的操作是在**断点处检查局部变量和闭包变量**——这些变量在全局作用域中不可见。
JS Reverse MCP 的 `evaluate_on_callframe` 可以在暂停的调用帧上下文中求值任意表达式:
```
AI: set_breakpoint(url="tdc.js", line=178, condition="H && H.find(...)")
用户: 触发验证
AI: get_paused_info ← 获取调用栈和 scope 变量
AI: evaluate_on_callframe("H[9][0]") ← 提取闭包中的 XTEA key
→ [1466650466, 1112888626, 1363366223, 1112430392]
```
在本项目中,XTEA 的 key buffer 存在于 VM 的闭包 scope 中,从全局作用域完全不可见。没有断点处求值的能力,就无法验证从字节码中静态提取的 key 是否正确。
### 7.4 在本项目中的实际应用
这个工具贯穿了整个逆向分析过程:
- **请求分析阶段**:用 `list_network_requests` 过滤 XHR/Fetch 请求,找到 `cap_union_new_verify` 接口。用 `get_network_request` 查看请求参数,识别出 `collect`、`eks` 等加密字段。
- **入口定位阶段**:用 `break_on_xhr("cap_union_new_verify")` 设置 XHR 断点 → 触发验证 → `get_paused_info()` 获取完整的 JavaScript 调用栈,直接从请求倒推到加密函数的入口。
- **Key 验证阶段**:在 XTEA 函数入口设置条件断点 → 用 `evaluate_on_callframe` 在断点处提取运行时的 key 和 scope 变量 → 与 Python 静态提取的结果对比验证。
- **iframe 切换**:验证码运行在跨域 iframe 中。用 `list_frames` 列出所有 frame → `select_frame(1)` 切换到 `tcaptcha_iframe_dy` → 然后就可以在 iframe 上下文中执行脚本,直接调用 `TDC.getData(true)`。
- **Hook 拦截**:用 `hook_function("encodeURIComponent")` 拦截 URL 编码函数,抓取 `collect` 加密后、URL 编码前的原始数据。用 `hook_function("String.fromCharCode")` 捕获 XTEA 加密输出的字节拆分过程(298 次调用 = 298 个 int32 = 1192 字节密文)。
- **源码搜索**:用 `search_in_sources("2654435769")` 在所有加载的脚本中搜索 XTEA delta 常量,虽然 `tdc.js` 通过 blob URL 加载(CDP 无法直接列出),但搜索功能仍能覆盖。
### 7.5 安装与配置
**GitHub 地址**:https://github.com/zhizhuodemao/js-reverse-mcp
```bash
# 克隆并构建
git clone https://github.com/zhizhuodemao/js-reverse-mcp.git
cd js-reverse-mcp
npm install
npm run build
```
配置到 AI 编码助手:
```bash
# Claude Code
claude mcp add js-reverse node /路径/js-reverse-mcp/build/src/index.js
# 连接已运行的 Chrome(推荐,保留登录状态和 cookie)
# 先启动 Chrome:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
# 再配置 MCP 连接:
claude mcp add js-reverse node /路径/js-reverse-mcp/build/src/index.js \
-- --browser-url=http://127.0.0.1:9222
```
Cursor 和 VS Code Copilot 也支持,在 MCP 设置中添加相同的配置即可。
### 7.6 典型工作流
一个完整的 AI 驱动逆向分析流程:
```
你: "帮我分析这个页面的登录请求是怎么加密的"
AI: list_network_requests → 找到 POST /api/login,发现 header 中有 x-sign 加密参数
AI: get_request_initiator → 获取调用栈,定位到 encrypt() 函数
AI: search_in_sources("x-sign") → 找到 x-sign 赋值的代码位置
AI: set_breakpoint_on_text("x-sign") → 在赋值处设断点
你: 刷新页面,点击登录
AI: get_paused_info → 查看当前作用域变量,发现 sign = hmacSHA256(body, key)
AI: evaluate_on_callframe("key") → 提取密钥值
AI: step_into → 进入 hmacSHA256 函数,确认算法细节
AI: resume → 继续执行
AI: "分析完成。x-sign = HMAC-SHA256(请求体, 固定密钥),密钥是 ..."
```
整个过程中,你只需要操作浏览器触发请求,AI 负责所有的调试和分析工作。
---
## 八、总结与思考
### 8.1 AI 的实际贡献
回顾整个项目,AI 在以下方面提供了实质性帮助:
- **字节码精读与伪代码还原**。面对 24000+ 条反汇编指令,AI 能快速将指令序列还原为可读的伪代码。一个 300 条指令的函数,人工分析可能需要半天,AI 几分钟就能给出伪代码初稿。
- **模式识别**。从 14 个 key 构建子函数中识别真实 vs 诱饵的判别规则,从 37 个模块的字节码中提取指纹特征——这类"在大量相似代码中找差异"的任务,AI 比人高效得多。
- **方案迭代速度**。模块分析器从 v1 到 v2 到 v3 再到最终的指纹方案,每次迭代都涉及重构核心逻辑。AI 让每次迭代的成本从"几天"降到了"几小时"。
- **跨文件关联**。追踪 scope chain(从 XTEA 函数的 `M[7]` → parent 的 `M[X]` → encrypt 的 `M[Y]`)、函数调用链(`mGetData` → `get_collected` → `encrypt` → XTEA)、模块注册映射(`PUSH_IMM mid` → `FUNC_DEF`)——这类需要在多个文件间跳转关联的任务,AI 的工作记忆是关键优势。
### 8.2 AI 做不好的部分
- **JS 位运算语义**。`+` 不截断但 `<<` 截断的差异,AI 在第一次实现时没有正确处理。这类"语言规范的细微之处"需要人工提醒和验证。
- **VM 动态行为推断**。`LOAD_DYN`(动态加载变量)、共享引用赋值、运行时 scope 修改——这些只有在 VM 实际执行时才能确定的行为,AI 的静态分析无法推断。模块分析器 v1-v3 的失败就是因为试图用静态分析解决本质上是动态的问题。
- **创造性突破**。从"逐个分析每个模块返回什么值"到"用指纹识别模块类型"的思路转换,是一个关键的创造性跳跃。AI 在已有思路上迭代很快,但提出全新思路的能力仍有限。这个转换是人工观察到"模块代码语义不变,只是 ID 变了"后提出的。
### 8.3 变化中找不变:方法论回顾
回顾整个项目,核心方法论可以概括为一句话:
> **在每次都变的代码中,找到跨版本不变的结构特征,然后基于这些不变量构建自动化提取管线。**
| 层面 | 每次变化的 | 跨版本不变的 | 提取方法 |
|---|---|---|---|
| 操作码 | handler 在数组中的索引 | handler 函数的代码特征 | 代码模式匹配 |
| XTEA key | 4 个 int32 的值 | 打包公式的字节码结构 | scope chain 追踪 |
| key 偏移 | 偏移值的大小 | 偏移值在字节码中的位置模式 | 特征搜索 |
| 真/假 key | buffer 的 scope 索引 | XTEA 必须引用真实 buffer | scope 追踪 |
| 模块 ID | 37 个模块的 ID 分配 | 每个模块的代码语义和字符串集合 | 字符串指纹 |
| 模块顺序 | cd 数组中的排列 | 编排函数的字节码结构 | 提取 require_seq |
这条路比补环境难得多,但收益也大得多:每个参数都在掌控中,可以精确控制指纹值、随机化设备画像、构造逼真的行为轨迹。
### 8.4 安全对抗趋势
腾讯 CHAOS VM 的动态化设计确实增加了逆向成本——不能做一次分析就硬编码结果,必须实现自动化的解析管线。但它的软肋在于:**动态化是"重新排列",不是"重新生成"**。模块代码不变,只是换了 ID 和位置,这给了指纹识别的机会。
如果未来对模块代码本身也进行变换(如指令替换、插入花指令、变量名混淆),指纹识别的难度会大幅增加。但这也会增加服务端的生成成本和 JS 体积。攻防永远是成本的博弈。
AI 工具的介入正在改变这个博弈的平衡点。过去需要一个经验丰富的逆向工程师花几周完成的工作,现在一个了解基本原理的开发者配合 AI 和专用工具(如 JS Reverse MCP)可以在几天内完成。
---
*本文首发于看雪论坛(https://bbs.kanxue.com/thread-290124.htm),作者:执着的猫,发布时间:2026-2-28。*