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
+7
View File
@@ -38,4 +38,11 @@ har/**
*.har
*.zip
# 探索性脚本与临时诊断产物(一次性、不入版本控制;可复现的验证脚本放 scripts/verify/)
scratch/
# 在线抓取的原始 tdc.js 样本(每份 ~78KB、每次请求都不同,属可再生数据)
# 需要时用 `scripts/fetch_online_samples.py --n 4` 重新抓取即可。
har/online/
+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。*
+116 -1
View File
@@ -4,7 +4,31 @@
> 全部在 Python 内完成; ~~Node 只保留一个用途: 从当前 tdc.js 提取本次构建的 XTEA 密钥字~~
> **2026-10-11 起 Node 连提取都不再需要**(纯 Python 静态还原,见已知 #19~#22;Node 仅作兜底)。
> 依据: AGENTS.md §2.3(拒绝补环境) / §2.4(分层交付) / §3.3(字节码 VM) / §4.3(运行时捕获) / §5.8(标定式交付)。
> 最后更新: 2026-10-11 (纯 Python XTEA 密钥静态提取落地 + opcode 表按构建推导)
> 最后更新: 2026-10-12 (**按 docs/tdc-案例.md 重做**:collect 四段加密判决 + 字符串指纹模块识别 37/37)
---
## ⚠️ 2026-10-12 路线修正(对照 `docs/tdc-案例.md`,全部基于**在线新抓样本**)
之前多轮「乱碰」的根因,是几条**基础假设错了**:
| 旧假设(已推翻) | 新结论(已判决) | 证据 |
| --- | --- | --- |
| collect = **整体 JSON 一次加密**(`\x00` 补齐) | **4 段独立加密**,前 3 段**空格 pad 到 24 字节**,sd 不 pad | 真机明文 3 处连续空格串 `[355:+5, 422:+10, 720:+24]`;密文长度算术 480+96+416+56 = **1048**,与真实 1048 精确相等;`==` 只出现在末尾(sd 段) |
| `kind=2` 的项"仍产出 1 槽" | **这条原本就是对的**(真机 cd 38 槽、轨迹在第 10 槽),本轮一度误判为"不进 cd"已撤回。补充一层理解:它**同时**是 chunk1/chunk2 的**分段触发点**(案例 §5.5) | 真机 cd 38 槽 + 轨迹槽位 10 + trajectory 独立成段 |
| 模块 id 可当跨构建锚点 | id **每份构建重排**:交集 28 个里 `id→语义` 相同仅 **1/11** | 模块 8 在 A 是 `navigator.platform`,在 B 是 canvas 指纹 |
| (自创)指令直方图是够用的锚点 | 字符串+结构指纹语义名 **37/37 唯一**;直方图只有 20/37 | 见已知 #43/#44 |
现在的代码路径:
```
tdc_modules.analyze() -> 每项的语义名(37/37,跨构建稳定)
tdc_collectgen.reorder(s, dst, src) -> 按语义名锚点把源样本对齐到本次构建
crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接
```
**数据纪律:本轮起所有新研究只用 `har/online/` 下在线抓取的样本**
(`scripts/fetch_online_samples.py --n 4`),不再依赖仓库里的旧抓包。
---
@@ -72,6 +96,20 @@
| 40 | **"谁在发 HTTP"是 12 的剩余唯一自变量**(结论,待最后一步坐实)| 证据链:① 槽位序证伪(§三);② 包内容证伪(本会话真实 collect 由 Python 发仍 12);③ 已知 #26:宽松 aid 190758559 上 **httpx + 无 cookie + 无头保真** 也能 0 —— 说明"请求侧细节"对宽松 aid 无所谓,但对 195672344 不够。④ 真机同 aid 只出 50/9/0(#25)。→ 剩余差在**会话↔请求方绑定**。**收口实验**:把同一 sess/eks/tkid + 同一 collect,分别由浏览器发与 Python 发,比较 errorCode | 结论(待收口) |
| 38 | **纯离线生成还没打通(诚实标注)** | 当前 `--from-sample` 依赖**一份同机的真机明文**做重排;`--values`(语义名→值)需要调用方提供取值字典。**仍缺**:纯算地为每个语义名算出值(多数是静态环境量可配置,但 canvas/WebGL 指纹只能取自真实环境)。这一步完成前,不能声称"纯离线过风控"。 | 状态说明 |
### 2026-10-12 新增(对照 `docs/tdc-案例.md`,全部基于 `har/online/` 在线新抓样本)
| # | 结论 | 证据 | 确信度 |
| --- | --- | --- | --- |
| 41 | **collect 是「4 段独立加密」拼接,不是整体一次加密**:`chunk1 + trajectory + chunk2 + sd`,前 3 段**空格 pad 到 24 字节对齐**,sd 不 pad;4 段 base64 **直接拼接无分隔符** | ① 真机明文 3 处连续空格串 `[355:+5, 422:+10, 720:+24]`,且 `pad == 24 - len%24` 三段全中;② **无需密钥的纯算术判决**:360/72/312/40 → base64 480+96+416+56 = **1048**,与真实 collect 的 1048 字符精确相等;③ `==` 只出现在位置 1046/1048(正是 sd 段 33B→补 7→40B 的产物);④ 段边界与案例 §5.5 逐字吻合(chunk1 以 `,2560,` 结尾、chunk2 以 `,"UTF-8"],` 结尾) | 确证 |
| 42 | **`kind=2`(`type_flag==2`)产出 1 槽(就是轨迹),并同时触发**分段加密**——它是 chunk1/chunk2 的分界** | 案例 §5.5;真机明文 cd **38 槽**、轨迹位于第 **10** 槽,trajectory 段 `[360:432]` 独立成段,结尾 `[1,1,12]`(案例 §6.1 的固定标记)。<br>→ 即「产槽」与「分段」**同时成立**:旧已知 #36 的"仍产出 1 槽"**未被推翻**,本轮早期我一度误判为"不进 cd",已用 cd 槽数(38)+ 轨迹槽位(10)纠正 | 确证(含一次自我纠错) |
| 43 | **模块 id 不能当跨构建锚点**(每份构建重排) | 两份构建清单交集 28 个模块里 `id→语义` 完全相同仅 **1/11**(模块 8 在 A 是 `navigator.platform`,在 B 是 canvas 指纹);案例 §1.2 亦明确列出"37 个模块的 ID 随机重新分配" | 确证 |
| 44 | **字符串+结构指纹可把 37 个采集器全部识别且唯一**(案例 §5.3 方案) | 4 份全新在线构建:`37/37` 识别、**0 unknown**、跨构建语义名集合**完全一致**、单构建内**0 重名**;端到端重排 3/3 正确且非恒等 | 确证 |
| 45 | **指纹必须按 specificity 排序 + 支持前缀匹配** | `?rand=` 在真机里是 `?rand=1519713624347`(带会话随机数),精确匹配永远命中不了 → `template_url` 被误判成 `location_href`;改为 `?rand=*` 前缀匹配 + 给 `location_href` 加禁用串后消歧 | 确证(踩坑修正) |
| 46 | **fp_salt 的随机串每构建都变**(`AyqhIdr`/`QtDKYSO`/`ryieWP_`/`mYmmgLx`),**不能**用字符串识别 | 4 份新构建各不相同;改用结构规则 `entry≥120 且 get≥100` 抓,每份构建恰好 1 个 | 确证 |
| 47 | **结构完全相同的 2 项可用「闭包最后写入什么」消歧** | 这 2 项 entry 40 条、.get 9 条、指令逐条相同(只差 JUMP 绝对地址);差异在**闭包 target**(`CREATE_CLOSURE.target`,不是 JUMP 目标):`new Array()[0] = ""` vs `new Array()[0] = <随机int32>`。实测每份构建恰好 empty×1 + rand×1 | 确证 |
| 48 | **配对正确性经独立证据 + 负对照验证** | 独立证据:同一归一化指令序列(抹掉地址/模块号/字符值)下 37/37 全等(唯一例外 fp_salt 的随机 int32,抹掉后 37/37);负对照:清单下标配对 2-3/37、模块 id 配对 2-3/37、随机配对 2/37 —— 对照组极低,证明归一化**没有过拟合** | 确证 |
| 49 | **`reorder` 旧实现是恒等映射(真 bug,已修)** | 旧代码 `sample[slot:slot+n]` 里的 `slot` 是**目标**构建自己的槽下标 → 顺序不同时全盘错位、相同时才碰巧对。原测试只断言"是置换",恒等函数也满足 → **弱断言把 bug 放过了**。已补强断言(== 已知正确答案 且 != 原样),并自查:旧实现过不了新断言 | 确证 |
---
## 二、未知(附假设 + 确信度)
@@ -111,6 +149,17 @@
| **H5:1ms 后端用 `identity_context` 把会话标记为可信** | 拿抓包里**签名过的** `identity_context`(599B `emctx1.*`) 先调 `POST /api/v1/captcha/params`(响应与不带时**完全相同**,无额外 token)再解题,仍 12 → H5 排除 |
| **"请求侧保真度不够"这一整类** | 直接反证:aid 190758559 上 **未做任何保真的 httpx + 无 cookie** 也拿到 ticket(0);即请求侧细节本就足够,12 与保真度无关 |
### 2026-10-12 新增推翻的假设(对照 `docs/tdc-案例.md`)
| 假设 | 否定证据 |
| --- | --- |
| **"整体加密"是 collect 的结构**(旧 `crypto.encrypt_string` / `tdc_collectgen.encrypt_plain` 的做法) | 判决书据 3 条,互相独立:<br>① 真机明文有 **3 处连续空格串**(`+5 / +10 / +24`),`\x00` 补齐不会产生空格;<br>② **不需要密钥**的纯算术:4 段长度 360/72/312/40 → base64 480+96+416+56 = **1048**,与真实 collect 的 1048 字符精确相等(差一个字节都不行);<br>③ `==` 只出现在 1046/1048,恰好对应 sd 段 33B→XTEA 补 7→40B。<br>→ 改为 `crypto.build_collect()`(4 段独立加密 + 空格 pad 到 24)。旧函数**仍保留但已不是主路径**。<br>⚠️ 注意被推翻的是**加密结构**,不是槽数模型——cd 仍是 38 槽(见已知 #42)。 |
| ~~**`kind=2` 的项"仍产出 1 槽"是错的**~~ ⚠️ **本条是我 2026-10-12 的误判,已撤回** | 我曾据案例 §5.5 断言 `type_flag==2`"不进 cd"、并据此认为旧"37 项→38 槽"推导被推翻。**实测否定了这个断言**:真机明文 cd 就是 **38 槽**,轨迹位于**第 10 槽**。→ 真相是「产 1 槽」与「触发分段」**同时成立**,旧已知 #36 仍然正确。教训:引用外部案例时必须用本项目数据复核,不能拿案例文字直接覆盖已有实测(见已知 #42)。 |
| **模块 id 可当跨构建对齐锚点** | 清单交集 28 个模块里 `id→语义` 完全相同仅 **1/11**;案例 §1.2 明确"37 个模块的 ID 随机重新分配"。→ 改用**语义名**做锚点(已知 #44)。 |
| **自创的「指令直方图」是够用的锚点** | 直方图跨构建交集 37/37 但单构建内唯一仅 **20/37**;语义名方案 **37/37 唯一**。直方图降级为兜底锚点。 |
| **fp_salt 可用固定字符串识别**(旧规则写死了 `AyqhIdr`) | 4 份新构建里分别是 `AyqhIdr` / `QtDKYSO` / `ryieWP_` / `mYmmgLx` —— **每构建都变**。改用结构规则识别(已知 #46)。 |
| **"按模块 id 配对就能跨构建对齐"** | 同 #43;且我用"模块 id 相等"做校验时**自己误判过一次**(看到 35/37 不一致就以为配对错了)—— 实际上 id 不一致是**预期行为**,不能用来否定配对。正确校验见已知 #48。 |
---
## 四、当前实现(纯算单一路径)
@@ -128,15 +177,53 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认
```
* 纯 Python 提取器: `tcaptcha/tdc_key.py` (算法) + `tcaptcha/tdc_opcodes.py` (opcode 表推导)
* **2026-10-12 新增(按案例重做后的主路径):**
* `tools/tdc_modules.py` —— 字符串+结构指纹识别 37 个采集器(**37/37 唯一**,案例 §5.3)
* `tcaptcha/crypto.py::build_collect()` —— **4 段独立加密**(前 3 段空格 pad 到 24,sd 不 pad)
* `tcaptcha/crypto.py::split_collect_segments()` —— 从已解密明文切回 4 段(对拍/标定用)
* `tools/tdc_collectgen.py::reorder(sample, dst, src)` —— 按语义名锚点**跨构建**对齐(旧恒等 bug 已修)
* `scripts/fetch_online_samples.py` —— 在线抓取全新 tdc.js(**新研究的数据来源**)
* CLI: `tools/tdc_key_pure.py [tdc.js] [-v] [--opcodes]`
* 模板: `tcaptcha/models/success_collect_template.b64`(真实成功样本明文)
* 加密: `tcaptcha/crypto.py` 中**唯一**的教科书 XTEA
* Node 现存用途: 仅 `tdc_worker.js` 兜底提取密钥(主路径已不需要)
### 2026-10-12 起的新主路径(替代上面 `generate_collect_success` 的模板回填)
```
1. scripts/fetch_online_samples.py -> har/online/<ts>/tdc_*.js(本次会话的构建)
2. tdc_key.extract_xtea_key_pure(js) -> 本次构建的 XTEA 密钥(纯 Python,零 Node)
3. tdc_modules.analyze(js) -> 37 项的语义名 + require 顺序(37/37 唯一)
4. tdc_collectgen.reorder(cd, dst, src) -> 把标定样本对齐到本次构建(跨构建锚点)
5. crypto.build_collect(c1,traj,c2,sd,k) -> 4 段独立加密后拼接 = collect
```
⚠️ 第 4 步仍需要一份**同源标定样本**提供取值(已知 #38)。这是当前唯一未打通的环节。
---
## 五、下一步(可被下一轮直接执行)
### 🆕 2026-10-12 优先(按案例重做后的收尾,按成本排序)
0. **把"纯离线生成"打通(唯一剩余阻塞点)**。当前链路第 4 步仍需一份**同源标定样本**
提供每个语义名的取值(已知 #38)。要做的是:为每个语义名写一个**取值生成器**
—— 多数是静态环境量(UA / 屏幕 / 时区 / 语言 / 核数 / charset)可直接配置,
canvas/WebGL 指纹用 `fingerprint/profiles.json` 画像(案例 §6.3 的做法)。
完成标志:不依赖任何真机明文,纯算生成 collect 并拿到 `errorCode=0`。
1. **把 4 段结构接进生成链路**(已知 #41 已判决,但 `SlotSpec` 还没产出段边界):
`tdc_collectgen` 目前只输出一个扁平 `cd` 列表;需要额外给出
`chunk1 / trajectory / chunk2 / sd` 四段(轨迹项所在位置即分界),
再交给 `crypto.build_collect()`。改完用真机明文对拍总长度(应 1048 字符量级)。
2. **用在线新样本做一次端到端验证**:`fetch_online_samples.py` 抓构建 → 纯算生成
collect → 打 verify。先在**宽松 aid 190758559** 上跑(已知 #26 那里 httpx 裸发就能 0),
通过后再上 1ms。
3. **1ms 的 12 收口实验**(已知 #40):同一 sess/eks/tkid + 同一 collect,
分别由浏览器发与 Python 发,比较 errorCode。这一步必须在上面 0~2 完成之后才有意义
—— 否则"包内容"这个变量本身还没被正确构造出来,实验没有区分度。
### 既有项
0. ~~**静态提取 XTEA 密钥(去掉最后一个 Node 依赖)**~~ —— **已完成**(已知 #19~#22):
`tcaptcha/tdc_key.py` 纯 Python 零 Node,6/6 构建与 Node 逐字一致。
残留增强项(非阻塞):① 若服务端改了 handler 语义,`tdc_opcodes.derive_opcode_table`
@@ -209,3 +296,31 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认
.venv/bin/python tools/tdc_key_pure.py --opcodes tcaptcha/tdc_sample.js # 打印按构建推导的 opcode 表
.venv/bin/python scripts/analyze_collect_layout.py --json /tmp/cap.json # collect 槽位序对比 (已知 #31)
```
### 2026-10-12 新增的复验入口(按案例重做后)
```bash
# ① 在线抓取全新样本(新研究的数据来源;同一个 URL 每次返回不同构建,见 #29)
.venv/bin/python scripts/fetch_online_samples.py --n 4
# ② 字符串+结构指纹识别 37 个采集器(应 37/37、0 unknown、0 重名)
.venv/bin/python tools/tdc_modules.py har/online/<ts>/tdc_01_*.js
# ③ 四段结构判决(不需要密钥:密文长度算术应精确等于 1048 / 该份的长度)
.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件>
.venv/bin/python scripts/verify/verify_seglen.py <collect 的 base64>
# ④ 指纹跨构建一致性(语义名集合应完全一致、单构建内 0 重名)
.venv/bin/python scripts/verify/verify_fp_consistency.py
# ⑤ 配对正确性(实验组≈37/37,三个负对照应 2-3/37 —— 证明没过拟合)
.venv/bin/python scripts/verify/verify_pair_correct.py
.venv/bin/python scripts/verify/verify_control_negative.py
# ⑥ 端到端重排(新数据,应 3/3 正确且非恒等)
.venv/bin/python scripts/verify/verify_reorder.py
# ⑦ 回归测试(已并入 selftest,含「reorder 非恒等」强断言)
.venv/bin/python -m tcaptcha.tests.test_collectgen
.venv/bin/python -m tcaptcha.selftest
```
+99
View File
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
在线抓取**全新**样本(2026-10-12 起,只使用本脚本产出的数据)。
抓什么:
1. prehandle -> sess / comm_captcha_cfg.tdc_path
2. 连抓 N 份 tdc.js(同一个 URL **每次请求都返回不同构建**,账本 #29)
3. 每份都算出: XTEA 密钥、eks、37 模块的字符串指纹、槽位序
产出目录: har/online/<时间戳>/
用法:
.venv/bin/python scripts/fetch_online_samples.py --n 4
"""
from __future__ import annotations
import argparse
import hashlib
import json
import random
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools"))
from tcaptcha.client import TencentCaptchaClient # noqa: E402
from tcaptcha.crypto import extract_eks # noqa: E402
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
OUT = ROOT / "har" / "online"
def main() -> int:
ap = argparse.ArgumentParser(description="在线抓取全新 tdc.js 样本")
ap.add_argument("--n", type=int, default=4, help="抓几份 tdc.js")
ap.add_argument("--aid", default="2039519451", help="雨云 aid(宽松,适合取样)")
ap.add_argument("--tag", default=None, help="输出子目录名(默认时间戳)")
ap.add_argument("--gap", type=float, default=1.0, help="两次抓取间隔秒")
args = ap.parse_args()
tag = args.tag or time.strftime("%Y%m%d-%H%M%S")
d = OUT / tag
d.mkdir(parents=True, exist_ok=True)
print("输出目录:", d)
client = TencentCaptchaClient(aid=args.aid,
entry_url="https://app.rainyun.com/account/reward/earn#")
try:
tkid = str(random.randint(100000000, 999999999))
ch, cfg, sid = client.prehandle(tkid)
print("prehandle: sess=%s sid=%s kind=%s" % (ch.sess[:16], sid, ch.kind))
tdc_path = cfg.get("tdc_path", "")
if not tdc_path:
print("❌ prehandle 没给 tdc_path")
return 1
print("tdc_path:", tdc_path[:90])
(d / "meta.json").write_text(json.dumps({
"aid": args.aid, "sess": ch.sess, "sid": sid,
"tdc_path": tdc_path, "kind": ch.kind,
"fetched_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}, ensure_ascii=False, indent=1), encoding="utf-8")
seen = set()
got = 0
for i in range(args.n * 3): # 允许重试,直到拿到 n 份不同构建
if got >= args.n:
break
body = client.fetch_tdc(tdc_path)
md5 = hashlib.md5(body.encode("utf-8")).hexdigest()
if md5 in seen:
time.sleep(args.gap)
continue
seen.add(md5)
got += 1
name = "tdc_%02d_%s.js" % (got, md5[:8])
(d / name).write_text(body, encoding="utf-8")
info = {"md5": md5, "bytes": len(body), "index": got}
try:
info["key"] = extract_xtea_key_pure(body)["key"]
except Exception as e:
info["key_error"] = "%s: %s" % (type(e).__name__, str(e)[:120])
info["eks"] = extract_eks(body)[:40]
print(" [%d] %s %6d B md5=%s key=%s"
% (got, name, len(body), md5[:12], info.get("key")))
(d / (name[:-3] + ".json")).write_text(
json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
time.sleep(args.gap)
print()
print(">>> 拿到 %d 份互不相同的新构建 -> %s" % (got, d))
return 0 if got else 1
finally:
client.close()
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""验证 struct_40_9 的消歧特征是否跨构建稳定。
判别:
A 组: 闭包体最后写入 **空串** (PUSH_EMPTY_STR; SET_PROP)
B 组: 闭包体最后写入 **随机 int32** (PUSH_IMM <big>; SET_PROP)
"""
import glob
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_modules as tm # noqa: E402
import tdc_program as tp # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
def closure_body(p, entry):
"""取模块唯一闭包的 target 体。"""
cls = [c for c in p.closures if p.owner(c.pc) == entry]
if not cls:
return None
return p.function_code(cls[0].target)
def disamb(p, entry):
"""返回 'empty' | 'rand' | None"""
body = closure_body(p, entry)
if body is None:
return None
imms = [x["operands"][0] for x in body
if x["name"] == "PUSH_IMM" and x["operands"]]
big = [v for v in imms if v > 1_000_000]
if big:
return "rand"
return "empty"
stats = defaultdict(lambda: defaultdict(list))
for f in BATCH:
p = tp.load(f)
reg = dict(p.module_registry(0))
rows = tm.analyze(f)
for r in rows:
if r["name"] != "struct_40_9":
continue
d = disamb(p, reg[r["module"]])
stats[f.split("/")[-1]][d or "?"].append((r["item"], r["module"]))
print("%-24s %-8s %s" % ("构建", "判别", "项(模块)"))
print("-" * 70)
ok = True
for f, groups in stats.items():
for d, members in sorted(groups.items()):
print("%-24s %-8s %s" % (f, d, members))
# 每份构建必须 empty 1 个 + rand 1 个
if len(groups.get("empty", [])) != 1 or len(groups.get("rand", [])) != 1:
ok = False
print()
print(">>> 每份构建都是 empty×1 + rand×1 :", ok)
print(">>> 结论:", "可静态消歧(写入空串 vs 写入随机 int32)" if ok else "仍不可分")
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""对照实验:证明「按语义名配对」不是归一化过拟合出来的。
风险: 归一化若抹掉太多,任何配对都会"全对",校验就失去意义。
做法(负对照,同一套归一化 + 同一套比对):
① 按语义名配对(实验组)
② 按清单下标配对(负对照:槽位序随机,必然错)
③ 按模块 id 配对(负对照:id 每次重排,必然错)
④ 随机配对(负对照基线)
若①接近 37/37 而 ②③④ 明显更低 => 归一化没有过拟合,配对确实有效。
"""
import glob
import random
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_modules as tm # noqa: E402
import tdc_program as tp # noqa: E402
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
def norm_code(p, entry, maxlen=400):
"""归一化指令序列(与 verify_pair_correct2.py 同一套规则,此处内联避免导入副作用)。
抹掉三类**会重排**的操作数,保留指令骨架与字符串的位置:
1. 代码地址(JUMP / JUMP_IF_* / PUSH_TRY)
2. require(N) 的模块号 N
3. PUSH_STR_CHAR 的具体字符值(fp_salt 随机盐串、混淆变量名每次都变)
"""
code = p.function_code(entry)
out = []
for k, inst in enumerate(code):
nm, ops = inst["name"], tuple(inst["operands"] or [])
if nm in ADDR_OPS:
ops = ("ADDR",) * len(ops)
elif nm == "PUSH_STR_CHAR":
ops = ("CH",)
elif nm == "PUSH_IMM" and ops:
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
ops = ("MOD",) * len(ops)
out.append((nm, ops))
return tuple(out[:maxlen])
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
def load(f):
p = tp.load(f)
rows = tm.analyze(f)
reg = dict(p.module_registry(0))
sig = {r["name"]: norm_code(p, reg[r["module"]]) for r in rows}
return rows, sig
A, fa = load(BATCH[0])
print("基准 %s (归一化:抹掉地址/模块号/字符值,保留指令骨架与串的位置)" % BATCH[0].split("/")[-1])
print()
print("%-22s %-22s %-8s %-8s" % ("对比构建", "配对方式", "一致", "不一致"))
print("-" * 66)
for f in BATCH[1:]:
B, fb = load(f)
names = sorted(set(fa) & set(fb))
def score(pairs):
return sum(1 for a, b in pairs if a == b)
# ① 实验组:语义名
p1 = [(fa[n], fb[n]) for n in names]
# ② 负对照:清单下标
p2 = [(fa[A[i]["name"]], fb[B[i]["name"]]) for i in range(min(len(A), len(B)))]
# ③ 负对照:模块 id(用 id 当 key 去查另一边同名集合的任意一项)
fb_by_id = {r["module"]: fb[r["name"]] for r in B}
p3 = [(fa[A[i]["name"]], fb_by_id.get(A[i]["module"], ())) for i in range(len(A))]
# ④ 负对照:随机配对
rnd = random.Random(7)
shuf = names[:]
rnd.shuffle(shuf)
p4 = [(fa[n], fb[m]) for n, m in zip(names, shuf)]
for label, pairs in (("①语义名(实验组)", p1), ("②清单下标(负对照)", p2),
("③模块id(负对照)", p3), ("④随机配对(负对照)", p4)):
s = score(pairs)
print("%-22s %-22s %-8d %-8d" % (f.split("/")[-1], label, s, len(pairs) - s))
print()
print(">>> 判读: ①应≈37/37;②③④应明显更低 => 归一化未过拟合,配对有效")
+67
View File
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""验证字符串+结构指纹方案的**跨构建一致性** —— 只基于 har/online/ 全新样本。
案例 §5.3 的验收标准: "3 个 tdc.js 版本、37 个模块、全部正确识别,无一 unknown"。
本脚本再进一步:不仅要求"无 unknown",还要求
① 每份构建内部 37 项都有名字
② 各构建之间的**语义名集合**一致(说明识别的是同一批采集器)
③ 语义名 -> 每项在 cd 里的**相对位置**可交叉核对
"""
import glob
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_modules as tm # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
print("全新在线样本 %d 份(只使用这批数据)" % len(BATCH))
print()
results = {}
for f in BATCH:
rows = tm.analyze(f)
names = [r["name"] for r in rows]
results[f] = rows
unk = sum(1 for n in names if n == "unknown")
via_struct = sum(1 for r in rows if r.get("via_struct"))
via_deleg = sum(1 for r in rows if r.get("via_delegate"))
print(" %-24s 项%2d unknown %d | 字符串直判 %2d 委派链 %d 结构兜底 %d"
% (f.split("/")[-1], len(rows), unk,
len(rows) - via_struct - via_deleg, via_deleg, via_struct))
print()
keys = list(results)
base = Counter(r["name"] for r in results[keys[0]])
print("以 %s 为基准:%d 个语义名(去重 %d)"
% (keys[0].split("/")[-1], len(base), len(set(base))))
print()
for f in keys[1:]:
c = Counter(r["name"] for r in results[f])
same = sum((base & c).values())
only_base = sum((base - c).values())
only_it = sum((c - base).values())
print(" vs %-22s 相同 %2d | 仅基准有 %2d | 仅有它有 %2d"
% (f.split("/")[-1], same, only_base, only_it))
print()
common = set(base)
for f in keys[1:]:
common &= set(Counter(r["name"] for r in results[f]))
allnames = set()
for f in keys:
allnames |= set(r["name"] for r in results[f])
print(">>> 所有构建**共同**识别出的语义名: %d 个" % len(common))
print(">>> 出现过的语义名总数 : %d 个" % len(allnames))
print(">>> 差异名:", sorted(allnames - common) if allnames - common else "无(完全一致)")
print()
# 唯一性检查:单构建内是否有重名(重名 = 锚点有歧义)
print("单构建内重名检查(重名说明该锚点有歧义):")
for f in keys:
c = Counter(r["name"] for r in results[f])
dup = {k: v for k, v in c.items() if v > 1}
print(" %-24s 重名 %d 组 %s"
% (f.split("/")[-1], len(dup), dict(sorted(dup.items()))))
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""判决「按语义名配对」是否**正确** —— 用独立于指纹的方法。
⚠️ 上一轮我用「模块 id 相等」做校验,那个假设是**错的**:
案例 §1.2 与账本 #29 都明确说「37 个模块的 ID 每次构建随机重新分配」。
registry 全集交集 62/62 只说明编号**范围**相同(都是 0..61),
不代表 id -> 语义 的映射相同。id 不一致是**预期行为**,不能用来否定配对。
本轮改用真正独立的证据:**函数体的归一化指令序列**。
指纹规则只用到「字符串集合」和「结构统计(指令数/直方图)」,
没有用到「指令的完整顺序 + 操作数」。所以拿它做校验是独立的。
归一化: 抹掉 JUMP 的绝对地址(每构建不同),保留其余操作数。
"""
import glob
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_layout # noqa: E402
import tdc_modules as tm # noqa: E402
import tdc_program as tp # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
def norm_code(p, entry, maxlen=400):
"""归一化指令序列。
必须抹掉两类**会重排**的操作数,否则校验必然失败:
1. 代码地址:JUMP / JUMP_IF_TRUE / JUMP_IF_FALSE / PUSH_TRY
2. require(N) 的模块号 N:`LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC`
(案例 §1.2:37 个模块的 ID 每次构建随机重新分配)
其余操作数(字符串 charCode、小常量、寄存器号)跨构建稳定,保留。
"""
code = p.function_code(entry)
out = []
for k, inst in enumerate(code):
nm, ops = inst["name"], tuple(inst["operands"] or [])
if nm in ADDR_OPS:
ops = ("ADDR",) * len(ops)
elif nm == "PUSH_STR_CHAR":
# 只保留"这是一个字符"这件事,**不看具体是哪个字符**。
# 这样 fp_salt 的随机盐串、混淆变量名这些「会变的内容」就不会干扰校验,
# 而"字符串的长度/位置"仍然参与比对 —— 足够独立,且仍然很强。
ops = ("CH",)
elif nm == "PUSH_IMM" and ops:
# require(N) 形状:前一个 LOAD_REG_IMM,后一个 CALL_FUNC
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
ops = ("MOD",) * len(ops)
out.append((nm, ops))
return tuple(out[:maxlen])
print("构建 : 每个模块的函数体归一化指令序列 -> 作为独立指纹")
print()
A = tm.analyze(BATCH[0])
pa = tp.load(BATCH[0])
ra = dict(pa.module_registry(0))
fa = {r["name"]: norm_code(pa, ra[r["module"]]) for r in A}
print("基准 %s (%d 项)" % (BATCH[0].split("/")[-1], len(A)))
print()
print("%-24s %-8s %-8s %-8s %s" % ("对比构建", "配对", "序列一致", "不一致", "判决"))
print("-" * 66)
all_ok = True
for f in BATCH[1:]:
B = tm.analyze(f)
pb = tp.load(f)
rb = dict(pb.module_registry(0))
fb = {r["name"]: norm_code(pb, rb[r["module"]]) for r in B}
common = set(fa) & set(fb)
same = [n for n in common if fa[n] == fb[n]]
diff = [n for n in common if fa[n] != fb[n]]
all_ok &= not diff
print("%-24s %-8d %-8d %-8d %s"
% (f.split("/")[-1], len(common), len(same), len(diff),
"✓" if not diff else "✗"))
for n in sorted(diff)[:3]:
a, b = fa[n], fb[n]
hit = next((k for k in range(min(len(a), len(b))) if a[k] != b[k]), -1)
print(" %-26s 首个分歧@%d A=%s B=%s" % (n, hit,
a[hit] if hit >= 0 else "-", b[hit] if hit >= 0 else "-"))
print()
print(">>> 全部配对的函数体序列一致:", all_ok)
print(">>> 判决:", "语义名配对**正确**(独立证据)" if all_ok else "配对有误,需检查")
+61
View File
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""用**全新在线样本**做端到端重排测试(不需要真机明文,自证)。
做法:
给每个语义名编一个**假值** value_i,按构建 A 的清单顺序排成 cd_A。
再用锚点把它重排到构建 B 的顺序,得到 cd_B。
正确答案 = 按 B 的清单顺序、取各自语义名的假值。
这个测试能抓住 reorder 的核心 bug(恒等映射):
若 B 的顺序 ≠ A 的顺序,恒等映射必然错;只有真对齐才对。
"""
import glob
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_collectgen as cg # noqa: E402
import tdc_modules as tm # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
print("新样本 %d 份" % len(BATCH))
print()
A = BATCH[0]
rowsA = tm.analyze(A)
namesA = [r["name"] for r in rowsA]
seqA = [r["module"] for r in rowsA]
n = len(namesA)
print("基准 %s 项 %d 唯一名 %d" % (A.split("/")[-1], n, len(set(namesA))))
# 假值:每项一个可区分的值
fake = {nm: "V<%s>" % nm for nm in namesA}
cd_A = [fake[nm] for nm in namesA]
src = cg.spec_from_seq(seqA, [None] * n, namesA, sample_cd=cd_A)
print()
for f in BATCH[1:]:
rowsB = tm.analyze(f)
namesB = [r["name"] for r in rowsB]
seqB = [r["module"] for r in rowsB]
dst = cg.spec_from_seq(seqB, [None] * len(rowsB), namesB)
# 正确答案:按 B 的顺序取各自语义名的假值
truth = [fake[nm] for nm in namesB]
try:
out = cg.reorder(cd_A, dst, src)
ok = out == truth
identical = out == cd_A
print(" vs %-24s" % f.split("/")[-1])
print(" 重排结果 == 正确答案 : %s" % ok)
print(" 重排结果 == 原样(说明没重排): %s" % identical)
if not ok:
bad = [i for i, (a, b) in enumerate(zip(out, truth)) if a != b]
print(" 错位 %d 处, 前5: %s" % (len(bad), bad[:5]))
except Exception as e:
print(" vs %-24s %s: %s" % (f.split("/")[-1], type(e).__name__, str(e)[:110]))
print()
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""用真机 collect 密文的**长度与填充**判决「四段加密」结构。
不需要密钥:段结构在 base64 的长度与 '=' 分布上留下指纹。
判决依据(docs/tdc-案例.md §5.1):
chunk1/trajectory/chunk2 各 pad 到 24 字节 -> 密文长度是 24 的倍数 -> base64 无 '='
sd 不 pad,只被 XTEA 补到 8 的倍数 -> 长度%3 != 0 -> base64 带 '='
用法:
.venv/bin/python scripts/verify/verify_seglen.py <collect 的 base64>
"""
import base64
import re
import sys
from pathlib import Path
if len(sys.argv) < 2:
print("用法: verify_seglen.py <collect 的 base64(文件路径或直接给串)>")
raise SystemExit(2)
_arg = sys.argv[1]
_p = Path(_arg)
b64 = (_p.read_text(encoding="ascii") if _p.exists() else _arg).strip()
print("真机 collect: %d 字符, 含 '=' : %s" % (len(b64), "=" in b64))
print()
raw = base64.b64decode(b64)
print("解码后密文: %d 字节" % len(raw))
print()
# 真机明文(另一份同会话样本)给出的各段长度
segs = [("chunk1", 355, 5), ("trajectory", 62, 10), ("chunk2", 288, 24), ("sd", 33, None)]
print("%-11s %8s %6s %10s %12s %10s" % ("段", "内容", "pad", "密文字节", "base64字符", "含'='"))
print("-" * 66)
total = 0
total_b64 = 0
for name, content, pad in segs:
if pad is not None:
n = content + pad # 空格 pad 到 24
assert n % 24 == 0
else:
n = content + ((8 - content % 8) % 8) # sd: 只被 XTEA 补到 8 的倍数
enc = base64.b64encode(b"\x00" * n).decode()
total += n
total_b64 += len(enc)
print("%-11s %8d %6s %10d %12d %10s"
% (name, content, pad if pad is not None else "(xtea)",
n, len(enc), "=" in enc))
print("-" * 66)
print("%-11s %8s %6s %10d %12d" % ("合计", "", "", total, total_b64))
print()
print(">>> 推算总密文字节 %d == 实际 %d : %s" % (total, len(raw), total == len(raw)))
print(">>> 推算 base64 字符 %d == 实际 %d : %s" % (total_b64, len(b64), total_b64 == len(b64)))
print()
# 反过来看:把整串按 24 字节切,前三段应能整除
print("[反向校验] 把密文按 24 字节切分:")
off = 0
for name, content, pad in segs:
if pad is None:
print(" %-11s 剩余 %3d 字节 (sd, 非 24 倍数)" % (name, len(raw) - off))
break
n = content + pad
print(" %-11s [%4d:%4d] %3d 字节 24倍数=%s"
% (name, off, off + n, n, n % 24 == 0))
off += n
print()
# 关键指纹: '=' 只应出现在最后(来自 sd 段)
idx = b64.find("=")
print("'=' 首次出现位置: %d / 总长 %d -> %s"
% (idx, len(b64), "在末尾区域(符合 sd 段不 pad)"
if idx >= len(b64) - 4 else "⚠️ 在中间(与四段结构矛盾)"))
if idx >= 0:
print(" 尾部填充: %r" % b64[idx:])
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""按 docs/tdc-案例.md §5.1 验证 collect 的「四段加密」结构(真机明文判决)。
要判决的问题:
项目现有 crypto.encrypt_string / tdc_collectgen.encrypt_plain 都是
「整体 JSON -> \x00 补齐到 8 -> 一次加密 -> 一个 base64」。
案例说「4 段独立加密,前 3 段空格 pad 到 24 字节对齐,sd 不 pad」。
两者只能有一个对 —— 用真机明文里的**空格填充串**来判。
用法:
.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件>
"""
import json
import re
import sys
if len(sys.argv) < 2:
print("用法: verify_segment.py <已解密的 collect 明文文件>")
print(" (明文来源: 用同会话 tdc.js 的密钥解密 TDC.getData(true) 的产出)")
raise SystemExit(2)
RAW = sys.argv[1]
data = open(RAW, encoding="latin1").read()
print("原始(含尾部 \\x00):", len(data), "字节")
stripped = data.rstrip("\x00")
print("去掉尾部 \\x00 后 :", len(stripped), "字节 (尾部 \\x00 个数 = %d)" % (len(data) - len(stripped)))
print()
runs = [(m.start(), m.end()) for m in re.finditer(r" {2,}", stripped)]
print("[1] 连续空格串(只能来自「空格 pad」):")
for a, b in runs:
print(" 位置 %3d..%3d 长度 %2d" % (a, b, b - a))
print()
# 段边界 = 每个空格串的结束位置(若空格串在段尾)
bounds = [0] + [b for _a, b in runs[:-1]] if len(runs) > 1 else [0]
# 更稳: 段边界取每个空格串的"起点所在段"的末尾 -> 直接用空格串终点
b1, b2, b3 = runs[0][1], runs[1][1], runs[2][1]
segs = [("chunk1", 0, b1), ("trajectory", b1, b2), ("chunk2", b2, b3), ("sd", b3, len(stripped))]
print("[2] 按空格串切出的 4 段:")
for name, a, b in segs:
s = stripped[a:b]
print(" %-11s [%3d:%3d] len=%3d 24倍数=%-5s 8倍数=%-5s"
% (name, a, b, len(s), len(s) % 24 == 0, len(s) % 8 == 0))
print()
print("[3] 空格 pad 公式校验 (pad_len = 24 - len%24,总是补 1..24 个):")
for name, a, b in segs[:3]:
s = stripped[a:b]
content = s.rstrip(" ")
pad = len(s) - len(content)
print(" %-11s 内容 %3d + pad %2d = %3d 公式给出 %2d -> %s"
% (name, len(content), pad, len(s), 24 - len(content) % 24,
"OK" if pad == 24 - len(content) % 24 else "FAIL"))
print()
print("[4] sd 段不 pad(只被 XTEA 的 8 字节块 \x00 补齐):")
sd = stripped[b3:]
print(" sd = %r" % sd)
print(" sd 长度 %d (非 8 的倍数 -> 加密时被补 %d 个 \\x00)"
% (len(sd), (8 - len(sd) % 8) % 8))
print(" 实际尾部 \\x00 个数 = %d" % (len(data) - len(stripped)))
print()
print("[5] 逻辑整体是否是合法 JSON,以及轨迹在 cd 里的位置:")
obj = json.loads(stripped)
cd = obj["cd"]
traj_idx = [i for i, v in enumerate(cd)
if isinstance(v, list) and v and all(isinstance(x, list) and len(x) == 3 for x in v)]
print(" cd 槽数 = %d" % len(cd))
print(" 轨迹槽下标 = %s" % traj_idx)
if traj_idx:
print(" 轨迹内容前 3 点 = %s" % cd[traj_idx[0]][:3])
print()
print("[6] 去 pad 后 4 段拼接 == 逻辑明文(应能解析成同一对象):")
rebuilt = "".join(stripped[a:b].rstrip(" ") for _name, a, b in segs[:3]) + stripped[b3:]
obj2 = json.loads(rebuilt)
print(" 可解析为 JSON :", True)
print(" 与原对象逐项相同 :", obj2 == obj)
print(" 重建长度 %d (原文去尾 \\x00 后 %d, 差 = pad 总量 %d)"
% (len(rebuilt), len(stripped), len(stripped) - len(rebuilt)))
print()
print("[7] 三段之间的连接处(验证 chunk1 以逗号结尾 / chunk2 以 '],' 结尾):")
print(" chunk1 内容末尾 : %r" % stripped[0:b1].rstrip(" ")[-24:])
print(" trajectory 内容 : %r" % stripped[b1:b2].rstrip(" ")[:40])
print(" chunk2 内容开头 : %r" % stripped[b2:b3].rstrip(" ")[:24])
print(" chunk2 内容末尾 : %r" % stripped[b2:b3].rstrip(" ")[-24:])
print(" sd 内容 : %r" % stripped[b3:])
+4 -2
View File
@@ -26,6 +26,7 @@ import logging
import random
import re
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -405,9 +406,10 @@ class TencentCaptchaClient:
if not eks:
logger.warning("未从 tdc.js 中提取到 eks, 继续尝试")
# 无论是预期的还是非预期题型,第一时间下载原图落盘到本地 scratch/captcha_debug/,辅助用户肉眼判断
# 无论是预期的还是非预期题型,第一时间下载原图落盘到系统临时目录,辅助用户肉眼判断。
# 注意: 别再写回仓库内的 scratch/ —— 该目录已清理且不入库(.gitignore)。
bg_bytes: Optional[bytes] = None
debug_dir = Path("scratch/captcha_debug")
debug_dir = Path(tempfile.gettempdir()) / "captcha_debug"
debug_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
safe_inst = re.sub(r"[^\w\u4e00-\u9fff]+", "_", challenge.instruction).strip("_") or "no_inst"
+81
View File
@@ -107,6 +107,7 @@ def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
"""XTEA 加密一段字节;不足 8 字节用 \\x00 补齐(XTEA 块对齐所需)。"""
data = data + b"\x00" * ((8 - len(data) % 8) % 8)
out = bytearray()
for i in range(0, len(data), 8):
@@ -116,6 +117,86 @@ def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
return bytes(out)
# --------------------------------------------------------------------------- #
# collect 的「四段加密」结构(按 docs/tdc-案例.md §5.1,真机明文已判决) #
# --------------------------------------------------------------------------- #
SEG_ALIGN = 24
SEG_PAD = b" "
_collect_seg_re = re.compile(rb" {2,}")
def xtea_encrypt_segment(text: str, key: List[int],
align: int = SEG_ALIGN, pad: bytes = SEG_PAD) -> bytes:
"""加密 collect 的一个**数据段**:空格 pad 到 align 字节对齐后 XTEA + Base64。
为什么要 pad 到 24(而不是 8):24 字节 -> 32 个 Base64 字符,且**不会产生 `=` 填充**。
4 段密文的 Base64 直接拼接后看起来就是一个完整的 Base64 串,没有分隔符。
(真机明文里能直接看到这些空格:chunk1 +5、trajectory +10、chunk2 +24。)
"""
data = text.encode("latin1")
# 注意:即使 len%align==0 也要补一整个 align(真机 chunk2 内容 288 字节、
# 288%24==0,却补了 24 个空格 -> pad 长度恒为 align - len%align,落在 1..align)
data += pad * (align - len(data) % align)
return base64.b64encode(xtea_encrypt_bytes(data, key))
def xtea_encrypt_segment_raw(text: str, key: List[int]) -> bytes:
"""加密 **sd 段**:不 pad 到 24,只由 XTEA 的 8 字节块补齐(真机实测如此)。"""
data = text.encode("latin1")
return base64.b64encode(xtea_encrypt_bytes(data, key))
def build_collect(chunk1: str, trajectory: str, chunk2: str, sd: str,
key: List[int], url_encode: bool = False) -> str:
"""按案例 §5.1 组装 collect —— **4 段密文直接拼接,无分隔符**。
collect = b64(xtea(chunk1 + 空格pad到24))
+ b64(xtea(traj + 空格pad到24))
+ b64(xtea(chunk2 + 空格pad到24))
+ b64(xtea(sd)) # sd 不 pad
段的内容边界(案例 §5.5,与真机明文逐字吻合):
chunk1 = '{"cd":[' + 轨迹之前的指纹值 + ','
trajectory = '[[x,y,t],[dx,dy,dt],...,[1,1,12]]'
chunk2 = ',' + 轨迹之后的指纹值 + '],'
sd = '"sd":{"od":"C","ft":"..."}}'
"""
parts = [
xtea_encrypt_segment(chunk1, key),
xtea_encrypt_segment(trajectory, key),
xtea_encrypt_segment(chunk2, key),
xtea_encrypt_segment_raw(sd, key),
]
b64 = b"".join(parts).decode("ascii")
if url_encode:
return urllib.parse.quote(b64, safe="")
return b64
def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
"""把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去 pad)。
用于从真机样本反解出 chunk1/trajectory/chunk2/sd,做对拍或标定。
"""
data = plain.encode("latin1")
bounds = [0]
for m in _collect_seg_re.finditer(data):
bounds.append(m.end())
bounds.append(len(data))
if len(bounds) != 5:
raise ValueError(
f"期望 3 个空格填充串(4 段结构),实际切出 {len(bounds) - 1} 段;"
f"这份明文可能不是「四段加密」格式")
out = []
for i in range(4):
seg = data[bounds[i]:bounds[i + 1]]
if i < 3:
seg = seg.rstrip(SEG_PAD) # 前三段去掉空格 pad
out.append(seg.decode("latin1"))
return out[0], out[1], out[2], out[3]
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
out = bytearray()
for i in range(0, len(data) - len(data) % 8, 8):
+78 -3
View File
@@ -10,8 +10,9 @@
4. 加密/解密**回环一致**(明文逐字节还原);
5. 槽数不匹配的样本**必须报错**,不能静默错位。
样本: tcaptcha/tests/fixtures/tdc_slotmap_case.json
= {seq, kinds, v0s, sample_cd},取自 2026-10-11 真机会话(37 项 -> 38 槽)。
样本:
* tcaptcha/tests/fixtures/tdc_slotmap_case.json —— 合成用例(已知正确答案)
* har/online/<时间戳>/tdc_*.js —— **在线抓取的全新构建**(真机数据)
运行:
.venv/bin/python -m tcaptcha.tests.test_collectgen
@@ -88,11 +89,85 @@ def main() -> int:
print("\n[5] 槽数不匹配必须报错")
raised = False
try:
cg.reorder(sample_cd[:-1], spec)
cg.reorder(sample_cd[:-1], spec) # src=None: 契约是"同序"
except ValueError:
raised = True
check("少一槽 -> ValueError", raised)
# ---------------------------------------------------------------- #
# [6] 强断言:重排**必须真的重排**(旧实现的 bug 就是被"是置换"放过的)
# ---------------------------------------------------------------- #
print("\n[6] reorder 不是恒等映射(抓旧 bug)")
print(" 旧实现的循环是 sample[slot:slot+n],其中 slot 是**目标**槽号,")
print(" 两份构建顺序不同时全盘错位;而'值集合不变'这个弱断言抓不住它。")
# 把清单顺序打乱,构造"另一份构建"
import random
idx = list(range(len(seq)))
random.Random(11).shuffle(idx)
seq2 = [seq[i] for i in idx]
kinds2 = [kinds[i] for i in idx]
v0s2 = [v0s[i] for i in idx]
# 给每项一个唯一语义名(真实场景由 tdc_modules 的指纹提供)
names1 = ["n%d" % i for i in range(len(seq))]
names2 = ["n%d" % i for i in idx]
src = cg.spec_from_seq(seq, kinds, names1, sample_cd=sample_cd, v0s=v0s)
dst = cg.spec_from_seq(seq2, kinds2, names2, v0s=v0s2)
# 正确答案:按 src 的 assign 把每项的值搬到目标顺序
truth: list = []
for i in idx:
_i, _m, sl, nn = src.assign[i]
truth.extend(sample_cd[sl:sl + nn])
try:
out2 = cg.reorder(sample_cd, dst, src)
check("重排结果 == 已知正确答案", out2 == truth)
check("重排结果 != 原样(证明真的重排了)", out2 != list(sample_cd))
except cg.UnmappedSlotsError as exc:
check("重排结果 == 已知正确答案", False, f"抛错: {str(exc)[:80]}")
# ---------------------------------------------------------------- #
# [7] 用在线抓取的**全新构建**做端到端重排(真机数据,非合成)
# ---------------------------------------------------------------- #
online = sorted((ROOT / "har" / "online").glob("*/tdc_*.js")) if (
ROOT / "har" / "online").exists() else []
print("\n[7] 在线新构建 + 语义名锚点的端到端重排")
if len(online) < 2:
print(" (跳过: 需要 ≥2 份 har/online/*/tdc_*.js;"
"用 scripts/fetch_online_samples.py --n 4 抓取)")
else:
try:
import tdc_modules
except Exception as exc:
check("导入 tdc_modules", False, str(exc)[:80])
online = []
if len(online) >= 2:
def names_of(f):
return [r["name"] for r in tdc_modules.analyze(f)]
na = names_of(online[0])
n = len(na)
check("新构建清单 37 项", n == 37, "实际 %d" % n)
check("语义名全部唯一(37/37)", len(set(na)) == n,
"唯一 %d/%d" % (len(set(na)), n))
check("无 unknown", not any(x == "unknown" for x in na))
# 给每个语义名一个假值,按 A 顺序排成 cd_A,重排到 B 顺序
fake = {nm: "V<%s>" % nm for nm in na}
cd_a = [fake[nm] for nm in na]
seqa = [r["module"] for r in tdc_modules.analyze(online[0])]
srca = cg.spec_from_seq(seqa, [None] * n, na, sample_cd=cd_a)
all_ok = True
for f in online[1:]:
nb = names_of(f)
seqb = [r["module"] for r in tdc_modules.analyze(f)]
dstb = cg.spec_from_seq(seqb, [None] * len(nb), nb)
truthb = [fake[nm] for nm in nb]
try:
got = cg.reorder(cd_a, dstb, srca)
good = got == truthb and got != cd_a
except Exception:
good = False
all_ok &= good
print(" %-28s 重排正确=%s" % (f.parent.name + "/" + f.name[:12], good))
check("全部新构建重排正确且非恒等", all_ok)
print(f"\n{'=' * 62}")
if _fails:
print(f"❌ collectgen 测试失败: {_fails}")
+268 -19
View File
@@ -31,6 +31,7 @@ from __future__ import annotations
import argparse
import base64
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
@@ -50,6 +51,202 @@ import tdc_program as tp # noqa: E402
# --------------------------------------------------------------------------- #
# 槽位映射 #
# --------------------------------------------------------------------------- #
def value_sig(v: Any) -> str:
"""值的**结构签名**(用于在没有环境指纹时当次级锚点)。
只描述"形状 + 常量",抹掉随时间/会话变化的具体数值:
1791736322 -> "int:epochish" [[34,96,3284],...] -> "list:pts"
"MacIntel" -> "str:MacIntel" [360,360] -> "list:2"
易变槽(时间戳/轨迹/随机串)的签名在两份样本里**类型一致**,数值不同 —— 正好
用来把它们彼此对应起来,再由调用方决定"沿用样本值"还是"重新生成"。
"""
if v is None:
return "null"
if isinstance(v, bool):
return f"bool:{v}"
if isinstance(v, int):
return "int:epochish" if 1_700_000_000 <= v <= 2_000_000_000 else f"int:{v}"
if isinstance(v, float):
return "float"
if isinstance(v, str):
if len(v) > 200:
return "str:big"
if _RANDSTR_RE.fullmatch(v):
return "str:rand"
return f"str:{v[:24]}"
if isinstance(v, list):
if v and all(isinstance(x, list) and len(x) == 3 for x in v):
return "list:pts"
return f"list:{len(v)}"
return type(v).__name__
_RANDSTR_RE = re.compile(r"[A-Za-z0-9+/=]{20,}")
class _Unmapped:
"""哨兵:标记"这个目标槽没找到源槽"。
为什么不能用 None:真机 cd 里本身就有 null 值(`out += "null"` 分支,
见 PROGRESS.md #36),用 None 当占位符会把合法的 null 槽误判成"没对齐"。
"""
__slots__ = ()
def __repr__(self) -> str: # pragma: no cover - 仅用于调试输出
return "<unmapped>"
_UNMAPPED = _Unmapped()
def build_anchor_map(src: "SlotSpec", dst: "SlotSpec") -> List[Tuple[int, int]]:
"""把「源构建的槽」对齐到「目标构建的槽」,返回 [(src_slot, dst_slot), ...]。
这是重排的核心。三层锚点,逐层降级(都必须**双向唯一**,否则宁可不对齐):
① **环境语义指纹**(主锚点,跨构建稳定,实测多重集交集 23/24)
源项 i 与目标项 j 的 env_fingerprint 相同 -> 对齐。
② **结构签名**(次锚点,用于常量槽 / 读不到环境的项)
value_sig 相同 -> 对齐。
③ 剩余未对齐的 -> 由调用方显式处理(不猜)。
为什么不能按项下标或模块 id 对齐(两个都已实测证伪):
* 项下标:两次构建的清单顺序**就是**不同的东西,按下标对齐 = 恒等映射;
* 模块 id:编译器每次重新分配,两份构建交集 28 个模块里 id->语义 相同的
只有 1/11(2026-10-12 实测)。
"""
def bipartite(keys_src: List[Any], keys_dst: List[Any]) -> Dict[int, int]:
"""只在**双向唯一**时配对,避免多对一的歧义错位。"""
from collections import defaultdict
by_dst: Dict[Any, List[int]] = defaultdict(list)
by_src: Dict[Any, List[int]] = defaultdict(list)
for i, k in enumerate(keys_src):
if k:
by_src[k].append(i)
for j, k in enumerate(keys_dst):
if k:
by_dst[k].append(j)
pair: Dict[int, int] = {}
for k, srcs in by_src.items():
dsts = by_dst.get(k) or []
if len(srcs) == 1 and len(dsts) == 1:
pair[srcs[0]] = dsts[0]
return pair
pairs: Dict[int, int] = {}
# 锚点优先级(2026-10-12 实测,4 份全新在线构建):
# 锚点 跨构建唯一可配对
# ① 语义名(案例§5.3 指纹) 35/37 <- 主锚点
# ② 静态语义指纹 fp 29/37
# ③ 指令直方图 hist 20/37
# ④ 结构签名 value_sig (仅双方都有实测值时)
# 所以:语义名先用,再逐级降级。每一级都要求**双向唯一**才配对。
# ① 语义名(案例 §5.3:字符串指纹 + 结构兜底 + 重名消歧)
pairs.update(bipartite([n or "" for n in src.names],
[n or "" for n in dst.names]))
# ②③ 降级:静态语义指纹 -> 指令直方图
for key in (lambda s: [f or "" for f in s.fps],
lambda s: [h or "" for h in s.hists]):
for i, j in bipartite(key(src), key(dst)).items():
pairs.setdefault(i, j)
# ④ 末级:结构签名(仅在双方都有实测值时可用)
sig_pair = bipartite([value_sig(v) for v in src.sample_values],
[value_sig(v) for v in dst.sample_values])
for i, j in sig_pair.items():
pairs.setdefault(i, j)
out: List[Tuple[int, int]] = []
for i, (_i, _m, sslot, n) in enumerate(src.assign):
j = pairs.get(i)
if j is None or j >= len(dst.assign):
continue
_dj, _dm, dslot, dn = dst.assign[j]
if dn != n:
continue # 槽数形状不一致 -> 拒绝猜,跳过
for k in range(n):
out.append((sslot + k, dslot + k))
return out
def reorder(sample: Sequence[Any], dst: "SlotSpec",
src: Optional["SlotSpec"] = None,
fill: Optional[Dict[int, Any]] = None) -> List[Any]:
"""把「源样本」的 cd 值重排成**目标构建**的槽位序。
⚠️ 旧实现的 bug(2026-10-12 修复):
旧版签名是 `reorder(sample, spec)`,其中 `spec` 只是**目标构建**的规格,
而循环里 `sample[slot:slot+n]` 用的 `slot` 恰恰是**目标构建自己的槽下标**。
两次构建的槽位序不同(#31),所以这等价于"按目标下标切源数组" —— 顺序不同
时全盘错位,顺序相同时才碰巧对。**必须同时给出源构建的规格**才能对齐。
参数:
sample - 源构建产出的 cd 值数组(真机明文)
dst - 目标构建的槽位规格(本次要发包的那个构建)
src - 源构建的槽位规格。**不传则退化为旧行为**(仅当两份构建同序时才正确,
此时本函数会先校验再放行,不再静默错位)。
fill - {目标槽号: 值},用于补齐锚点匹配不上的槽。
锚点能力上限(2026-12 实测,见 tdc_layout.static_fingerprint / opcode_histogram):
静态锚点组合 `fp+hist` 单构建内唯一 **29/37**,跨构建交集 35。
剩下 8 项(分成 2 组)**静态不可区分**:它们的函数体只有一处 JUMP 目标不同,
语义藏在跳转目标里,符号求值取不到。这些槽必须由调用方用 `fill` 显式给值。
未给 fill 且仍对不齐的槽 -> 抛 `UnmappedSlotsError`(宁可暴露"不知道",
也不要静默填错值 —— 旧实现正是静默填错,导致多轮实验建立在坏数据上)。
"""
if src is None:
# 契约:不提供源构建规格 = 调用方**承诺**样本与目标构建同序。
# 此时重排只能是恒等映射;先把槽数校验掉,再直接返回副本。
if len(sample) != dst.n_slots:
raise ValueError(
f"样本槽数 {len(sample)} != 目标构建槽数 {dst.n_slots};"
f"未提供源构建规格时不跨构建重排(见 PROGRESS.md #31)")
out: List[Any] = [_UNMAPPED] * dst.n_slots
for d in range(dst.n_slots):
out[d] = sample[d]
for d, v in (fill or {}).items():
if 0 <= d < dst.n_slots:
out[d] = v
return out
if len(sample) != src.n_slots:
raise ValueError(
f"样本槽数 {len(sample)} != 源构建槽数 {src.n_slots};"
f"样本必须来自 `src` 描述的那个构建")
pairs = build_anchor_map(src, dst)
# 用哨兵而不是 None 标记"未映射":真机 cd 里本身就有 null 值,
# 拿 None 当占位符会把合法的 null 槽误判成"没对齐"。
out: List[Any] = [_UNMAPPED] * dst.n_slots
for s_slot, d_slot in pairs:
out[d_slot] = sample[s_slot]
unmapped = [d for d, v in enumerate(out) if v is _UNMAPPED]
for d, v in (fill or {}).items():
if 0 <= d < dst.n_slots:
out[d] = v
if d in unmapped:
unmapped.remove(d)
if unmapped:
raise UnmappedSlotsError(unmapped, dst, src)
return out
class UnmappedSlotsError(RuntimeError):
"""有目标槽找不到源槽与之对应(锚点匹配失败)。"""
def __init__(self, slots: List[int], dst: "SlotSpec", src: "SlotSpec") -> None:
self.slots = slots
super().__init__(
f"目标构建有 {len(slots)} 个槽无法与源样本对齐(槽号 {slots[:12]}"
f"{'...' if len(slots) > 12 else ''})。"
f"锚点不足时宁可报错也不要静默填错值 —— 请用 `--values` 显式提供这些槽的取值,"
f"或检查两份构建是否来自同一采集器集合。")
def outer_commas(s: str) -> int:
"""数**不在**任何 `[]` / `{}` 内的逗号个数(方括号内的逗号不构成顶层分隔)。"""
depth = 0
@@ -69,11 +266,27 @@ class SlotSpec:
def __init__(self, seq: Sequence[int],
kinds: Optional[Sequence[Any]] = None,
v0s: Optional[Sequence[str]] = None) -> None:
v0s: Optional[Sequence[str]] = None,
fps: Optional[Sequence[Optional[str]]] = None,
hists: Optional[Sequence[str]] = None,
names: Optional[Sequence[Optional[str]]] = None) -> None:
self.seq = list(seq)
self.kinds = list(kinds) if kinds is not None else [None] * len(seq)
# v0s[i] = 该项 get()[0] 的字符串化结果;kind=1 时按它的逗号数切出额外槽
self.v0s = list(v0s) if v0s is not None else [""] * len(seq)
# fps[i] = 该项的**环境语义指纹**(跨构建稳定, 见 tdc_layout.env_fingerprint);
# None/空串 表示该项读不到环境路径(常量槽 / 轨迹槽),需退到值签名或直接填值
self.fps: List[Optional[str]] = list(fps) if fps is not None else [None] * len(seq)
# hists[i] = 该项的**指令直方图**(兜底锚点,覆盖 37/37,见 tdc_layout.opcode_histogram)
self.hists: List[str] = list(hists) if hists is not None else [""] * len(seq)
# names[i] = 该项的**语义名**——案例 §5.3 的字符串+结构指纹(主锚点)。
# 实测 4 份全新在线构建:37/37 识别、0 unknown、跨构建完全一致、**35/37 唯一可配对**。
# 明显优于本项目早期自创的 fp/hist 锚点(29/37),所以放在最前面用。
self.names: List[Optional[str]] = (
list(names) if names is not None else [None] * len(seq))
# 该构建下的一份**实测 cd 值**(源侧重排时要用它算结构签名)。
# 目标侧可以留空;只有当成"源规格"参与 build_anchor_map 时才必须给。
self.sample_values: List[Any] = []
self.assign: List[Tuple[int, int, int, int]] = [] # (item, module, first_slot, n_slots)
self._build()
@@ -130,20 +343,40 @@ def load_sample_cd(path: str) -> List[Any]:
return raw["cd"] if isinstance(raw, dict) else raw
def reorder(sample: Sequence[Any], spec: SlotSpec) -> List[Any]:
"""把"上一份 cd"的值按**本次构建**的槽位序重排。
def spec_from_js(path: str, kinds_path: Optional[str] = None,
sample_cd: Optional[Sequence[Any]] = None) -> "SlotSpec":
"""从一份 tdc.js 直接构造该构建的槽位规格(含环境语义指纹)。"""
prog = tp.load(path)
_mid, _entry, seq = tdc_layout.find_manifest_module(prog)
kinds, v0s = load_shape(kinds_path, seq) if kinds_path else (None, None)
fps = tdc_layout.item_fingerprints(prog, seq)
hists = tdc_layout.item_histograms(prog, seq)
# 主锚点:案例 §5.3 的字符串+结构指纹语义名(实测 35/37 唯一可配对)
# 复用已加载的 prog(analyze_prog),避免把同一份 tdc.js 再解析一遍。
# 不做静默降级(AGENTS.md 附录 A#8):锚点算不出来就抛错,
# 让调用方明确知道"这次没有主锚点",而不是悄悄退化到较弱的锚点上。
import tdc_modules
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
spec = SlotSpec(seq, kinds, v0s, fps, hists, names)
if sample_cd is not None:
spec.sample_values = list(sample_cd)
return spec
原理(#31):同一台机器上的多份样本,**取值集合相同**、只有顺序不同;
所以只要样本与本次构建的"槽位数一致",就能按"项 -> 槽"的对应关系做置换。
def spec_from_seq(seq: Sequence[int],
kinds: Optional[Sequence[Any]] = None,
names: Optional[Sequence[Optional[str]]] = None,
sample_cd: Optional[Sequence[Any]] = None,
v0s: Optional[Sequence[str]] = None) -> "SlotSpec":
"""只用清单序列构造规格(**不需要 tdc.js**,供测试/合成用例使用)。
纯算重排只需要「每一项占几槽」+「每项的锚点名」;
tdc.js 只用来**导出**这两样东西,本身不是重排的输入。
"""
if len(sample) != spec.n_slots:
raise ValueError(
f"样本槽数 {len(sample)} != 本次构建槽数 {spec.n_slots};"
f"两者必须来自同一环境(见 PROGRESS.md #31)")
out: List[Any] = []
for (_i, _m, slot, n) in spec.assign:
out.extend(sample[slot:slot + n])
return out
spec = SlotSpec(seq, kinds, v0s, names=names)
if sample_cd is not None:
spec.sample_values = list(sample_cd)
return spec
# --------------------------------------------------------------------------- #
@@ -162,7 +395,10 @@ def encrypt_plain(plain: str, key: Sequence[int]) -> str:
def main() -> int:
ap = argparse.ArgumentParser(description="按本次构建生成 collect")
ap.add_argument("path", help="本次会话的 tdc.js")
ap.add_argument("path", help="本次会话的 tdc.js(**目标构建**)")
ap.add_argument("--src-build", default=None,
help="源样本所属的那份 tdc.js。**跨构建重排必须给**;"
"不给则假定源样本与目标构建同序(并会校验槽数)")
ap.add_argument("--from-sample", default=None, help="一份已解密的 cd 数组(json)")
ap.add_argument("--values", default=None, help="语义名->值的 json 字典(实验性)")
ap.add_argument("--out", default=None, help="输出 collect(base64) 的文件")
@@ -176,24 +412,37 @@ def main() -> int:
key = extract_xtea_key_pure(open(args.path, encoding="utf-8", errors="replace").read())["key"]
kinds, v0s = load_shape(args.kinds, seq) if args.kinds else (None, None)
spec = SlotSpec(seq, kinds, v0s)
fps = tdc_layout.item_fingerprints(prog, seq)
hists = tdc_layout.item_histograms(prog, seq)
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
dst = SlotSpec(seq, kinds, v0s, fps, hists, names)
anchored = sum(1 for f in fps if f)
print(f"文件 : {args.path}")
print(f"清单模块 : registry[{_mid}] 项数 {len(seq)}")
print(f"槽位规格 : {spec.describe()}")
print(f"槽位规格 : {dst.describe()}")
print(f"锚点覆盖 : 语义名 {sum(1 for n in names if n and n != 'unknown')}/{len(seq)}"
f" 静态指纹 {anchored}/{len(seq)}")
print(f"XTEA 密钥 : {key}")
if args.from_sample:
sample = load_sample_cd(args.from_sample)
if args.src_build:
src = spec_from_js(args.src_build, args.kinds, sample)
else:
src = None
try:
cd = reorder(sample, spec)
except ValueError as exc:
cd = reorder(sample, dst, src)
except (ValueError, UnmappedSlotsError) as exc:
print(f"❌ {exc}")
return 1
print(f"样本槽数 : {len(sample)} -> 重排后 {len(cd)}")
if args.src_build:
# 自证:锚点对齐后,值集合应当守恒(易变槽数值允许不同)
print(f"对齐方式 : 环境语义指纹 + 结构签名(源={args.src_build})")
elif args.values:
vals = json.load(open(args.values, encoding="utf-8"))
cd = []
for (_i, m, _slot, n) in spec.assign:
for (_i, m, _slot, n) in dst.assign:
v = vals[str(m)]
cd.extend(v[:n] if isinstance(v, list) else [v])
print(f"按语义字典 : {len(cd)} 槽")
+82 -1
View File
@@ -35,8 +35,9 @@ import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Sequence, Tuple
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
@@ -152,6 +153,86 @@ def slot_semantics(p: tp.Program, reg: Dict[int, int], mid: int, depth: int = 2)
return out
_ENV_RE = re.compile(r"c\.|screen|navigator|document|window")
_DELEG_RE = re.compile(r"r\d+\(\d*\)\s*$")
def _is_delegation(e: str) -> bool:
"""纯模块委派 `r5(22)` / `r5()` —— 括号里的模块号每份构建重排,不能进指纹。"""
return bool(_DELEG_RE.fullmatch(e))
def static_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
depth: int = 4, keep: int = 3) -> str:
"""模块的**静态语义指纹** —— 跨构建对齐的主锚点(2026-10-12 实测)。
为什么不用模块 id(实测证伪):模块 id 是编译器序号,每份构建重新洗牌。
两份构建清单交集的 28 个模块里 `id -> 语义` 完全相同的只有 **1/11**
(模块9 在 A 是 canvas,在 B 是 webkitRTCPeerConnection)。按 id 对齐必错位。
构造规则(两条,都是为了抹掉"会重排"的部分、留下"不会变"的部分):
1. 丢掉纯模块委派 `r5(22)` —— 括号里的模块号会重排;
2. 其余符号表达式**排序**后取前 keep 条 —— 排序消除遍历顺序差异。
实测(har/1ms_dianxuan/tdc.js vs live/tdc_fresh.js):
* 覆盖 29/37 项(比只取"环境路径"的 24/37 高不少 —— 常量槽、结构槽也有稳定形状)
* 两份构建指纹**多重集交集 27/29**
* 单构建内 **27/29 唯一**(只有 "Not supported | createElement(canvas)" 这一组重复)
"""
keepers: List[str] = []
for e in slot_semantics(p, reg, mid, depth=depth):
e = e.strip()
if not e or _is_delegation(e):
continue
keepers.append(e)
return " | ".join(sorted(set(keepers))[:keep])
def env_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
depth: int = 4, keep: int = 3) -> str:
"""只保留**读环境**的表达式(`c.`/screen/navigator/document/window)。
覆盖比 `static_fingerprint` 低(24/37 vs 29/37),仅在需要"这一槽读什么环境"
这种**人类可读标签**时使用。做**对齐**请用 static_fingerprint。
"""
keepers: List[str] = []
for e in slot_semantics(p, reg, mid, depth=depth):
e = e.strip()
if not e or _is_delegation(e) or not _ENV_RE.search(e):
continue
keepers.append(e)
return " | ".join(sorted(set(keepers))[:keep])
def opcode_histogram(p: tp.Program, reg: Dict[int, int], mid: int) -> str:
"""模块的**指令直方图** —— 覆盖 37/37、跨构建多重集交集 37/37 的兜底锚点。
有些采集器(常量槽、纯闭包槽)的符号求值拿不到表达式(实测 8/37 项返回空串),
但它们**函数体的 opcode 分布**是稳定的:编译器把同一份源码编成同一串指令,
只是模块 id 换了。实测两份构建 37/37 全部匹配。
缺点:唯一性差一些(单构建内 20/37 唯一),所以只能当**第三级**锚点,
在静态指纹匹配不上时兜底,并且同样要求"双向唯一"才配对。
"""
entry = reg.get(mid)
if entry is None:
return ""
counts = Counter(inst["name"] for inst in p.function_code(entry))
return "|".join(f"{k}:{v}" for k, v in sorted(counts.items()))
def item_fingerprints(p: tp.Program, seq: Sequence[int]) -> List[str]:
"""按清单顺序, 给每一项算出静态语义指纹(与 seq 等长)。"""
reg = dict(p.module_registry(0))
return [static_fingerprint(p, reg, m) for m in seq]
def item_histograms(p: tp.Program, seq: Sequence[int]) -> List[str]:
"""按清单顺序, 给每一项算出指令直方图(与 seq 等长, 兜底锚点)。"""
reg = dict(p.module_registry(0))
return [opcode_histogram(p, reg, m) for m in seq]
def find_manifest_module(p: tp.Program) -> Tuple[int, int, List[int]]:
"""找"清单模块": 体内 `push(require(N))` 次数最多者。
+454
View File
@@ -0,0 +1,454 @@
# -*- coding: utf-8 -*-
"""
tdc_modules.py —— **按案例 §5.3 的字符串指纹识别 37 个采集器模块**。
> "从 v1 的全部错位到指纹方案的全部正确,关键不是更复杂的分析,
> 而是换了一个更好的问题 —— 不再问『这个模块返回什么值』,
> 而是问『这个模块的代码长什么样』。" —— docs/tdc-案例.md §5.3
为什么必须做这件事(2026-10-12 实测,推翻项目早期假设):
* **模块 id 每份构建重新分配**。两份构建清单交集的 28 个模块里,
`id -> 语义` 完全相同的只有 **1/11**(模块 8 在 A 是 `navigator.platform`,
在 B 是 canvas 指纹)。按 id 对齐必然错位。
* 但**模块代码里的字符串集合跨构建完全不变**:采集 userAgent 的模块
一定含 `'userAgent'`;采集时区的一定含 `'getTimezoneOffset'`。这就是不变量。
本模块做三件事:
1. `module_strings()` —— 取一个模块的**可达**字符串集合(含内部闭包,递归委派)
2. `resolve_delegate()` —— 解析**委派链**(见下)
3. `identify()` —— 用指纹规则表把模块判成语义名
委派链(本项目新发现,案例未提但实测必需):
清单里有 6 个模块**自身不采集任何东西**,身体只是 `exports.get = require(N)`:
项8 模块21 -> require(22)
项9 模块24 -> require(25)
项27 模块49 -> require(31)
项31 模块53 -> require(0)
它们的字符串集合只有 {'Array','Object','exports','get'} —— **零区分性**,
必须顺着委派链跟到真正的采集器才能识别。这 6 个就是案例 §5.2 说的
"6 个无 .get 模块"的来源之一。
用法:
from tdc_modules import analyze
info = analyze("har/1ms_dianxuan/tdc.js")
for row in info:
print(row["item"], row["module"], row["name"], row["strings"][:4])
"""
from __future__ import annotations
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Set, Tuple
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if str(ROOT / "tools") not in sys.path:
sys.path.insert(0, str(ROOT / "tools"))
import tdc_layout # noqa: E402
import tdc_program as tp # noqa: E402
# --------------------------------------------------------------------------- #
# 通用字符串(每个模块都有,无区分性,进指纹前必须剔除) #
# --------------------------------------------------------------------------- #
NOISE: Set[str] = {
"Array", "Object", "exports", "get", "Math", "RegExp", "String", "Number",
"JSON", "set", "reset", "on", "length", "slice", "prototype", "constructor",
"push", "apply", "call", "bind", "toString", "valueOf", "hasOwnProperty",
}
# --------------------------------------------------------------------------- #
# 指纹规则表(案例 §5.3)—— 每个语义名对应一组「必须出现的字符串」 #
# --------------------------------------------------------------------------- #
# 形式: 语义名 -> (必需串集合, 权重提示)
# 判定: 模块的区分串集合 **包含** 某个规则的必需串 -> 命中该规则
# 形式: (语义名, 必需串, 禁用串)
# 判定: required ⊆ strings 且 forbidden ∩ strings == ∅
#
# ⚠️ 规则必须**按 specificity 从高到低排**(必需串多的在前)。
# 早期版本只按列表顺序取第一个命中,结果 webdriver_detect 被误判成 user_agent
# —— 两者都只要求 1 个串,而 user_agent 恰好排在前面。
# 给特征明显的规则补上更多必需串(如 webdriver 要求 2 个自动化标记),
# 它们就会天然压过 user_agent 这类单串规则。
#
# fp_salt 之所以需要 forbidden: 它和 iframe_detect 的串集合几乎相同
# (都是 appendChild/body/createElement/document),唯一区别是 iframe_detect
# 多一个 contentWindow。用禁用串把两者分开。
FINGERPRINTS: List[Tuple[str, Set[str], Set[str]]] = [
# ---- 复合指纹(多必需串,天然压过单串规则)----
("screen_fingerprint", {"screen", "width", "height", "availHeight", "colorDepth"}, set()),
("webrtc_ip", {"RTCPeerConnection", "createDataChannel", "createOffer"}, set()),
("webgl_renderer", {"UNMASKED_RENDERER_WEBGL", "WEBGL_debug_renderer_info"}, set()),
("webgl_vendor", {"UNMASKED_VENDOR_WEBGL", "WEBGL_debug_renderer_info"}, set()),
("webdriver_detect", {"$cdc_asdjflasutopfhvcZLmcfl_", "__webdriver_script_fn"}, set()),
("canvas_fingerprint", {"ClientJS,org <canvas> 1.0", "#069"}, set()),
("dom_compliance", {"::marker", "CSS"}, set()),
("template_url", {"?rand=*", "URL"}, set()),
("charset", {"characterSet", "charset"}, set()),
("viewport_size", {"clientWidth", "clientHeight"}, set()),
("time_since_load", {"getTime", "load"}, set()),
# ---- 单串规则(放最后,避免压过上面的复合规则)----
("user_agent", {"userAgent"}, set()),
("platform", {"platform"}, set()),
("languages", {"languages"}, set()),
("hardware_concurrency", {"hardwareConcurrency"}, set()),
("cookie_enabled", {"cookieEnabled"}, set()),
("timezone_offset", {"getTimezoneOffset"}, set()),
("itoken", {"TDC_itoken"}, set()),
("guid_token", {r"^\d+:\d+$"}, set()),
("referrer", {"TCaptchaReferrer"}, set()),
("location_href", {"href"}, {"?rand=*", "TCaptchaReferrer"}),
("iframe_detect", {"contentWindow"}, set()),
("console_detect", {"console"}, set()),
("touch_detect", {"ontouchstart"}, set()),
("window_time", {"getTime"}, set()),
("constant_98k", {"98k"}, set()),
("trajectory", {"stringify"}, set()),
("screen_width", {"screen", "width"}, set()),
("screen_height", {"screen", "height"}, set()),
("screen_color_depth", {"colorDepth"}, set()),
]
# --------------------------------------------------------------------------- #
# 结构兜底规则(案例 §5.3: "对于没有区分性字符串的通用模块,
# 用 .get 函数的指令数 + entry 函数的指令数 + 特定指令来区分")
# --------------------------------------------------------------------------- #
# 形式: (语义名, 判定函数) —— 只在**字符串规则全落空**时启用。
#
# ⚠️ 命名纪律:下面这些名字**只描述「观测到的结构分组」,不猜测业务语义**。
# 早期版本把 entry48/get9/call1 那组臆断成 "itoken_relay" —— 那是没有证据的猜测。
# 现在统一用 "struct_<entry>_<get>[_call]" 这种**纯结构标签**:
# 它诚实地表达「我们只知道它长这样,还不知道它采集什么」,
# 同时又因为跨构建稳定,仍然可以当**对齐锚点**用。
#
# 实测(4 份全新在线构建 har/online/20261012-01*,2026-10-12):
# entry135/get121/… -> 每份构建恰好 1 个 => fp_salt(随机串每次都变,只能靠结构)
# entry46/get9/… -> 每份构建恰好 1 个 => 稳定分组
# entry48/get9/call1 -> 每份构建恰好 1 个 => 稳定分组
# entry40/get9/… -> 每份构建 **2 个** => 组内无法再分,需运行时取样才能定
STRUCT_RULES = [
("fp_salt", lambda s: s["entry_instr"] >= 120 and s["get_instr"] >= 100),
("struct_46_9", lambda s: s["entry_instr"] == 46 and s["get_instr"] == 9),
("struct_48_9_call", lambda s: s["entry_instr"] == 48 and s["get_instr"] == 9
and s["call_func"]),
("struct_40_9", lambda s: s["entry_instr"] == 40 and s["get_instr"] == 9),
]
def struct_signature(p: tp.Program, reg: Dict[int, int], mid: int) -> Dict:
"""取一个模块的**结构签名**(案例 §5.3 兜底用)。
entry_instr = 模块工厂函数体的指令数
get_instr = `.get` 闭包(JUMP 目标)函数体的指令数
"""
entry = reg.get(mid)
if entry is None:
return {"entry_instr": -1, "get_instr": -1, "call_func": False,
"closure_kind": ""}
code = p.function_code(entry)
ops = Counter(inst["name"] for inst in code)
get_n = -1
for inst in code:
if inst["name"] == "JUMP" and inst["operands"]:
try:
get_n = len(p.function_code(inst["operands"][0]))
break
except Exception:
continue
return {
"entry_instr": len(code),
"get_instr": get_n,
"call_func": ops.get("CALL_FUNC", 0) > 0,
"closure_kind": _closure_kind(p, entry),
}
def _closure_kind(p: tp.Program, entry: int) -> str:
"""模块唯一闭包**最后写入什么** —— 用于拆开结构完全相同的模块。
背景(2026-10-12,4 份全新在线构建):
有 2 个模块 entry 都是 40 条、.get 都是 9 条、指令逐条相同,
只差一处 JUMP 绝对地址(每构建不同,不能当特征)。
真正的区别在**闭包体**(CREATE_CLOSURE 的 target,不是 JUMP 目标):
A: new Array()[0] = "" (PUSH_EMPTY_STR; SET_PROP)
B: new Array()[0] = 1558325408 (PUSH_IMM <随机int32>; SET_PROP)
实测每份构建恰好 empty×1 + rand×1 —— 稳定、可静态消歧。
返回: "empty" | "rand" | "const" | ""(无闭包)
"""
cls = [c for c in p.closures if p.owner(c.pc) == entry]
if not cls:
return ""
try:
body = p.function_code(cls[0].target)
except Exception:
return ""
imms = [inst["operands"][0] for inst in body
if inst["name"] == "PUSH_IMM" and inst["operands"]]
if any(v > 1_000_000 for v in imms):
return "rand"
if any(inst["name"] == "PUSH_EMPTY_STR" for inst in body):
return "empty"
return "const" if imms else ""
def _identify_struct(p: tp.Program, reg: Dict[int, int], mid: int) -> Optional[str]:
"""字符串规则落空时的兜底识别。"""
sig = struct_signature(p, reg, mid)
for name, pred in STRUCT_RULES:
try:
if pred(sig):
return name
except Exception:
continue
return None
def _clean(s: str) -> str:
return s.strip()
def module_strings(p: tp.Program, reg: Dict[int, int], mid: int,
depth: int = 3) -> Set[str]:
"""取一个模块的**可达**字符串集合(模块自身 + 内部闭包,递归 depth 层)。"""
entry = reg.get(mid)
if entry is None:
return set()
by_owner: Dict[int, Set[str]] = defaultdict(set)
for pc, s in p.strings:
by_owner[p.owner(pc)].add(s)
seen: Set[int] = set()
frontier = {entry}
out: Set[str] = set()
for _ in range(depth):
nxt: Set[int] = set()
for e in frontier:
if e in seen:
continue
seen.add(e)
out |= by_owner.get(e, set())
for c in p.closures:
if p.owner(c.pc) == e and c.target not in seen:
nxt.add(c.target)
frontier = nxt
if not frontier:
break
return out
def resolve_delegate(p: tp.Program, reg: Dict[int, int], mid: int,
max_hops: int = 6) -> List[int]:
"""解析模块 m 的委派链: `exports.X = require(N)` 里的 N。
返回 [m, N1, N2, ...](含自身)。取不到委派时返回 [m]。
形状(实测): 模块体里出现 `LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC` 且 N 在
registry 里 -> 该模块委派给 N。有些模块 require 的是自己,要防环。
"""
chain: List[int] = [mid]
seen = {mid}
cur = mid
for _ in range(max_hops):
entry = reg.get(cur)
if entry is None:
break
code = p.function_code(entry)
nxt: Optional[int] = None
for k, inst in enumerate(code):
if inst["name"] != "PUSH_IMM" or not inst["operands"]:
continue
if k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM" \
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC":
cand = int(inst["operands"][0])
if cand in reg and cand not in seen:
nxt = cand
break
if nxt is None:
break
chain.append(nxt)
seen.add(nxt)
cur = nxt
return chain
def _has(strings: Set[str], token: str) -> bool:
"""判断 token 是否出现在字符串集合里。
两种模式:
* 普通 token -> **精确**相等
* `前缀*` 结尾 -> **子串/前缀**匹配
为什么需要后者(踩过的坑): `?rand=` 在真机里是 `?rand=1519713624347`
(带会话随机数),精确匹配永远命中不了,导致 template_url 被误判成
location_href。凡是"带运行时随机后缀"的串都必须用前缀匹配。
"""
if token in strings:
return True
if token.endswith("*"):
stem = token[:-1]
return any(stem in s for s in strings)
return False
def _matches(strings: Set[str]) -> List[Tuple[str, int, Set[str]]]:
"""列出所有命中的规则,按 specificity(必需串个数)降序。"""
hits: List[Tuple[str, int, Set[str]]] = []
for name, required, forbidden in FINGERPRINTS:
if not all(_has(strings, t) for t in required):
continue
if any(_has(strings, t) for t in forbidden):
continue
hits.append((name, len(required), required))
hits.sort(key=lambda x: -x[1])
return hits
def _identify(strings: Set[str]) -> Tuple[str, Set[str]]:
"""用指纹规则判定语义名。返回 (名字, 命中的必需串)。
取**必需串最多**的那条规则(specificity 优先),而不是列表里第一条 ——
这样 `webdriver_detect`(2 个必需串)不会被 `user_agent`(1 个)抢走。
"""
hits = _matches(strings)
if not hits:
return "unknown", set()
return hits[0][0], hits[0][2]
def disambiguate(rows: List[Dict]) -> None:
"""给**重名**的项加稳定后缀,使 37 个锚点尽可能唯一(原地修改 name)。
为什么需要这一步(2026-10-12 实测,只看 har/online/ 全新样本):
字符串规则会把不同采集器判成同一个名字。跨 4 份新构建统计,重名是**稳定的**:
user_agent x2 -> entry48(委派链) / entry68(直判) 每份各 1 个 ✓ 可消歧
window_time x3 -> entry279 / entry40 / entry48(委派链) 每份各 1 个 ✓ 可消歧
struct_40_9 x2 -> 结构完全相同 每份各 2 个 ✗ 静态不可分
消歧后缀用**结构签名**(entry/get 指令数 + 是否含 CALL_FUNC),因为它是
跨构建稳定的实测分组依据,而不是猜的语义。
⚠️ struct_40_9 那组每份构建固定 2 个、结构完全一致 —— 这是**静态识别的能力上限**。
要靠运行时取样(TDC.getData 对照)才能分开。本函数会如实保留重名,
并在 row["ambiguous_static"] 上标记,调用方据此决定是否需要运行时标定。
"""
groups: Dict[str, List[Dict]] = defaultdict(list)
for r in rows:
groups[r["name"]].append(r)
for name, members in groups.items():
if len(members) <= 1:
continue
for r in members:
s = r.get("struct") or {}
tag = "e%s_g%s%s%s" % (s.get("entry_instr", "-"),
s.get("get_instr", "-"),
"_call" if s.get("call_func") else "",
# 结构完全相同时,用"闭包最后写入什么"再拆一层
("_" + s["closure_kind"]) if s.get("closure_kind") else "")
# 同组内的 tag 是否唯一?唯一才能消歧
r["_tag"] = tag
tags = [r["_tag"] for r in members]
can_split = len(set(tags)) == len(tags)
for r in members:
r["ambiguous_static"] = not can_split
r["name_unique"] = "%s#%s" % (name, r["_tag"]) if can_split else name
if can_split:
for r in members:
r["name"] = r["name_unique"]
# --------------------------------------------------------------------------- #
# 主入口 #
# --------------------------------------------------------------------------- #
def analyze_prog(p: tp.Program, depth: int = 3) -> List[Dict]:
"""分析一个**已加载**的 Program(供调用方复用,避免重复解析 tdc.js)。"""
reg = dict(p.module_registry(0))
_mid, _entry, seq = tdc_layout.find_manifest_module(p)
rows: List[Dict] = []
for i, m in enumerate(seq):
own = module_strings(p, reg, m, depth=depth)
chain = resolve_delegate(p, reg, m)
# 顺着委派链往下找,直到能识别出语义(或链走完)
name, hit = _identify(own - NOISE)
used_chain = False
if name == "unknown" and len(chain) > 1:
for deeper in chain[1:]:
s = module_strings(p, reg, deeper, depth=depth)
n2, h2 = _identify(s - NOISE)
if n2 != "unknown":
name, hit = n2, h2
used_chain = True
break
# 字符串规则全落空 -> 启用案例 §5.3 的结构兜底(按指令数识别)
via_struct = False
if name == "unknown":
n3 = _identify_struct(p, reg, m)
if n3:
name, hit = n3, set()
via_struct = True
sig = sorted((own - NOISE))
rows.append({
"item": i,
"module": m,
"name": name,
"hit": sorted(hit),
"delegate": chain if len(chain) > 1 else [],
"via_delegate": used_chain,
"via_struct": via_struct,
"struct": struct_signature(p, reg, m),
"ambiguous_static": False,
"strings": sig,
})
disambiguate(rows)
return rows
def analyze(path: str, depth: int = 3) -> List[Dict]:
"""分析一份 tdc.js,返回清单每一项的识别结果(按清单顺序)。"""
return analyze_prog(tp.load(path), depth=depth)
def main() -> int:
import argparse
import json
ap = argparse.ArgumentParser(description="按字符串指纹识别 37 个采集器模块")
ap.add_argument("path")
ap.add_argument("--json", default=None, help="把结果写成 JSON")
ap.add_argument("--only-unknown", action="store_true", help="只列出未识别的")
args = ap.parse_args()
rows = analyze(args.path)
unknown = [r for r in rows if r["name"] == "unknown"]
print("文件 : %s" % args.path)
print("清单项 : %d 已识别 %d 未识别 %d"
% (len(rows), len(rows) - len(unknown), len(unknown)))
print()
print("项 模块 语义名 委派链 区分串")
print("-" * 108)
for r in rows:
if args.only_unknown and r["name"] != "unknown":
continue
dele = "->".join(str(x) for x in r["delegate"]) if r["delegate"] else ""
mark = " *" if r["via_delegate"] else " "
print("%2d %-5d %s%-21s %-17s %s"
% (r["item"], r["module"], mark, r["name"], dele,
r["strings"][:5]))
if args.json:
Path(args.json).write_text(
json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
print("\n结果 -> %s" % args.json)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+19 -14
View File
@@ -193,6 +193,11 @@ class Program:
def top() -> Tuple[str, str]:
return st[-1] if st else ("sym", "?")
# 栈上元素形如 ("reg", int) | ("sym", str):`[1]` 可能是 int,
# 插值进 f-string 前必须统一转 str(早期版本在 CALL_* 处直接 join 会 TypeError)。
def txt(x: Tuple[str, object]) -> str:
return str(x[1]) if isinstance(x, tuple) else str(x)
def popn(n: int) -> List[Tuple[str, str]]:
r = st[-n:] if n else []
del st[len(st) - n:]
@@ -224,44 +229,44 @@ class Program:
st.append(("sym", regs.get(int(v), f"r{v}") if str(v).isdigit() else v))
elif n == "BIND_THIS":
k, v = st.pop() if st else ("sym", "?")
st.append(("sym", f"c.{v}"))
st.append(("sym", f"c.{txt(('sym', v))}"))
elif n == "MAKE_PROP_REF":
key = top()[1]
key = txt(top())
popn(1)
base = top()[1]
base = txt(top())
popn(1)
st.append(("sym", f"{base}.{key}"))
elif n == "MAKE_REG_PROP_REF":
key = top()[1]
key = txt(top())
popn(1)
k, v = st.pop() if st else ("sym", "?")
st.append(("sym", f"{regs.get(v, f'r{v}')}.{key}"))
elif n == "GET_GLOBAL_PROP":
st[-1] = ("sym", f"c.{top()[1]}")
st[-1] = ("sym", f"c.{txt(top())}")
elif n in ("GET_PROP", "GET_ELEM"):
pass
elif n in ("ADD", "SUB", "MUL", "DIV", "MOD", "BIT_AND", "BIT_OR", "BIT_XOR",
"SHL", "SHR", "SHR_U", "EQ", "EQ_STRICT", "GREATER"):
if len(st) >= 2:
b = top()[1]
b = txt(top())
popn(1)
a = top()[1]
a = txt(top())
popn(1)
st.append(("sym", f"({a} {n} {b})"))
elif n in ("NEW_FUNC", "NEW_METHOD"):
args = popn(int(ops[0]) if ops else 0)
fn = st.pop()[1] if st else "?"
st.append(("sym", f"new {fn}({','.join(x[1] for x in args)})"))
fn = txt(st.pop()) if st else "?"
st.append(("sym", f"new {fn}({','.join(txt(x) for x in args)})"))
elif n == "CALL_FUNC":
args = popn(int(ops[0]) if ops else 0)
fn = st.pop()[1] if st else "?"
st.append(("sym", f"{fn}({','.join(x[1] for x in args)})"))
fn = txt(st.pop()) if st else "?"
st.append(("sym", f"{fn}({','.join(txt(x) for x in args)})"))
elif n == "CALL_METHOD":
args = popn(int(ops[0]) if ops else 0)
tgt = st.pop() if st else ("sym", "?")
st.append(("sym", f"{tgt[1]}({','.join(x[1] for x in args)})"))
tgt = txt(st.pop()) if st else "?"
st.append(("sym", f"{tgt}({','.join(txt(x) for x in args)})"))
elif n == "STORE_REG":
v = st.pop()[1] if st else "?"
v = txt(st.pop()) if st else "?"
k, ref = st.pop() if st else ("sym", "?")
if k == "reg":
regs[ref] = v