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 个住宅代理)、短时频控、静默窗口
@@ -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 探针可复现性清单 |
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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 "未取到代理")
|
||||
@@ -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())
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -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 长条, 被长宽比过滤器丢弃; 收紧阈值后正常"
|
||||
}
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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())
|
||||