Files
hello_js_reverse_skill/har/1ms_dianxuan/ANALYSIS.md
T
yml2213 be7a6ee97b chore(git): har/ 下只忽略抓包产物,放行分析文档入库
做了什么
- .gitignore 的 `har/` 改为 `har/**` + `!har/**/` + `!har/**/*.md`
  —— 仍忽略 har/ 下的 jpg/js/json 抓包产物,但 *.md 分析文档正常入库
- 首次纳入两份文本证据资产:
  har/1ms_dianxuan/ANALYSIS.md  (330+ 行协议分析报告)
  har/1ms_dianxuan/PROGRESS.md  (已知/未知/已排除假设/下一步)

为什么
- 原规则是整目录忽略,把「证据文档」和「大文件抓包」一起挡在版本控制外;
  按 AGENTS.md §1.3,进度与证据必须落盘可追溯,文档应随代码版本化

怎么验证的
- git check-ignore 逐项确认: PROGRESS.md/ANALYSIS.md 入库,
  bg_3.jpg/tdc.js/live/session.json 仍忽略
- git add 预演仅 3 个文本文件, 无二进制混入
- 已检查文档内无 API key 等凭据 (仅几个已过期住宅代理 IP)
2026-10-11 18:10:15 +08:00

376 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 毫秒 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.<b64>.<b64>"}
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=<base64 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=<sid>&t=<rand>",
"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*'<base64>'` 可从 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` 次)。