diff --git a/har/1ms_dianxuan/ANALYSIS.md b/har/1ms_dianxuan/ANALYSIS.md index b3d36a0..caabfd1 100644 --- a/har/1ms_dianxuan/ANALYSIS.md +++ b/har/1ms_dianxuan/ANALYSIS.md @@ -428,6 +428,35 @@ E3(成功)与 E4(失败)之间**同时变了三个变量**: ### 7.6 与本题无关但已完成的资产 * XTEA 算法逐字节还原 + 每构建密钥提取(`tcaptcha/crypto.py`、`tdc_worker.js` captureKey) -* 文字点选求解器 8/8 回归 -* 轨迹 dt 修正为真实毫秒分布(`human_dt_ms`) -* 代理提取/换 IP、模板标定回填管线 +* 文字点选求解器 8/8 回归(`tcaptcha/tests/test_glyph_solver.py`) +* 模板标定 + 纯 Python 回填管线(`generate_collect_success`) + +--- + +## 八、路线修订(2026-10-11 晚) + +### 8.1 已废弃「补环境」路线 + +曾实现 3 个"在伪造/真实环境里跑 tdc.js 产出 collect"的后端(node VM / browser CDP / +probe 深度 Proxy),**现已全部从代码移除**。依据:§7.1 E6 与 §6.2 对照 B/C 已证明 +collect 内容、遥测后端、出口 IP、静默时长 **都不是** 12 的自变量。 + +废弃后唯一路径:`python_keyed`(Node 仅提取 XTEA 密钥字)+ 纯 Python 模板回填。 +已删除的模块级符号:`apply_real_environment`、4 段拼接旧结构、`human_dt_ms`、 +`generate_telemetry_node/browser/probe`、`generate_collect_pure`。 + +### 8.2 一处必须留意的置信度缺陷(已修) + +`glyph_solver` 的"字形一致性强校验"此前用**单引擎 `ocr_text`** 判断,会把正确解误判为 +不匹配(实测 `簿→垂`、`笆→芭`、`筹→努`),导致回归从 8/8 掉到 5/8。 +现改为用**用于指派的集成得分矩阵行 argmax** 做自洽校验(每个字形的行 argmax 必须等于 +其被指派目标),8/8 恢复,且该校验仍能拦住真正的"图字不符"。 + +### 8.3 下一步(唯一有价值的实验) + +按 §7.5 做**单变量实验**,不要再用环境假说: + +1. captcha 域带上成功样本的 `uin/skey/qcmainCSRFToken/intl` cookie,其余不变; +2. 登录态真页面触发 vs 未登录触发。 + +二选一闭合即可判定 H1(页面上下文)还是 H2(自动化检测)。 diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md index 6938407..561b310 100644 --- a/har/1ms_dianxuan/PROGRESS.md +++ b/har/1ms_dianxuan/PROGRESS.md @@ -1,8 +1,28 @@ # 反 VM 密钥还原 & 纯算交付 进度文档 -> 目标: **算法 100% 纯 Python**,Node 仅用于"跑出密钥 + 一次性标定明文模板"。 -> 依据: AGENTS.md §2.4 分层交付 / §3.3 字节码 VM 还原 / §4.3 运行时捕获 / §5.8 标定式交付。 -> 最后更新: 2026-10-11 +> 目标: **算法 100% 纯 Python** —— collect 的明文组装 / 轨迹 / 时间戳 / XTEA 加密 / 编码 +> 全部在 Python 内完成; Node 只保留**一个**用途: 从当前 tdc.js 提取本次构建的 XTEA 密钥字。 +> 依据: AGENTS.md §2.3(拒绝补环境) / §2.4(分层交付) / §3.3(字节码 VM) / §4.3(运行时捕获) / §5.8(标定式交付)。 +> 最后更新: 2026-10-11 (纯算收敛: 移除全部补环境后端) + +--- + +## 〇、路线变更(重要) + +**已废弃「补环境」路线。** 曾经实现过 3 个"在伪造/真实环境里跑 tdc.js 产出 collect"的后端 +(`node` VM 沙箱 / `browser` CDP Dispatch / `probe` 深度 Proxy),现已从代码中**全部移除**, +只保留纯算单一路径 (`python_keyed` / `python`)。 + +废弃依据(本项目自己的实验,见 `ANALYSIS.md` §7): + +| 变量 | 结果 | +| --- | --- | +| collect 变体(空 / 垃圾 / 截断 / 真实环境值 / 真实轨迹 dt)| 全部 `errorCode=12` (E6) | +| 遥测后端 python / python_keyed / node / browser | 全部 12 | +| 出口 IP(4 个不同省份住宅代理)| 全部 12 | +| 静默 9 分钟 + 单发一次 | 仍 12 | + +即"环境 / 指纹 / IP / 时序"都**不是** 12 的自变量 → 补环境没有收益,只会引入大量手写桩的脆弱面。 --- @@ -11,14 +31,14 @@ | # | 结论 | 证据 | 确信度 | | --- | --- | --- | --- | | 1 | `collect` 的加密算法 = **教科书 XTEA**(32 轮,`delta=0x9E3779B9`,sum 取 mod 2³²) | 用提取密钥做纯 Python 加密,与真实 tdc.js 产物**前 48 字节逐字节一致**(`f6a24e3e4fb5dd2b...`) | 确证 | -| 2 | 原 `crypto.py` 里带加盐的写法与该算法**等价**,只是把两个密钥字折叠了:`K' = [k0, k1+salt1, k2+salt2, k3]` | 标准 XTEA + `K'=[1448563793,1850559083,1733513813,1364613714]` 解密样本 collect 得到 `{"cd":[[],"1920-1080..."` | 确证 | -| 3 | **密钥按构建固定,不随运行变化** | 同一 tdc.js 连续 3 次运行,密文首块均为 `f6a24e3e4fb5dd2b`(首段明文是常量) | 确证 | -| 4 | 密钥不在脚本字面量里(全脚本只 3 个数 >1e9,无一个是密钥字) | 正则清点全部 ≥7 位数字 | 确证 | -| 5 | 密钥由 VM 在运行时通过 ADD 算出:`(sum + K[j])` 的两操作数必有一个等于 `r*DELTA` | 插桩 ADD opcode 后,样本 Top4 候选恰好是 `K'`;实时构建同样只出现 4 个高频道候选 | 确证 | -| 6 | opcode 表的**栈变量名每份构建都不同**(样本 `g` / 雨云实时 `j` / 1ms `M`) | 同一正则在三份脚本上分别命中,写死 `g` 时实时脚本匹配数为 0 | 确证 | -| 7 | tdc.js 至少两代明文布局,**新版字段序还随构建变化** | 旧版首段 `{"cd":[[],"1920-1080..."`;雨云实时 `{"cd":[1,[],"Apple M1",24,...`;1ms 实时 `{"cd":[1791712995,0,...` | 确证 | -| 8 | 服务端**不校验** `collect` 与 `eks` 的同源性(宽松 aid) | 雨云 aid + 硬编码旧密钥 + 旧版明文 → `errorCode=0` + 真实 ticket | 确证 | -| 9 | 「Node 跑密钥 + Python 全算法」端到端可用 | 雨云 aid 纯 Python 出包 → `errorCode=0`,`ticket=tr03IsAVmQyixMlGay--3wugC_...` | 确证 | +| 2 | 早期带加盐的写法与该算法**等价**,只是把两个密钥字折叠了:`K' = [k0, k1+salt1, k2+salt2, k3]` | 标准 XTEA + `K'=[1448563793,1850559083,1733513813,1364613714]` 解密样本 collect 得到 `{"cd":[[],"1920-1080..."` | 确证 | +| 3 | **密钥按构建固定,不随运行变化** | 同一 tdc.js 连续 3 次运行,密文首块均为 `f6a24e3e4fb5dd2b` | 确证 | +| 4 | 密钥不在脚本字面量里 | 正则清点全脚本 ≥7 位数字,无一个是密钥字 | 确证 | +| 5 | 密钥由 VM 运行时通过 ADD 算出:`(sum + K[j])` 有一操作数等于 `r*DELTA` | 插桩 ADD opcode 后 Top4 候选即 `K'` | 确证 | +| 6 | opcode 表的**栈变量名每份构建都不同**(样本 `g` / 雨云 `j` / 1ms `M`) | 同一正则在三份脚本分别命中;写死 `g` 时实时脚本匹配 0 | 确证 | +| 7 | tdc.js 至少两代明文布局,**新版字段序随构建变化** | 旧版首段 `{"cd":[[],...`;雨云实时 `{"cd":[1,[],...`;1ms 实时 `{"cd":[1791712995,0,...` | 确证 | +| 8 | 宽松 aid 下服务端**不校验** `collect` 与 `eks` 的同源性 | 雨云 aid + 硬编码旧密钥 + 旧版明文 → `errorCode=0` + 真实 ticket | 确证(仅宽松 aid,勿外推) | +| 9 | 「Node 跑密钥 + Python 全算法」端到端可用 | 雨云 aid 纯 Python 出包 → `errorCode=0` | 确证 | --- @@ -26,9 +46,9 @@ | # | 未知 | 当前假设 | 确信度 | | --- | --- | --- | --- | -| 1 | 1ms aid=195672344 为何在答案正确时返回 `errorCode=12` | 该 aid 启用了更严格风控,要求会话具备**真实验证码页面上下文** | 中 | -| 2 | 新版明文字段语义(哪一位是时间戳/指纹/轨迹) | 时间戳是 `1[6-9]\d{8}` 形态的大整数;轨迹是 `[x,y,t]` 三元组数组 | 中高 | -| 3 | 明文里 `[360,360]`、`"1920-1080-1055-24-*-*-|-*"` 的作用 | 后者是屏幕/窗口指纹串,在新版被移到了 `cd` 数组末尾 | 中 | +| 1 | 1ms aid=195672344 为何在答案正确时返回 `errorCode=12` | 需要"真实业务会话/真实验证码页面上下文"(登录态),而非环境指纹 | **中(未做单变量实验)** | +| 2 | 新版明文字段语义(哪一位是时间戳/指纹/轨迹) | 时间戳是 `1[6-9]\d{8}` 大整数;轨迹是 `[x,y,t]` 三元组 | 中高 | +| 3 | 明文里 `[360,360]`、`"2560-1440-1345-24-*-*-| -*"` 的作用 | 屏幕/窗口指纹串 | 中 | --- @@ -37,31 +57,40 @@ | 假设 | 否定证据 | | --- | --- | | 密钥每次运行随机 | 同一脚本 3 次运行首块完全一致 | -| 密钥可从 `eks` 推导(MD5/SHA/切片) | 34 种推导 + 40 种偏移切片全部解密失败 | +| 密钥可从 `eks` 推导 | 34 种推导 + 40 种偏移切片全部失败 | | 密钥是脚本里的字面量 | 全脚本 >1e9 的数只有 3 个 | -| 用 `Array.prototype.push` 打桩能抓到密钥 | VM 算术 opcode 用 `g[g.length-2]=...` 直接下标赋值,`push` 桩只记录到 5783 个值且无信号 | -| 实时构建复用旧版明文布局 | 旧版校验器对两份实时脚本全部失败;解密后首部格式完全不同 | -| 换出口 IP 能解决 1ms 的 12 | 4 个不同省份住宅 IP(含代理)全部 12 | -| 换遥测引擎能解决 1ms 的 12 | `python` / `python_keyed` / `node` / `browser`(真实 Chrome)全部 12 | -| 长静默窗口能解决 1ms 的 12 | 静默 9 分钟 + 单发一次 → 仍 12 | +| 用 `Array.prototype.push` 打桩能抓密钥 | 算术 opcode 用 `g[g.length-2]=...` 直接下标赋值 | +| 实时构建复用旧版明文布局 | 旧版校验器对两份实时脚本全部失败 | +| **换出口 IP 能解决 1ms 的 12** | 4 个不同省份住宅 IP 全部 12 | +| **换遥测引擎能解决 1ms 的 12** | python / python_keyed / node / browser 全部 12 | +| **长静默窗口能解决 1ms 的 12** | 静默 9 分钟 + 单发 → 仍 12 | +| **"补环境"能解决 1ms 的 12** | 同上三条;环境不是自变量 | +| 轨迹 dt 太小是 12 的"直接原因" | 换成真实 ms 分布后仍 12(E6) | --- -## 四、本轮目标(已完成) +## 四、当前实现(纯算单一路径) -- [x] 识别 `collect` 加密算法为教科书 XTEA 并逐字节验证 -- [x] 实现版本无关的 opcode 插桩(反向引用自动识别栈变量名) -- [x] 从任意 tdc.js 无歧义复原 4 个密钥字(含索引歧义处理) -- [x] 实现模板标定 + 纯 Python 回填,端到端拿到真实 ticket -- [x] 固化回归测试(`tcaptcha/tests/test_xtea_key.py`,11 项断言) +``` +TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 + ├─ extract_xtea_key(tdc.js) Node 插桩 ADD opcode, 仅提取 4 个密钥字 (不可用时退回 DEFAULT_KEY) + ├─ generate_collect_success() 模板回填: 轨迹 / 当前时间戳 / 随机盐 → XTEA → Base64 (单层 URL 编码) + └─ extract_eks(tdc.js) 通用正则取最长 Base64 候选 (不再写死变量名) +``` + +* 模板: `tcaptcha/models/success_collect_template.b64`(真实成功样本明文) +* 加密: `tcaptcha/crypto.py` 中**唯一**的教科书 XTEA +* 已从代码移除: `apply_real_environment`、4 段拼接旧结构、node/browser/probe 后端、`human_dt_ms` + +--- ## 五、下一步(可被下一轮直接执行) -1. **拿下 1ms 的 12**:用 CDP 打开真实验证码页面,让原生 SDK 自己跑 tdc.js, - 复用本仓库视觉求解器算坐标、`Input.dispatchMouseEvent` 在 iframe 内真实点击。 - 骨架在 `tcaptcha/tdc_browser.js`。 -2. **补齐新版明文字段语义**:对照 10+ 份实时模板做差分,把时间戳/指纹/轨迹位点名。 -3. **实现 2408 图标点选**:用 1ms 的图标类别重训 `yolo_detector` + `icon_classifier`。 +1. **拿下 1ms 的 12**:做账本 §7.5 的**单变量实验** —— + (a) 给 captcha 域带上成功样本里的 `uin/skey/qcmainCSRFToken/intl` cookie; + (b) 登录态真页面触发 vs 未登录触发。二选一即可闭合。 +2. **补齐新版明文字段语义**:对照 10+ 份实时模板做差分。 +3. **实现 2408 图标点选**:按 1ms 图标类别重训 `yolo_detector` + `icon_classifier`。 4. **实现滑块 (2401/2402)**:模板匹配找缺口 + 生成滑动轨迹。 --- @@ -69,7 +98,8 @@ ## 六、复验入口 ```bash -.venv/bin/python -m tcaptcha.tests.test_xtea_key # XTEA + 密钥提取 (11 项) +.venv/bin/python -m tcaptcha.tests.test_crypto_core # XTEA 往返 + encrypt_string 一致性 +.venv/bin/python -m tcaptcha.tests.test_xtea_key # XTEA + 密钥提取 (逐字节对拍) .venv/bin/python -m tcaptcha.tests.test_glyph_solver # 文字点选求解 (8 张真实样本) .venv/bin/python -m tcaptcha.selftest # 全部 ``` diff --git a/tcaptcha/TOOLCHAIN_REPORT.md b/tcaptcha/TOOLCHAIN_REPORT.md index f7b0b65..8331c1f 100644 --- a/tcaptcha/TOOLCHAIN_REPORT.md +++ b/tcaptcha/TOOLCHAIN_REPORT.md @@ -9,7 +9,7 @@ | 工具 | 路径 | 核心功能 | 解决什么问题 | | :--- | :--- | :--- | :--- | | **Webpack 解包索引器** | [`tools/unpack_webpack.js`](file:///Users/yml/reverse/hello_js_reverse_skill/tools/unpack_webpack.js) | 将 230KB 单文件 bundle 拆解为 122 个独立模块,建立加签/风控关键词索引 | 圈定加签核心,剔除 80% 开源库与 UI 噪音 (L1 解构) | -| **Chaos VM 字节码反汇编器** | [`tools/tdc_vm_disasm.js`](file:///Users/yml/reverse/hello_js_reverse_skill/tools/tdc_vm_disasm.js) | 映射全部 70 个操作码语义,自动折叠字符拼接,反编译 45,636 字节码为 8,152 条指令流 | 彻底透明化 TDC 字节码调度器,提取 246 个环境检测特征 (L2/L3 解构) | +| **Chaos VM 字节码反汇编器** | [`tools/tdc_vm_disasm.js`](file:///Users/yml/reverse/hello_js_reverse_skill/tools/tdc_vm_disasm.js) | 映射全部 70 个操作码语义,自动折叠字符拼接,反编译 45,636 字节码为 8,151 条指令流 | 彻底透明化 TDC 字节码调度器 (L2/L3 解构) | | **深度 Proxy 语义探针** | [`tools/tdc_probe.js`](file:///Users/yml/reverse/hello_js_reverse_skill/tools/tdc_probe.js) | 零 Headless/CDP 依赖,通过深度 Proxy 沙箱执行 `tdc.js`,拦截并记录全部 95 项环境读取 | 毫秒级生成与当前会话时间戳和密钥 100% 自洽的动态 `collect` 与 `eks` (L4 数据层) | --- @@ -65,16 +65,27 @@ --- -## 三、旧方案致死原因归纳(为什么之前报 12) +## 三、关于 errorCode=12 的诚实结论(**已更正**) -1. **`re.sub` 破坏内部校验和与字段序**: - 旧方案拿固定的字符串模板进行正则替换,改变了明文长度,导致内部求和校验全部失效;且不同会话的 `tdc.js` 构建字段顺序会动态轮换,静态模板必死。 -2. **时间戳严重错位**: - 旧方案使用数天前抓包的硬编码时间戳,与当前服务器时间差数天,风控系统秒判拒绝。 -3. **机械假轨迹**: - 旧代码用 `random.randint` 随机构造假点,dt 分布为 0 或 1ms(相当于 20ms 内完成数十次移动),动力学完全反人类。 -4. **CDP 特征泄露**: - 浏览器自动化直接被 `document.$cdc_asdjflasutopfhvcZLmcfl_` 抓现行。 +> 本报告早期版本曾把 12 归因于"`re.sub` 破坏校验和 / 时间戳错位 / 机械假轨迹 / CDP 特征泄露"。 +> 这 4 条**均与本项目自己的证据账本冲突**(见 `har/1ms_dianxuan/ANALYSIS.md` §7 与 `PROGRESS.md` §三), +> 现更正如下: + +| 早期断言 | 现状 | +| --- | --- | +| 时间戳错位(硬编码数天前)| ❌ 撤回:纯算路径自模板回填起就刷新为当前时间戳,仍 12 | +| 机械假轨迹 dt=0/1ms | ❌ 撤回:换真实 ms 分布后仍 12 | +| CDP 特征泄露 | ❌ 撤回:纯 Python(无 CDP)后端同样 12 | +| `re.sub` 破坏内部校验和 | ⚠️ 仅为假设,无对照实验 | + +**已确证**:12 与遥测后端 / 出口 IP / 静默时长均无关。真正的自变量 +(登录态 / 真实验证码页面上下文)尚未由单变量实验区分 —— 见 `ANALYSIS.md` §7.5。 + +### 关于深度 Proxy 探针(`tools/tdc_probe.js`)的定位 + +探针是**分析工具**(用于枚举 tdc.js 真实读取了哪些环境属性),**已不在遥测生成路径上** +(`crypto.generate_telemetry` 只保留纯算)。它注入的 Canvas / WebGL / 屏幕取值来自 +**单份成功样本**,属于"复刻一份画像"而非"真实环境",因此不作为交付依据。 --- diff --git a/tcaptcha/__init__.py b/tcaptcha/__init__.py index e41864c..f16e32a 100644 --- a/tcaptcha/__init__.py +++ b/tcaptcha/__init__.py @@ -21,9 +21,9 @@ from .client import ( from .crypto import ( encrypt_string, extract_eks, - generate_collect, + extract_xtea_key, + generate_collect_success, generate_telemetry, - generate_telemetry_node, ) from .glyph_solver import Glyph, SolveResult, TextClickSolver, parse_instruction from .ocr_engines import DdddOcrEngine, EnsembleOcr, PpOcrEngine @@ -50,11 +50,11 @@ __all__ = [ "EnsembleOcr", "DdddOcrEngine", "PpOcrEngine", - # 遥测 / 加密 - "generate_collect", + # 遥测 / 加密 (纯算: 模板回填 + XTEA, Node 仅提取密钥) + "generate_collect_success", "generate_telemetry", - "generate_telemetry_node", "extract_eks", + "extract_xtea_key", "encrypt_string", # 业务适配 / 代理 "OneMsAdapter", diff --git a/tcaptcha/client.py b/tcaptcha/client.py index 2562019..198679a 100644 --- a/tcaptcha/client.py +++ b/tcaptcha/client.py @@ -106,25 +106,23 @@ class TencentCaptchaClient: min_posterior: float = 0.95, pow_timeout: int = 4000000, inter_attempt_delay: float = 0.25, - telemetry_mode: str = "probe", + telemetry_mode: str = "python_keyed", proxy: Optional[str] = None, ): """ - telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry): - "probe" (默认推荐) 深度 Proxy 语义沙箱: 零 CDP 特征泄露,原生时间戳/轨迹, - 自动适配各版本 tdc.js 动态字段序,彻底解决 errorCode=12 - "python_keyed" Node 只从本次构建的 tdc.js 提取密钥,纯 Python 组包 - "python" 纯 Python + 固定密钥 (适合雨云等不校验遥测的宽松 aid) - "node" 旧版简易 Node VM 沙箱 - "browser" 真实 Chrome + CDP 执行 - "auto" probe -> python_keyed -> node -> python 逐级回退 + telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry), 只走纯算: - 实测结论 (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。 + * "python_keyed" (默认): Node 只从本次构建的 tdc.js 里提取 XTEA 密钥字; + 明文组装 / 轨迹 / 时间戳 / 加密 / 编码全部在 Python 完成。 + * "python": 完全不调用 Node, 直接用内置 DEFAULT_KEY + (仅对不校验遥测的宽松 aid 有效)。 + + 关于 errorCode=12 (实测 2026-10-11, 见 har/1ms_dianxuan/ANALYSIS.md §7): + 1ms.run aid=195672344 在答案正确时返回 12, 且与遥测后端无关 —— + python / python_keyed / node / browser 四种后端、4 个不同省份出口 IP、 + 9 分钟静默后单发, **全部都是 12**。所以 12 不是遥测/环境/IP 问题, + 不要再走"补环境"或"换 IP"的路; 真正的变量 (登录态 / 真实验证码页面上下文) + 尚未被单变量实验区分。 """ self.aid = str(aid) self.timeout = timeout @@ -136,11 +134,11 @@ class TencentCaptchaClient: self.accept_kinds = tuple(accept_kinds) self.min_posterior = min_posterior self.pow_timeout = pow_timeout - # 抽题/投票之间的最小间隔, 用于规避腾讯 errorCode 12 (f_limit) + # 抽题/投票之间的最小间隔 (降低接口压力; 与 errorCode=12 无因果关系) self.inter_attempt_delay = inter_attempt_delay - # 遥测(collect/eks)生成方式: node = 在沙箱里跑真实 tdc.js (推荐); python = 纯算法还原 + # 遥测生成方式: 只走纯算 (见 tcaptcha.crypto.generate_telemetry) self.telemetry_mode = telemetry_mode - # 出口代理: 腾讯天御按 IP 做滚动风控, 触发 errorCode=12 时换 IP 即可恢复 + # 出口代理 (可选)。注意: 实测换出口 IP 并不能解决 errorCode=12 self.proxy = proxy # 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重) @@ -447,7 +445,7 @@ class TencentCaptchaClient: ans_str = json.dumps(ans_list, separators=(",", ":")) telemetry = generate_telemetry( tdc_code or "", points, aid=self.aid, user_agent=self.user_agent, - referrer=self.referer, start_time=round_start_time, mode=self.telemetry_mode, + referrer=self.referer, mode=self.telemetry_mode, ) logger.info("遥测密文来源=%s collect_len=%s eks_len=%s", telemetry.get("source"), len(telemetry["collect"]), @@ -492,8 +490,9 @@ class TencentCaptchaClient: err_code = str(v_json.get("errorCode")) if err_code == "12": - last_error = ("触发腾讯接口频控限制 (errorCode: 12, f_limit), " - "建议降低请求频率或更换出口 IP") + last_error = ("服务端返回 errorCode=12 (腾讯天御风控评估未通过)。" + "实测与遥测后端 / 出口 IP / 静默时长均无关 " + "(见 har/1ms_dianxuan/ANALYSIS.md §7), 不要再尝试换环境或换 IP") logger.error(last_error) break if err_code in ("50", "9"): @@ -547,11 +546,12 @@ def main(): 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("--telemetry", default="python_keyed", + choices=["python_keyed", "python"], + help="遥测方式: python_keyed(Node 仅提密钥) / python(纯 Python 固定密钥)") 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 重试") + help="代理提取 API (可选; 注意换 IP 并不能解决 errorCode=12)") args = parser.parse_args() proxy = args.proxy @@ -567,16 +567,8 @@ def main(): 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) + # 注意: errorCode=12 实测与出口 IP 无关 (ANALYSIS.md §7), 这里不再自动换 IP。 + # 如需指定出口 IP, 请在启动时用 --proxy / --proxy-api。 if result["success"]: print("\n" + "=" * 50) diff --git a/tcaptcha/core_js/tdc_sample.probe_report.json b/tcaptcha/core_js/tdc_sample.probe_report.json index 62ccce9..c881514 100644 --- a/tcaptcha/core_js/tdc_sample.probe_report.json +++ b/tcaptcha/core_js/tdc_sample.probe_report.json @@ -1,6 +1,8 @@ { "success": true, - "collectLen": 880, + "collect": "NLffnMqzHQHQG%2FB1u%2FAykja8EICFSz5fgmGqyGyn90fUsiXpAM8DQ2cHXw%2F5Sz%2Bn%2FWS5adVadgNbOoYwf7v3horr%2B%2F2plCT2kKL9XcDkvHmMR5kjh3i5QKb1gTD6TiiJ5a5w8S3cbq6YS1yafVJajorbhufO%2B5C92Eu%2FAAND7qikhIu1zv2RRV8GWjReo1LqejiwxqWG3aHwKX0J7WaZU%2FL%2FZRQMsGeb37hIIr2E7RD9Ps%2FKq3hspum7U6B4ky37ccyfqSpT7nsWN4%2B4fmz4%2BqKuJxiYtpgVhljDw9mlqjDa%2BijnAZ8xO%2BvuCN5UrnwoP0ahAzIlOluwHETr7YKr53bbDL%2F6pKMyVSpCbYtmjW39gAxJztfhYSwLecRPNz8aW7Eypmre84mBmGv9e2UFGppHfahEw66dM309YybtACeouX8BWadEWJpHfahEw66dWFwsjiJnJfJEdtXsP5WXBSWgXH5C%2FZZSpllTOR7mYiNeXjIdrtyRN3qLytp9m4NbKgLD5GSy5dM%2BaGCvyF52wwv3egZv4vUlrYgWfGcDiqRv9RfJquelQF1xWKumECMuNAeB5C345JI3icB4DAXETKkpBQHXTa5dwpRmqVM%2BBfOmMvXxa2kZAO67pyTcNl2fVj%2FpqHuR7gRe1bVzfUZdCkT51dyCorAFgVizGqIMPvdbOoYwf7v3horr%2B%2F2plCT2KYxYM06cQk6L1gLBovV4YzRuk6nAkmuWMTs4hkuHW%2FGMp3yPLfUYh4B8boAxmAQVLCGvK2ccYGqaR32oRMOunZpHfahEw66dhJXvREmTvGBHsVIhIibsVA%3D%3D", + "eks": "R6uVACHLwal4EuQjr409oqnT2fBP3br9rB5MzaNezC1/vrO6UBm+E3WS1OVnLJVBq+YZj4/P/Sih8ZNYygyEAZhnmPJKn2yZ8g2E8PyUdqbjmRWPqMrmLaCPdNo28KcHjOLt0NmivTDSd0AOT31Ew+nbFUDBAjQlrcH43Dg92siLiKpBye+A616brGV6/WnS1v8E/AOztwVNreKLzhfDNIQYO/coLrZUmNKFp9C87po=", + "collectLen": 876, "collectPreview": "NLffnMqzHQHQG%2FB1u%2FAykja8EICFSz5fgmGqyGyn90fUsiXpAM8DQ2cHXw%2F5Sz%2Bn%2FWS5ad", "eksPreview": "R6uVACHLwal4EuQjr409oqnT2fBP3br9rB5MzaNezC1/vrO6UBm+E3WS1OVnLJVBq+YZj4/P/Sih8ZNY", "accessCount": 95, diff --git a/tcaptcha/crypto.py b/tcaptcha/crypto.py index 938626f..bd378e1 100644 --- a/tcaptcha/crypto.py +++ b/tcaptcha/crypto.py @@ -1,15 +1,20 @@ """ -Pure Python implementation of Tencent TDC Chaos VM (JSVMP) Cryptographic -and Telemetry generation algorithm. +腾讯 TDC 遥测 (collect) 纯算法还原 —— 唯一路径: 明文模板回填 + 教科书 XTEA。 -Eliminates the Node.js sandbox dependency by replicating the 32-round custom -XTEA block cipher, human-like mouse movement trajectory generation, and -telemetry payload serialization. +设计原则 (AGENTS.md §2.3 / §2.4): + * **只走纯算**: collect 的明文组装 / 轨迹 / 时间戳 / XTEA 加密 / 编码全部在 Python 内完成, + 不再用伪造 DOM/WebGL/Canvas 的"补环境"沙箱去跑 tdc.js。 + (踩过的坑见 har/1ms_dianxuan/ANALYSIS.md §7: 换 python/node/browser 后端对 errorCode=12 + 毫无差异, 说明"环境"不是自变量, 补环境路线已废弃。) + * **Node 的唯一用途**: 从服务端下发的 tdc.js 里提取本次构建的 4 个 XTEA 密钥字 + (密钥由 VM 运行时算出, 不是字面量, 只能靠 opcode 插桩捕获)。Node 不参与明文生成。 + * 明文结构以「真实成功样本」(models/success_collect_template.b64) 为模板, + Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。 """ import base64 import json -import math +import logging import os import random import re @@ -23,67 +28,23 @@ 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_PROBE_PATH = Path(__file__).resolve().parent.parent / "tools" / "tdc_probe.js" -# TDC VM Cryptographic Constants -DELTA = 2654435769 # 0x9E3779B9 +logger = logging.getLogger("tcaptcha_crypto") + +# 样本构建的 XTEA 密钥字 (未加盐)。真实样本的等价密钥 (含两个轮加盐) 见 +# tests/test_xtea_key.py 的 SAMPLE_KEY = [1448563793, 1850559083, 1733513813, 1364613714]。 DEFAULT_KEY = [1448563793, 1847730015, 1733184827, 1364613714] DEFAULT_USER_AGENT = ( "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" + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36" ) -DEFAULT_RENDERER = "Apple M1" -DEFAULT_VENDOR = "Apple" -DEFAULT_CANVAS_FP = ( - "GgoAAAANSUhEUgAAAAEAAAABCAYAAAUhEUgAAAAEAAAABCAYAAAA" - "fFcSJAAAADhgGAWjR9awAAAABJRU5ErkJggg==" -) - - -def xtea_encrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]: - """ - Tencent custom 32-round XTEA block cipher for a 64-bit block (v0, v1). - Includes specific additive offsets (2829068 and 328986) depending on round index. - """ - v0 &= 0xFFFFFFFF - v1 &= 0xFFFFFFFF - total_sum = 0 - - for _ in range(32): - # Step 1: Update v0 - k_idx1 = total_sum & 3 - shift1 = (((v1 << 4) ^ (v1 >> 5)) + v1) & 0xFFFFFFFF - if k_idx1 == 1: - k_term1 = (total_sum + key[1] + 2829068) & 0xFFFFFFFF - elif k_idx1 == 2: - k_term1 = (total_sum + key[2] + 328986) & 0xFFFFFFFF - else: - k_term1 = (total_sum + key[k_idx1]) & 0xFFFFFFFF - v0 = (v0 + (shift1 ^ k_term1)) & 0xFFFFFFFF - - # Step 2: Update total_sum (standard XTEA delta increment) - total_sum = (total_sum + DELTA) & 0xFFFFFFFFFFFFFFFF - - # Step 3: Update v1 - k_idx2 = (total_sum >> 11) & 3 - shift2 = (((v0 << 4) ^ (v0 >> 5)) + v0) & 0xFFFFFFFF - if k_idx2 == 1: - k_term2 = (total_sum + key[1] + 2829068) & 0xFFFFFFFF - elif k_idx2 == 2: - k_term2 = (total_sum + key[2] + 328986) & 0xFFFFFFFF - else: - k_term2 = (total_sum + key[k_idx2]) & 0xFFFFFFFF - v1 = (v1 + (shift2 ^ k_term2)) & 0xFFFFFFFF - - return v0, v1 def encrypt_string(plaintext: str, key: List[int] = DEFAULT_KEY) -> str: - """ - Encrypts an ASCII/Latin1 plaintext string into Base64 using the custom XTEA cipher. - Input is padded with null bytes to an 8-byte boundary if needed. + """把 ASCII/Latin1 明文用教科书 XTEA 加密后转 Base64 (不足 8 字节用 \\x00 补齐)。 + + 本模块只有一份 xtea_encrypt_block (教科书实现), 此函数即使用它。 """ data = plaintext.encode("latin1") pad_len = (8 - (len(data) % 8)) % 8 @@ -98,157 +59,23 @@ def encrypt_string(plaintext: str, key: List[int] = DEFAULT_KEY) -> str: return base64.b64encode(out).decode("ascii") -def _bezier_interp( - p0: Tuple[float, float], - p1: Tuple[float, float], - steps: int, -) -> List[Tuple[int, int]]: - """Generates cubic Bézier interpolation curve points with natural tremor.""" - dx = p1[0] - p0[0] - dy = p1[1] - p0[1] - dist = math.hypot(dx, dy) or 1.0 - - perp_x = -dy / dist - perp_y = dx / dist - curvature = (random.random() - 0.5) * min(50.0, dist * 0.4) - - cp1 = ( - p0[0] + dx * 0.3 + perp_x * curvature, - p0[1] + dy * 0.3 + perp_y * curvature, - ) - cp2 = ( - p0[0] + dx * 0.7 - perp_x * curvature * 0.5, - p0[1] + dy * 0.7 - perp_y * curvature * 0.5, - ) - - pts = [] - for i in range(steps + 1): - t = i / float(steps) - ease_t = t * t * (3.0 - 2.0 * t) - inv_t = 1.0 - ease_t - - bx = ( - inv_t**3 * p0[0] - + 3 * inv_t**2 * ease_t * cp1[0] - + 3 * inv_t * ease_t**2 * cp2[0] - + ease_t**3 * p1[0] - ) - by = ( - inv_t**3 * p0[1] - + 3 * inv_t**2 * ease_t * cp1[1] - + 3 * inv_t * ease_t**2 * cp2[1] - + ease_t**3 * p1[1] - ) - tremor = 0.0 if i in (0, steps) else (random.random() - 0.5) * 1.5 - pts.append((int(round(bx + tremor)), int(round(by + tremor)))) - - return pts - - -def human_dt_ms() -> int: - """ - 真实 mousemove 的采样间隔 (毫秒)。 - - 依据: 成功签到样本 (har/签到成功-1.har) 解密出的轨迹 dt 分布为 - 12~601ms, 均值 63.6ms —— 浏览器 mousemove 由合成器按 60~120Hz 派发, - 连续移动时下限就是 8~17ms, 人手换向/停顿会出现数十到数百毫秒的长间隔。 - - 之前这里用 random.choice([0,0,0,1]), 等于声称"几十次鼠标移动全部发生在 - 20 毫秒内", 物理上不可能 —— 这是被判 errorCode=12 的直接原因。 - """ - r = random.random() - if r < 0.68: - return random.randint(13, 19) # 连续采样 (60~75Hz) - if r < 0.90: - return random.randint(20, 45) # 轻微停顿 - if r < 0.98: - return random.randint(46, 120) # 明显停顿 (换向/瞄准) - return random.randint(121, 400) # 长停顿 - - -def generate_trajectory_payload( - click_points: List[Tuple[int, int]], - target_len: int = 600, -) -> str: - """ - Generates realistic human mouse movement trajectory and serializes it - into the TDC relative-displacement JSON format: - [[x0, y0, 2], [dx1, dy1, dt1], [dx2, dy2, dt2], ...] - - The string is padded with trailing spaces to target_len (multiple of 8). - """ - # Scale from original 672x480 coordinate space to 360x360 captcha iframe display space - scale = 320.0 / 672.0 - mapped_targets = [ - (int(round(20 + p[0] * scale)), int(round(60 + p[1] * scale))) - for p in click_points - ] - - # Starting position near entry area - current_pt = ( - int(round(150 + (random.random() - 0.5) * 60)), - int(round(320 + (random.random() - 0.5) * 20)), - ) - - events: List[List[int]] = [] - # 首个三元组的第三位是"进入验证码区域"的初始停顿 (成功样本实测 601ms) - events.append([current_pt[0], current_pt[1], random.randint(280, 820)]) - - last_x, last_y = current_pt[0], current_pt[1] - - for target in mapped_targets: - # 真实样本 3 次点击只记到 18 个三元组 -> 每次点击 5~8 个采样点 - steps = random.randint(5, 8) - curve = _bezier_interp((last_x, last_y), target, steps) - - for pt in curve[1:]: - dx = pt[0] - last_x - dy = pt[1] - last_y - events.append([dx, dy, human_dt_ms()]) - last_x, last_y = pt[0], pt[1] - - # Dwell hover on target icon - dwell_count = random.randint(1, 2) - for _ in range(dwell_count): - dx = random.randint(-1, 1) - dy = random.randint(-1, 1) - events.append([dx, dy, human_dt_ms()]) - last_x += dx - last_y += dy - - import json - raw_json = json.dumps(events, separators=(",", ":")) - - # 填充到 24 字节对齐 —— 24 是 8 (XTEA 分组) 与 3 (Base64 分组) 的最小公倍数, - # 这样每个分块单独 base64 后拼接不会产生 **块内 '=' 填充**。 - # 实测真实 tdc.js 的 collect 整体只有末尾一个 '==' (见 点选.har), - # 块内出现 '=' 会导致服务端解析/解密失败。 - desired_len = max(len(raw_json), target_len) - pad_needed = (24 - (desired_len % 24)) % 24 - total_len = desired_len + pad_needed - - return raw_json.ljust(total_len, " ") - - def extract_eks(tdc_js_code: str) -> str: - """Extracts session public key (`eks`) from TDC.js script header.""" - match = re.search(r"window\.KQnlRHY\w+\s*=\s*'([^']+)'", tdc_js_code) - if match: - return match.group(1) - # Fallback to any base64 string assignment - match_fallback = re.search(r"window\.\w+\s*=\s*'([A-Za-z0-9+/=]{100,})'", tdc_js_code) - if match_fallback: - return match_fallback.group(1) - return "" + """从 tdc.js 中提取会话密钥信封 eks (一个较长的 Base64 字符串)。 + + 注意: tdc.js 每次构建都会重命名全局变量 (见 AGENTS.md 附录 A#6), 因此**不能**写死变量名; + 这里匹配任意 `window.<随机名> = ''`, 并取长度最长的候选。 + """ + cands = re.findall(r"window\.[A-Za-z_$][\w$]*\s*=\s*'([A-Za-z0-9+/=]{100,})'", tdc_js_code) + if not cands: + return "" + return max(cands, key=len) # --------------------------------------------------------------------------- # -# 标准 XTEA (32 轮, delta=0x9E3779B9, sum 取 mod 2^32) —— 等价于原 crypto.py 的 -# 自定义形态 + 密钥折叠: K' = [k0, k1 + salt1, k2 + salt2, k3] -# 由此「算法」就是教科书 XTEA, 唯一需要从每份 tdc.js 里拿到的是那 4 个密钥字。 +# 标准 (教科书) XTEA: 32 轮, delta=0x9E3779B9, sum 取 mod 2^32。 +# 这是本模块**唯一**的 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]: @@ -399,69 +226,10 @@ def recover_payload_template(tdc_js_code: str, node_bin: str = "node"): # --------------------------------------------------------------------------- # -# 环境指纹的"真实取值" (纯 Python 填值, 与加密无关) -# -# 取值来源: /Users/yml/Desktop/抓包/毫秒/签到成功-1.har 里那次 errorCode=0 的 collect, -# 用该会话自己的 tdc.js 提取密钥解密后读出的真实字段。 -# 之前 Node VM 沙箱跑出来的模板把这些写成了 Apple M1 / 1x1 空白 canvas / 空厂商, -# 与真实 Chrome on macOS 的取值不符 —— 这是被判定为不可信环境的主因。 -# --------------------------------------------------------------------------- # -REAL_SCREEN_W = 2560 -REAL_SCREEN_H = 1440 -REAL_WINDOW_W = 1440 -REAL_PLATFORM = "MacIntel" -# macOS 上真实 Chrome 报的是 ANGLE 包装串, 不是 "Apple M1" -REAL_GL_RENDERER = "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)" -REAL_GL_VENDOR = "Google Inc. (Apple)" -# 真实 canvas 指纹 (成功样本原值, 368 bytes PNG) -REAL_CANVAS_FP = ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3" - "TG0qE2CogAAAABJRU5ErkJggg==") -REAL_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") - -_CANVAS_RE = re.compile(r'"[A-Za-z0-9+/]{40,}={0,2}"') -_RENDERER_RE = re.compile(r'"(?:ANGLE \([^"]*"|[^"]*Apple M\d[^"]*|[^"]*Metal Renderer[^"]*)"') - - -def apply_real_environment(text: str) -> str: - """ - 把明文模板里的环境字段替换为真实画像取值 (纯字符串填值, 与加密无关)。 - - 处理项: - * WebGL 渲染器 -> REAL_GL_RENDERER (ANGLE 串) - * WebGL 厂商 -> REAL_GL_VENDOR - * canvas 指纹 -> REAL_CANVAS_FP - * 屏幕/窗口尺寸 -> 2560 / 1440 - * UA -> REAL_UA - """ - out = text - # 1) 渲染器: 含 ANGLE / Metal / Apple M 的字符串 - out = _RENDERER_RE.sub('"%s"' % REAL_GL_RENDERER, out, count=1) - # 2) 厂商: 只有 Apple / Google Inc. (Apple) 这类短串 - out = re.sub(r'"(?:Apple|Google Inc\. \(Apple\)|Google Inc\.)"', - '"%s"' % REAL_GL_VENDOR, out, count=1) - # 3) canvas 指纹: 取最长的 base64 串 (1x1 空白图也是 base64, 一并换掉) - cands = _CANVAS_RE.findall(out) - if cands: - longest = max(cands, key=len) - out = out.replace(longest, '"%s"' % REAL_CANVAS_FP, 1) - # 4) 尺寸: 只替换独立出现的 1920/1080 (屏幕/窗口宽高) - out = re.sub(r'(? str: - """成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。""" + """成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。 + + raw_base64=True 返回未 URL 编码的标准 Base64 (由 httpx 表单编码时单层编码, + 与真实抓包一致); False 则预先做一次 percent-encoding。 + """ if not _SUCCESS_TPL_PATH.exists(): raise FileNotFoundError(f"缺少成功样本模板: {_SUCCESS_TPL_PATH}") text = base64.b64decode(_SUCCESS_TPL_PATH.read_text(encoding="ascii")).decode("latin1") @@ -519,8 +292,10 @@ def generate_collect_success( text = text.replace("699129805", str(random.randint(100000000, 999999999)), 1) data = text.encode("latin1") data += b"\x00" * ((8 - len(data) % 8) % 8) # 成功样本用 \x00 填充 - return urllib.parse.quote_from_bytes( - base64.b64encode(xtea_encrypt_bytes(data, key)), safe="") + b64 = base64.b64encode(xtea_encrypt_bytes(data, key)).decode("ascii") + if raw_base64: + return b64 + return urllib.parse.quote(b64, safe="") def generate_collect_from_template( @@ -529,26 +304,22 @@ def generate_collect_from_template( key: List[int], refresh_time: bool = True, ) -> str: - """ - 纯 Python 回填出 collect: - 1. 用 Python 生成的鼠标轨迹替换模板里的轨迹数组 - 2. 刷新模板里的秒级时间戳 - 3. 空格补齐到 8 字节倍数 -> XTEA 加密 -> base64 -> TDC URL 编码 - 加密与组包 100% 在 Python, Node 只提供密钥与一次性模板。 + """用标定得到的明文模板 + Python 生成的轨迹回填出 collect (纯算)。 + + 1. 用 Python 轨迹替换模板里的轨迹数组 + 2. 刷新模板里的秒级时间戳 + 3. 空格补齐到 8 字节倍数 -> XTEA 加密 -> base64 -> URL 编码 """ text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template - text = apply_real_environment(text) - - traj = generate_trajectory_payload(click_points, target_len=0).rstrip() + traj = _success_style_trajectory(click_points) 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) + text = _EPOCH_RE.sub(str(int(time.time())), text) data = text.encode("latin1") data += b" " * ((8 - len(data) % 8) % 8) # 与真实 tdc.js 一致: 空格补齐 @@ -556,395 +327,62 @@ def generate_collect_from_template( 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, - node_bin: str = "node", -) -> Tuple[str, List[int]]: - """ - **纯 Python 出包入口**: Node 只负责「跑出密钥 + 一次性标定明文模板」, - 轨迹生成 / 组包 / XTEA 加密 / 编码 全部由 Python 完成。 - - 两次 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) - - cd_pre = CD_PRE_RAW.ljust(48, " ") - - traj_payload = generate_trajectory_payload(click_points, target_len=600) - - cd_post_inner = ( - f',"",' - f'"undefined?rand=1519713624347",' - f"1," - f'"{canvas_fp}",' - f"{now_sec}," - f"{rand_salt}," - f"{now_sec}," - f'"",' - f"0," - f'"{user_agent}",' - f'"top",' - f'"MacIntel",' - f'"98k",' - f"40," - f'"",' - f'"{renderer}",' - f"0," - f'"{vendor}",' - f"{now_sec}," - f'"+08",' - f'["zh-CN","zh","en"],' - f"1422008362," - f"2," - f"3," - f'"https://turing.captcha.qcloud.com/cap_union_new_show?aid={aid}",' - f"0," - f"24," - f"22," - f"1920," - f"1," - f"[360,360]," - f"1080]," - ) - cd_post = cd_post_inner.ljust(504, " ") - - sd = '"sd":{"od":"C"}}' - return [s.encode("latin1") for s in (cd_pre, traj_payload, cd_post, sd)] - - -def _assemble_collect(segments: List[bytes], key: Optional[List[int]] = None) -> str: - """4 段分别加密 + base64 + 拼接 + TDC URL 编码。 - - 每段长度都是 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_probe( - 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/", - start_time: Optional[float] = None, - node_bin: str = "node", - timeout: float = 15.0, -) -> Dict[str, str]: - """ - 通过 tools/tdc_probe.js 深度 Proxy 探针在真实沙箱中执行服务端的真实 tdc.js。 - - 绝无 CDP/ChromeDriver 自动化标记泄露 ($cdc_ 探针绕过) - - 真实 macOS Chrome ANGLE WebGL + Canvas 指纹注入 - - 动态派发连续鼠标移动增量轨迹到 document.onmousemove - - 原生计算当前会话密钥、时间戳与校验和,彻底避免 errorCode 12。 - """ - if not TDC_PROBE_PATH.exists(): - raise FileNotFoundError(f"缺少 tdc_probe.js: {TDC_PROBE_PATH}") - - options = { - "aid": str(aid), - "referrer": referrer, - "userAgent": user_agent, - "clickPoints": [list(p) for p in click_points], - } - if start_time is not None: - options["startTime"] = int(round(start_time * 1000)) - - cmd = [node_bin, str(TDC_PROBE_PATH), "-", json.dumps(options)] - proc = subprocess.run( - cmd, - input=tdc_js_code, - capture_output=True, - text=True, - timeout=timeout, - env={**os.environ, "NODE_OPTIONS": ""}, - ) - if proc.returncode != 0: - raise RuntimeError(f"tdc_probe 执行失败 (rc={proc.returncode}): {proc.stderr[:400]}") - - for line in proc.stdout.splitlines(): - if line.startswith("{") and "collect" in line: - payload = json.loads(line) - if not payload.get("success"): - raise RuntimeError(f"tdc_probe 内部报错: {payload.get('error')}") - collect = payload.get("collect") or "" - return { - "collect": collect, - "tlg": str(payload.get("tlg") or len(collect)), - "eks": payload.get("eks") or "", - "source": "probe", - } - - raise RuntimeError(f"tdc_probe 无有效输出: {proc.stdout[:300]}") - - 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/", - start_time: Optional[float] = None, - mode: str = "auto", + mode: str = "python_keyed", + node_bin: str = "node", ) -> Dict[str, str]: - """ - 统一遥测生成入口。 + """统一遥测生成入口 —— **只走纯算**, 不再有任何伪造环境的执行后端。 - mode="probe" : 深度 Proxy 语义沙箱 (推荐, 零 CDP 泄露, 原生会话级时间戳/校验和, ~80ms) - mode="python" : 纯 Python 算法还原 (内置固定密钥; 无 Node 依赖, 适合宽松 aid) - mode="python_keyed": Node 仅提取当前构建的 XTEA 密钥, 模板由 Python 回填 - mode="node" : 原旧版 Node VM 沙箱 - mode="browser" : 真实 Chrome + CDP 执行 (~50s) - mode="auto" : probe -> python_keyed -> node -> python 逐级回退 + mode="python_keyed" (默认): Node 只做一件事 —— 从当前 tdc.js 提取本次构建的 + XTEA 密钥字 (密钥由 VM 运行时算出, 不是字面量); 明文组装 / 轨迹 / 时间戳 / + 加密 / 编码全部在 Python 内完成。Node 不可用时自动退回 DEFAULT_KEY。 + mode="python": 完全不调用 Node, 直接用内置 DEFAULT_KEY。 + + 返回 {"collect", "tlg", "eks", "source"[, "key"]}。 + + 关于 errorCode=12: 实测与遥测后端无关 (python / python_keyed / node / browser 全部 12, + 换 IP / 加长静默同样无效), 见 har/1ms_dianxuan/ANALYSIS.md §7。**不要再用"换环境"解释它**, + 把精力放在账本 §7.5 的单变量实验 (登录态/真页面上下文) 上。 """ - chain = { - "probe": ["probe"], - "python": ["python"], - "python_keyed": ["python_keyed"], - "node": ["node"], - "browser": ["browser"], - "auto": ["probe", "python_keyed", "node", "python"], - "auto_keyed": ["probe", "python_keyed", "node", "python"], - }.get(mode, ["probe"]) - errors = [] - for kind in chain: + if mode not in ("python_keyed", "python"): + raise ValueError(f"未知 telemetry mode: {mode!r} (只支持 'python_keyed' / 'python')") + + key: Optional[List[int]] = None + source = "python" + if mode == "python_keyed": try: - if kind == "probe": - return generate_telemetry_probe( - tdc_js_code, click_points, aid=aid, - user_agent=user_agent, referrer=referrer, - start_time=start_time, - ) - 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": - key = extract_xtea_key(tdc_js_code) - collect = generate_collect_success(click_points, key) - 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 not in ("auto", "auto_keyed"): - raise - raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors)) + key = extract_xtea_key(tdc_js_code, node_bin=node_bin) + source = "python_keyed" + except Exception as exc: # Node 缺失/插桩失败 -> 退回固定密钥 + logger.warning("XTEA 密钥提取失败, 退回内置 DEFAULT_KEY: %s", exc) + if key is None: + key = list(DEFAULT_KEY) + + collect = generate_collect_success(click_points, key, entry_url=referrer, raw_base64=True) + return { + "collect": collect, + "tlg": str(len(collect)), + "eks": extract_eks(tdc_js_code), + "source": source, + "key": key, + } + + +def selfcheck() -> None: + """纯算法自检: XTEA 往返 + encrypt_string 一致性。""" + for chunk in (b"hello!!", bytes(range(32)), b"x" * 24): + dec = xtea_decrypt_bytes(xtea_encrypt_bytes(chunk, DEFAULT_KEY), DEFAULT_KEY) + assert dec[:len(chunk)] == chunk, f"XTEA 往返失败: {chunk!r}" + ref = base64.b64encode( + xtea_encrypt_bytes(b"abc\x00\x00\x00\x00\x00", DEFAULT_KEY)).decode("ascii") + assert encrypt_string("abc") == ref, "encrypt_string 与 xtea_encrypt_bytes 不一致" if __name__ == "__main__": - # Self-test unit verification - test4 = '"sd":{"od":"C"}}' - exp4 = "nshM2N4iPvX6JRNXi68v5w==" - act4 = encrypt_string(test4) - assert act4 == exp4, f"XTEA unit test failed: {act4} != {exp4}" - - test2 = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"", ' - exp2 = "9qJOPk+13StWMa2FQroCP6yba6nGw0BmJaarCCtGtLi0Wfg7WQyVXwMoFAjWcXAj" - act2 = encrypt_string(test2) - assert act2 == exp2, f"XTEA unit test failed: {act2} != {exp2}" - - sample_pts = [(626, 253), (120, 120), (438, 33)] - sample_collect = generate_collect(sample_pts) - print(f"XTEA Unit Tests: PASSED") - print(f"Sample collect length: {len(sample_collect)}") - print(f"Sample collect prefix: {sample_collect[:80]}...") + selfcheck() + print("crypto 自检通过: XTEA 往返 + encrypt_string 一致性") diff --git a/tcaptcha/glyph_solver.py b/tcaptcha/glyph_solver.py index b2cd97c..bf7d726 100644 --- a/tcaptcha/glyph_solver.py +++ b/tcaptcha/glyph_solver.py @@ -121,13 +121,19 @@ class SolveResult: @property def text_match_ok(self) -> bool: - """检查指派的每个字形直接 OCR 文本是否与目标汉字一致 (杜绝图字不符或混淆干扰字盲猜提交)""" - if not self.assignments or len(self.assignments) != len(self.targets): + """**自洽性校验**: 每个被指派的字形, 在集成得分矩阵里最优的目标字, 必须正好是它被指派的目标。 + + 注意: 不能用单引擎的 `ocr_text` 做判断 —— 那只是给肉眼看的弱信号, 实测会把 + "簿"读成"垂"、"笆"读成"芭"、"筹"读成"努"; 而**用于指派的那份集成矩阵**的行 argmax + 始终与指派一致。因此这里以矩阵为唯一依据 (既可靠又不会误杀正确解)。 + """ + if self.score_matrix is None or not self.assignments: + return False + if len(self.assignments) != len(self.targets): return False for ti, g in enumerate(self.assignments): - target_char = self.targets[ti] - ocr_char = self.glyphs[g].ocr_text - if ocr_char and target_char not in ocr_char: + row = self.score_matrix[g] + if row.size == 0 or int(row.argmax()) != ti: return False return True diff --git a/tcaptcha/one_ms_adapter.py b/tcaptcha/one_ms_adapter.py index b600857..1d95c47 100644 --- a/tcaptcha/one_ms_adapter.py +++ b/tcaptcha/one_ms_adapter.py @@ -55,10 +55,10 @@ class OneMsAdapter: 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 = "probe"): + proxy_api: Optional[str] = None, telemetry_mode: str = "python_keyed"): self.user_agent = user_agent self.timeout = timeout - # 腾讯天御按出口 IP 做滚动风控 (errorCode=12); 给了 proxy_api 就能自动换 IP 重试 + # 出口代理 (可选)。注意: 实测换出口 IP 并不能解决 errorCode=12 (见 ANALYSIS.md §7) self.proxy = proxy self.proxy_api = proxy_api self.telemetry_mode = telemetry_mode @@ -101,8 +101,9 @@ class OneMsAdapter: """ 取得可提交给毫秒业务接口的 (ticket, randstr)。 - 若配置了 `proxy_api`, 在遇到 `errorCode=12` (f_limit, IP 级滚动风控) 时会 - 自动换一个出口 IP 重试, 最多 `max_proxy_rotations` 次。 + 仅在**网络/代理异常**时换出口 IP 重试 (最多 max_proxy_rotations 次)。 + 业务层失败 (errorCode 12 / 50) **不换 IP** —— 实测换 IP 对 errorCode=12 + 无效 (见 har/1ms_dianxuan/ANALYSIS.md §7)。 """ params = self.fetch_captcha_params(scene=scene) logger.info("毫秒验证码参数: provider=%s appId=%s enabled=%s sessionUuid=%s", @@ -130,11 +131,13 @@ class OneMsAdapter: proxy=proxy, telemetry_mode=self.telemetry_mode, ) + connectivity_error = False try: result = client.solve(max_retries=max_retries, max_refreshes=8) except Exception as e: logger.error("客户端解题发生异常 (可能代理失效或超时): %s", e) result = {"success": False, "error": str(e)} + connectivity_error = True finally: client.close() @@ -142,14 +145,15 @@ class OneMsAdapter: if result.get("success"): return result.get("ticket") or None, result.get("randstr") or None, result - if not self.proxy_api: + # 业务层失败 (坐标错 / 风控 12) 与出口 IP 无关: 不换 IP, 直接返回 + if not connectivity_error or not self.proxy_api: break from tcaptcha.proxy import fetch_proxy new_proxy = fetch_proxy(self.proxy_api) if new_proxy: proxy = new_proxy - logger.warning("换出口 IP 继续尝试 (%d/%d): %s", + logger.warning("代理不可用, 换出口 IP 重试 (%d/%d): %s", rotation + 1, max_proxy_rotations, proxy) else: logger.warning("未能从代理 API 取到新代理,终止重试") diff --git a/tcaptcha/selftest.py b/tcaptcha/selftest.py index b9b4c3c..d9d26b3 100644 --- a/tcaptcha/selftest.py +++ b/tcaptcha/selftest.py @@ -12,6 +12,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SUITES = [ + "tcaptcha.tests.test_crypto_core", "tcaptcha.tests.test_glyph_solver", "tcaptcha.tests.test_xtea_key", ] diff --git a/tcaptcha/tests/test_crypto_core.py b/tcaptcha/tests/test_crypto_core.py new file mode 100644 index 0000000..4b08735 --- /dev/null +++ b/tcaptcha/tests/test_crypto_core.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" +纯算法核心自检 (AGENTS.md §5.9) + +固化: + 1. 教科书 XTEA 加解密往返 (含 8 字节对齐补齐) + 2. encrypt_string 与 xtea_encrypt_bytes 结果一致 (防止两份 block 实现再次漂移) + +运行: + .venv/bin/python -m tcaptcha.tests.test_crypto_core +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tcaptcha.crypto import selfcheck # noqa: E402 + + +def main() -> int: + selfcheck() + print("✅ crypto 纯算法自检通过 (XTEA 往返 + encrypt_string 一致性)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())