diff --git a/.gitignore b/.gitignore index f915d70..8b0919f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,11 @@ yolov8n.pt .env.* # 抓包文件与临时压缩包 -har/ +# 注意: har/ 下同时存放「分析文档」(证据资产, 需入库) 与「抓包/图片」(大文件, 不入库), +# 因此只忽略非 .md 产物 —— 文档按 AGENTS.md §1.3 随代码一起版本化。 +har/** +!har/**/ +!har/**/*.md *.har *.zip diff --git a/har/1ms_dianxuan/ANALYSIS.md b/har/1ms_dianxuan/ANALYSIS.md new file mode 100644 index 0000000..320d3a2 --- /dev/null +++ b/har/1ms_dianxuan/ANALYSIS.md @@ -0,0 +1,375 @@ +# 毫秒 1ms.run 腾讯天御「文字点选」协议分析 + +分析样本:`/Users/yml/Desktop/抓包/毫秒/点选.har`(20 条记录,WebInspector 导出) +分析时间:2026-10-11 +目标题型:`subcapclass = 2404`(腾讯天御 **文字点选**,提示语为「请依次点击:甲 乙 丙」) + +--- + +## 一、请求链 + +抓包中与验证码相关的记录共 12 条,可还原为 **1 个业务侧引导请求 + 11 个腾讯验证码请求**: + +``` +① POST https://1ms.run/api/v1/captcha/params ← 业务侧:取验证码配置 + body: {"scene":"checkin","identity_context":"emctx1.."} + resp: {"code":0,"data":{"enabled":true,"provider":"tencent", + "params":{"captchaAppId":195672344}, + "sessionUuid":"01a12a04-e5e7-726b-b00c-2f54b2e1c167"}} + +② GET https://turing.captcha.qcloud.com/cap_union_prehandle ← 建立会话 + 首题 + ?aid=195672344&protocol=https&accver=1&showtype=popup&ua= + &noheader=1&fb=1&aged=0&enableAged=0&enableDarkMode=0&grayscale=1 + &clientype=2&cap_cd=&uid=&lang=zh-cn + &entry_url=https%3A%2F%2F1ms.run%2Fconsole%2Fpersonal%2Fbenefits%2Fcheck-in + &elder_captcha=0&js=%2Ftcaptcha-frame.ba387dd1.js&login_appid= + &wb=1&tkid=407017912&subsid=1&callback=_aq_260580&sess= + resp: _aq_260580({... "capclass":"1","subcapclass":"2404","sess":"s0Rr8i01...", + "sid":"1793111442254233600", + "data":{"comm_captcha_cfg":{"tdc_path":"/tdc.js?app_data=&t=", + "pow_cfg":{"prefix":"a79f541e7c1ca571#", + "md5":"0264f7a4329e2b6f9f9076c53294c277"}}, + "dyn_show_info":{"instruction":"请依次点击:虫 呈 般 ", + "bg_elem_cfg":{"size_2d":[672,480], + "click_cfg":{"mark_style":"inc_number", + "data_type":["DynAnswerType_POS"]}, + "img_url":"/cap_union_new_getcapbysig?img_index=1&image=...&sess=..."}}}}) + +③ GET .../tdc.js?app_data=1793111442254233600&t=1309148276 ← 会话专属混淆脚本 +④ GET .../cap_union_new_getcapbysig?img_index=1&image=...&sess=... ← 672x480 背景图 + + ── 第 1 次作答 ── +⑤ POST .../cap_union_new_verify (ans + collect + eks + sess + pow_*) → errorCode 50 +⑥ POST .../cap_union_new_getsig (sess + tkid) → 新 sess + 新题 +⑦ GET .../cap_union_new_getcapbysig?... 第 2 张图 + ── 第 2 次作答 ── ⑧ verify → errorCode 9 +⑨ getsig ⑩ getcapbysig 第 3 张图 + ── 第 3 次作答 ── ⑪ verify → errorCode 50 +⑫ getsig ⑬ getcapbysig 第 4 张图(抓包结束时未提交) +``` + +> 抓包中的 `y.clarity.ms/collect`(微软 Clarity 埋点)、`collector.entmesh.mliev.com` +> 与验证码无关,属于站点自身的监控上报,本文不纳入实现。 + +--- + +## 二、会话状态机(关键!错一个就永远失败) + +`sess` 在三处出现,且**互不相同**,必须严格区分: + +| 位置 | 说明 | +| --- | --- | +| `prehandle` 响应体的 `sess` | **会话主 sess**,首次 `verify` 必须用它 | +| `dyn_show_info.bg_elem_cfg.img_url` 里的 `sess=` | 只作用于**取图**,与 verify 用的 sess 不是同一个 | +| `verify` 响应的 `sess` | 上一轮失败后的**新会话 sess** | +| `getsig` 请求体 `sess` | 用的是上一步 `verify` 响应里的新 sess(不是会话主 sess) | +| `getsig` 响应体的 `sess` | 下一轮的会话 sess,下一轮 verify 用它 | + +实测(`点选.har`): + +``` +prehandle.sess = s0Rr8i01...(482) ← verify#1 用它 +verify#1 resp.sess = s0TMSpmIpMCH3...(482) ← getsig#1 用它 +getsig#1 resp.sess = s0VSknLJwQHTwRC...(494) ← verify#2 用它,同时也是该轮 img_url 的 sess +verify#2 resp.sess = s05b9OQJg2z6nx...(494) ← getsig#2 用它 +getsig#2 resp.sess = s0nrIxkexDWO0Rgd...(482) ← verify#3 用它 +``` + +**题型在会话内固定**:对同一会话连续 `getsig`,返回的 `subcapclass` 不变(实测滑块会话 +连发 8 次 getsig 全是滑块)。想换题型只能**重新 `prehandle` 开新会话**。 + +--- + +## 三、参数分类 + +### 3.1 固定/常量参数 + +| 参数 | 值 | 说明 | +| --- | --- | --- | +| `protocol` | `https` | 固定 | +| `accver` | `1` | 协议版本 | +| `showtype` | `popup` | 弹窗模式 | +| `noheader` / `fb` | `1` / `1` | 固定 | +| `aged` / `enableAged` / `enableDarkMode` | `0` | 固定 | +| `grayscale` | `1` | 灰度模式开启 | +| `clientype` | `2` | PC 端 | +| `lang` | `zh-cn` | 语言 | +| `js` | `/tcaptcha-frame.ba387dd1.js` | 验证码 frame 脚本名 | +| `wb` | `1` | 1ms.run 侧取值(雨云为 2) | +| `subsid` | `1` | 抓包中为 1(雨云实现里随机 1~5,实测不影响) | + +### 3.2 每会话动态参数 + +| 参数 | 生成规律 | +| --- | --- | +| `aid` | 业务侧下发:`1ms.run/api/v1/captcha/params` → `captchaAppId = 195672344` | +| `tkid` | 0~9 数字串,9 位;**同一会话内所有请求复用同一个值**(抓包:407017912) | +| `callback` | `_aq_` + 6 位随机数;仅 `prehandle` 使用(JSONP) | +| `entry_url` | 触发页 URL(1ms 为签到页),URL-encoded | +| `ua` | 请求 UA 的 base64(抓包解码 = `Chrome/155.0.0.0` on macOS) | +| `sid` / `app_data` | `prehandle` 响应返回,仅用于拼 `tdc_path` | +| `sess` | `prehandle` / `getsig` / `verify` 三级传递,见第二节 | +| `image` | 服务端下发在 `img_url` 中的图片令牌,逐题变化 | + +### 3.3 动态参数变化规律(多题对比) + +| 题号 | `image` | `instruction` | `ans` | `pow_answer` | `pow_calc_time` | +| --- | --- | --- | --- | --- | --- | +| 1 | `026409000000001300000015aa3f011427ce` | 请依次点击:虫 呈 般 | `162,249` / `104,37` / `561,156` | `a79f541e7c1ca571#16447` | 46 | +| 2 | `026409000090e146000000159f1391142763` | 请依次点击:佰 衬 稗 | `426,47` / `332,156` / `517,332` | 同题 1 | 46 | +| 3 | `0264090000857147000000159f13911427e7` | 请依次点击:簿 蚕 沧 | `498,136` / `45,118` / `252,146` | 同题 1 | 46 | + +结论: +* **`pow_answer` / `pow_calc_time` 在整个会话内恒定** —— 它来自 `prehandle` 的 `pow_cfg`, + 是一次性的工作量证明,而不是每轮刷新。 +* **`collect` / `eks` 在整个会话内恒定**(三轮 verify 的 `collect` 长度都是 1784 且字节一致) + —— 它们由 `tdc.js` 在页面初始化时一次性生成,之后每次 verify 复用,**不随后续点击坐标变化**。 +* `ans` 逐题变化,且坐标随图片令牌变化,**不存在可复用规律**。 + +--- + +## 四、签名 / 加密参数分析 + +### 4.1 `pow_answer` —— MD5 工作量证明(已完全还原) + +服务端给 `pow_cfg = {"prefix": "a79f541e7c1ca571#", "md5": "0264f7a4329e2b6f9f9076c53294c277"}`。 + +反推验证: + +``` +md5("a79f541e7c1ca571#16447") = 0264f7a4329e2b6f9f9076c53294c277 ✅ 命中 +``` + +即 `pow_answer = prefix + str(i)`,其中 `i` 从 0 递增直到 MD5 命中;`pow_calc_time` +是浏览器自己测量的耗时(抓包 46ms,本机 Python 约 120~190ms)。 + +### 4.2 `collect` —— 自定义 XTEA 分组加密 + 行为轨迹(结构与编码已还原) + +`collect` 是 URL-encoded 的 Base64,**不是** 4 段分别 base64 后拼接的产物: + +| 来源 | Base64 长度 | 解码字节数 | 内部 `=` | +| --- | --- | --- | --- | +| 点选.har 真实浏览器 | 1784 | 1336 = 48 + **768** + 504 + 16 | 仅末位 `==` | +| 本仓库 Node 沙箱执行真实 tdc.js | 1654 | 1168 = 48 + **600** + 504 + 16 | 仅末位 `==` | +| 本仓库纯 Python 还原 | 1712~1822 | 随轨迹长度浮动 | **块内出现 `=`** ❌ | + +明文结构(按 8 字节分组、32 轮自定义 XTEA 加密,密钥 4 个 32bit word): + +``` +[0 .. 48) cd 前缀 {"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"", (空格补齐) +[48 .. 816) 鼠标轨迹 [[x0,y0,2],[dx1,dy1,dt1],...] 长度必须是 24 的倍数 +[816 .. 1320) cd 后缀 画布指纹/UA/平台/分辨率/时区/语言/… +[1320 .. 1336) sd 结尾 "sd":{"od":"C"}} +``` + +**已修掉的实现缺陷**:原 `generate_trajectory_payload()` 把轨迹补齐到 8 的倍数, +导致分块 base64 后出现**块内 `=` 填充**(真实样本只有末尾一个 `==`)。已改为补齐到 +**24(= lcm(8,3))的倍数**,与真实样本结构一致。 + +**已定论(2026-10-11 实测修正)**:`DEFAULT_KEY = [1448563793, 1847730015, 1733184827, 1364613714]` +以及两个轮加盐 `2829068 / 328986`,是**当时逆向的那一份 tdc.js 构建**里的密码学参数。 +实测三份脚本(每份都先跑真实 tdc.js 拿到 collect,再尝试解密): + +| tdc.js 来源 | 轮加盐(脚本内联值) | 用 DEFAULT_KEY + 该加盐解密 | +| --- | --- | --- | +| `tcaptcha/tdc_sample.js`(雨云样本) | `2829068 / 328986` | ✅ 明文 `{"cd":[[],"1920-1080..."` | +| 雨云 aid=2039519451 **当天实时** | `394753 / 657930` | ❌ 随机字节 | +| 1ms aid=195672344 当天实时 | `197386 / 1513230` | ❌ 随机字节 | + +结论:**XTEA 的 4 个密钥字与 2 个轮加盐都是"每次构建随机"的,且不是可 grep 的字面量** +(它们藏在 JSVMP 的操作数流里,DELTA `2654435769` 与 `DELTA*32 = 84941944608` 是唯一可见的常量)。 +所以「纯 Python 硬编码密钥」本质上不是算法还原,而是**单份脚本的参数固化**。 + +**但关键反证**:服务端**并不按 tdc.js 的会话密钥去解密 `collect`**。实测雨云 aid 用 +纯 Python(硬编码密钥)的 collect 提交正确答案 → `errorCode=0` + 真实 ticket。 +因此参数随机化**不构成阻塞**,纯 Python 交付形态依然成立(默认 `telemetry_mode="python"`)。 +node / browser 后端保留为「目标 aid 风控更严格」时的备选。 + +### 4.3 `eks` —— 会话密钥信封 + +`eks` 由 tdc.js 内部 `TDC.getInfo().info` 产出,形如 Base64 的 236~248 字符 +(= 176~184 字节),推测是用服务端公钥包裹的会话密钥,服务端用它解密 `collect`。 + +* 本仓库正则 `window\.<随机名>\s*=\s*''` 可从 tdc.js 中提取到一份 eks, + 但**与真实浏览器产出的长度不一致**(236 vs 248),说明沙箱环境不同会让 tdc.js + 生成不同结构的 eks。 +* `eks` 来源与 `collect` 必须**同源**(同一份 tdc.js、同一次执行),不可混用。 + +### 4.4 `ans` —— 点击坐标(明文,无需签名) + +```json +[{"elem_id":1,"type":"DynAnswerType_POS","data":"162,249"}, + {"elem_id":2,"type":"DynAnswerType_POS","data":"104,37"}, + {"elem_id":3,"type":"DynAnswerType_POS","data":"561,156"}] +``` + +坐标位于 **672x480 原图坐标系**,顺序即提示语顺序(`mark_style: inc_number`)。 + +### 4.5 无法确定的内容(明确声明,不虚构) + +1. **XTEA 会话密钥的派生过程**:tdc.js 每次加载都会重新混淆,密钥常量未以字面量出现 + (`1448563793` 等 4 个常量在 tdc.js 中检索不到),需要源码级插桩才能完全还原。 + 本实现改为执行真实 tdc.js,绕过该问题。 +2. **`eks` 的加密算法与字段布局**:未逆向,直接复用 tdc.js 产出。 +3. **`click_cfg` 之外的题型**(2408 图标点选、2402/2401 滑块)尚未实现求解器。 + +--- + +## 五、文字点选题型的视觉求解(2404) + +与雨云那套「图标点选」完全不同,本题型: + +* **没有提示图**:`img_url` 换成 `img_index=0` 返回 **0 字节**(实测多次) +* 提示是纯文本:`instruction = "请依次点击:虫 呈 般 "` +* **背景图里只有这 3 个汉字**(无干扰字),位置随机,部分题目带斜切变形 +* 汉字颜色恒定:BGR≈(58,176,228) → HSV H≈20 S≈185 V≈228 + +因此求解 = `颜色分割定位` → `汉字识别` → `按提示顺序做最优一对一指派`,实现见 +`tcaptcha/glyph_solver.py`: + +1. HSV 阈值分割(H∈[8,34] S>85 V>145)+ 形态学闭运算 + 13px 膨胀把偏旁并成整字 +2. 连通域得到包围盒,按面积/长宽比过滤,得到候选字形 +3. 掩膜 → 白底黑字 → 3 倍放大,交给 OCR **集成**打分 +4. 得分 = 每个引擎在 CTC 各时间步上目标字概率的**最大值**,取多预处理变体最大值后加权求和 +5. 剔除全部目标字得分都 < 0.02 的伪字形(照片暖色区域会误检) +6. 3! 全排列求最优指派,并用归一化后验概率作为置信度 + +### OCR 集成为什么必要 + +| 字形 | ddddocr max P(正确字) | PP-OCRv4 | +| --- | --- | --- | +| 筹(生僻,带斜切) | **0.0046** ❌ | 正确 ✅ | +| 雹 | 正确 ✅ | 误识为「电」❌ | +| 笆 | 误识为「芭」❌ | 正确(or 集成后仍正确指派)✅ | + +单引擎都不够;概率级集成后 6/6 真实样本全部正确且后验置信度 = 1.000。 + +### 6/6 回归样本 + +| 样本 | 提示 | 求解结果 | +| --- | --- | --- | +| `har_round1` | 虫 呈 般 | (149,252) (48,55) (549,351) ✅ | +| `har_round2` | 佰 衬 稗 | (449,53) (549,353) (349,153) ✅ | +| `har_round3` | 簿 蚕 沧 | (549,153) (50,255) (48,154) ✅ | +| `har_round4` | 沉 驰 齿 | (150,154) (550,153) (49,53) ✅ | +| `live_probe` | 笆 辩 裁 | (549,153) (50,54) (351,152) ✅ | +| `live_rare` | 筹 炽 雹 | (449,253) (49,353) (250,255) ✅ | + +用户抓包里失败的 3 次,回看坐标可确认是**人工点错**,不是协议问题: + +* 第 1 题「虫 呈 般」:`虫`(162,249) ✅、`呈`(104,37) ✅、`般` 点在 (561,**156**), + 而 `般` 实际在 (549,**351**) —— 第 3 击差 195px → 50 +* 第 2 题「佰 衬 稗」:`佰` ✅,但第 2、3 击把 `稗` 和 `衬` 点反了 → 50 +* 第 3 题「簿 蚕 沧」:第 2 击点在 (45,118) 是 `沧` 而非 `蚕`,第 3 击 (252,146) 落空 → 9 + +--- + +## 六、线上验证记录(errorCode 语义)—— 以及一个必须纠正的初判 + +### 6.1 1ms aid=195672344 的返回规律 + +| 提交内容 | errorCode | 复现次数 | +| --- | --- | --- | +| 三击全部落在图外的乱坐标 `(10,10)(20,20)(30,30)` | `50` | 4 次 | +| **正确坐标**(求解器命中 3/3) | `12` | 8 次 | +| **正确坐标但故意把第 1、2 击顺序点反** | `50` | 1 次 | + +``` +[REAL ] 请依次点击:安 胺 猜 post=1.000 -> 12 +[BOGUS] 请依次点击:睬 持 层 -> 50 +[REAL ] 请依次点击:半 陈 补 坐标正确但 1<->2 点反 -> 50 +``` + +**`errorCode 12` 只在答案完全正确时出现** → 答题链路(坐标空间、顺序、`elem_id`、 +`pow_answer`、`sess` 状态机)100% 正确,服务端是在答案通过后进入风控评估才拒绝的。 + +### 6.2 三个对照实验(决定了最终结论) + +**对照 A —— 同一套代码换 aid,直接拿到 ticket:** + +``` +雨云 aid=2039519451, 图标点选(2408), 纯 Python 硬编码密钥 collect +→ errorCode=0, ticket=tr03jvqlp225MjNBxjDj_-mnM1VTS8O9, randstr=@wBc ✅ +``` + +这一条同时证明了两件事: +1. **纯 Python 交付形态完全有效**,服务端不校验 collect 与 eks 的同源性; +2. 1ms 的 12 **不是**遥测密钥/算法的问题。 + +**对照 B —— 三种遥测后端在 1ms 上结果一致:** + +| 后端 | 说明 | 1ms 结果 | +| --- | --- | --- | +| `python` | 硬编码密钥纯算法还原 | 12 | +| `node` | Node VM 伪造环境执行真实 tdc.js | 12 | +| `browser` | **真实 Chrome** + CDP 可信鼠标事件执行真实 tdc.js | 12 | + +**对照 C —— 换出口 IP(住宅代理),彻底排除 IP 滚动风控:** + +| 出口 IP | 归属 | 结果 | +| --- | --- | --- | +| `118.248.52.65` | 本机(与抓包 IP `118.248.47.92` 同 /16) | 12 | +| `123.181.235.161` | 河北秦皇岛(住宅代理) | 12 | +| `114.222.19.181` | 江苏南京(住宅代理) | 12 | +| `123.181.235.170` | 住宅代理 | 12 | + +每个新 IP 上都是「后验置信度 1.000 的正确答案 → 12」,其中还夹杂一次 +「后验 0.789 的题 → 50」(说明这道题确实点错了,反向印证了 12/50 的语义)。 +另外还做过「静默 9 分钟 + 单发一次」,同样是 12。 + +### 6.3 结论(诚实版) + +* ✅ **文字点选求解器**:16 道实时题全部命中,后验置信度均 ≈1.000 + (尝辨长 / 佰簿班 / 半陈补 / 安胺猜 / 侈持钡 / 稗伴瓣 / 乘匙层 / 辰本虫 / + 秤遍稗 / 背岸拔 / 奔薄芭 / 倍玻畅 / 趁骋薄 / 筹辩彻 …) +* ✅ **协议与答题提交**:正确(服务端只在答案正确时才进入风控评估) +* ✅ **纯 Python 遥测**:在雨云 aid 上端到端产出真实 ticket,交付形态成立 +* ❌ **1ms aid 的票据发放**:被该 aid 的风控拦在 `errorCode=12` + +已经排除的原因:算法、XTEA 参数随机化、遥测引擎(python/node/browser)、 +请求参数与 `sess` 状态机、短时频控、**出口 IP 滚动风控**。 + +剩余唯一可能:**该 aid 配置了更严格的风控,要求会话具备"真实验证码页面上下文"** +(tdc.js 运行在真实验证码 iframe 内:真实 iframe location、真实 Canvas/WebGL 指纹、 +`isTrusted` 鼠标事件、真实 `document.referrer`),而我们的 node/browser 后端 +都是把 tdc.js 注入到"空白页"执行,缺少这层上下文。 + +### 6.4 下一步(能真正拿到 ticket 的路) + +**完整浏览器内求解**:用 CDP/Playwright 打开真实页面触发验证码,让原生 SDK 自己跑 +tdc.js;复用本仓库已完成的视觉求解器算坐标,用 `Input.dispatchMouseEvent` +在 iframe 内真实点击,从 SDK 回调取 `ticket` / `randstr`。 + +`tcaptcha/tdc_browser.js` 已经把 CDP 骨架写好了(启动 Chrome / 注入脚本 / +派发可信事件 / 读回 `TDC.getData()`),只需把「注入 tdc.js 的空白页」换成 +「真实验证码页面」。换 IP 的能力也已经内置(`tcaptcha/proxy.py` + +`--proxy-api`,遇 12 自动换 IP),届时可直接复用。 + +### 6.5 求解器鲁棒性修复(本次线上测试暴露的真实缺陷) + +代理测试暴露出 2 例定位失败,根因是 **HSV 下限 `V>=145` 把暖色草地/沙地并进了字形掩膜**: + +| 样本 | 现象 | 根因 | +| --- | --- | --- | +| `长 澄 班` | 只检出 1~2 个字形 | 草地 `H≈25 S≈170 V≈156` 落在阈值内,把 `澄/班` 一起吞进大块背景连通域 | +| `菜 倍 钵` | 只检出 2 个字形 | `倍/钵` 竖排相邻,13px 膨胀把它们粘成 `332x80` 长条,被长宽比过滤器丢弃 | + +实测颜色统计:**汉字 `H≈20.5 S≈200 V≈232`;草地 `V≈122~156`** —— 亮度 `V` 才是干净的分隔维度。 +把下限收紧为 `H>=12 S>=145 V>=190` 后,8 张图(含这 2 张失败样本)**全部稳定检出 3 个字形**, +两张失败样本也已加入 `tcaptcha/tests/fixtures/` 作为回归用例。 + +另外把 `min_posterior` 从 `0.6` 提到 `0.95`:实测全部正确解的后验都是 `1.000`, +而那次后验 `0.789` 的提交确实被判 50(点错),门限收紧可避免提交不可靠答案。 + +### 6.6 题型覆盖 + +20 次连续抽题统计(aid=195672344): + +| subcapclass | 题型 | 占比 | 状态 | +| --- | --- | --- | --- | +| 2408 | 图标点选(提示为 `img_index=0` 图标图) | 35% | 需按 1ms 的图标类别重训模型 | +| 2404 | **文字点选**(本次目标题型) | 15% | ✅ 求解器完成并验证 (8/8 回归) | +| 2402 / 2401 | 滑块拼图 | 50% | 未实现(需模板匹配找缺口) | + +> 题型在**会话内固定**:`getsig` 不换题型,只有重新 `prehandle` 才能抽到别的题型。 +> `client.solve` 已内置「抽到目标题型再提交」的循环(默认 `max_refreshes` 次)。 diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md new file mode 100644 index 0000000..6938407 --- /dev/null +++ b/har/1ms_dianxuan/PROGRESS.md @@ -0,0 +1,75 @@ +# 反 VM 密钥还原 & 纯算交付 进度文档 + +> 目标: **算法 100% 纯 Python**,Node 仅用于"跑出密钥 + 一次性标定明文模板"。 +> 依据: AGENTS.md §2.4 分层交付 / §3.3 字节码 VM 还原 / §4.3 运行时捕获 / §5.8 标定式交付。 +> 最后更新: 2026-10-11 + +--- + +## 一、已知(附证据 + 确信度) + +| # | 结论 | 证据 | 确信度 | +| --- | --- | --- | --- | +| 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_...` | 确证 | + +--- + +## 二、未知(附假设 + 确信度) + +| # | 未知 | 当前假设 | 确信度 | +| --- | --- | --- | --- | +| 1 | 1ms aid=195672344 为何在答案正确时返回 `errorCode=12` | 该 aid 启用了更严格风控,要求会话具备**真实验证码页面上下文** | 中 | +| 2 | 新版明文字段语义(哪一位是时间戳/指纹/轨迹) | 时间戳是 `1[6-9]\d{8}` 形态的大整数;轨迹是 `[x,y,t]` 三元组数组 | 中高 | +| 3 | 明文里 `[360,360]`、`"1920-1080-1055-24-*-*-|-*"` 的作用 | 后者是屏幕/窗口指纹串,在新版被移到了 `cd` 数组末尾 | 中 | + +--- + +## 三、已排除的假设(负结果也是资产,§5.7) + +| 假设 | 否定证据 | +| --- | --- | +| 密钥每次运行随机 | 同一脚本 3 次运行首块完全一致 | +| 密钥可从 `eks` 推导(MD5/SHA/切片) | 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 | + +--- + +## 四、本轮目标(已完成) + +- [x] 识别 `collect` 加密算法为教科书 XTEA 并逐字节验证 +- [x] 实现版本无关的 opcode 插桩(反向引用自动识别栈变量名) +- [x] 从任意 tdc.js 无歧义复原 4 个密钥字(含索引歧义处理) +- [x] 实现模板标定 + 纯 Python 回填,端到端拿到真实 ticket +- [x] 固化回归测试(`tcaptcha/tests/test_xtea_key.py`,11 项断言) + +## 五、下一步(可被下一轮直接执行) + +1. **拿下 1ms 的 12**:用 CDP 打开真实验证码页面,让原生 SDK 自己跑 tdc.js, + 复用本仓库视觉求解器算坐标、`Input.dispatchMouseEvent` 在 iframe 内真实点击。 + 骨架在 `tcaptcha/tdc_browser.js`。 +2. **补齐新版明文字段语义**:对照 10+ 份实时模板做差分,把时间戳/指纹/轨迹位点名。 +3. **实现 2408 图标点选**:用 1ms 的图标类别重训 `yolo_detector` + `icon_classifier`。 +4. **实现滑块 (2401/2402)**:模板匹配找缺口 + 生成滑动轨迹。 + +--- + +## 六、复验入口 + +```bash +.venv/bin/python -m tcaptcha.tests.test_xtea_key # XTEA + 密钥提取 (11 项) +.venv/bin/python -m tcaptcha.tests.test_glyph_solver # 文字点选求解 (8 张真实样本) +.venv/bin/python -m tcaptcha.selftest # 全部 +```