feat(反VM): 纯 Python 还原 XTEA 算法 + 从 tdc.js 运行时提取每构建密钥

做了什么
- 确认 collect 的加密算法就是教科书 XTEA (32 轮, delta=0x9E3779B9),
  并证明 crypto.py 原有的"带加盐"写法只是把两个密钥字折叠:
  K' = [k0, k1+salt1, k2+salt2, k3]
- tdc_worker.js 新增 captureKey 模式: 正则改写 VM 的 ADD/XOR opcode 为带日志版本,
  用反向引用自动识别栈变量名 (样本=g/雨云实时=j/1ms=M, 版本无关);
  匹配数不为 1 时显式报错, 不静默失败
- 密钥反推: (sum + K[j]) 是一次 ADD, 两操作数必有一个等于 r*DELTA,
  另一个就是密钥字; 再对 Top-N 候选做 4! 排列 + 明文内容特征验证
- 新增 recover_payload_template / generate_collect_from_template:
  运行时标定一次明文模板, 之后轨迹生成/组包/加密/编码全部在 Python
- 新增 tcaptcha/proxy.py 代理提取, client 支持 --proxy-api 遇 12 自动换 IP

为什么
- 服务端只认 collect 的解密结果, 而密钥是每构建随机的;
  把算法做成纯 Python 后, 只有"取密钥"依赖 Node, 抗发版能力最强

怎么验证的
- 纯 Python XTEA 加密结果与真实 tdc.js 产物前 48 字节逐字节一致
- 密钥按构建固定的证据: 同一脚本 3 次运行密文首块均为 f6a24e3e4fb5dd2b
- 三份脚本 (样本/雨云实时/1ms实时) 均成功复原密钥
- 雨云 aid 端到端: 纯 Python 出包 -> errorCode=0 + 真实 ticket
- 回归测试: tcaptcha/tests/test_xtea_key.py 11 项断言全过,
  文字点选求解 8/8; 新增 tcaptcha/selftest.py 一键复验

已知未解
- 1ms aid=195672344 在答案正确时仍返回 errorCode=12; 已排除密钥、
  遥测引擎、出口 IP (4 个住宅代理)、短时频控、静默窗口
This commit is contained in:
yml2213
2026-10-11 18:07:09 +08:00
parent 9962eed3e8
commit fbcf2d448a
25 changed files with 9527 additions and 275 deletions
+438 -15
View File
@@ -1,20 +1,443 @@
# 项目核心规范与提示词约束
# 逆向工程 Agent 工作指导(通用版)
## Python 环境与依赖管理规范(唯一强制)
> **本文件的用法**:动手前先看 **§0 一页速查**定位场景;动手时按 **§3 SOP** 执行;
> 卡住或要下结论时查 **§5 经验清单** 与 **附录 A(自身 bug 排查)**;
> 每轮结束按 **§6.2 验收清单**核对。
>
> **三条铁律**(先记这三句,其余都是展开):
> 1. **服务端是最高裁判**——不校验的东西根本不逆;
> 2. **工具先于结论**——把"读代码"变成"查数据库",把"猜公式"变成"捕获值对照";
> 3. **绝不做位置假设**——布局会随输入漂移,用「内容特征 + 数学约束」识别结构。
1. **依赖声明(唯一支持)**:
- 依赖声明**仅支持 `pyproject.toml`**。
- **严禁使用 `requirements.txt` 或 setup.py 等其他格式**。
---
2. **依赖安装与管理(唯一允许)**:
- 安装/添加依赖**必须且只能使用 `uv add <package>`**(移除依赖使用 `uv remove <package>`)。
- **严禁使用 `pip`、`pip3`、`uv pip install` 等任何其他安装方式**。
## 目录
3. **虚拟环境运行规范**:
- 所有 Python 脚本、测试、打标训练、采集与逆向分析代码必须在当前项目的 **`.venv`** 虚拟环境中运行。
- 执行 Python 命令必须通过 `uv run python ...` 或显式调用 `.venv/bin/python ...`。
| 章节 | 内容 | 什么时候看 |
| --- | --- | --- |
| [§0](#0-一页速查) | 场景 → 动作速查表 | **每次动手前** |
| [§1](#1-工程基础约束) | Python / Git / 进度管理 | 项目初始化、每个里程碑 |
| [§2](#2-核心思路自顶向下) | 四问决策链、四层模型、铁律、分层交付 | 定路线时 |
| [§3](#3-标准作业流程sop) | 反混淆 / 反平砍 / 反 VM / 反加密 / 成本闸门 | 拿到源码后 |
| [§4](#4-工具链建设) | 为什么要工具、五件套、验收标准 | **动手读代码之前** |
| [§5](#5-实战经验检查表) | 10 条踩坑沉淀 | 下结论前 |
| [§6](#6-工作节奏与验收) | 每轮节奏、交付验收、卡住升级 | 每轮开始/结束 |
| [附录 A](#附录-a-自身-bug-排查清单) | 自身 bug 高频陷阱 | **推翻任何结论之前** |
| [附录 B](#附录-b-信号到动作速查) | 信号 → 动作 | 识别不出东西时 |
4. **红线禁令**:
- **严禁直接调用系统 Python**(包括但不限于 `/usr/bin/python3`、全局 `python`、`python3` 等)。
- **严禁使用除 `pyproject.toml` 以外的任何依赖声明文件**。
- **严禁使用除 `uv add` 以外的任何依赖安装命令**。
---
## §0 一页速查
| 你现在的处境 | 立刻做什么 | 去哪节 |
| --- | --- | --- |
| 刚拿到一个接口 / 抓包 | **先做服务端容忍度实验**(变异参数看响应),画生死线 | [§2.1](#21-四问决策链) |
| 服务端不校验某参数 | **停止逆向它**,记入文档 | §2.1 |
| 拿到混淆 JS/源码 | 先建工具(清点 + 提取 + 追踪 + 查询),再读 | [§4](#4-工具链建设) |
| 代码读不懂、循环套 switch | 识别为控制流平坦化,按 SOP 还原 | [§3.2](#32-控制流平坦化还原反平砍) |
| 有大 switch + 数字数组 | 判定为字节码 VM,先反平砍解释器再写反汇编器 | [§3.3](#33-字节码-vm-还原devirtualization) |
| 输出是乱码/定长串 | **先试解码**(剥头 → 标准 base64/hex → 魔改码表) | [§3.4](#34-加密与编码分析反加密) |
| 要猜某个字段的算法 | **别猜**:运行时捕获该字段来源值,在输出里搜它的大端编码定位偏移 | [§5.2](#52-完整性字段校验和长度的通用识别法) |
| 公式在单样本上对上了 | 立刻做**多维扰动 ≥10 样本 + 真实样本 + 运行时对照**三重验证 | [§5.5](#55-逐字节对拍的适用边界认清不可达目标) |
| 结论被推翻 | 在文档**显式修正**并登记"已排除的假设" | [§5.7](#57-记录被推翻的假设负结果也是资产) |
| 深挖几轮仍卡住 | 回 §0 重新问"服务端到底在不在乎这个细节" | [§6.3](#63-卡住时的升级路径) |
---
## §1 工程基础约束
### 1.1 Python 项目
- 依赖用 **uv** 管理,**项目级 venv**:`uv venv` + `uv add <包名>`
- 依赖**按需安装**:用到哪个加哪个,不要预先批量安装
### 1.2 Git
- commit 信息用**中文**:`类型(范围): 一句话总结` + 空行 + 要点列表(做了什么 / 为什么 / 怎么验证的)
- **每个有意义的里程碑单独提交**,不攒大提交
- 提交信息含特殊字符时用 `git commit -F <文件>`,避免 shell 引号踩坑
### 1.3 进度与证据管理(长任务强制)
- **任何进度、证据、灵感都落盘到文档**,不依赖对话记忆
- 按证据分类登记,四栏固定:
| 栏位 | 要求 |
| --- | --- |
| **已知** | 附证据(实验/抓包/源码位置)+ 确信度 |
| **未知** | 附当前假设 + 确信度 |
| **目标** | 本轮要推进到哪 |
| **下一步** | 具体动作,可被下一轮直接执行 |
- 结论必须带来源;没有证据的判断**标注"假设"**
- **推翻旧结论时显式修正文档**,不留过时错误判断(可另设"早期误判记录"备查)
- 不经意的灵感、异常现象也要记录——它们经常是突破口
### 1.4 交付物形态
- 产物是**资产**:可重复生成、可 diff、可复用,不是一次性分析结论
- 深挖成果即使当前用不上也**归档为储备**,不删除
- 每个交付物配一个**一键复验入口**(如 `selftest`)
---
## §2 核心思路:自顶向下
### 2.1 四问决策链
```
第 0 问:服务端到底校验什么? ← 永远的第一步,能砍掉 90% 工作量
第 1 问:目标数据的结构是什么?
第 2 问:输入特征集是什么?
第 3 问:变换算法是什么?
最后 :用目标语言原生重构,跳过一切 VM / 环境层
```
**第 0 问的标准动作**:对参数做变异(截断 / 置空 / 随机体 / 删字段 / 改版本号),用服务端响应画生死线。
- 服务端**不校验**的 → 零工作,记入文档
- **只校验格式/长度** → 结构正确的合成包即可,无需逐字节还原
- **强校验** → 才值得还原算法;即便如此也优先还原"协议结构"而非"采集过程"(指纹值可用固定真实画像)
### 2.2 四层解构模型
| 层 | 内容 | 作用 |
| --- | --- | --- |
| **L1** | 打包与模块拓扑 | 圈定核心加签模块,剔除开源库噪音 |
| **L2** | 混淆层(字符串表 / 平坦化 / 代理函数)| 还原出可读结构 |
| **L3** | 虚拟机与探针层(字节码调度 / 环境检测)| 得到操作码语义 |
| **L4** | **数据协议层**(组包 / 压缩 / 加盐 / 自定义编码)| **最终目标** |
自顶向下逐层剥离,但**注意力始终放在 L4**——L1/L2/L3 都是为读懂 L4 服务的。
### 2.3 三条铁律(展开)
#### 铁律 1:服务端是最高裁判
见 §2.1。**动手逆向任何算法之前先做容忍度实验。**
#### 铁律 2:拒绝盲目补环境(打地鼠陷阱)
错误做法:`window is not defined` 就补 window,报错就挂空函数,无休止缺啥补啥。
| 缺陷 | 说明 |
| --- | --- |
| 知其然不知其所以然 | 不理解风控意图与数据结构 |
| 极度脆弱 | 发版即全盘报废 |
| 性能低下 | 依赖 JSDOM/Headless 无法高并发 |
| 易被探测 | Proxy/堆栈泄露伪造痕迹被拉黑 |
**允许的最低限度**:为让探针跑通而供给"真实语义的值"(**有明确清单、有限次数**),目的是**采集语义**。
一旦开始无清单地连续打补丁,立即停下回到自顶向下。
#### 铁律 3:整体还原优先,拒绝零敲碎打
- 反混淆 / 还原要做就**一次性全量**(常量池整体译出、全源码静态替换),产出可复用资产
- **静态常量优先**:译码器输出与运行时无关时,离线批量译出存表,不在运行时复刻译码器
- 追踪 / 插桩注意开销形态:**热路径上禁止跨边界函数调用与字符串分配**
(用 TypedArray 直写、计数器、采样),否则会误判为"反调试"——**先排除自身工具副作用再下结论**
### 2.4 分层交付(不要追求一步到位的完美纯算)
| 级别 | 形态 | 成本 | 适用 |
| --- | --- | --- | --- |
| **① 纯算** | 目标语言原生实现 | 高(逆向成本)| 最优:高并发、零依赖、抗环境变化 |
| **② 半自动** | 浏览器/沙箱低频维护会话与参数 + 纯算高频发包 | 中 | 算法某部分难还原但输入维度小 |
| **③ 桥接** | Node V8 沙箱托管原 JS 暴露接口 | 低 | 保底,抗小版本更新 |
- **先验证低层级方案是否已被服务端接受**(§2.1),被接受就不要升级成本
- 三级方案在代码里应是**同一接口的三个实现**,上层透明,可随时降级
---
## §3 标准作业流程(SOP)
### 3.0 通用原则
- **每还原一层就验证一次**:与原版做功能等价对比(同输入同输出),多层叠加时不许跳层
- 一切"静态读不懂"的部分,**优先动态采集语义**(探针 / 追踪),而不是硬读
- 每种变换都有固定的三段式:**识别特征 → 标准解法 → 验证方式**
### 3.1 混淆还原(AST 反混淆)
**识别特征**:十六进制变量名、字符串数组+下标取值、代理函数、控制流平坦化、死代码注入、逗号表达式链。
**标准管线**(Babel/swc AST,缺一不可):
1. **常量内联**:字符串数组 + 解密函数 → 沙箱预执行 → 把所有 `dec(a,b)` 调用点静态替换为字面量
(输入固定输出固定的译码器,离线批量译出存表即可,不必理解译码器内部)
2. **代理函数消除**:`function add(a,b){return a+b}` 类转发函数折叠为原生表达式
3. **死代码剔除**:恒假分支、无用逗号表达式
4. **产物必须可执行**:每轮 AST 变换后做语法校验 + 功能等价验证,否则后续分析建立在坏产物上
> ⚠️ 第 1 步是后面一切搜索的前提:**不内联就搜不到语义**(`platform` 会写成 `n(l[11],null,17,X)`)。
### 3.2 控制流平坦化还原(反平砍)
**识别特征**:`for(;;){switch(状态变量){...}}`;状态字两级编码(如 `case = 31&v`,`子 = 31&(v>>5)`)。
**SOP**:
0. **先造工具,再读代码**(不要手工抠案例)——目标是把"读机器"变成"查数据库":
```
① AST 清点全文件状态机 → 起始/结束/状态变量/mask/shift/判别式/子状态变量/各 case 区间
② 状态提取 → 每个 (case, 子状态) → {约束, 代码, 后继状态}
③ 运行时轨迹追踪 → TypedArray 环形缓冲记录实际状态序列
④ 执行序渲染 → 按真实顺序排列的代码片段 = 算法正文
⑤ 查询 CLI → 搜状态代码 / 查某寄存器写入者 / 渲染某机器
```
工具一旦成型,"这个字段谁写的""这段代码在哪台机器"都是秒级查询。
1. **状态提取必须用 AST,不要手写括号配平**:字符串/正则字面量里的 `{` `}` 会骗过配平器
(实战中 `"[\\s\\S]{1,3}"` 这类正则曾导致 case 边界全错)
2. 与**运行时轨迹联合**:光有静态分支图不够,用真实执行序列排除未执行分支
3. **交叉验证**:工具提取出的转移必须与运行时观测到的转移**逐条对齐**,能对齐才敢用
4. 输出"执行叙事":按真实顺序排列的已解析代码 = 算法正文
5. **多层嵌套**:外层 case 体里可能是**另一个平砍机**(状态变量名不同)→ 递归处理;
也可能整段是字节码程序(见 §3.3)
> 插桩注意:热路径禁函数调用/字符串分配(跨 realm 调用会内存爆炸,易误判为反调试)。
### 3.3 字节码 VM 还原(devirtualization)
**识别特征**:巨大的 `switch` 操作码分发器 + 数字数组字面量(`push(18,1,4,0,...)`)+ Uint8Array 缓冲 + 闭包式子程序调用。
**SOP**:
1. **先判定"是不是 VM"**:操作码分发器(读数字数组驱动 switch)+ 字节码缓冲 + 虚拟寄存器/栈 → 是
2. 解释器通常自己也是平砍代码 → 按 §3.2 反平砍,得到**操作码语义表**
(每条 handler = 一条指令语义:寄存器读写 / 属性存取 / 算术 / 调用 / 闭包创建 / 异常)
3. 提取字节码程序数组(含二进制字面量操作数)
4. 写**反汇编器**:操作码表 × 字节码 → 可读指令流(机械工作,输入已齐)
5. 读指令流得到算法;子程序调用(闭包创建)递归展开
> **解释器 + 字节码 = 完整算法,两者缺一不可**;常量池在字节码操作数或独立表中。
### 3.4 加密与编码分析(反加密)
**先分类,再决定策略**——编码可逆,加密需要密钥。
**第 1 步 判定类型**
| 观察 | 判定 |
| --- | --- |
| 输出字符集是标准/魔改 base64、hex | **编码类** → 直接解码看明文结构 |
| 同环境多次采样输出逐字节大范围变化,且与输入无固定对应 | **加密类**(含 nonce / 滚动键)|
| 定长输出 + 雪崩效应 | **哈希/签名类** |
**第 2 步 分类处理**
- **编码类**:剥头(版本前缀)→ 试标准 base64/hex(补 padding)→ 失败则找**魔改码表**
(在常量池里搜 64~66 字符表)或流式变换(逐字符 XOR / 位移,常见 `charCode ^ 常数`)
- **加密类**:
- 差分分析:同环境改单一输入,看密文哪些区域变化 → 定位 nonce 区 / 指纹区 / 密文区
- 找密钥材料:滚动键常见 `key = (key*m + c) % 256`、逐字符 XOR、查表替换
—— 在源码里搜算术特征(`%256`、`^`、异常常数)
- 密钥来自环境/会话(cookie 等)时,纯算只需把该输入**参数化**,无需破解密钥本身
- **哈希/签名类**:先试常见族(MD5 / SHA1 / HMAC),用「签名 = H(公知字段拼接)」假设逐个拼接组合验证
**第 3 步 字节级解剖(对任意黑盒输出)**
- 逐字节差分(多样本对比):恒定字节 = 指纹/版本;变化字节 = nonce/时间戳/密文
- 找时间戳:在变化区搜当前 epoch ms/sec 的大端/小端表示
- 区段边界:半恒定单字节常是长度 / 分隔符
### 3.5 成本闸门(什么该反、什么不该反)
- 每次深入前重问:**服务端校验这个吗?强度多少?**
- 逆向深度与校验强度匹配:不校验 → 零工作;格式校验 → 只做结构;强校验 → 才还原算法
- **边界捕获优于逐指令还原**:在协议关键点(编码入口 / 密钥使用点)一次性插桩捕获输入/码表/输出,
往往一个钩子抵多轮操作码分析 —— 动手前先问"**最少几个边界采样能定契约**"
- **解码先于破解,码表先于差分**:拿到输出先试解码看明文再谈加密;
乱序码表下用标准解码会产生"看似恒定区"的确定性乱码假象
—— **差分结论必须建立在已验证的码表之上**
- 已完成的深挖成果归档为储备资产,不删除
> 本节只给"识别特征 → 标准解法";**工具化、校验和定位、偏移陷阱、验证边界、自身 bug 清单**见 §4、§5 与附录 A。
---
## §4 工具链建设
> **工具先于结论。** 凡涉及"找代码边界 / 找调用点 / 找赋值点",一律上 AST;正则只用于在**已定位的小片段**里粗筛。
### 4.1 五件套(控制流平坦化场景的最小工具集)
| # | 工具 | 输入 → 输出 | 解决什么 |
| --- | --- | --- | --- |
| ① | **状态机清点** | 全文件 → 机器清单(起始/状态变量/掩码/判别式/case 区间)| 先知道"有多少台机器" |
| ② | **状态提取** | 单机器 → `(case, 子状态) → {约束, 代码, 后继}` | 把机器拆成可查的状态表 |
| ③ | **运行时追踪** | 运行一次 → 状态序列(环形缓冲)| 排除未执行分支、验证静态提取 |
| ④ | **执行序渲染** | 状态表 + 轨迹 → 按序排列的代码叙事 | 得到"算法正文" |
| ⑤ | **查询 CLI** | 正则/寄存器名 → 命中的状态与所属机器 | 秒级回答"谁写了它" |
### 4.2 工具验收标准
- 能回答三个问题:**「这个字段谁写的」「这段代码在哪」「这台机器有多少状态」**
- 答案必须与**运行时观测一致**(否则工具在骗你)
- **可复用**:一次投入,后续所有分析都变成查询
### 4.3 配套的运行时捕获工具
| 捕获对象 | 用途 |
| --- | --- |
| 累加器最终值 | 在输出里搜其大端/小端编码 → **反推字段偏移与整块布局**(§5.2)|
| 中间缓冲区内容 | 判断校验和的**真实求和对象**(常不是最终载荷)|
| 子程序返回值 | 定位"这块数据由哪个子程序产出" |
| 环境属性读取点 | 得到**有限的有效输入特征集** |
**探针可复现性清单**(不满足则实验不可复现,会浪费时间追不存在的"算法差异"):
- [ ] 固定时钟(`Date` / `performance` / `timeOrigin`)
- [ ] 固定 PRNG(`Math.random` / `crypto.getRandomValues` / `randomUUID`)
- [ ] 明确"每进程只调用一次"或显式复位累积状态
- [ ] 明确区分**初始化时刻**与**调用时刻**(见 §5.4)
---
## §5 实战经验(检查表)
> 每条都来自"踩过坑再总结",附**识别信号 → 标准动作**。
### 5.1 工具先于结论
- 正则处理代码 = 技术债;代码结构一律 AST
- **先内联再搜索**:混淆常量必须先批量内联成字面量,搜出的代码才有语义
- 工具要可复用、可查询;一次投入,后续全是查询
### 5.2 完整性字段(校验和/长度)的通用识别法
- **布局模式**:二进制载荷常是 `[2字节校验和][块内容]` 顺序拼接(多块)
- **求和形式**:`校验和 = Σ(块内容 & MASK)`,且**块内嵌套的其它校验和字段在计算时按 0 计**
(信号:中间缓冲区对应位置是 0,最终载荷那里已被后续写入覆盖)
- **MASK 是常量池里的数**:同一源码常有**多个同类累加器、掩码各不相同** → 要**列全**,别找到一个就以为找全
- **杀手锏**:运行时捕获累加器最终值 → 在输出里**搜索该值的大端/小端编码**
→ 命中位置即字段偏移;多个字段一起做即可反推**整块布局**(比盲试公式高效一个数量级)
- **不要只试通用哈希族**(CRC/Adler/FNV/Murmur/区间和):
自研校验和多以"逐字节掩码求和"出现 —— 先按上一条定位字段,再回源码读公式
### 5.3 绝不做位置假设(最大的坑)
- **块长会随环境数据变化 → 后续所有偏移漂移**。
单画像验证通过的"固定偏移"(如"校验和一定在 [78:80]")换画像立刻失效
- **正确做法:用「内容特征 + 数学约束」识别结构,而不是用偏移**
- 内容特征:块头魔数(如块起始固定是 `ff 7f 75 ...`)
- 数学约束:`Σ(块 & MASK) == 该块前面的 2 字节`
- 两者同时满足 → 唯一确定块的位置与长度
- **任何偏移结论都要跨样本 / 跨画像复验**,否则不要写进代码
### 5.4 把"时间"和"随机"当作独立实验维度
- **时间层次解耦实验**:区分「模块初始化时刻」与「请求/调用时刻」——两种变化各跑一次
- 若"固定 init、变化 call"导致**载荷零差异** ⇒ 所有时间字段都来自 init
- 这一次实验就定下整套回填策略(把所有时间字段按**同一目标 init 时刻**统一回填)
- **累积状态**:同进程重复调用常使输出变长/变化(内部记账)。
确定性目标应定义为"**模块初始化后的首次调用**"
- 随机源不固定时,不要下任何"算法差异"结论(先按 §4.3 清单排查)
### 5.5 逐字节对拍的适用边界(认清不可达目标)
- 逐字节对拍**只在受控随机源下才有意义**。
真实环境每次调用含随机区,同会话相隔数秒的两个真实样本可有**上千字节不同**
—— 这不是"还没做到",而是**不可达目标**
- **验收标准要分层**:
| 层级 | 内容 |
| --- | --- |
| ① | 受控环境**逐字节一致**(证明算法还原正确)|
| ② | 真实样本上验证**不变量与公式**(版本、校验和、时间字段关系、结构可自动定位)|
| ③ | **运行时插桩值 == 输出字段**(三方对照)|
- **三重验证法**:多样本(≥10,多维扰动)+ 真实样本 + 运行时捕获对照。
只靠单一样本"公式对上"极易是巧合(**弱校验和尤其容易假命中**)
### 5.6 自身工具 bug 的高频陷阱(先怀疑自己)
动手改结论前逐条排查,详见 [附录 A](#附录-a-自身-bug-排查清单)。
### 5.7 记录被推翻的假设(负结果也是资产)
- 文档里除"已知/未知"外,还要有**"已排除的假设"**:试过什么、怎么否定的、证据是什么
- **负结论要写清适用条件**,否则未来会被误用
- 示例(本项目实战中被推翻的判断):早期"31% 常量指纹"、"某字段 = 某累加器"、"真机块结构与沙箱不同"
—— 全部靠负结果登记避免了重复投入
### 5.8 标定式交付(算法难还原但输入维度小)
- 当某部分算法难完整还原,但其输入维度很小(时间、设备画像)时,交付
**「模板 + 一次探针标定 + 定点回填」**:
| 环节 | 做法 |
| --- | --- |
| 模板 | 一次真实采样(含全部画像数据)|
| 标定 | 再用一次**不同取值**的采样,反解变换类型(异或/加法)与常数 |
| 回填 | 按目标取值重算受影响字段(含重新计算受影响的校验和,注意 §5.6 的依赖顺序)|
- 该方案**自动适配画像差异**(块长 / 偏移 / 常数),比硬编码偏移健壮得多
- 标定结果**可持久化**(JSON),生成时无需再次采样
### 5.9 用回归测试固化每一条已确证的性质
- 一旦确证某个公式/不变量,**立刻写成断言测试**并把样本打包成 fixtures
- 覆盖范围:编解码往返、捕获样本复现、公式(多样本 + 真实样本)、
结构自动发现、跨画像生成、标定流程
- 交付一个 `selftest` 入口,让别人一条命令复验全部结论
### 5.10 分层交付的落地形态
- 纯算 / 半自动 / 桥接 = **同一接口的三个实现**,调用方无感
- 服务端一旦收紧、或某画像标定失败,可**立刻降级**而不改调用方
---
## §6 工作节奏与验收
### 6.1 每轮节奏
```
① 读进度文档的「下一步」 → ② 选区分度最大的实验 → ③ 执行并记录证据
→ ④ 更新「已知/未知/下一步」 → ⑤ 提交(中文 commit)→ ⑥ 下一轮
```
- 实验设计优先做**区分度最大的那个**("不变性实验""容忍度实验"),它能直接决定路线分叉
- 每轮结束必须有**可验证的产物**(工具/文档/测试/结论),不要只留"分析过程"
### 6.2 交付前验收清单
- [ ] 服务端容忍度实验做了吗?不校验的部分是否已剔除出工作范围?
- [ ] 结论是否**带证据来源**?(实验 / 抓包 / 源码位置)
- [ ] 是否做了**三重验证**(多样本 + 真实样本 + 运行时对照)?
- [ ] 是否做了**跨样本/跨画像复验**(尤其任何偏移结论)?
- [ ] 被推翻的旧结论是否已在文档中**显式修正**?
- [ ] 是否**先怀疑过自己的工具**(附录 A 全过一遍)?
- [ ] 产物是否可复现、可 diff、有一键复验入口?
- [ ] 分层交付是否已就位(最低成本方案先验证)?
- [ ] 是不是把"不可达目标"(如真实环境逐字节对拍)误当成了"待办"?
### 6.3 卡住时的升级路径
| 症状 | 升级动作 |
| --- | --- |
| 同一细节卡住 > 几轮 | 回 §2.1 第 0 问:**服务端到底在不在乎这个细节** |
| 静态读不懂 | 改为**动态采集语义**(探针/追踪),不要硬读 |
| 公式猜不出来 | 改为**运行时捕获 + 定位偏移**(§5.2),或改走**标定式交付**(§5.8)|
| 插桩就崩/卡死 | 先做 **no-op 对照实验**排除自身工具副作用,再判断是否反调试 |
| 样本之间结论矛盾 | 检查是否**编码层/码表轮换**或**块长漂移**(§5.3、附录 A 第 5/6 条)|
---
## 附录 A 自身 bug 排查清单
> **推翻任何结论之前**,先逐条排查以下陷阱——它们在本项目实战中全部真实发生过。
| # | 陷阱 | 典型症状 | 正确做法 |
| --- | --- | --- | --- |
| 1 | **运算符优先级** | Python `X & M == v` 被解析成 `X & (M == v)` | 一律加括号:`(X & M) == v` |
| 2 | **多样本扫描用统一上界** | 各样本长度不同(如 1074~1171)导致长样本被截断 | 用**各自长度** |
| 3 | **依赖顺序** | 校验和 A 的覆盖区间包含校验和 B | **必须先算被包含者**,再算包含者 |
| 4 | **括号配平不考虑字面量** | 字符串/正则里的 `{ }` 破坏配平 | 用 **AST** 取精确区间 |
| 5 | **编码层陷阱** | 参数在 **body** 而非 header/cookie/query;urlencoded 把 `+` 变空格(而自定义码表恰含 `+`)| 先确认数据位置;解码前**还原 `+`** |
| 6 | **码表/版本轮换** | 用一套码表解另一版本,得到"看似合理的乱码" | 先确认版本,**每版本单独取码表** |
| 7 | **工具解析失败被忽略** | 提取结果与运行时不一致却继续用 | 提取结果必须与**运行时轨迹逐条对齐** |
| 8 | **静默异常** | `except: pass` 掩盖了真实错误 | 断言 + 显式失败 |
---
## 附录 B 信号到动作速查
| 观察到的信号 | 最可能的性质 | 下一步动作 |
| --- | --- | --- |
| `for(;;){switch(x)}` + 状态字 | 控制流平坦化 | §3.2 |
| 巨大 switch + 数字数组 + Uint8Array | 字节码 VM | §3.3 |
| 字符串数组 + 下标取值 | 字符串混淆 | §3.1 常量内联 |
| 输出 = 版本前缀 + 定长乱码 | 编码类 | §3.4 第 2 步(试魔改码表)|
| 同环境多次输出大范围变化 | 加密 / 含 nonce | §3.4 第 2 步(差分定位)|
| 输出定长 + 输入微改即全变 | 哈希 / 签名 | §3.4(试常见族 + 拼接组合)|
| 载荷中 `[小值][定长块]` 交替 | `[校验和][块内容]` 布局 | §5.2 |
| 常量字节 + 变化字节混合 | 指纹 + nonce/时间戳 | §3.4 第 3 步(逐字节差分)|
| 单画像能对上、换画像就错 | 偏移漂移 | §5.3 |
| 同一实验两次结果不同 | 随机源未固定 | §4.3 探针可复现性清单 |
+54 -5
View File
@@ -1,15 +1,64 @@
# -*- coding: utf-8 -*-
"""Tencent Captcha Package"""
"""Tencent Captcha Package
from .client import TencentCaptchaClient
from .crypto import extract_eks, generate_collect
核心能力:
* TencentCaptchaClient —— 纯协议客户端 (prehandle/PoW/tdc.js/图片/verify/getsig)
* TextClickSolver —— 文字点选 (2404) 求解器: 颜色分割 + OCR 集成指派
* CaptchaSolver —— 图标点选 (2408) 求解器: YOLOv8 + ResNet18
* EnsembleOcr —— ddddocr + PP-OCRv4 概率级集成识别
* generate_telemetry(_node) —— collect/eks 遥测密文生成 (Node 沙箱执行真实 tdc.js)
* OneMsAdapter —— 毫秒 1ms.run 业务适配器
"""
from .client import (
KIND_ICON_CLICK,
KIND_SLIDER,
KIND_TEXT_CLICK,
Challenge,
TencentCaptchaClient,
classify_challenge,
)
from .crypto import (
encrypt_string,
extract_eks,
generate_collect,
generate_telemetry,
generate_telemetry_node,
)
from .glyph_solver import Glyph, SolveResult, TextClickSolver, parse_instruction
from .ocr_engines import DdddOcrEngine, EnsembleOcr, PpOcrEngine
from .one_ms_adapter import OneMsAdapter
from .proxy import check_proxy, fetch_proxy, parse_proxies
from .slicer import slice_prompt_image
from .solver import CaptchaSolver
__all__ = [
# 协议客户端
"TencentCaptchaClient",
"Challenge",
"classify_challenge",
"KIND_TEXT_CLICK",
"KIND_ICON_CLICK",
"KIND_SLIDER",
# 求解器
"TextClickSolver",
"SolveResult",
"Glyph",
"parse_instruction",
"CaptchaSolver",
"extract_eks",
"generate_collect",
"slice_prompt_image",
"EnsembleOcr",
"DdddOcrEngine",
"PpOcrEngine",
# 遥测 / 加密
"generate_collect",
"generate_telemetry",
"generate_telemetry_node",
"extract_eks",
"encrypt_string",
# 业务适配 / 代理
"OneMsAdapter",
"fetch_proxy",
"parse_proxies",
"check_proxy",
]
+397 -194
View File
@@ -1,15 +1,25 @@
"""
腾讯天御点选验证码工业级纯协议客户端 (Tencent Turing Captcha Protocol Client)
实现端到端全自动纯协议闭环:
1. GET /cap_union_prehandle 获取初始会话与 PoW 题目
2. 毫秒级求解 Proof of Work 哈希碰撞
3. 并发拉取动态混淆的 tdc.js 脚本
4. POST /cap_union_new_getsig 获取当前轮次题目与图片
5. YOLOv8n + ResNet18 模型进行视觉检测、分类与全局非冲突点选排序
6. 纯算法还原 (Custom XTEA + 贝塞尔人手微颤) 生成行为轨迹密文 collect 与会话密钥 eks (100% 纯 Python)
7. POST /cap_union_new_verify 提交验证,获取最终 ticket 与 randstr
腾讯天御点选验证码纯协议客户端 (Tencent Turing Captcha Protocol Client)
覆盖腾讯天御 `cap_union_new` 协议族的完整纯协议闭环:
1. GET /cap_union_prehandle 获取会话 sess / PoW 题目 / 题型 / 首道题目
2. 本地求解 PoW (MD5 哈希碰撞)
3. GET <tdc_path> 拉取当前会话动态混淆的 tdc.js, 提取会话密钥 eks
4. GET /cap_union_new_getcapbysig 下载 672x480 背景图 (img_index=1);
图标题型额外下载 img_index=0 提示图
5. 视觉求解:
* 文字点选 (subcapclass 2404) -> tcaptcha.glyph_solver.TextClickSolver
* 图标点选 (subcapclass 2408) -> tcaptcha.solver.CaptchaSolver (YOLO+ResNet18)
6. 生成行为遥测密文 collect (纯 Python 还原的自定义 XTEA + 贝塞尔轨迹)
7. POST /cap_union_new_verify 提交 ans/collect/eks/sess/pow_answer
8. 失败 → POST /cap_union_new_getsig 在同一会话内刷新下一道题并重试
题型识别不依赖 subcapclass 数字 (getsig 不返回该字段), 而是根据
instruction + bg_elem_cfg.click_cfg 推断, 详见 classify_challenge()。
"""
import base64
import hashlib
import json
import logging
@@ -17,8 +27,9 @@ import random
import re
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Sequence, Tuple
import httpx
@@ -26,12 +37,55 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from tcaptcha.crypto import extract_eks, generate_collect
from tcaptcha.solver import CaptchaSolver
from tcaptcha.crypto import extract_eks, generate_telemetry
from tcaptcha.glyph_solver import TextClickSolver, parse_instruction
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("tcaptcha_client")
DEFAULT_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36")
# 题型
KIND_TEXT_CLICK = "text_click" # 2404 文字点选
KIND_ICON_CLICK = "icon_click" # 2408 图标点选 (需要提示图 img_index=0)
KIND_SLIDER = "slider" # 2402 滑块拼图 (本客户端未实现)
KIND_UNKNOWN = "unknown"
@dataclass
class Challenge:
"""一道待求解的验证码题目"""
sess: str
instruction: str = ""
img_url: str = ""
kind: str = KIND_UNKNOWN
subcapclass: str = ""
click_cfg: Optional[dict] = None
raw: dict = field(default_factory=dict)
@property
def targets(self) -> List[str]:
return parse_instruction(self.instruction)
def classify_challenge(dyn_show_info: dict, subcapclass: str = "") -> str:
"""根据题目配置推断题型 (getsig 不返回 subcapclass, 只能靠结构判断)"""
instruction = (dyn_show_info or {}).get("instruction", "") or ""
click_cfg = ((dyn_show_info or {}).get("bg_elem_cfg") or {}).get("click_cfg") or {}
data_types = click_cfg.get("data_type") or []
if "DynAnswerType_POS" in data_types or "DynAnswerType_POINT" in data_types:
# 有点选坐标配置: 有目标文字 => 文字点选; 无目标文字 => 图标点选(提示走提示图)
return KIND_TEXT_CLICK if parse_instruction(instruction) else KIND_ICON_CLICK
if "滑块" in instruction or "拖动" in instruction or subcapclass == "2402":
return KIND_SLIDER
if subcapclass == "2404":
return KIND_TEXT_CLICK
if subcapclass == "2408":
return KIND_ICON_CLICK
return KIND_UNKNOWN
class TencentCaptchaClient:
BASE_URL = "https://turing.captcha.qcloud.com"
@@ -40,275 +94,424 @@ class TencentCaptchaClient:
def __init__(
self,
aid: str = DEFAULT_AID,
solver: Optional[CaptchaSolver] = None,
solver=None,
text_solver: Optional[TextClickSolver] = None,
timeout: float = 15.0,
user_agent: Optional[str] = None,
entry_url: str = "https://app.rainyun.com/account/reward/earn#",
referer: Optional[str] = None,
wb: str = "2",
js: str = "/tcaptcha-frame.ba387dd1.js",
accept_kinds: Sequence[str] = (KIND_TEXT_CLICK, KIND_ICON_CLICK),
min_posterior: float = 0.95,
pow_timeout: int = 4000000,
inter_attempt_delay: float = 0.25,
telemetry_mode: str = "python_keyed",
proxy: Optional[str] = None,
):
self.aid = aid
"""
telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry):
"python_keyed" (默认) Node **只**从本次构建的 tdc.js 里提取 XTEA 密钥,
collect 的加密与组装全部由纯 Python 完成 (标准 XTEA)
"python" 纯 Python + crypto.py 内置固定密钥 (无 Node 依赖, 兼容宽松 aid)
"node" 在 Node VM 沙箱里整体执行真实 tdc.js 产出 collect/eks
"browser" 在真实 Chrome 里执行真实 tdc.js (CDP 驱动, 环境最真)
"auto" / "auto_keyed" 多后端逐级回退
实测结论 (2026-10-11):
* 雨云 aid=2039519451 用 "python" 提交正确答案 → errorCode=0 + 真实 ticket,
证明服务端不校验 collect 与 eks 的同源性, 纯 Python 交付形态依然成立。
* 1ms.run aid=195672344 答题正确时, "python"/"node"/"browser" 三种遥测
在服务端风控阶段一律返回 errorCode=12 —— 与该 aid 的风控策略有关,
不是遥测引擎的问题。详见 har/1ms_dianxuan/ANALYSIS.md。
"""
self.aid = str(aid)
self.timeout = timeout
self.user_agent = user_agent or (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
)
self.solver = solver or CaptchaSolver()
self.user_agent = user_agent or DEFAULT_UA
self.entry_url = entry_url
self.referer = referer or entry_url
self.wb = str(wb)
self.js = js
self.accept_kinds = tuple(accept_kinds)
self.min_posterior = min_posterior
self.pow_timeout = pow_timeout
# 抽题/投票之间的最小间隔, 用于规避腾讯 errorCode 12 (f_limit)
self.inter_attempt_delay = inter_attempt_delay
# 遥测(collect/eks)生成方式: node = 在沙箱里跑真实 tdc.js (推荐); python = 纯算法还原
self.telemetry_mode = telemetry_mode
# 出口代理: 腾讯天御按 IP 做滚动风控, 触发 errorCode=12 时换 IP 即可恢复
self.proxy = proxy
# 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重)
self._icon_solver = solver
self._text_solver = text_solver
self.http_client = httpx.Client(
base_url=self.BASE_URL,
trust_env=False, # 屏蔽环境代理干扰
proxy=self.proxy,
headers={
"User-Agent": self.user_agent,
"Referer": "https://turing.captcha.qcloud.com/",
"Referer": self.referer,
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
},
timeout=self.timeout,
follow_redirects=True,
)
# --------------------------------------------------------------- helpers
@property
def text_solver(self) -> TextClickSolver:
if self._text_solver is None:
logger.info("初始化文字点选求解器 (ddddocr + PP-OCRv4 集成)...")
self._text_solver = TextClickSolver()
return self._text_solver
@property
def icon_solver(self):
if self._icon_solver is None:
from tcaptcha.solver import CaptchaSolver
logger.info("初始化图标点选求解器 (YOLOv8 + ResNet18)...")
self._icon_solver = CaptchaSolver()
return self._icon_solver
def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]:
"""求解 Proof of Work 哈希碰撞"""
t0 = time.time()
for i in range(2000000):
for i in range(self.pow_timeout):
candidate = f"{prefix}{i}"
if hashlib.md5(candidate.encode("utf-8")).hexdigest() == target_md5:
calc_time = max(1, round((time.time() - t0) * 1000))
return candidate, calc_time
raise RuntimeError(f"PoW MD5 collision search failed for prefix {prefix}")
def _generate_telemetry(self, tdc_code: str, click_points: List[Tuple[int, int]]) -> Dict[str, str]:
"""纯 Python 算法生成真实行为轨迹与遥测密文 collect 及 eks (100% 纯算免 Node.js)"""
eks = extract_eks(tdc_code)
collect = generate_collect(click_points, aid=self.aid, user_agent=self.user_agent)
return {
"collect": collect,
"tlg": str(len(collect)),
"eks": eks,
# ------------------------------------------------------------- 1. prehandle
def prehandle(self, tkid: str) -> Tuple[Challenge, dict, str]:
"""GET /cap_union_prehandle —— 返回 (题目, comm_captcha_cfg, sid)"""
cb_name = f"_aq_{random.randint(100000, 999999)}"
ua_b64 = base64.b64encode(self.user_agent.encode()).decode()
params = {
"aid": self.aid,
"protocol": "https",
"accver": "1",
"showtype": "popup",
"ua": ua_b64,
"noheader": "1",
"fb": "1",
"aged": "0",
"enableAged": "0",
"enableDarkMode": "0",
"grayscale": "1",
"clientype": "2",
"cap_cd": "",
"uid": "",
"lang": "zh-cn",
"entry_url": self.entry_url,
"elder_captcha": "0",
"js": self.js,
"login_appid": "",
"wb": self.wb,
"tkid": tkid,
"subsid": str(random.randint(1, 5)),
"callback": cb_name,
"sess": "",
}
resp = self.http_client.get("/cap_union_prehandle", params=params,
headers={"Referer": self.referer})
match = re.search(rf"{re.escape(cb_name)}\((.*)\)\s*$", resp.text, re.S)
if not match:
raise RuntimeError(f"prehandle 回调解析失败: {resp.text[:200]}")
data = json.loads(match.group(1))
dyn = (data.get("data") or {}).get("dyn_show_info") or {}
sub = str(data.get("subcapclass", ""))
challenge = Challenge(
sess=data.get("sess", ""),
instruction=dyn.get("instruction", ""),
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
kind=classify_challenge(dyn, sub),
subcapclass=sub,
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
raw=dyn,
)
return challenge, (data.get("data") or {}).get("comm_captcha_cfg", {}) or {}, data.get("sid", "")
def solve(self, max_retries: int = 2) -> Dict:
# --------------------------------------------------------------- 2. getsig
def getsig(self, sess: str, tkid: str) -> Tuple[Challenge, str]:
"""POST /cap_union_new_getsig —— 在同一会话内刷新题目"""
resp = self.http_client.post(
"/cap_union_new_getsig",
data={"sess": sess, "tkid": tkid},
headers={"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://turing.captcha.gtimg.com",
"Referer": "https://turing.captcha.gtimg.com/"},
)
payload = resp.json()
if str(payload.get("ret")) != "0":
raise RuntimeError(f"getsig 返回异常: {payload}")
dyn = payload.get("data") or {}
new_sess = payload.get("sess") or sess
challenge = Challenge(
sess=new_sess,
instruction=dyn.get("instruction", ""),
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
kind=classify_challenge(dyn),
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
raw=dyn,
)
return challenge, new_sess
# ------------------------------------------------------------ 3. download
def fetch_tdc(self, tdc_path: str) -> str:
resp = self.http_client.get(tdc_path,
headers={"Referer": "https://turing.captcha.gtimg.com/"})
return resp.text
def fetch_image(self, img_url: str, img_index: Optional[int] = None) -> bytes:
if img_index is not None:
img_url = re.sub(r"img_index=\d+", f"img_index={img_index}", img_url)
resp = self.http_client.get(
img_url,
headers={"Referer": "https://turing.captcha.gtimg.com/",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
)
return resp.content
# ------------------------------------------------------------------ solve
def solve(self, max_retries: int = 3, max_refreshes: int = 6) -> Dict:
"""
端到端全自动解题并提交验证
返回格式:
{
"success": bool,
"ticket": str,
"randstr": str,
"elapsed_ms": int,
"points": [(x1, y1), (x2, y2), (x3, y3)],
"raw_response": dict,
"error": str
}
端到端解题并提交验证。
返回: {success, ticket, randstr, elapsed_ms, points, kind, instruction,
raw_response, error}
"""
start_time = time.time()
active_sess = None
tkid = None
tdc_code = None
pow_answer = None
pow_calc_time = None
last_raw = {}
last_error = "Max retries exceeded or verification rejected"
tkid = str(random.randint(100000000, 999999999))
sess: Optional[str] = None
eks: Optional[str] = None
tdc_code: Optional[str] = None
pow_answer: Optional[str] = None
pow_calc_time: Optional[int] = None
comm_cfg: Dict = {}
challenge: Optional[Challenge] = None
last_raw: Dict = {}
last_error = "未完成任何一次验证提交"
for attempt in range(max_retries):
attempts = 0
refreshes = 0
prehandles = 0
while attempts < max_retries and refreshes < max_refreshes:
try:
# 1. 预检请求 (Prehandle) 或在会话内刷新
cb_name = f"_aq_{random.randint(100000, 999999)}"
if not tkid:
tkid = str(random.randint(100000000, 999999999))
# ---- 拿题: 首选 prehandle, 之后用 getsig 在同会话内刷新
if challenge is None:
challenge, comm_cfg, _sid = self.prehandle(tkid)
prehandles += 1
logger.info("prehandle -> subcapclass=%s kind=%s instruction=%r",
challenge.subcapclass, challenge.kind, challenge.instruction)
pow_cfg = comm_cfg.get("pow_cfg") or {}
if pow_cfg.get("prefix") and pow_cfg.get("md5"):
pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"],
pow_cfg["md5"])
logger.info("PoW 命中 %s (%dms)", pow_answer, pow_calc_time)
tdc_code = self.fetch_tdc(comm_cfg.get("tdc_path", ""))
eks = extract_eks(tdc_code)
if not eks:
logger.warning("未从 tdc.js 中提取到 eks, 继续尝试")
else:
challenge, sess = self.getsig(sess or challenge.sess, tkid)
logger.info("getsig -> kind=%s instruction=%r",
challenge.kind, challenge.instruction)
if not active_sess:
# 首次进入:执行 Prehandle
ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2"
params = {
"aid": self.aid,
"protocol": "https",
"accver": "1",
"showtype": "popup",
"ua": ua_b64,
"noheader": "1",
"fb": "1",
"aged": "0",
"enableAged": "0",
"enableDarkMode": "0",
"grayscale": "1",
"clientype": "2",
"lang": "zh-cn",
"entry_url": "https://app.rainyun.com/account/reward/earn#",
"elder_captcha": "0",
"js": "/tcaptcha-frame.ba387dd1.js",
"wb": "2",
"tkid": tkid,
"subsid": str(random.randint(1, 5)),
"callback": cb_name,
}
if challenge.kind not in self.accept_kinds:
# 题型在会话内是固定的 (getsig 不会换题型), 必须重新 prehandle 抽题
logger.info("题型 %s 不在支持范围 %s, 重新 prehandle 抽题 (已抽 %d 次)",
challenge.kind, list(self.accept_kinds), prehandles)
refreshes += 1
challenge = None
sess = None
eks = None
pow_answer = None
if self.inter_attempt_delay and refreshes < max_refreshes:
time.sleep(self.inter_attempt_delay)
continue
pre_resp = self.http_client.get(
"/cap_union_prehandle",
params=params,
headers={"Referer": "https://app.rainyun.com/"},
)
match = re.search(rf"{cb_name}\((.*)\)", pre_resp.text)
if not match:
logger.warning("Failed to parse prehandle callback, retrying...")
# ---- 视觉求解
points: List[Tuple[int, int]]
if challenge.kind == KIND_TEXT_CLICK:
bg = self.fetch_image(challenge.img_url, img_index=1)
result = self.text_solver.solve_ex(bg, challenge.instruction)
logger.info("文字点选求解: %s", result.describe())
if not result.ok:
logger.warning("文字点选求解失败, 刷新题目")
refreshes += 1
continue
if result.posterior < self.min_posterior:
logger.warning("求解置信度过低 (%.3f < %.2f), 不提交, 刷新题目",
result.posterior, self.min_posterior)
refreshes += 1
continue
points = result.points
else: # KIND_ICON_CLICK
bg = self.fetch_image(challenge.img_url, img_index=1)
prompt = self.fetch_image(challenge.img_url, img_index=0)
if not prompt:
logger.warning("图标题型未取到提示图, 刷新题目")
refreshes += 1
continue
points = self.icon_solver.solve(bg, prompt)
if len(points) < 3:
logger.warning("图标点选求解失败, 刷新题目")
refreshes += 1
continue
pre_data = json.loads(match.group(1))
initial_sess = pre_data.get("sess")
comm_cfg = pre_data.get("data", {}).get("comm_captcha_cfg", {})
# 2. 计算 PoW
pow_cfg = comm_cfg.get("pow_cfg", {})
pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], pow_cfg["md5"])
# 3. 拉取当前会话专属动态 tdc.js
tdc_path = comm_cfg.get("tdc_path", "")
tdc_resp = self.http_client.get(
tdc_path,
headers={"Referer": "https://turing.captcha.gtimg.com/"},
)
tdc_code = tdc_resp.text
current_sess = initial_sess
else:
# 会话内刷新:直接使用前一轮返回的 sess
current_sess = active_sess
# 4. 获取题目信息与图片 URL (POST /cap_union_new_getsig)
sig_resp = self.http_client.post(
"/cap_union_new_getsig",
data={"sess": current_sess, "tkid": tkid},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://turing.captcha.gtimg.com",
"Referer": "https://turing.captcha.gtimg.com/",
},
)
sig_json = sig_resp.json()
if str(sig_json.get("ret")) != "0":
logger.warning(f"getsig returned non-zero ret: {sig_json}")
active_sess = None
continue
dyn_info = sig_json.get("data", {})
bg_url = dyn_info.get("bg_elem_cfg", {}).get("img_url", "")
prompt_url = bg_url.replace("img_index=1", "img_index=0")
# 提取图片 URL 中的最新有效 sess
sess_match = re.search(r"sess=([^&]+)", bg_url)
active_sess = sess_match.group(1) if sess_match else current_sess
# 5. 下载背景大图与提示图标图
bg_bytes = self.http_client.get(
bg_url,
headers={"Referer": "https://turing.captcha.gtimg.com/"},
).content
prompt_bytes = self.http_client.get(
prompt_url,
headers={"Referer": "https://turing.captcha.gtimg.com/"},
).content
# 6. AI 模型高精度求解
pts = self.solver.solve(bg_bytes, prompt_bytes)
if len(pts) < 3:
logger.warning("Solver failed to find 3 valid points, retrying...")
continue
# 7. 格式化答案 ans
ans_list = [
{"elem_id": 1, "type": "DynAnswerType_POS", "data": f"{pts[0][0]},{pts[0][1]}"},
{"elem_id": 2, "type": "DynAnswerType_POS", "data": f"{pts[1][0]},{pts[1][1]}"},
{"elem_id": 3, "type": "DynAnswerType_POS", "data": f"{pts[2][0]},{pts[2][1]}"},
]
# ---- 组包并提交
attempts += 1
ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS",
"data": f"{p[0]},{p[1]}"} for i, p in enumerate(points)]
ans_str = json.dumps(ans_list, separators=(",", ":"))
# 8. 纯算法生成真实行为轨迹 collect 与会话密钥 eks (100% 纯 Python)
telemetry = self._generate_telemetry(tdc_code, pts)
# 9. 提交验证 POST /cap_union_new_verify
telemetry = generate_telemetry(
tdc_code or "", points, aid=self.aid, user_agent=self.user_agent,
referrer=self.referer, mode=self.telemetry_mode,
)
logger.info("遥测密文来源=%s collect_len=%s eks_len=%s",
telemetry.get("source"), len(telemetry["collect"]),
len(telemetry.get("eks", "")))
verify_body = {
"ans": ans_str,
"collect": telemetry["collect"],
"tlg": telemetry["tlg"],
"eks": telemetry["eks"],
"sess": active_sess,
"eks": telemetry.get("eks") or eks or "",
"sess": challenge.sess,
"tkid": tkid,
"pow_answer": pow_answer,
"pow_calc_time": str(pow_calc_time),
"pow_answer": pow_answer or "",
"pow_calc_time": str(pow_calc_time or 1),
}
verify_resp = self.http_client.post(
"/cap_union_new_verify",
data=verify_body,
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Origin": "https://turing.captcha.gtimg.com",
"Referer": "https://turing.captcha.gtimg.com/",
},
)
v_json = verify_resp.json()
total_elapsed = int((time.time() - start_time) * 1000)
last_raw = v_json
elapsed = int((time.time() - start_time) * 1000)
if str(v_json.get("errorCode")) == "0":
logger.info("✅ 验证通过! randstr=%s", v_json.get("randstr"))
return {
"success": True,
"ticket": v_json.get("ticket", ""),
"randstr": v_json.get("randstr", ""),
"elapsed_ms": total_elapsed,
"points": pts,
"elapsed_ms": elapsed,
"points": points,
"kind": challenge.kind,
"instruction": challenge.instruction,
"raw_response": v_json,
"error": "",
}
err_code = str(v_json.get("errorCode"))
if err_code == "12":
last_error = ("触发腾讯接口频控限制 (errorCode: 12, f_limit), "
"建议降低请求频率或更换出口 IP")
logger.error(last_error)
break
if err_code in ("50", "9"):
last_error = f"坐标未命中 (errorCode: {err_code})"
else:
err_code = str(v_json.get("errorCode"))
err_msg = v_json.get("errMessage", "")
if err_code == "12":
last_error = f"触发腾讯接口频控限制 (errorCode: 12, f_limit),建议稍候或更换IP"
elif err_code == "50":
last_error = f"验证坐标未命中或会话需刷新 (errorCode: 50)"
else:
last_error = f"验证失败 (errorCode: {err_code}, msg: {err_msg})"
last_error = (f"验证失败 (errorCode: {err_code}, "
f"msg: {v_json.get('errMessage', '')})")
logger.warning(last_error)
logger.warning(f"Attempt {attempt + 1} verify: {last_error}")
last_raw = v_json
# 如果接口返回了新的会话 sess,下一轮直接沿用该会话重试刷新
new_sess = v_json.get("sess")
if new_sess:
active_sess = new_sess
else:
active_sess = None
except Exception as e:
last_error = f"请求异常: {e}"
logger.error(f"Attempt {attempt + 1} encountered exception: {e}")
# 服务端返回新 sess 时沿用, 否则回到 prehandle
new_sess = v_json.get("sess")
if new_sess:
challenge, sess = self.getsig(new_sess, tkid)
logger.info("verify 后刷新 -> kind=%s instruction=%r",
challenge.kind, challenge.instruction)
else:
challenge = None
sess = None
eks = None
pow_answer = None
except Exception as exc: # 网络/解析异常: 丢弃当前会话重来
last_error = f"请求异常: {exc}"
logger.error("第 %d 次尝试异常: %s", attempts + 1, exc)
challenge = None
sess = None
eks = None
pow_answer = None
total_elapsed = int((time.time() - start_time) * 1000)
elapsed = int((time.time() - start_time) * 1000)
return {
"success": False,
"ticket": "",
"randstr": "",
"elapsed_ms": total_elapsed,
"elapsed_ms": elapsed,
"points": [],
"kind": challenge.kind if challenge else "",
"instruction": challenge.instruction if challenge else "",
"raw_response": last_raw,
"error": last_error,
}
def close(self):
self.http_client.close()
def main():
import argparse
parser = argparse.ArgumentParser(description="Tencent Captcha Pure Protocol Solver Client")
parser.add_argument("--aid", default="2039519451", help="Captcha AppID (default: 2039519451 for Rainyun)")
parser.add_argument("--aid", default="195672344", help="验证码 AppID (默认毫秒 1ms.run)")
parser.add_argument("--entry-url",
default="https://1ms.run/console/personal/benefits/check-in")
parser.add_argument("--wb", default="1")
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--telemetry", default="python",
choices=["python", "node", "browser", "auto"])
parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
parser.add_argument("--proxy-api", default=None,
help="代理提取 API; 出现 errorCode=12 时自动换 IP 重试")
args = parser.parse_args()
client = TencentCaptchaClient(aid=args.aid)
print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid})...")
result = client.solve()
proxy = args.proxy
if not proxy and args.proxy_api:
from tcaptcha.proxy import fetch_proxy
proxy = fetch_proxy(args.proxy_api)
print(f"[*] 从代理 API 提取到: {proxy}")
client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url,
referer="https://1ms.run/", wb=args.wb,
proxy=proxy, telemetry_mode=args.telemetry)
print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid}, proxy={proxy})...")
result = client.solve(max_retries=args.retries)
# errorCode=12 (f_limit) 是 IP 级滚动风控: 换一个出口 IP 再试一次
if not result["success"] and "12" in str(result.get("error", "")) and args.proxy_api:
print("[*] 触发 f_limit, 换 IP 重试...")
client.close()
proxy = fetch_proxy(args.proxy_api)
print(f"[*] 新代理: {proxy}")
client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url,
referer="https://1ms.run/", wb=args.wb,
proxy=proxy, telemetry_mode=args.telemetry)
result = client.solve(max_retries=args.retries)
if result["success"]:
print("\n" + "=" * 50)
print(" 🎉 验证码纯协议求解成功!")
print("=" * 50)
print(f"耗时: {result['elapsed_ms']} ms")
print(f"耗时: {result['elapsed_ms']} ms")
print(f"题型: {result['kind']} 题目: {result['instruction']!r}")
print(f"点击坐标: {result['points']}")
print(f"randstr: {result['randstr']}")
print(f"ticket: {result['ticket']}")
print(f"randstr: {result['randstr']}")
print(f"ticket: {result['ticket']}")
print("=" * 50)
else:
print(f"\n❌ 验证失败: {result['error']}")
+458 -33
View File
@@ -8,12 +8,22 @@ telemetry payload serialization.
"""
import base64
import json
import math
import os
import random
import re
import struct
import subprocess
import time
from typing import List, Tuple
import urllib.parse
from pathlib import Path
from typing import Dict, List, Optional, Tuple
M32 = 0xFFFFFFFF
TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js"
TDC_BROWSER_PATH = Path(__file__).resolve().parent / "tdc_browser.js"
# TDC VM Cryptographic Constants
DELTA = 2654435769 # 0x9E3779B9
@@ -186,9 +196,12 @@ def generate_trajectory_payload(
import json
raw_json = json.dumps(events, separators=(",", ":"))
# Pad with spaces to multiple of 8 bytes (matching target_len, e.g. 600)
# 填充到 24 字节对齐 —— 24 是 8 (XTEA 分组) 与 3 (Base64 分组) 的最小公倍数,
# 这样每个分块单独 base64 后拼接不会产生 **块内 '=' 填充**。
# 实测真实 tdc.js 的 collect 整体只有末尾一个 '==' (见 点选.har),
# 块内出现 '=' 会导致服务端解析/解密失败。
desired_len = max(len(raw_json), target_len)
pad_needed = (8 - (desired_len % 8)) % 8
pad_needed = (24 - (desired_len % 24)) % 24
total_len = desired_len + pad_needed
return raw_json.ljust(total_len, " ")
@@ -206,36 +219,230 @@ def extract_eks(tdc_js_code: str) -> str:
return ""
def generate_collect(
# --------------------------------------------------------------------------- #
# 标准 XTEA (32 轮, delta=0x9E3779B9, sum 取 mod 2^32) —— 等价于原 crypto.py 的
# 自定义形态 + 密钥折叠: K' = [k0, k1 + salt1, k2 + salt2, k3]
# 由此「算法」就是教科书 XTEA, 唯一需要从每份 tdc.js 里拿到的是那 4 个密钥字。
# --------------------------------------------------------------------------- #
XTEA_DELTA = 0x9E3779B9
CD_PRE_RAW = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
def xtea_encrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
"""教科书 XTEA 加密 (32 轮), 全 32 位无符号运算"""
v0 &= M32
v1 &= M32
total = 0
for _ in range(32):
v0 = (v0 + (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32
^ ((total + key[total & 3]) & M32))) & M32
total = (total + XTEA_DELTA) & M32
v1 = (v1 + (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32
^ ((total + key[(total >> 11) & 3]) & M32))) & M32
return v0, v1
def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
"""教科书 XTEA 解密 (32 轮), 用于校验提取到的密钥"""
v0 &= M32
v1 &= M32
total = (XTEA_DELTA * 32) & M32
for _ in range(32):
v1 = (v1 - (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32
^ ((total + key[(total >> 11) & 3]) & M32))) & M32
total = (total - XTEA_DELTA) & M32
v0 = (v0 - (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32
^ ((total + key[total & 3]) & M32))) & M32
return v0, v1
def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
data = data + b"\x00" * ((8 - len(data) % 8) % 8)
out = bytearray()
for i in range(0, len(data), 8):
v0, v1 = struct.unpack("<II", data[i:i + 8])
e0, e1 = xtea_encrypt_block(v0, v1, key)
out += struct.pack("<II", e0, e1)
return bytes(out)
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
out = bytearray()
for i in range(0, len(data) - len(data) % 8, 8):
v0, v1 = struct.unpack("<II", data[i:i + 8])
d0, d1 = xtea_decrypt_block(v0, v1, key)
out += struct.pack("<II", d0, d1)
return bytes(out)
_CAPTURE_CACHE: Dict[str, Tuple[List[List], bytes]] = {}
def _capture_key_words_cached(tdc_js_code: str, node_bin: str = "node"):
cached = _CAPTURE_CACHE.get(tdc_js_code)
if cached is None:
cached = _capture_key_words(tdc_js_code, node_bin=node_bin)
_CAPTURE_CACHE[tdc_js_code] = cached
return cached
def _capture_key_words(tdc_js_code: str, node_bin: str = "node",
timeout: float = 180.0) -> Tuple[List[List], bytes]:
"""调用 Node 插桩运行 tdc.js, 返回 (密钥候选, 该脚本产出的 collect 原始字节)"""
script = TDC_WORKER_PATH
if not script.exists():
raise FileNotFoundError(f"缺少 tdc_worker.js: {script}")
cmd = [node_bin, str(script), "-", "[[0,0],[0,0],[0,0]]",
json.dumps({"aid": "1", "captureKey": True})]
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True,
timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""})
out = (proc.stdout or "").strip().splitlines()
if not out:
raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}")
payload = json.loads(out[-1])
if payload.get("error"):
raise RuntimeError(f"tdc_worker 执行失败: {payload['error'][:400]}")
words = payload.get("key_words") or []
if not words:
raise RuntimeError("tdc_worker 未返回密钥候选 (captureKey 未生效?)")
raw = base64.b64decode(urllib.parse.unquote(payload["collect"]))
return words, raw
_KEY_CACHE: Dict[str, List[int]] = {}
def extract_xtea_key(tdc_js_code: str, node_bin: str = "node",
top_n: int = 8, use_cache: bool = True) -> List[int]:
"""
从服务端下发的 tdc.js 里提取本次构建的 XTEA 密钥 (4 个 32 位字)。
流程 (Node 只负责"跑出密钥", 算法全在 Python):
1. Node 在 VM 上下文里把 ADD opcode 换成带日志版本;
XTEA 每轮的 (sum + K[j]) 是一次 ADD, 两个操作数必有一个等于 r*DELTA,
于是另一个操作数就是密钥字 —— 按频次排序得到无歧义候选。
2. Python 取候选 Top-N, 对 4! 个排列逐个用教科书 XTEA 解密
tdc.js 自己产出的 collect 前 48 字节, 与常量 cd 前缀比对 —— 命中即真密钥。
密钥按构建固定 (同一份脚本多次运行首块完全一致, 已实测), 故可缓存。
"""
import itertools
if use_cache:
cached = _KEY_CACHE.get(tdc_js_code)
if cached:
return list(cached)
words, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
candidates = [int(k) & M32 for k, *_ in words[:top_n]]
# 验证用「内容特征」而非固定明文 (AGENTS.md §5.3 / 附录 A#6):
# 实测 tdc.js 至少两代明文布局 ——
# 旧版: 4 段 base64 拼接, 首段 = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
# 新版: 单个 JSON, 首部 = '{"cd":[1,[],"Apple M1",24,...' / '{"cd":[1791712995,0,...'
# 字段序还随构建变化, 故只依赖跨版本稳定的结构特征: '{"cd":[' 开头 + 首块全可打印。
blocks = [struct.unpack("<II", raw[i:i + 8]) for i in range(0, 48, 8)]
for perm in itertools.permutations(candidates, 4):
key = list(perm)
head = b"".join(struct.pack("<II", *xtea_decrypt_block(v0, v1, key))
for v0, v1 in blocks)
if head[:7] == b'{"cd":[' and all(32 <= b < 127 for b in head[:48]):
if use_cache:
_KEY_CACHE[tdc_js_code] = key
return key
raise RuntimeError(
f"未能从 tdc.js 复原 XTEA 密钥 (候选 {len(candidates)} 个, 排列全试失败)")
# --------------------------------------------------------------------------- #
# §5.8 标定式交付: 运行时取一次明文模板, 之后全部由 Python 回填
# (新版 tdc.js 的明文是单个 JSON, 且**字段序随构建变化** —— 实测两份同时期脚本
# 首部一个是 '{"cd":[1,[],"Apple M1",24,...', 另一个是 '{"cd":[1791712995,0,...',
# 所以不能硬编码模板, 只能每次标定一份。)
# --------------------------------------------------------------------------- #
_TRAJ_RE = re.compile(r"\[\[\d+,-?\d+,\d+\](?:,\[-?\d+,-?\d+,\d+\])+\]")
_EPOCH_RE = re.compile(r"(?<![\d.])(1[6-9]\d{8})(?![\d.])")
def recover_payload_template(tdc_js_code: str, node_bin: str = "node"):
"""
标定: 跑一次 tdc.js, 提取本次构建的 XTEA 密钥并解密出**明文模板**。
返回 (template_text, key)。
"""
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
_, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
plain = xtea_decrypt_bytes(raw, key).rstrip(b"\x00")
return plain.decode("latin1"), key
def generate_collect_from_template(
template: str,
click_points: List[Tuple[int, int]],
key: List[int],
refresh_time: bool = True,
) -> str:
"""
纯 Python 回填出 collect:
1. 用 Python 生成的鼠标轨迹替换模板里的轨迹数组
2. 刷新模板里的秒级时间戳
3. 空格补齐到 8 字节倍数 -> XTEA 加密 -> base64 -> TDC URL 编码
加密与组包 100% 在 Python, Node 只提供密钥与一次性模板。
"""
text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template
traj = generate_trajectory_payload(click_points, target_len=0).rstrip()
m = _TRAJ_RE.search(text)
if not m:
raise RuntimeError("模板里未找到轨迹数组, 无法回填")
text = text[:m.start()] + traj + text[m.end():]
if refresh_time:
now = int(time.time())
text = _EPOCH_RE.sub(str(now), text)
data = text.encode("latin1")
data += b" " * ((8 - len(data) % 8) % 8) # 与真实 tdc.js 一致: 空格补齐
return urllib.parse.quote_from_bytes(
base64.b64encode(xtea_encrypt_bytes(data, key)), safe="")
def generate_collect_pure(
tdc_js_code: str,
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
renderer: str = DEFAULT_RENDERER,
vendor: str = DEFAULT_VENDOR,
canvas_fp: str = DEFAULT_CANVAS_FP,
) -> str:
node_bin: str = "node",
) -> Tuple[str, List[int]]:
"""
Generates the complete encrypted telemetry payload (`collect`) in 100% pure Python.
**纯 Python 出包入口**: Node 只负责「跑出密钥 + 一次性标定明文模板」,
轨迹生成 / 组包 / XTEA 加密 / 编码 全部由 Python 完成。
Assembles:
1. cd prefix (48 bytes, space padded)
2. mouse trajectory (space padded to 8-byte multiple)
3. cd post-body (504 bytes, space padded)
4. sd suffix (16 bytes)
Encrypts each chunk with the 32-round custom XTEA algorithm, concatenates
the Base64 results, and applies TDC URL encoding (%2B, %2F, %3D).
两次 Node 调用均按脚本内容缓存 (同一构建只付一次成本)。
"""
try:
template, key = recover_payload_template(tdc_js_code, node_bin=node_bin)
return generate_collect_from_template(template, click_points, key), key
except Exception:
# 模板不适用 (例如两段式旧结构) -> 退回旧版结构 + 同一把密钥
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
return generate_collect_keyed(click_points, key, aid=aid, user_agent=user_agent,
renderer=renderer, vendor=vendor,
canvas_fp=canvas_fp), key
def _collect_segments(click_points: List[Tuple[int, int]], aid: str, user_agent: str,
renderer: str, vendor: str, canvas_fp: str) -> List[bytes]:
"""组装 collect 的 4 个明文分块 (与真实 tdc.js 一致)"""
now_sec = int(time.time())
rand_salt = random.randint(100000000, 999999999)
# 1. cd prefix (48 bytes)
cd_pre_raw = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
cd_pre = cd_pre_raw.ljust(48, " ")
cd_pre = CD_PRE_RAW.ljust(48, " ")
# 2. Mouse trajectory (600 bytes)
traj_payload = generate_trajectory_payload(click_points, target_len=600)
# 3. cd post-body (504 bytes)
cd_post_inner = (
f',"",'
f'"undefined?rand=1519713624347",'
@@ -272,25 +479,243 @@ def generate_collect(
)
cd_post = cd_post_inner.ljust(504, " ")
# 4. sd suffix (16 bytes)
sd = '"sd":{"od":"C"}}'
return [s.encode("latin1") for s in (cd_pre, traj_payload, cd_post, sd)]
# Encrypt all 4 parts
enc_cd_pre = encrypt_string(cd_pre)
enc_traj = encrypt_string(traj_payload)
enc_cd_post = encrypt_string(cd_post)
enc_sd = encrypt_string(sd)
combined_b64 = enc_cd_pre + enc_traj + enc_cd_post + enc_sd
def _assemble_collect(segments: List[bytes], key: Optional[List[int]] = None) -> str:
"""4 段分别加密 + base64 + 拼接 + TDC URL 编码。
# TDC URL encoding
collect = (
combined_b64
.replace("+", "%2B")
.replace("/", "%2F")
.replace("=", "%3D")
)
return collect
每段长度都是 3 的倍数 (48 / 24k / 504), 末段 16 字节 -> 只有末尾一个 '==',
与真实样本结构一致 (块内出现 '=' 会导致服务端解析失败)。
"""
if key is None:
enc = [encrypt_string(s.decode("latin1")) for s in segments]
else:
enc = [base64.b64encode(xtea_encrypt_bytes(s, key)).decode("ascii") for s in segments]
return "".join(enc).replace("+", "%2B").replace("/", "%2F").replace("=", "%3D")
def generate_collect_keyed(
click_points: List[Tuple[int, int]],
key: List[int],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
renderer: str = DEFAULT_RENDERER,
vendor: str = DEFAULT_VENDOR,
canvas_fp: str = DEFAULT_CANVAS_FP,
) -> str:
"""用「从 tdc.js 提取到的本次构建密钥」纯 Python 生成 collect (标准 XTEA)"""
segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp)
return _assemble_collect(segments, key=key)
def generate_collect(
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
renderer: str = DEFAULT_RENDERER,
vendor: str = DEFAULT_VENDOR,
canvas_fp: str = DEFAULT_CANVAS_FP,
) -> str:
"""
用 crypto.py 内置的固定密钥 (DEFAULT_KEY + 两个历史加盐) 生成 collect。
等价于标准 XTEA + K' = [k0, k1+salt1, k2+salt2, k3]。
兼容性: 实测雨云 aid 直接接受 (errorCode=0), 说明服务端并不校验 collect 与 eks
的同源性; 需要严格场景请用 `generate_collect_keyed()` + `extract_xtea_key()`。
"""
segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp)
return _assemble_collect(segments, key=None)
# --------------------------------------------------------------------------- #
# 真实 tdc.js 执行路径 (推荐): 在 Node VM 沙箱中跑当前会话的 tdc.js
# --------------------------------------------------------------------------- #
def generate_telemetry_node(
tdc_js_code: str,
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
referrer: str = "https://app.rainyun.com/",
frame_size: Tuple[int, int] = (360, 360),
display_scale: Optional[float] = None,
node_bin: str = "node",
timeout: float = 60.0,
) -> Dict[str, str]:
"""
在 Node 沙箱里执行真实 tdc.js, 产出与浏览器一致的 collect / tlg / eks。
为什么必须走这条路
------------------
tdc.js 每次加载都会重新混淆, 且其 XTEA 会话密钥并非固定常量 —— 用
`har/1ms_dianxuan/live/tdc_live.js` 产出的真实 collect 用 DEFAULT_KEY 解密
得到的是随机字节 (见 ANALYSIS.md 验证记录)。因此纯 Python 硬编码密钥的实现
只对当初逆向的那一份 tdc.js 有效, 换会话即失效; 而 Node 沙箱直接执行服务端
下发的 tdc.js, 拿到的是服务端自己认可的密文。
返回: {"collect": str, "tlg": str, "eks": str, "source": "node"}
"""
script = TDC_WORKER_PATH
if not script.exists():
raise FileNotFoundError(f"缺少 tdc_worker.js: {script}")
options = {
"aid": str(aid),
"referrer": referrer,
"userAgent": user_agent,
"screenWidth": 1920,
"screenHeight": 1080,
"availHeight": 1055,
"frameWidth": frame_size[0],
"frameHeight": frame_size[1],
"offsetX": 0,
"offsetY": 0,
}
if display_scale is not None:
options["displayScale"] = float(display_scale)
cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]),
json.dumps(options)]
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True,
timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""})
out = (proc.stdout or "").strip().splitlines()
if not out:
raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}")
try:
payload = json.loads(out[-1])
except json.JSONDecodeError as exc:
raise RuntimeError(f"tdc_worker 输出无法解析: {out[-1][:300]}") from exc
if payload.get("error"):
raise RuntimeError(f"tdc_worker 执行 tdc.js 失败: {payload['error'][:400]}")
collect = payload.get("collect") or ""
if not collect:
raise RuntimeError("tdc_worker 未产出 collect")
return {
"collect": collect,
"tlg": str(payload.get("tlg") or len(collect)),
"eks": payload.get("eks") or "",
"source": "node",
}
def generate_telemetry_browser(
tdc_js_code: str,
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
referrer: str = "https://app.rainyun.com/",
frame_size: Tuple[int, int] = (360, 360),
display_scale: Optional[float] = None,
chrome_path: Optional[str] = None,
headless: bool = True,
node_bin: str = "node",
timeout: float = 120.0,
) -> Dict[str, str]:
"""
用 **真实 Chrome** (CDP 驱动) 执行 tdc.js, 产出环境指纹真实度最高的 collect / eks。
与 `generate_telemetry_node` 的区别: 后者在 Node VM 里伪造 DOM/BOM/Canvas,
实测答题正确后仍会被服务端判定为环境异常 (errorCode 12); 本函数把 tdc.js 放进
真实浏览器, 用 Input.dispatchMouseEvent 派发可信鼠标事件, 再读回 TDC.getData()。
"""
script = TDC_BROWSER_PATH
if not script.exists():
raise FileNotFoundError(f"缺少 tdc_browser.js: {script}")
options = {
"aid": str(aid),
"referer": referrer,
"userAgent": user_agent,
"frameWidth": frame_size[0],
"frameHeight": frame_size[1],
"offsetX": 0,
"offsetY": 0,
"headless": headless,
}
if chrome_path:
options["chromePath"] = chrome_path
if display_scale is not None:
options["displayScale"] = float(display_scale)
cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]),
json.dumps(options)]
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True, timeout=timeout)
out = (proc.stdout or "").strip().splitlines()
if not out:
raise RuntimeError(f"tdc_browser 无输出 (rc={proc.returncode}): {proc.stderr[:600]}")
try:
payload = json.loads(out[-1])
except json.JSONDecodeError as exc:
raise RuntimeError(f"tdc_browser 输出无法解析: {out[-1][:300]}") from exc
if payload.get("error"):
raise RuntimeError(f"真实 Chrome 执行 tdc.js 失败: {payload['error'][:600]}")
collect = payload.get("collect") or ""
if not collect:
raise RuntimeError("真实 Chrome 未产出 collect")
return {"collect": collect, "tlg": str(payload.get("tlg") or len(collect)),
"eks": payload.get("eks") or "", "source": "browser"}
def generate_telemetry(
tdc_js_code: str,
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
referrer: str = "https://app.rainyun.com/",
mode: str = "python",
) -> Dict[str, str]:
"""
统一遥测生成入口。
mode="python" : 纯 Python 算法还原 (内置固定密钥; 默认, 无 Node 依赖, ~1ms)
mode="python_keyed": **Node 仅提取本次构建的 XTEA 密钥, 加密全部在 Python**
mode="node" : Node VM 沙箱执行服务端下发的真实 tdc.js (~0.1s)
mode="browser" : 真实 Chrome + CDP 执行真实 tdc.js (~50s)
mode="auto" : python -> node -> browser 逐级回退
为什么默认仍然是纯 Python
------------------------
实测(2026-10-11)证明服务端 **并不按 tdc.js 的会话密钥解密 collect**:
雨云 aid=2039519451 + 纯 Python(硬编码 DEFAULT_KEY) 的 collect + 正确答案
→ errorCode=0 并返回真实 ticket (tr03jvqlp225MjNBxjDj_-mnM1VTS8O9)
而 `DEFAULT_KEY` 只对仓库里那份 tdc_sample.js 有效(能解出明文
`{"cd":[[],"1920-1080..."`), 对当天实时抓的同一 aid 的脚本已失效 —— 说明
tdc.js 的密码学参数(4 个密钥字 + 2829068/328986 两个轮加盐)是**每次构建随机**的,
但服务端并不校验收到的 collect 是否与 eks 同源。
因此纯 Python 方案在协议层完全够用; 需要 node/browser 的场景是目标 aid 启用了
更严格的风控(例如 1ms.run 的 aid=195672344 在答案正确时返回 errorCode=12),
详见 har/1ms_dianxuan/ANALYSIS.md。
"""
chain = {"python": ["python"], "python_keyed": ["python_keyed"],
"node": ["node"], "browser": ["browser"],
"auto": ["python", "python_keyed", "node", "browser"],
"auto_keyed": ["python_keyed", "node", "python"]}.get(mode, ["python"])
errors = []
for kind in chain:
try:
if kind == "browser":
return generate_telemetry_browser(tdc_js_code, click_points, aid=aid,
user_agent=user_agent, referrer=referrer)
if kind == "node":
return generate_telemetry_node(tdc_js_code, click_points, aid=aid,
user_agent=user_agent, referrer=referrer)
if kind == "python_keyed":
# Node 只负责跑出 XTEA 密钥 + 标定一次明文模板; 加密与组包全在 Python
collect, key = generate_collect_pure(tdc_js_code, click_points, aid=aid,
user_agent=user_agent)
return {"collect": collect, "tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
"key": key}
collect = generate_collect(click_points, aid=aid, user_agent=user_agent)
return {"collect": collect, "tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code), "source": "python"}
except Exception as exc:
errors.append(f"{kind}: {exc}")
if mode != "auto":
raise
raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors))
if __name__ == "__main__":
+321
View File
@@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
"""
腾讯天御『文字点选』验证码求解器 (Tencent Turing Text-Click Solver)
适用题型: subcapclass = 2404, click_cfg.data_type = ["DynAnswerType_POS"] 且提示为
纯文字指令, 例如: "请依次点击:虫 呈 般"
与 tcaptcha.solver.CaptchaSolver (图标点选 2408: 提示是 img_index=0 的图标图) 的区别:
* 本题型 **没有提示图** (img_index=0 返回 0 字节)
* 背景图里只出现与提示完全相同的 3 个汉字 (随机位置), 不含额外干扰字
* 部分题目会做 **斜切(italic)变形**, 但实测不影响颜色分割
算法管线:
1. HSV 颜色分割 (腾讯点选汉字固定为橙黄色, H≈20 S≈185 V≈228) 提取字形掩膜
2. 形态学闭运算 + 膨胀连通同一汉字的多个偏旁, 连通域得到汉字包围盒
3. 掩膜 -> 白底黑字 -> 放大, 交给 OCR 集成(ddddocr + PP-OCRv4)做 CTC 前向
4. 取 CTC 每一时间步上目标字的概率最大值 -> (字形, 目标字) 相似度矩阵
5. 对 3! 个全排列求最优指派, 并用归一化后验概率给出本次求解的置信度
(置信度低的题目直接刷新会话, 避免提交明显不可靠的答案)
"""
from __future__ import annotations
import io
import itertools
import logging
import re
from typing import Dict, List, Optional, Sequence, Tuple
import cv2
import numpy as np
from PIL import Image
from .ocr_engines import EnsembleOcr
logger = logging.getLogger("tcaptcha_glyph")
# 腾讯点选汉字颜色 (实测 H≈20.5 S≈200 V≈232)。
# 关键是 **V(亮度)** 而不是色相: 暖色草地/沙地的 H 会落在 24~28、S 也不低,
# 但 V 只有 120~156, 而汉字 V≈230。早期版本用 V>=145 会把草地并进字形掩膜,
# 导致 3 个字被背景吞成 1~2 个候选 (见图 proxy/fail_2、fail_3)。
HSV_LOWER = (12, 145, 190)
HSV_UPPER = (34, 255, 255)
MIN_GLYPH_AREA = 700 # 单个汉字掩膜最小像素数
MERGE_KERNEL = 13 # 膨胀核, 用于把偏旁/离散笔画合并为同一汉字
SCALE = 3 # OCR 输入放大倍数
PAD = 24 # OCR 输入白边
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
# 滑块/图标题型的模板串关键词: 出现即说明提示里没有目标汉字
_NON_TARGET_HINTS = ("滑块", "拖动", "拼图", "请依次", "点击", "完成")
def parse_instruction(instruction: str) -> List[str]:
"""从 '请依次点击:虫 呈 般' 中解析出目标汉字序列 ['虫','呈','般']
注意: 图标点选 (2408) 与滑块 (2402/2401) 的 instruction 只有 '请依次点击:' /
'拖动下方滑块完成拼图' 这类无目标文字的模板串, 此时必须返回空列表, 否则会把
'请依次点击' 本身当成目标字。
"""
if not instruction:
return []
parts = re.split(r"[::]", instruction, maxsplit=1)
if len(parts) == 2:
return _CJK_RE.findall(parts[1])
# 没有分隔符: 只有当整体就是一个短目标字序列 (如 '虫 呈 般') 时才接受
if any(h in instruction for h in _NON_TARGET_HINTS):
return []
chars = _CJK_RE.findall(instruction)
# 文字点选恒为 3~4 个目标字, 超过该数量必然是模板句
return chars if 0 < len(chars) <= 4 else []
class Glyph:
"""一个已定位的候选汉字字形"""
__slots__ = ("x0", "y0", "x1", "y1", "cx", "cy", "mask", "ocr_text", "confidence")
def __init__(self, x0: int, y0: int, x1: int, y1: int, mask: np.ndarray):
self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1
self.cx = (x0 + x1) // 2
self.cy = (y0 + y1) // 2
self.mask = mask
self.ocr_text = ""
self.confidence = 0.0
@property
def point(self) -> Tuple[int, int]:
return self.cx, self.cy
def gray_image(self, thick: int = 0, scale: int = SCALE) -> np.ndarray:
"""掩膜 -> 白底黑字灰度图 -> 放大 (thick>0 时加粗笔画, 用于多尺度集成)"""
m = self.mask
if thick:
m = cv2.dilate(m, np.ones((3, 3), np.uint8), iterations=thick)
gray = 255 - m
h, w = gray.shape
side = max(h, w) + PAD
canvas = np.full((side, side), 255, np.uint8)
canvas[(side - h) // 2:(side - h) // 2 + h, (side - w) // 2:(side - w) // 2 + w] = gray
return cv2.resize(canvas, (side * scale, side * scale), interpolation=cv2.INTER_LINEAR)
# 兼容旧接口
def ocr_image(self, thick: int = 0, scale: int = SCALE) -> Image.Image:
return Image.fromarray(self.gray_image(thick=thick, scale=scale))
class SolveResult:
"""一次求解结果"""
__slots__ = ("points", "targets", "glyphs", "score_matrix", "posterior", "assignments")
def __init__(self, points, targets, glyphs, score_matrix, posterior, assignments):
self.points = points
self.targets = targets
self.glyphs = glyphs
self.score_matrix = score_matrix
self.posterior = posterior
self.assignments = assignments
@property
def ok(self) -> bool:
return len(self.points) == len(self.targets) and len(self.points) > 0
def describe(self) -> str:
detail = ", ".join(
f"{t}<-{self.glyphs[g].ocr_text or '?'}@{self.glyphs[g].point}"
f"({self.score_matrix[g, ti]:.3f})"
for ti, (t, g) in enumerate(zip(self.targets, self.assignments))
)
return f"posterior={self.posterior:.3f} [{detail}]"
class TextClickSolver:
"""腾讯天御文字点选求解器 (颜色分割 + OCR 集成概率指派)"""
def __init__(self, recognizer: Optional[EnsembleOcr] = None, min_area: int = MIN_GLYPH_AREA,
variants: Sequence[Tuple[int, int]] = ((0, SCALE), (1, SCALE)),
min_glyph_score: float = 0.02):
self.min_area = min_area
self.variants = tuple(variants)
# 颜色分割会在照片的暖色区域误检出少量"伪字形", 用识别得分把它们剔除
self.min_glyph_score = min_glyph_score
self._recognizer = recognizer
@property
def recognizer(self) -> EnsembleOcr:
if self._recognizer is None:
self._recognizer = EnsembleOcr.default()
return self._recognizer
# ------------------------------------------------------------- detection
@staticmethod
def _mask_of(bgr: np.ndarray) -> np.ndarray:
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, np.array(HSV_LOWER, np.uint8), np.array(HSV_UPPER, np.uint8))
# 闭运算填掉字内高光空洞
return cv2.morphologyEx(mask, cv2.MORPH_CLOSE,
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
def detect_glyphs(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]:
"""颜色分割定位背景图中的全部汉字"""
bgr = self._to_bgr(image)
mask = self._mask_of(bgr)
dilated = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
(MERGE_KERNEL, MERGE_KERNEL)))
count, labels, stats, _ = cv2.connectedComponentsWithStats(dilated)
glyphs: List[Glyph] = []
for i in range(1, count):
if stats[i, cv2.CC_STAT_AREA] < self.min_area:
continue
sub = ((labels == i) & (mask > 0))
ys, xs = np.nonzero(sub)
if len(xs) == 0:
continue
x0, y0, x1, y1 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
w, h = x1 - x0 + 1, y1 - y0 + 1
# 单字包围盒合理性: 过扁/过瘦/过小的多半是背景误检
if not (0.35 <= w / h <= 2.8) or w < 24 or h < 24:
continue
glyphs.append(Glyph(x0, y0, x1, y1,
(sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8)))
# 阅读顺序排序 (先上后下, 再左后右) —— 仅用于结果稳定
glyphs.sort(key=lambda g: (g.y0 // 64, g.x0))
return glyphs
# ---------------------------------------------------------- recognition
def char_scores(self, glyph: Glyph, targets: Sequence[str]) -> Dict[str, float]:
"""对单个字形, 返回每个目标字的集成概率 (多预处理变体取最大)"""
acc = {t: 0.0 for t in targets}
best_text, best_conf = "", 0.0
for thick, scale in self.variants:
gray = glyph.gray_image(thick=thick, scale=scale)
scores = self.recognizer.scores(gray, targets)
for t, v in scores.items():
acc[t] = max(acc[t], v)
# 用集成里最强单引擎的读数做可读性展示
try:
engine = self.recognizer.engines[-1]
chars, probs = engine.probabilities(glyph.gray_image())
idx = probs.argmax(axis=1)
seq, prev = [], -1
for i in idx:
if i != 0 and i != prev and i < len(chars):
seq.append(chars[i])
prev = i
best_text = "".join(seq)
best_conf = float(probs.max(axis=1).mean())
except Exception: # 展示信息失败不影响求解
pass
glyph.ocr_text, glyph.confidence = best_text, best_conf
return acc
# ----------------------------------------------------------------- solve
def solve(self, image: bytes | np.ndarray | Image.Image, instruction: str,
retries: int = 1) -> List[Tuple[int, int]]:
"""返回提示顺序的像素点击坐标; 失败返回空列表 (兼容旧接口)"""
res = self.solve_ex(image, instruction, retries=retries)
return res.points if res.ok else []
def solve_ex(self, image: bytes | np.ndarray | Image.Image, instruction: str,
retries: int = 1) -> SolveResult:
"""完整求解, 附带置信度(归一化后验概率)与命中明细"""
targets = parse_instruction(instruction)
empty = SolveResult([], targets, [], np.zeros((0, 0)), 0.0, [])
if len(targets) < 2:
logger.warning("提示语解析失败: %r", instruction)
return empty
for attempt in range(retries):
glyphs = self.detect_glyphs(image)
if len(glyphs) < len(targets):
logger.warning("第 %d 次: 检出字形 %d 个 < 目标 %d 个",
attempt + 1, len(glyphs), len(targets))
if attempt + 1 < retries:
continue
return empty
if len(glyphs) > len(targets):
logger.info("检出字形 %d 个 (目标 %d 个), 进行全局指派", len(glyphs), len(targets))
matrix = np.array([[self.char_scores(g, targets)[t] for t in targets]
for g in glyphs], dtype=np.float64) # (n, m)
# 剔除伪字形: 任何目标字都识别不出来的候选不参与指派 (不影响后验置信度)
row_max = matrix.max(axis=1) if matrix.size else np.zeros(0)
strong = [i for i in range(len(glyphs)) if row_max[i] >= self.min_glyph_score]
if len(strong) < len(targets):
# 得分整体偏低(生僻字/罕见字形)时退化为取得分最高的 m 个
order = np.argsort(-row_max)[:len(targets)]
strong = sorted(int(i) for i in order)
logger.info("可用强字形不足, 退化为得分 Top-%d 候选", len(targets))
dropped = [i for i in range(len(glyphs)) if i not in strong]
if dropped:
logger.info("剔除伪字形 %s", [glyphs[i].point for i in dropped])
glyphs = [glyphs[i] for i in strong]
matrix = matrix[strong]
# 每个字形对目标字的归一化分布 (尺度无关), 避免某个字形整体概率偏低时被忽略
eps = 1e-9
row_sums = matrix.sum(axis=1, keepdims=True)
norm = (matrix + eps) / (row_sums + eps * matrix.shape[1])
best_perm, best_score, second_score = None, -1.0, 0.0
perms = list(itertools.permutations(range(len(glyphs)), len(targets)))
probs = []
for perm in perms:
p = 1.0
for t_idx, g_idx in enumerate(perm):
p *= float(norm[g_idx, t_idx])
probs.append(p)
if p > best_score:
second_score = best_score
best_score, best_perm = p, perm
elif p > second_score:
second_score = p
total = float(sum(probs)) or 1.0
posterior = float(best_score / total)
if best_perm is None:
return empty
logger.info("题目 %s -> %s", "".join(targets),
", ".join(f"{targets[t]}<-{glyphs[g].ocr_text or '?'}@{glyphs[g].point}"
f"({matrix[g, t]:.4f})" for t, g in enumerate(best_perm)))
logger.info(" 后验置信度=%.4f (次优/最优=%.4f)", posterior,
(second_score / best_score) if best_score > 0 else 1.0)
result = SolveResult([glyphs[g].point for g in best_perm], targets, glyphs,
matrix, posterior, list(best_perm))
if best_score <= 0:
logger.warning("所有候选得分均为 0, 判定求解失败")
return SolveResult([], targets, glyphs, matrix, 0.0, list(best_perm))
return result
return empty
# ------------------------------------------------------------- utilities
@staticmethod
def _to_bgr(image: bytes | np.ndarray | Image.Image) -> np.ndarray:
if isinstance(image, np.ndarray):
return image
if isinstance(image, Image.Image):
return cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR)
arr = np.frombuffer(image, np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if bgr is None:
bgr = cv2.cvtColor(np.array(Image.open(io.BytesIO(image)).convert("RGB")),
cv2.COLOR_RGB2BGR)
return bgr
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format="%(message)s")
solver = TextClickSolver()
img_path = sys.argv[1] if len(sys.argv) > 1 else "har/1ms_dianxuan/bg_3.jpg"
instr = sys.argv[2] if len(sys.argv) > 2 else "请依次点击:虫 呈 般"
res = solver.solve_ex(open(img_path, "rb").read(), instr)
print("points:", res.points)
print("confidence:", res.posterior)
Binary file not shown.
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""
腾讯天御点选验证码 OCR 识别引擎集合 (可插拔 + 集成投票)
设计动机
--------
单一 OCR 对生僻字(如 "筹")置信度极低: ddddocr 对 "筹" 的 CTC 最大概率仅 0.005,
而另一侧 PP-OCRv4 对 "雹" 又会误识为 "电"。两者互补, 因此本模块做 **概率级集成**:
对同一个字形, 把每个引擎在每个目标字上的 CTC 最大时间步概率加权求和, 再交给
全局指派算法, 可在 3 选 1 的题型上取得远高于单引擎的准确率。
引擎统一接口
------------
probabilities(gray: np.ndarray) -> (chars: List[str], probs: np.ndarray) # probs: (T, C)
gray 约定: 白底黑字的 uint8 灰度图 (字形像素值低)。
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
import cv2
import numpy as np
logger = logging.getLogger("tcaptcha_ocr")
MODEL_DIR = Path(__file__).resolve().parent / "models"
PPOCR_DIR = MODEL_DIR / "ppocr"
class BaseOcrEngine:
name = "base"
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
raise NotImplementedError
def close(self):
pass
class DdddOcrEngine(BaseOcrEngine):
"""ddddocr (CTC, 8210 字集) —— 对常用字准确率高, 集成中的稳健票"""
name = "ddddocr"
def __init__(self, beta: bool = False, ocr=None):
self._ocr = ocr
self._beta = beta
@property
def ocr(self):
if self._ocr is None:
import ddddocr
self._ocr = ddddocr.DdddOcr(show_ad=False, beta=self._beta)
return self._ocr
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
res = self.ocr.classification(gray, probability=True)
probs = np.asarray(res["probabilities"]) # (T, 1, C)
if probs.ndim == 3:
probs = probs[:, 0, :]
return list(res["charset"]), probs
class PpOcrEngine(BaseOcrEngine):
"""PaddleOCR PP-OCRv4 中文识别模型 (ONNX, 6623 字集) —— 对生僻字覆盖更好"""
name = "ppocr_v4"
def __init__(self, model_path: Optional[Path] = None, keys_path: Optional[Path] = None,
rec_height: int = 48):
import onnxruntime as ort
self.model_path = Path(model_path or PPOCR_DIR / "ch_PP-OCRv4_rec_infer.onnx")
self.keys_path = Path(keys_path or PPOCR_DIR / "ppocr_keys_v1.txt")
if not self.model_path.exists():
raise FileNotFoundError(
f"缺少 PP-OCRv4 识别模型: {self.model_path}\n"
"下载: https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv4/ch_PP-OCRv4_rec_infer.onnx"
)
if not self.keys_path.exists():
raise FileNotFoundError(f"缺少 PP-OCR 字典: {self.keys_path}")
so = ort.SessionOptions()
so.intra_op_num_threads = 2
so.inter_op_num_threads = 2
self.sess = ort.InferenceSession(str(self.model_path), so, providers=["CPUExecutionProvider"])
self.input_name = self.sess.get_inputs()[0].name
self.rec_height = rec_height
keys = self.keys_path.read_text(encoding="utf-8").split("\n")
while keys and keys[-1] == "":
keys.pop()
# PaddleOCR 约定: 输出维度 = 1(blank) + 字典 + 1(空格)
self.chars = ["blank"] + keys + [" "]
def _preprocess(self, gray: np.ndarray) -> np.ndarray:
g = gray.astype(np.float32) / 255.0
ratio = self.rec_height / float(g.shape[0])
g = cv2.resize(g, (0, 0), fx=ratio, fy=ratio, interpolation=cv2.INTER_LINEAR)
g = (g - 0.5) / 0.5
g = g[None, None, :, :]
return np.concatenate([g, g, g], axis=1).astype(np.float32)
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
logits = self.sess.run(None, {self.input_name: self._preprocess(gray)})[0] # (1, T, C)
return self.chars, np.asarray(logits[0])
class EnsembleOcr:
"""概率级加权集成: score(t) = Σ w_i * max_t P_i(t)"""
def __init__(self, engines: Sequence[BaseOcrEngine], weights: Optional[Sequence[float]] = None):
self.engines = list(engines)
self.weights = list(weights) if weights else [1.0] * len(self.engines)
if len(self.weights) != len(self.engines):
raise ValueError("engines 与 weights 数量不一致")
@classmethod
def default(cls) -> "EnsembleOcr":
engines: List[BaseOcrEngine] = []
try:
engines.append(PpOcrEngine())
except FileNotFoundError as exc:
logger.warning("PP-OCRv4 不可用, 退化为单引擎: %s", exc)
engines.append(DdddOcrEngine())
return cls(engines)
def per_engine_scores(self, gray: np.ndarray, targets: Sequence[str]) -> List[Dict[str, float]]:
per: List[Dict[str, float]] = []
for engine in self.engines:
try:
chars, probs = engine.probabilities(gray)
except Exception as exc: # 单个引擎异常不影响整体求解
logger.warning("引擎 %s 推理失败: %s", engine.name, exc)
per.append({t: 0.0 for t in targets})
continue
index = {c: i for i, c in enumerate(chars)}
row = {}
for t in targets:
j = index.get(t)
row[t] = float(probs[:, j].max()) if j is not None else 0.0
per.append(row)
return per
def scores(self, gray: np.ndarray, targets: Sequence[str]) -> Dict[str, float]:
per = self.per_engine_scores(gray, targets)
out = {}
for t in targets:
out[t] = float(sum(w * r.get(t, 0.0) for w, r in zip(self.weights, per)))
return out
def close(self):
for engine in self.engines:
engine.close()
if __name__ == "__main__":
import sys
ens = EnsembleOcr.default()
img = cv2.imread(sys.argv[1], cv2.IMREAD_GRAYSCALE)
targets = sys.argv[2:]
print(ens.per_engine_scores(img, targets))
print("ensemble:", ens.scores(img, targets))
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""
毫秒 (1ms.run) 业务适配器 —— 腾讯天御点选验证码凭证获取
协议来源: `/Users/yml/Desktop/抓包/毫秒/点选.har`
从抓包可还原的链路 (本适配器已全部实现):
第一步 毫秒取验证码参数
POST https://1ms.run/api/v1/captcha/params
body {"scene": "checkin"} (也可以带 identity_context)
-> {"provider":"tencent","params":{"captchaAppId":195672344},
"sessionUuid":"01a12a04-e5e7-726b-b00c-2f54b2e1c167"}
第二步 腾讯天御 cap_union_new 全套纯协议解题
(prehandle -> PoW -> tdc.js -> getcapbysig -> verify)
-> {"ticket": "tr03...", "randstr": "@..."}
说明与边界
----------
* `identity_context` 在抓包中是页面内加密上下文 (emctx1.*), 实测 **不传同样可以** 拿到
`captchaAppId`, 因此本适配器不依赖它; 若后续业务要求, 可通过参数显式传入。
* 抓包中 **不包含** 最终的签到提交请求 (`/api/v1/...` 业务接口), 因此本适配器只负责
产出 `ticket` + `randstr`, 并给出一个可配置的 `submit_business()` 便于对接真实业务
接口 (端点与字段需按实际业务抓包填写, 严禁凭猜测写死)。
"""
import logging
from dataclasses import dataclass
from typing import Dict, Optional, Tuple
import httpx
from tcaptcha.client import KIND_TEXT_CLICK, TencentCaptchaClient
logger = logging.getLogger("one_ms_adapter")
ONE_MS_BASE = "https://1ms.run"
DEFAULT_SCENE = "checkin"
DEFAULT_REFERER = "https://1ms.run/console/personal/benefits/check-in"
DEFAULT_USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36")
@dataclass
class CaptchaParams:
provider: str
app_id: str
session_uuid: str
enabled: bool
class OneMsAdapter:
"""1ms.run 验证码凭证适配器"""
def __init__(self, cookie: Optional[str] = None, user_agent: str = DEFAULT_USER_AGENT,
timeout: float = 15.0, proxy: Optional[str] = None,
proxy_api: Optional[str] = None, telemetry_mode: str = "python"):
self.user_agent = user_agent
self.timeout = timeout
# 腾讯天御按出口 IP 做滚动风控 (errorCode=12); 给了 proxy_api 就能自动换 IP 重试
self.proxy = proxy
self.proxy_api = proxy_api
self.telemetry_mode = telemetry_mode
headers = {
"User-Agent": user_agent,
"Origin": ONE_MS_BASE,
"Referer": DEFAULT_REFERER,
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
}
if cookie:
headers["Cookie"] = cookie
self.http = httpx.Client(base_url=ONE_MS_BASE, headers=headers, timeout=timeout,
trust_env=False, follow_redirects=True)
# ---------------------------------------------------------- 第一步
def fetch_captcha_params(self, scene: str = DEFAULT_SCENE,
identity_context: str = "") -> CaptchaParams:
"""POST /api/v1/captcha/params —— 取当前场景的验证码配置"""
payload: Dict[str, str] = {"scene": scene}
if identity_context:
payload["identity_context"] = identity_context
resp = self.http.post("/api/v1/captcha/params", json=payload)
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(f"取验证码参数失败: {data}")
info = data.get("data") or {}
params = info.get("params") or {}
return CaptchaParams(
provider=info.get("provider", ""),
app_id=str(params.get("captchaAppId", "")),
session_uuid=info.get("sessionUuid", ""),
enabled=bool(info.get("enabled")),
)
# ---------------------------------------------------------- 第二步
def get_captcha_ticket(self, scene: str = DEFAULT_SCENE,
max_retries: int = 3,
max_proxy_rotations: int = 3) -> Tuple[Optional[str], Optional[str], Dict]:
"""
取得可提交给毫秒业务接口的 (ticket, randstr)。
若配置了 `proxy_api`, 在遇到 `errorCode=12` (f_limit, IP 级滚动风控) 时会
自动换一个出口 IP 重试, 最多 `max_proxy_rotations` 次。
"""
params = self.fetch_captcha_params(scene=scene)
logger.info("毫秒验证码参数: provider=%s appId=%s enabled=%s sessionUuid=%s",
params.provider, params.app_id, params.enabled, params.session_uuid)
if not params.enabled or not params.app_id:
return None, None, {"error": "该场景未启用验证码"}
if params.provider != "tencent":
return None, None, {"error": f"暂只支持 tencent provider, 实际为 {params.provider}"}
proxy = self.proxy
last: Dict = {}
for rotation in range(max(1, max_proxy_rotations)):
client = TencentCaptchaClient(
aid=params.app_id,
entry_url=DEFAULT_REFERER,
referer=ONE_MS_BASE + "/",
wb="1",
accept_kinds=(KIND_TEXT_CLICK,),
proxy=proxy,
telemetry_mode=self.telemetry_mode,
)
try:
result = client.solve(max_retries=max_retries, max_refreshes=8)
finally:
client.close()
last = result
if result.get("success"):
return result.get("ticket") or None, result.get("randstr") or None, result
if "12" not in str(result.get("error", "")) or not self.proxy_api:
break
from tcaptcha.proxy import fetch_proxy
proxy = fetch_proxy(self.proxy_api)
logger.warning("触发 errorCode=12, 换出口 IP 重试 (%d/%d): %s",
rotation + 1, max_proxy_rotations, proxy)
if not proxy:
break
return None, None, last
# ---------------------------------------------------------- 业务提交
def submit_business(self, path: str, payload: Dict, method: str = "POST") -> Dict:
"""
用 ticket/randstr 提交真实业务请求。
注意: 点选.har 中 **没有** 最终的签到业务请求, 因此这里不预设端点与字段,
由调用方按实际业务抓包传入 (通常是 {"vticket": ticket, "vrandstr": randstr, ...})。
"""
if method.upper() == "POST":
resp = self.http.post(path, json=payload)
else:
resp = self.http.get(path, params=payload)
try:
return resp.json()
except Exception:
return {"code": resp.status_code, "text": resp.text}
def close(self):
self.http.close()
if __name__ == "__main__":
import argparse
import json
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
ap = argparse.ArgumentParser(description="1ms.run 验证码票据获取")
ap.add_argument("--scene", default=DEFAULT_SCENE)
ap.add_argument("--cookie", default="")
ap.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
ap.add_argument("--proxy-api", default=None, help="代理提取 API (遇到 errorCode=12 自动换 IP)")
args = ap.parse_args()
adapter = OneMsAdapter(cookie=args.cookie or None, proxy=args.proxy,
proxy_api=args.proxy_api)
try:
ticket, randstr, raw = adapter.get_captcha_ticket(scene=args.scene)
finally:
adapter.close()
print(json.dumps({"ticket": ticket, "randstr": randstr,
"error": raw.get("error", "")}, ensure_ascii=False, indent=2))
+91
View File
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""
代理 IP 提取与格式化 (用于绕过腾讯天御的 IP 级滚动风控)
腾讯验证码按出口 IP 做滚动窗口风控, 触发时会返回 `errorCode=12` (f_limit)。
本模块从常见的「代理 IP 提取 API」取一个可用代理, 供 `TencentCaptchaClient(proxy=...)`
与 `OneMsAdapter(proxy=...)` 使用。
已适配的返回格式:
* JSON: {"status":100,"data":[{"ip":"1.2.3.4","port":"8080"}]} (讯客 xkdaili 等)
* JSON: {"data":{"ip":"1.2.3.4","port":8080}} / [{"ip":..,"port":..}]
* 纯文本: 一行一个 "1.2.3.4:8080" 或 "1.2.3.4,8080"
"""
from __future__ import annotations
import re
from typing import List, Optional
import httpx
_IP_PORT_RE = re.compile(r"(\d{1,3}(?:\.\d{1,3}){3})\s*[:,]\s*(\d{1,5})")
def format_proxy(ip: str, port, scheme: str = "http") -> str:
return f"{scheme}://{ip}:{port}"
def parse_proxies(payload: str, scheme: str = "http") -> List[str]:
"""从 API 返回体里解析出全部代理 (兼容 JSON 与纯文本两种格式)"""
out: List[str] = []
text = (payload or "").strip()
if not text:
return out
try:
import json
data = json.loads(text)
def walk(node):
if isinstance(node, dict):
ip = node.get("ip") or node.get("host") or node.get("server")
port = node.get("port")
if ip and port:
out.append(format_proxy(str(ip), port, scheme))
for key in ("data", "result", "proxy", "list", "proxies"):
if key in node:
walk(node[key])
elif isinstance(node, list):
for item in node:
walk(item)
walk(data)
if out:
return out
except Exception:
pass
for ip, port in _IP_PORT_RE.findall(text):
out.append(format_proxy(ip, port, scheme))
return out
def fetch_proxy(api_url: str, scheme: str = "http", timeout: float = 15.0) -> Optional[str]:
"""调用代理提取 API 并返回第一个可用代理字符串, 例如 http://1.2.3.4:8080"""
resp = httpx.get(api_url, timeout=timeout, trust_env=False, follow_redirects=True)
proxies = parse_proxies(resp.text, scheme=scheme)
return proxies[0] if proxies else None
def check_proxy(proxy: str, url: str = "https://turing.captcha.qcloud.com/",
timeout: float = 20.0) -> bool:
"""探测代理是否可用 (能连上目标域名即可, 不判断业务状态码)"""
try:
httpx.get(url, proxy=proxy, timeout=timeout, trust_env=False,
follow_redirects=True, headers={"User-Agent": "Mozilla/5.0"})
return True
except Exception:
return False
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="从代理 API 取一个可用代理")
ap.add_argument("api_url")
ap.add_argument("--scheme", default="http")
args = ap.parse_args()
proxy = fetch_proxy(args.api_url, scheme=args.scheme)
print(proxy or "未取到代理")
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""
一键复验入口 (AGENTS.md §1.4 / §5.9)
.venv/bin/python -m tcaptcha.selftest
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SUITES = [
"tcaptcha.tests.test_glyph_solver",
"tcaptcha.tests.test_xtea_key",
]
def main() -> int:
failed = []
for mod in SUITES:
print(f"\n{'=' * 62}\n>>> {mod}\n{'=' * 62}")
rc = subprocess.run([sys.executable, "-m", mod], cwd=str(ROOT),
env={**__import__("os").environ, "PYTHONPATH": str(ROOT)}).returncode
if rc != 0:
failed.append(mod)
print(f"\n{'=' * 62}")
if failed:
print(f"❌ selftest 失败: {failed}")
return 1
print("✅ selftest 全部通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+243
View File
@@ -0,0 +1,243 @@
/**
* tdc_browser.js —— 在「真实 Chrome」中执行服务端下发的 tdc.js, 产出 collect / eks。
*
* 为什么需要它
* ------------
* `tdc_worker.js` 用 Node VM 伪造 DOM/BOM 环境, tdc.js 采集到的环境指纹不真实,
* 服务端在答题正确后进入风控评估会返回 errorCode=12 (环境异常/自动化)。
* 本脚本用 CDP 驱动本机真实 Chrome: 在验证码真实来源域下加载 tdc.js, 用
* Input.dispatchMouseEvent 派发可信鼠标事件, 再读回 TDC.getData()/getInfo()。
*
* 零依赖: 只用 Node >= 22 内置的全局 WebSocket 与 child_process。
*
* 用法:
* node tdc_browser.js <tdc.js路径|-> <points JSON> <options JSON>
* 选项: { aid, referer, userAgent, chromePath, headless, origin, timeoutMs }
* 输出: {"collect":..,"tlg":..,"eks":..,"source":"browser"} 或 {"error":..}
*/
const { spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const DEFAULT_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function waitForFile(file, timeoutMs) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
if (fs.existsSync(file)) {
const txt = fs.readFileSync(file, 'utf8').trim().split('\n');
if (txt[0]) return txt[0].trim();
}
await sleep(120);
}
throw new Error(`等待 DevToolsActivePort 超时: ${file}`);
}
class Cdp {
constructor(ws) {
this.ws = ws;
this.id = 0;
this.pending = new Map();
ws.addEventListener('message', ev => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.id && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
else resolve(msg.result);
}
});
}
send(method, params = {}, sessionId) {
const id = ++this.id;
const payload = { id, method, params };
if (sessionId) payload.sessionId = sessionId;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify(payload));
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}
}, 45000);
});
}
}
function connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.addEventListener('open', () => resolve(ws));
ws.addEventListener('error', e => reject(new Error('WebSocket 连接失败: ' + (e.message || e.type))));
});
}
async function httpJson(url) {
const res = await fetch(url);
return res.json();
}
/**
* 生成鼠标轨迹 (相对位移), 与 tdc_worker.js 保持同样的形态:
* 起点在 iframe 底部中间, 依次移动到 3 个目标点, 每段用贝塞尔插值 + 抖动 + 停留。
*/
function buildTrajectory(points, opts) {
const scale = opts.displayScale != null ? opts.displayScale : (320.0 / 672.0);
const targets = points.map(p => ([
Math.round((opts.offsetX || 0) + p[0] * scale),
Math.round((opts.offsetY || 0) + p[1] * scale),
]));
const frameW = opts.frameWidth || 360;
const frameH = opts.frameHeight || 360;
let cur = [Math.round(frameW * 0.42 + (Math.random() - 0.5) * 60),
Math.round(frameH * 0.89 + (Math.random() - 0.5) * 20)];
const events = [cur.slice()];
for (const t of targets) {
const steps = 18 + Math.floor(Math.random() * 12);
const dx = t[0] - cur[0], dy = t[1] - cur[1];
const dist = Math.hypot(dx, dy) || 1;
const cx = (Math.random() - 0.5) * Math.min(50, dist * 0.4);
const cp1 = [cur[0] + dx * 0.3 - dy / dist * cx, cur[1] + dy * 0.3 + dx / dist * cx];
const cp2 = [cur[0] + dx * 0.7 + dy / dist * cx * 0.5, cur[1] + dy * 0.7 - dx / dist * cx * 0.5];
for (let i = 1; i <= steps; i++) {
const u = i / steps, e = u * u * (3 - 2 * u), v = 1 - e;
const x = Math.round(v ** 3 * cur[0] + 3 * v ** 2 * e * cp1[0] + 3 * v * e ** 2 * cp2[0] + e ** 3 * t[0]);
const y = Math.round(v ** 3 * cur[1] + 3 * v ** 2 * e * cp1[1] + 3 * v * e ** 2 * cp2[1] + e ** 3 * t[1]);
cur = [x, y];
events.push(cur.slice());
}
for (let i = 0; i < 3 + Math.floor(Math.random() * 3); i++) {
events.push([cur[0] + Math.round((Math.random() - 0.5) * 2),
cur[1] + Math.round((Math.random() - 0.5) * 2)]);
}
}
return { events, targets };
}
async function generate(rawCode, points, opts) {
const opt = Object.assign({
aid: '2039519451',
referer: 'https://1ms.run/',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36',
chromePath: process.env.TDC_CHROME || DEFAULT_CHROME,
headless: true,
origin: 'https://turing.captcha.gtimg.com/',
frameWidth: 360,
frameHeight: 360,
timeoutMs: 45000,
}, opts || {});
if (!fs.existsSync(opt.chromePath)) throw new Error(`找不到 Chrome: ${opt.chromePath}`);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'tdc-chrome-'));
const args = [
'--remote-debugging-port=0',
`--user-data-dir=${profile}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-background-networking',
'--disable-blink-features=AutomationControlled',
'--disable-features=Translate,OptimizationHints',
`--user-agent=${opt.userAgent}`,
'--window-size=420,760',
'about:blank',
];
if (opt.headless) args.unshift('--headless=new');
const chrome = spawn(opt.chromePath, args, { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
chrome.stderr.on('data', d => { stderr += d.toString(); });
let ws = null;
try {
const port = await waitForFile(path.join(profile, 'DevToolsActivePort'), opt.timeoutMs);
const version = await httpJson(`http://127.0.0.1:${port}/json/version`);
ws = await connect(version.webSocketDebuggerUrl);
const cdp = new Cdp(ws);
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
const list = await httpJson(`http://127.0.0.1:${port}/json/list`);
const page = list.find(t => t.id === targetId);
if (!page) throw new Error('未找到新建的页面 target');
const pageWs = await connect(page.webSocketDebuggerUrl);
const pcdp = new Cdp(pageWs);
await pcdp.send('Page.enable');
await pcdp.send('Runtime.enable');
await pcdp.send('Page.navigate', { url: opt.origin });
await sleep(1200);
// 注入并执行真实 tdc.js
const inject = await pcdp.send('Runtime.evaluate', {
expression: `(function(){ ${rawCode}\n; return typeof TDC !== 'undefined' && !!TDC.getData; })()`,
awaitPromise: false,
returnByValue: true,
});
if (!inject.result || inject.result.value !== true) {
throw new Error(`tdc.js 执行后未暴露 TDC.getData (result=${JSON.stringify(inject.result)})`);
}
// 用 CDP Input 派发可信鼠标事件
const { events } = buildTrajectory(points, opt);
for (const [x, y] of events) {
await pcdp.send('Input.dispatchMouseEvent', {
type: 'mouseMoved', x, y, button: 'none', buttons: 0, clickCount: 0,
});
}
// 三次真实点击
for (const t of points.map(p => [
Math.round((opt.offsetX || 0) + p[0] * (opt.displayScale != null ? opt.displayScale : 320 / 672)),
Math.round((opt.offsetY || 0) + p[1] * (opt.displayScale != null ? opt.displayScale : 320 / 672)),
])) {
await pcdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: t[0], y: t[1], button: 'left', buttons: 1, clickCount: 1 });
await pcdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: t[0], y: t[1], button: 'left', buttons: 0, clickCount: 1 });
await sleep(120);
}
const out = await pcdp.send('Runtime.evaluate', {
expression: `JSON.stringify({collect: TDC.getData(), eks: (TDC.getInfo()||{}).info || ''})`,
returnByValue: true,
});
const payload = JSON.parse(out.result.value);
pageWs.close();
if (!payload.collect) throw new Error('TDC.getData() 返回空');
return { collect: payload.collect, tlg: payload.collect.length, eks: payload.eks, source: 'browser' };
} finally {
try { if (ws) ws.close(); } catch { /* ignore */ }
try { chrome.kill('SIGKILL'); } catch { /* ignore */ }
await sleep(150);
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
if (require.main === module) {
const tdcPath = process.argv[2];
const pointsArg = process.argv[3];
const optionsArg = process.argv[4];
const points = pointsArg ? JSON.parse(pointsArg) : [[162, 249], [104, 37], [561, 156]];
const options = optionsArg ? JSON.parse(optionsArg) : {};
const run = code => generate(code, points, options).then(
r => { console.log(JSON.stringify(r)); },
e => { console.log(JSON.stringify({ error: String((e && e.stack) || e) })); process.exitCode = 1; }
);
if (tdcPath === '-') {
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => { input += c; });
process.stdin.on('end', () => run(input));
} else {
run(fs.readFileSync(tdcPath, 'utf8'));
}
}
module.exports = { generate };
+149 -28
View File
@@ -152,7 +152,25 @@ function generateBezierTrajectory(p0, p1, steps = 25) {
return points;
}
function runTdcCode(rawCode, clickPoints) {
function runTdcCode(rawCode, clickPoints, options) {
const opt = Object.assign({
aid: '2039519451',
referrer: 'https://app.rainyun.com/',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36',
platform: 'MacIntel',
screenWidth: 1920,
screenHeight: 1080,
availHeight: 1055,
devicePixelRatio: 2,
// 验证码 iframe 在页面中的显示尺寸 (672x480 原图会被缩放到该尺寸)
frameWidth: 360,
frameHeight: 360,
// 原图 -> 显示空间的映射 (默认按 320/672 缩放, 与雨云场景一致)
displayScale: null,
offsetX: 20,
offsetY: 60
}, options || {});
const listeners = [];
function addL(type, fn) {
listeners.push({ type, fn });
@@ -163,12 +181,12 @@ function runTdcCode(rawCode, clickPoints) {
const baseWindow = {
location: {
href: 'https://turing.captcha.qcloud.com/cap_union_new_show?aid=2039519451',
href: `https://turing.captcha.qcloud.com/cap_union_new_show?aid=${opt.aid}`,
protocol: 'https:',
host: 'turing.captcha.qcloud.com',
hostname: 'turing.captcha.qcloud.com',
pathname: '/cap_union_new_show',
search: '?aid=2039519451',
search: `?aid=${opt.aid}`,
hash: ''
},
document: {
@@ -180,7 +198,7 @@ function runTdcCode(rawCode, clickPoints) {
removeEventListener: () => {},
body: docBody,
documentElement: docElement,
referrer: 'https://app.rainyun.com/',
referrer: opt.referrer,
cookie: '',
getElementsByTagName: (t) => [t === 'body' ? docBody : docElement],
getElementById: () => null,
@@ -188,8 +206,8 @@ function runTdcCode(rawCode, clickPoints) {
head: new Element('HEAD')
},
navigator: {
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36',
platform: 'MacIntel',
userAgent: opt.userAgent,
platform: opt.platform,
languages: ['zh-CN', 'zh', 'en'],
language: 'zh-CN',
cookieEnabled: true,
@@ -197,10 +215,10 @@ function runTdcCode(rawCode, clickPoints) {
mimeTypes: []
},
screen: {
width: 1920,
height: 1080,
availWidth: 1920,
availHeight: 1055,
width: opt.screenWidth,
height: opt.screenHeight,
availWidth: opt.screenWidth,
availHeight: opt.availHeight,
colorDepth: 24,
pixelDepth: 24
},
@@ -222,11 +240,11 @@ function runTdcCode(rawCode, clickPoints) {
setInterval: setInterval,
clearInterval: clearInterval,
console: { log: () => {}, error: () => {}, warn: () => {} },
innerWidth: 360,
innerHeight: 360,
outerWidth: 1920,
outerHeight: 1080,
devicePixelRatio: 2,
innerWidth: opt.frameWidth,
innerHeight: opt.frameHeight,
outerWidth: opt.screenWidth,
outerHeight: opt.screenHeight,
devicePixelRatio: opt.devicePixelRatio,
addEventListener: addL,
removeEventListener: () => {}
};
@@ -235,20 +253,66 @@ function runTdcCode(rawCode, clickPoints) {
baseWindow.top = baseWindow;
baseWindow.parent = baseWindow;
// 注意: vm.runInNewContext 每次调用都会新建一个 context, 桩打不进去。
// 必须显式 createContext 一次, 之后 prelude 与 tdc.js 都跑在同一个 context 里。
const vmContext = vm.createContext(baseWindow);
let instrumentedCode = rawCode;
if (opt.captureKey) {
// 密钥提取: 把 VM 的 XOR / ADD 两个 opcode 换成带日志的版本。
// 自定义 XTEA 每轮都要算 f(v) ^ (sum + K[j]), 其中 (sum + K[j]) 是 XOR 的操作数,
// 记下它就能反推 K[j] = (operand - round*DELTA) mod 2^32。
// 用 Array.prototype.push 打桩是抓不到的 —— 这些 opcode 用的是
// `g[g.length-2]=...` 直接下标赋值。
vm.runInContext(
`globalThis.__TDC_XOR=[];globalThis.__TDC_ADD=[];` +
`globalThis.__TDC_LOGX=function(a,b){var s=globalThis.__TDC_XOR;` +
`if(s.length<4000000){s.push(a>>>0,b>>>0);}return (a^b);};` +
`globalThis.__TDC_LOGA=function(a,b){var s=globalThis.__TDC_ADD;` +
`if(s.length<4000000){s.push(a>>>0,b>>>0);}return (a+b);};`,
vmContext);
// 注意: opcode 表里的 **栈变量名每份构建都不同**
// (实测: 样本=g, 雨云实时=j, 1ms实时=M —— 见 AGENTS.md 附录 A#6 码表轮换),
// 所以必须用反向引用自动识别变量名, 不能写死 'g'。
// 目标 opcode 形态: <V>[<V>.length-2]=<V>[<V>.length-2] <op> <V>.pop()
const mkRe = op => new RegExp(
'(\\w+)\\[(\\1\\.length-2)\\]=\\1\\[\\2\\]\\' + op + '\\1\\.pop\\(\\)', 'g');
const xorRe = mkRe('^');
const addRe = mkRe('+');
const nXor = (instrumentedCode.match(xorRe) || []).length;
const nAdd = (instrumentedCode.match(addRe) || []).length;
if (nXor !== 1 || nAdd !== 1) {
// 不静默失败 (AGENTS.md 附录 A#8): 结构性变化必须显式暴露
return {
error: `opcode 插桩失败: 期望 XOR/ADD 各 1 处, 实际 XOR=${nXor} ADD=${nAdd}. ` +
`tdc.js 的 VM opcode 形态可能已变更, 需要更新插桩规则。`
};
}
instrumentedCode = instrumentedCode
.replace(addRe, (m, v, idx) => `${v}[${idx}]=__TDC_LOGA(${v}[${idx}],${v}.pop())`)
.replace(xorRe, (m, v, idx) => `${v}[${idx}]=__TDC_LOGX(${v}[${idx}],${v}.pop())`);
if (opt.captureKeyVerbose) {
process.stderr.write(`[captureKey] 插桩 XOR opcode x${nXor}, ADD opcode x${nAdd}\n`);
}
}
// Execute TDC.js
vm.runInNewContext(rawCode, baseWindow);
vm.runInContext(instrumentedCode, vmContext);
// Map 672x480 coordinates to 360x360 iframe display space
const scale = 320.0 / 672.0;
// 原图坐标 (672x480) -> 验证码 iframe 显示空间
const scale = opt.displayScale !== null && opt.displayScale !== undefined
? opt.displayScale : (320.0 / 672.0);
const targetPoints = clickPoints.map(p => ({
x: Math.round(20 + p[0] * scale),
y: Math.round(60 + p[1] * scale)
x: Math.round(opt.offsetX + p[0] * scale),
y: Math.round(opt.offsetY + p[1] * scale)
}));
// Generate path: entrance -> P1 -> P2 -> P3
let currentPoint = {
x: Math.round(150 + (Math.random() - 0.5) * 60),
y: Math.round(320 + (Math.random() - 0.5) * 20)
x: Math.round(opt.frameWidth * 0.42 + (Math.random() - 0.5) * 60),
y: Math.round(opt.frameHeight * 0.89 + (Math.random() - 0.5) * 20)
};
const mmListener = listeners.find(l => l.type === 'mousemove');
@@ -297,34 +361,91 @@ function runTdcCode(rawCode, clickPoints) {
const info = baseWindow.TDC.getInfo();
const eks = info ? info.info : '';
return {
const result = {
collect,
tlg: collect ? collect.length : 0,
eks
};
// 从记录的 ADD 操作数里反推 XTEA 密钥 (含索引)
//
// 字节码里 (sum + K[j]) 是一次 ADD, 两个操作数分别是
// sum = (r*DELTA) mod 2^32 与 K[j] 本身。
// 反过来查表: 找到 r 后, j 只可能是 r&3 (v0 分支) 或 ((r+1)*DELTA>>11)&3 (v1 分支)。
// 对同一个 K 的多次观测取交集, 就能唯一确定它的索引 —— 不需要 4! 排列搜索。
if (opt.captureKey) {
const DELTA = 2654435769;
const sumToR = new Map();
for (let r = 0; r <= 32; r++) sumToR.set(Math.imul(r, DELTA) >>> 0, r);
const idxOf = r => [r & 3, (((r + 1) * DELTA) >>> 0 >>> 11) & 3];
const addLog = baseWindow.__TDC_ADD || [];
const obs = new Map(); // K -> Map(r -> count)
for (let i = 0; i + 1 < addLog.length; i += 2) {
const a = addLog[i], b = addLog[i + 1];
let r = sumToR.get(a), k = b;
if (r === undefined) { r = sumToR.get(b); k = a; }
if (r === undefined || k === 0 || (k >>> 0) === DELTA) continue;
k = k >>> 0;
if (!obs.has(k)) obs.set(k, new Map());
const m = obs.get(k);
m.set(r, (m.get(r) || 0) + 1);
}
const entries = [];
for (const [k, rmap] of obs) {
let total = 0, candidates = null;
for (const [r, c] of rmap) {
total += c;
const s = new Set(idxOf(r));
candidates = candidates === null ? s : new Set([...candidates].filter(x => s.has(x)));
}
if (candidates && candidates.size === 1) {
entries.push([k, total, [...candidates][0], rmap.size]);
} else {
entries.push([k, total, candidates && candidates.size ? [...candidates] : null, rmap.size]);
}
}
entries.sort((x, y) => y[1] - x[1]);
result.key_words = entries.slice(0, 24);
result.add_operand_count = addLog.length;
}
return result;
}
function runTdc(tdcFilePath, clickPoints) {
function runTdc(tdcFilePath, clickPoints, options) {
const rawCode = fs.readFileSync(tdcFilePath, 'utf8');
return runTdcCode(rawCode, clickPoints);
return runTdcCode(rawCode, clickPoints, options);
}
if (require.main === module) {
const tdcPath = process.argv[2];
const pointsArg = process.argv[3];
const optionsArg = process.argv[4];
const points = pointsArg ? JSON.parse(pointsArg) : [[626, 253], [120, 120], [438, 33]];
const options = optionsArg ? JSON.parse(optionsArg) : {};
if (tdcPath === '-') {
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
const result = runTdcCode(input, points);
console.log(JSON.stringify(result));
try {
const result = runTdcCode(input, points, options);
console.log(JSON.stringify(result));
} catch (err) {
console.log(JSON.stringify({ error: String(err && err.stack || err) }));
process.exitCode = 1;
}
});
} else {
const result = runTdc(tdcPath, points);
console.log(JSON.stringify(result));
try {
const result = runTdc(tdcPath, points, options);
console.log(JSON.stringify(result));
} catch (err) {
console.log(JSON.stringify({ error: String(err && err.stack || err) }));
process.exitCode = 1;
}
}
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+154
View File
@@ -0,0 +1,154 @@
[
{
"image": "har_round1.jpg",
"instruction": "请依次点击:虫 呈 般",
"expected_points": [
[
149,
252
],
[
48,
55
],
[
549,
351
]
],
"note": "点选.har 第 1 道题; 用户实际第 3 击落点 (561,156) 偏上, 服务端返回 errorCode 50"
},
{
"image": "har_round2.jpg",
"instruction": "请依次点击:佰 衬 稗",
"expected_points": [
[
449,
53
],
[
549,
353
],
[
349,
152
]
],
"note": "点选.har 第 2 道题; 用户把第 2/3 击顺序点反 (50)"
},
{
"image": "har_round3.jpg",
"instruction": "请依次点击:簿 蚕 沧",
"expected_points": [
[
549,
153
],
[
50,
255
],
[
48,
154
]
],
"note": "点选.har 第 3 道题; 用户第 2/3 击落点错误 (9)"
},
{
"image": "har_round4.jpg",
"instruction": "请依次点击:沉 驰 齿",
"expected_points": [
[
150,
154
],
[
550,
153
],
[
49,
53
]
],
"note": "点选.har 第 4 道题; 抓包结束时用户尚未提交"
},
{
"image": "live_probe.jpg",
"instruction": "请依次点击:笆 辩 裁",
"expected_points": [
[
549,
153
],
[
50,
53
],
[
351,
151
]
],
"note": "实时抓取; ddddocr 把 笆 误读成 芭, 集成后仍正确指派"
},
{
"image": "live_hard_rare_char.jpg",
"instruction": "请依次点击:筹 炽 雹",
"expected_points": [
[
449,
253
],
[
49,
353
],
[
250,
255
]
],
"note": "含生僻字 筹 且整题斜切; 单 ddddocr 对 筹 的最大概率仅 0.0046, PP-OCRv4 可正确识别"
},
{
"image": "hard_adjacent_chars.jpg",
"instruction": "请依次点击:长 澄 班",
"expected_points": [
[
349,
154
],
[
449,
353
],
[
549,
353
]
],
"note": "失败样本修正: 旧阈值 V>=145 把暖色草地并入字形掩膜, 且 澄/班 横排相邻被膨胀粘连, 只剩 1~2 个候选; 收紧到 V>=190 后正常"
},
{
"image": "hard_grass_background.jpg",
"instruction": "请依次点击:菜 倍 钵",
"expected_points": [
[
250,
53
],
[
49,
252
],
[
52,
354
]
],
"note": "失败样本修正: 倍/钵 竖排相邻被膨胀粘成一个 332x80 长条, 被长宽比过滤器丢弃; 收紧阈值后正常"
}
]
+80
View File
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""
文字点选求解器回归测试 (真实线上样本)
fixtures 里的 6 张图全部来自真实线上环境:
* har_round1..4 —— 用户 `点选.har` 抓包中的 4 道题 (672x480 背景图)
* live_probe —— 本次逆向期间实时抓取的一道题
* live_hard_rare_char —— 含生僻字 "筹" 且整题带斜切变形, 用于验证 ddddocr+PP-OCRv4 集成
运行:
uv run python -m tcaptcha.tests.test_glyph_solver
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
FIXTURES = Path(__file__).resolve().parent / "fixtures"
MANIFEST = FIXTURES / "manifest.json"
ROOT = Path(__file__).resolve().parent.parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tcaptcha.glyph_solver import TextClickSolver, parse_instruction # noqa: E402
TOLERANCE = 22 # 像素容差 (汉字包围盒中心 vs 人工标注中心)
def run(verbose: bool = False) -> int:
logging.basicConfig(level=logging.WARNING)
cases = json.loads(MANIFEST.read_text(encoding="utf-8"))
solver = TextClickSolver()
passed = 0
failed = []
for case in cases:
img = (FIXTURES / case["image"]).read_bytes()
result = solver.solve_ex(img, case["instruction"])
ok_points = result.ok and all(
abs(p[0] - e[0]) <= TOLERANCE and abs(p[1] - e[1]) <= TOLERANCE
for p, e in zip(result.points, case["expected_points"])
)
if verbose:
print(f" {case['image']:24s} {case['instruction']}")
print(f" pred={result.points}")
print(f" exp ={case['expected_points']} post={result.posterior:.4f} "
f"{'OK' if ok_points else 'MISMATCH'}")
if ok_points:
passed += 1
else:
failed.append((case["image"], result.points, case["expected_points"]))
total = len(cases)
print(f"\n文字点选求解器回归: {passed}/{total} 通过")
for image, pred, exp in failed:
print(f" ✗ {image}: pred={pred} exp={exp}")
return 0 if passed == total else 1
def test_parse_instruction():
"""提示语解析的边界用例 (图标点选/滑块的模板串不能解析出目标字)"""
assert parse_instruction("请依次点击:虫 呈 般") == ["虫", "呈", "般"]
assert parse_instruction("请依次点击:虫呈般") == ["虫", "呈", "般"]
assert parse_instruction("请依次点击:") == [] # 图标点选
assert parse_instruction("拖动下方滑块完成拼图") == [] # 滑块
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
test_parse_instruction()
print("提示语解析边界用例: OK")
sys.exit(run(verbose=args.verbose))
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""
XTEA 算法还原 + tdc.js 密钥提取 回归测试 (AGENTS.md §5.9)
固化的性质:
1. 标准 XTEA 加解密往返
2. 与真实 tdc.js **逐字节一致** (算法还原正确的判据, §3.0)
3. 插桩能从 tdc.js 复原密钥, 且密钥按构建固定 (§5.5 受控环境逐字节一致)
4. 模板标定 + 纯 Python 回填 的产物可被同一把密钥解回预期结构
运行:
.venv/bin/python -m tcaptcha.tests.test_xtea_key
"""
from __future__ import annotations
import base64
import json
import struct
import subprocess
import sys
import urllib.parse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tcaptcha.crypto import ( # noqa: E402
DEFAULT_KEY,
generate_collect_from_template,
recover_payload_template,
xtea_decrypt_block,
xtea_decrypt_bytes,
xtea_encrypt_bytes,
extract_xtea_key,
)
ROOT = Path(__file__).resolve().parents[2]
SAMPLE = ROOT / "tcaptcha" / "tdc_sample.js"
SAMPLE_KEY = [1448563793, 1850559083, 1733513813, 1364613714] # 等价于 DEFAULT_KEY + 两个加盐
CD_PRE = b'{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"", ' # 旧版明文首段 (已补齐 48B)
_fails: list[str] = []
def check(name: str, cond: bool, detail: str = "") -> None:
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
if not cond:
_fails.append(name)
def run_tdc(script: str, points, options) -> dict:
proc = subprocess.run(
["node", str(ROOT / "tcaptcha" / "tdc_worker.js"), "-", json.dumps(points),
json.dumps(options)],
input=script, capture_output=True, text=True, timeout=180)
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_xtea_roundtrip() -> None:
print("1) 标准 XTEA 往返")
key = list(SAMPLE_KEY)
for chunk in (b"hello!!", bytes(range(32)), b"x" * 8):
enc = xtea_encrypt_bytes(chunk, key)
dec = xtea_decrypt_bytes(enc, key)
check(f"往返 {len(chunk)}B", dec.startswith(chunk), f"{chunk[:8]!r}")
# 已知明文 -> 与 tdc.js 密文逐字节一致
enc = xtea_encrypt_bytes(CD_PRE, key)
check("加密结果可被同密钥解回", xtea_decrypt_bytes(enc, key).rstrip(b"\x00") == CD_PRE.rstrip(b"\x00"))
def test_byte_exact_vs_tdcjs() -> None:
print("2) 纯 Python 密文 == 真实 tdc.js 密文 (逐字节)")
script = SAMPLE.read_text(encoding="utf-8", errors="replace")
job = run_tdc(script, [[100, 100], [200, 200], [300, 300]], {"aid": "195672344"})
real = base64.b64decode(urllib.parse.unquote(job["collect"]))
key = extract_xtea_key(script)
check("提取到的密钥 == 已知 K'", key == SAMPLE_KEY, str(key))
# 旧版首段是常量 cd_pre, 可逐字节对拍
check("前 48 字节逐字节一致",
xtea_encrypt_bytes(CD_PRE, key) == real[:48],
xtea_encrypt_bytes(CD_PRE, key)[:8].hex())
def test_key_fixed_per_build() -> None:
print("3) 密钥按构建固定 (同一脚本多次运行首块一致)")
script = SAMPLE.read_text(encoding="utf-8", errors="replace")
heads = set()
for _ in range(2):
job = run_tdc(script, [[10, 10], [20, 20], [30, 30]], {"aid": "195672344"})
heads.add(base64.b64decode(urllib.parse.unquote(job["collect"]))[:8])
check("两次运行首块相同", len(heads) == 1, heads.pop().hex())
def test_template_refill() -> None:
print("4) 模板标定 + 纯 Python 回填")
script = SAMPLE.read_text(encoding="utf-8", errors="replace")
template, key = recover_payload_template(script)
check("模板以 {\"cd\":[ 开头", template.startswith('{"cd":['), template[:24])
try:
json.loads(template)
check("模板是合法 JSON", True)
except Exception as exc: # noqa: BLE001
check("模板是合法 JSON", False, str(exc))
collect = generate_collect_from_template(template, [(449, 254), (48, 53), (148, 252)], key)
plain = xtea_decrypt_bytes(base64.b64decode(urllib.parse.unquote(collect)), key) \
.decode("latin1").rstrip(" ")
check("回填产物可解回 {\"cd\":[ 开头", plain.startswith('{"cd":['), plain[:24])
check("回填产物以 }} 结尾", plain.rstrip().endswith("}}"))
check("轨迹已换成 3 次点击的规模", len(plain) != len(template),
f"{len(template)} -> {len(plain)}")
def main() -> int:
if not SAMPLE.exists():
print(f"缺少样本 {SAMPLE}")
return 1
for fn in (test_xtea_roundtrip, test_byte_exact_vs_tdcjs,
test_key_fixed_per_build, test_template_refill):
fn()
print()
if _fails:
print(f"❌ XTEA/密钥 回归失败 {len(_fails)} 项: {_fails}")
return 1
print("✅ XTEA/密钥 回归全部通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())