diff --git a/.gitignore b/.gitignore
index 8b0919f..39321fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,4 +38,11 @@ har/**
*.har
*.zip
+# 探索性脚本与临时诊断产物(一次性、不入版本控制;可复现的验证脚本放 scripts/verify/)
+scratch/
+
+# 在线抓取的原始 tdc.js 样本(每份 ~78KB、每次请求都不同,属可再生数据)
+# 需要时用 `scripts/fetch_online_samples.py --n 4` 重新抓取即可。
+har/online/
+
diff --git a/docs/tdc-案例.md b/docs/tdc-案例.md
new file mode 100644
index 0000000..49f6430
--- /dev/null
+++ b/docs/tdc-案例.md
@@ -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。*
\ No newline at end of file
diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md
index d6f97fc..6454b93 100644
--- a/har/1ms_dianxuan/PROGRESS.md
+++ b/har/1ms_dianxuan/PROGRESS.md
@@ -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 的固定标记)。
→ 即「产槽」与「分段」**同时成立**:旧已知 #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 条,互相独立:
① 真机明文有 **3 处连续空格串**(`+5 / +10 / +24`),`\x00` 补齐不会产生空格;
② **不需要密钥**的纯算术:4 段长度 360/72/312/40 → base64 480+96+416+56 = **1048**,与真实 collect 的 1048 字符精确相等(差一个字节都不行);
③ `==` 只出现在 1046/1048,恰好对应 sd 段 33B→XTEA 补 7→40B。
→ 改为 `crypto.build_collect()`(4 段独立加密 + 空格 pad 到 24)。旧函数**仍保留但已不是主路径**。
⚠️ 注意被推翻的是**加密结构**,不是槽数模型——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//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//tdc_01_*.js
+
+# ③ 四段结构判决(不需要密钥:密文长度算术应精确等于 1048 / 该份的长度)
+.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件>
+.venv/bin/python scripts/verify/verify_seglen.py
+
+# ④ 指纹跨构建一致性(语义名集合应完全一致、单构建内 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
+```
diff --git a/scripts/fetch_online_samples.py b/scripts/fetch_online_samples.py
new file mode 100644
index 0000000..5a8c353
--- /dev/null
+++ b/scripts/fetch_online_samples.py
@@ -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())
diff --git a/scripts/verify/verify_40_9_disamb.py b/scripts/verify/verify_40_9_disamb.py
new file mode 100644
index 0000000..536fba2
--- /dev/null
+++ b/scripts/verify/verify_40_9_disamb.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+"""验证 struct_40_9 的消歧特征是否跨构建稳定。
+
+判别:
+ A 组: 闭包体最后写入 **空串** (PUSH_EMPTY_STR; SET_PROP)
+ B 组: 闭包体最后写入 **随机 int32** (PUSH_IMM ; 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 "仍不可分")
diff --git a/scripts/verify/verify_control_negative.py b/scripts/verify/verify_control_negative.py
new file mode 100644
index 0000000..4a9b2b0
--- /dev/null
+++ b/scripts/verify/verify_control_negative.py
@@ -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;②③④应明显更低 => 归一化未过拟合,配对有效")
diff --git a/scripts/verify/verify_fp_consistency.py b/scripts/verify/verify_fp_consistency.py
new file mode 100644
index 0000000..0bff1e0
--- /dev/null
+++ b/scripts/verify/verify_fp_consistency.py
@@ -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()))))
diff --git a/scripts/verify/verify_pair_correct.py b/scripts/verify/verify_pair_correct.py
new file mode 100644
index 0000000..f4ffeb6
--- /dev/null
+++ b/scripts/verify/verify_pair_correct.py
@@ -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 "配对有误,需检查")
diff --git a/scripts/verify/verify_reorder.py b/scripts/verify/verify_reorder.py
new file mode 100644
index 0000000..b87732f
--- /dev/null
+++ b/scripts/verify/verify_reorder.py
@@ -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()
diff --git a/scripts/verify/verify_seglen.py b/scripts/verify/verify_seglen.py
new file mode 100644
index 0000000..c8c0610
--- /dev/null
+++ b/scripts/verify/verify_seglen.py
@@ -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
+"""
+import base64
+import re
+import sys
+from pathlib import Path
+
+if len(sys.argv) < 2:
+ print("用法: verify_seglen.py ")
+ 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:])
diff --git a/scripts/verify/verify_segment.py b/scripts/verify/verify_segment.py
new file mode 100644
index 0000000..c42f3d1
--- /dev/null
+++ b/scripts/verify/verify_segment.py
@@ -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:])
diff --git a/tcaptcha/client.py b/tcaptcha/client.py
index d1108a9..cd1e672 100644
--- a/tcaptcha/client.py
+++ b/tcaptcha/client.py
@@ -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"
diff --git a/tcaptcha/crypto.py b/tcaptcha/crypto.py
index 2c0256d..8a5451b 100644
--- a/tcaptcha/crypto.py
+++ b/tcaptcha/crypto.py
@@ -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):
diff --git a/tcaptcha/tests/test_collectgen.py b/tcaptcha/tests/test_collectgen.py
index 4c32c7c..f026bd2 100644
--- a/tcaptcha/tests/test_collectgen.py
+++ b/tcaptcha/tests/test_collectgen.py
@@ -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}")
diff --git a/tools/tdc_collectgen.py b/tools/tdc_collectgen.py
index fd3d798..3958de1 100644
--- a/tools/tdc_collectgen.py
+++ b/tools/tdc_collectgen.py
@@ -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()
+
+
+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)} 槽")
diff --git a/tools/tdc_layout.py b/tools/tdc_layout.py
index 325c724..5510381 100644
--- a/tools/tdc_layout.py
+++ b/tools/tdc_layout.py
@@ -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))` 次数最多者。
diff --git a/tools/tdc_modules.py b/tools/tdc_modules.py
new file mode 100644
index 0000000..507f899
--- /dev/null
+++ b/tools/tdc_modules.py
@@ -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