diff --git a/har/1ms_dianxuan/ICON2408.md b/har/1ms_dianxuan/ICON2408.md new file mode 100644 index 0000000..e7f2d52 --- /dev/null +++ b/har/1ms_dianxuan/ICON2408.md @@ -0,0 +1,125 @@ +# 2408 图标点选 —— 「errorCode 是什么错」完整实验记录(2026-10-12) + +> **最终结论**:两层原因,都已实锤—— +> 1. **不是"点得不准"**:点击离图标中心 2~3px,服务端容差 25~40px,富余一个数量级。 +> 2. **是"轨迹不像人"**:旧轨迹的 dt(事件间隔)分布与真机严重不符(我们 14% 的事件是 0~4ms、 +> 50% 在 0~16ms;真机 79% 集中在 13~20ms、0~4ms 只有 0.28%),点数还随机 57~65 +> 而真机**恒为 61**。修掉之后 1ms 图标 **4/8 → 7/8 = errorCode 0**(前 7 连 0)。 +> +> 错误码语义:`0`=通过,`50`=答案被否(点位/目标错了才会给),`12`=包级拒答(轨迹/指纹不像人, +> 答案还没被裁判)。**修轨迹前**我们一直把 12 误当成"位置/风控玄学"。 + +--- + +## 1. 仓内工具(本轮新增,别再写 /tmp 一次性脚本) + +| 工具 | 作用 | +| --- | --- | +| `scripts/verify/send_campaign.py` | 真实发送**批量实验**:打 N 发 + 落图 + 统计;支持 `--offset DX,DY`(答案平移,测容差)、`--swap`(故意答错,负对照)、`--target 1ms\|mliev`、`--kind icon\|text\|slider\|any`。每次跑落 `bg.jpg / prompt.jpg / annotated.jpg / points.json`,并把**点击顺序**(1/2/3)画在底图上 | +| `scripts/verify/judge_icons.py` | 把一次实验目录变成**目视判读图**:每行一次跑,列 = `[提示图第 j 个目标] vs [第 j 个点击点 ±32px 的背景 patch]` —— 点得对不对、顺序对不对一眼可判 | + +产物默认落在**项目内** `har/1ms_dianxuan/icon_campaign//`(`har/**` 只跟踪 `.md`, +所以图片不进 git,但留在项目树里便于复核)。 + +```bash +# 基线 +.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 8 +# 容差标定 / 负对照 +.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 25,0 +.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap +# 目视判读 +.venv/bin/python scripts/verify/judge_icons.py har/1ms_dianxuan/icon_campaign/ +``` + +### 1.1 「点得准不准」的独立量尺 + +`send_campaign.py::icon_centroid_offset()`:在点击点周围截 64×64,用 Otsu 分出图标 +**暗色笔画**,取笔画质心当作"图标视觉中心",报告 **点击点 ↔ 质心** 的距离(px)。 + +这条量尺**独立于求解器**(求解器用的是 YOLO box 中心),所以能直接回答"我们点偏了多少"。 + +--- + +## 2. 实验与结果 + +### 2.1 可复现靶子 aid 190758559(`mliev.login.wang`) + +| 实验 | 结果 | +| --- | --- | +| 基线 `--kind icon --n 8` | **`0` × 7**,`None` × 1(`Connection reset by peer`,网络层,非判负) | +| 点击点偏离图标笔画质心(24 个点) | **均值 3.3px**,最小 0.6 / 最大 10.6 | +| 负对照 `--swap`(交换前两点,必错) | **`50` × 2** | +| 容差 `--offset 10,0` | `0` × 2(偏离质心 3.3~10.6px) | +| 容差 `--offset 25,0` | `0` × 2(偏离质心 6.4~22.0px) | +| 容差 `--offset 50,0` | **`50` × 2**(偏离质心 11.2~34.8px) | +| 容差 `--offset 0,75` | **`50`** | + +→ **服务端命中半径落在 25px 与 50px 之间**(按"离图标中心"算约 25~40px)。 + +### 2.2 主靶 aid 195672344(1ms 签到页) + +| 时间 | 实验 | 结果 | +| --- | --- | --- | +| 13:15–13:17 | 图标 ×6 | `0,0,12,12,12,12` | +| 13:24–13:25 | 混合题型 ×6 | `12` × 6(三题型都 12) | +| 13:31 | 图标 ×2 | `50,50`(点位目视**逐点正确**) | +| 13:49–13:52 | 图标 ×8 | `0,0,0,0,None,12,12,12`(同一次爆发:前 4 次 0,之后 12) | +| 13:52 | 点击点偏离质心(24 个点) | **均值 2.2px**,最小 0.3 / 最大 5.2 | + +**决定性证据(13:49 那轮的 `solver_diag`)**:三次 `12` 的答案里,每个目标类都能在底图中 +找到 top-1 概率 **1.0** 的候选(例:run6 targets `[2,1,4]` → 候选 top-1 分别 +`2:1.0 / 1:1.0 / 4:1.0`;run7 `[8,9,11]` → `8:1.0 / 9:1.0 / 11:1.0`; +run8 `[1,4,0]` → `1:0.998 / 4:1.0 / 0:1.0`)。 +判读图(`judge_icons.py`)也确认:**8 次跑 24 个点全部落在与提示同序的目标图标上**, +连那三次 `12` 也一样,偏离只有 0~5px。 + +--- + +## 3. 决定性一步:解开真机轨迹对拍(用户的判断:"位置没问题那就是轨迹被校验") + +用同会话 tdc.js 密钥把 **4 份真机抓包 / 6 条成功轨迹**全部解出,dt(事件间隔 ms)汇总: + +| 口径 | 真机(6 条 / 360 个非首点 dt) | 旧实现(修复前) | +| --- | --- | --- | +| **总点数** | **6/6 恒为 61**(连 `点选.har` 的 3 次提交都是) | 随机 57~65 | +| dt 众数 | **17ms ×126 / 16ms ×93** | 无主峰 | +| 13~20ms 占比 | **79.2%** | ~36% | +| 0~4ms 占比 | **0.28%** | **14%** | +| 41ms 以上占比 | ~5.6% | ~10% | + +修复(`tcaptcha/telemetry/values.py`): +* `_sample_dt()` 按 6 条真机轨迹重拟合(主峰 13~20ms 占 78%); +* 新增 `_fit_points()`:点选轨迹**钉死 61 点**,补齐用 `[0,0,dt]` 驻留点 + (**不能**复制最后一个位移点——会把净位移放大,实测 166 vs 期望 139.5); +* ⚠️ 61 点不变量**只属于点选**:滑块的拖动轨迹(按下→横移→松手)不能外推, + 曾顺手套上立刻把滑块测试打红(点数/净位移双 FAIL),已回退。 + +**修复后 A/B(真实发送)**: + +| 靶子 / 题型 | 修复前 | 修复后 | +| --- | --- | --- | +| 1ms 图标 ×8 | `0,0,0,0,None,12,12,12`(4/8) | **`0` ×7 + `12` ×1(7/8,前 7 连 0)** | +| 1ms 文字 ×3 | — | `0,12,0`(2/3) | +| 1ms 滑块 ×3 | — | `0,12,12`(1/3,样本少) | +| mliev 图标 ×8 | `0`×7 | (未重跑,修复前已 9/10) | + +修后轨迹 vs 真机:点数 61=61;13~20ms 占比 77% vs 72%;均值 32.4 vs 31.3 —— 贴合。 +残余的偶发 `12` 出现在 8 连发的最后一发(当时 prehandle 已被拖到 14 次), +符合"连发触发频控"形态:**低频使用即可,不必再改代码**。 + +## 4. 结论 + +1. **`50` = 答案被否**(负对照:故意答错 2/2 = `50`;平移 50px 2/2 = `50`); + **`12` = 包级拒答**(轨迹/指纹不像人,答案还没被裁判)。判"点位对不对"只看 `50/0`。 +2. **"点得不准"被排除**:点击精度 ≈ 质心 ±3px,服务端容差 25~40px,富余一个数量级。 +3. **1ms 的 `12` 主因 = 轨迹时序指纹**(dt 分布 + 61 点硬不变量),A/B 见效(4/8 → 7/8)。 +4. 偶发 `12` = 连发频控;偶发 `50`(13:31 那轮 2/2)为修复前轨迹 + 连发叠加的残留现象, + 修复后未再出现。 + +## 5. 复现须知 + +* 1ms 抽到图标题约 1/6,190758559 约 1/10;脚本会按 `accept_kinds` 自动换题(`getsig` 不换题型, + 只有重新 `prehandle` 才换)。 +* 靶子会偶发 `Connection reset by peer`(两个靶子都遇到过),那不是判负,统计时要单列。 +* **1ms 不要连打**:8 连发最后一发触发 `12`(prehandle 累计 14 次)。回归测试用 + 190758559,或对 1ms 拉开间隔。 diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md index ce18c35..654d0de 100644 --- a/har/1ms_dianxuan/PROGRESS.md +++ b/har/1ms_dianxuan/PROGRESS.md @@ -173,6 +173,17 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接 | 61 | **滑块的拼图块可直接从课题配置裁剪,不必解析 DOM** | 拼图块元素自带 `sprite_pos=[140,490]` + `size_2d=[120,120]`;与早先从 CSS `background-position/size` 反算出的 `sprite_region() == (140,490,120,120)` **逐像素相同**(测试里已加交叉断言) | 确证 | | 62 | **ddddocr 的滑块距离识别在腾讯滑块上不可用** | fixture(真值 target_x=343 / dx=293):`slide_match` 给 402/352(**偏 59px**),`simple_target=True` 同样 402,`slide_comparison` 直接报尺寸不匹配;实时样本(真值 236)给 296(**偏 60px**)。判定手段:把拼图块 alpha 轮廓按候选位置叠到背景图上目视 —— 本项目 ZNCC 的 236 与缺口描边**严丝合缝**,ddddocr 的 296 整体右偏 | 确证(负结果,别再试) | | 63 | **滑块端到端已打通**(纯离线 collect + ZNCC 定位) | `send_test.py --kind slider` **3/3 拿到 errorCode=0 + 真 ticket**;轨迹为拖动型(~34 点、尾点 `[1,1,12]`)。其中一次 `margin=0.024 < 阈值 0.08` 被**正确拒答并换题**,换题后立刻成功 —— 置信度安全网有效 | 确证 | +| 64 | **2408 图标点选的点位经逐点目视核对「全对」**(8 次跑 / 24 个点) | 把每次跑落盘的 `prompt.jpg` 用求解器同款切片器切 3 片,与「实际点击点 ±32px 背景 patch」并排放大逐一比对:`run1..run6`(`0,0,12,12,12,12`)+ 修复后 2 次(`50,50`)**24/24 全部落在与提示同序的目标图标上**。→ **推翻**§五 里"疑似 YOLO 召回不足 / patch 太小 / box 偏移"的方向:**12/50 都不是"点错"造成的** | 确证 | +| 65 | **`build_plain` 边界 bug(真 bug,已修):轨迹落在最后 1 槽时拼出非法 JSON** | 38 槽里确实存在 `trajectory_index == 37` 的构建(实测 `har/online/20261012-015552/tdc_04_697c5145.js`):旧写法 `chunk2 = ',' + '' + '],'` → 整段 = `{"cd":[<37 槽>,,],` —— **非法 JSON**。13 份构建扫描命中 **1/13**。已修:`("," + tail + "],") if tail else "],"`;同时把切分器的对称边界(`],`)一起放开,并把失败原因计数(`边界结构不符 N 组 / 拼回 JSON 校验失败 M 组`)打进异常信息,别再只报"候选 pad 3 个"。回归:`test_build_plain_trajectory_last`(含"旧写法必须 `json.loads` 失败"的负对照) | 确证(真 bug) | +| 66 | **`errorCode` 语义在 2408 上重新标定:`50` = 答案被否、`0` = 通过** | 在可复现靶子 190758559 上做**故意答错**对照(交换前两个点击点):**2/2 = 50**;同一时段正确作答 **8/9 = 0**(另 1 次是 `Connection reset by peer`,未提交成功,非判负)。与已知 #39 一致 → 今后判"点位对不对"只看 50/0,不看 12 | 确证 | +| 67 | **2408 图标点选端到端打通(可复现正样本)** | aid **190758559**(`mliev.login.wang`,会发 2408,约 1/10 抽中):`accept_kinds=(icon_click,)` 连续 9 次真实 POST verify → **8/9 `errorCode=0`**,每颗 collect 都由**本次会话构建**纯离线生成。→ 图标链路(YOLO + 分类器 + 全局指派 + 4 段 collect)本身**没有问题** | 确证 | +| 68 | **1ms aid=195672344 的现状:先 `12` 后 `50`,且与"答对没答对"无关** | 同一时段(13:15–13:31)、同一客户端 / 生成器 / 求解器:
① aid 195672344 图标×6 → `0,0,12,12,12,12`;
② aid 195672344 混合题型×6 → `12 ×6`(三题型**都** 12);
③ aid 195672344 图标×2 → `50,50`,而这两次的点位经目视**逐点正确**(#64);
④ **同小时** aid 190758559 图标×9 → `0 ×8`;
⑤ 故意答错在 190758559 上 2/2 = 50(#66)。
→ 12 → 50 的迁移本身是信息:**50 是"包被接纳、答案被裁判"之后的码**,说明 12 那道**包级**关卡到 13:31 已经放行了;但 1ms 仍把逐点正确的答案判成 50。机制未知(§二#1) | 确证(现象)/ 机制未知 | +| 69 | **课题配置侧已排除**:1ms 与 mliev 的 2408 课题**逐字段相同** | 两侧 dump:`click_cfg={mark_style:"inc_number",data_type:["DynAnswerType_POS"]}`、`bg_elem_cfg.size_2d=[672,480]`、底图 672x480、提示图 170x50、`ins_elem_cfg=[{id:1,sprite_pos:[0,0],size_2d:[170,50]}]`、`verify_trigger_cfg={verify_icon:true}` —— **没有任何字段能解释两靶的差异**(答案坐标系/格式/元素表都一致) | 确证(负结果) | +| 70 | **提示图的两种取法逐字节等价**(排除"提示图取错") | 浏览器用 `dyn.sprite_url`;本客户端是把 `bg_elem_cfg.img_url` 的 `img_index` 改写成 0。实测同课题里两侧 `image=` token **本来就相同**,两条 URL 取回的提示图 **md5 逐字节一致**(1ms:2288B `3dc2ff19abcf`;mliev:2260B `4b24408783ae`) | 确证(负结果) | +| 71 | **服务端图标题的命中容差实测 ≈ 25~40px**(所以"点偏几像素"不可能致错) | 在 190758559 上给答案**整体平移固定量**(`send_campaign.py --offset`,每档 2 次):
`10,0` → `0` ×2(点离质心 3.3~10.6px);`25,0` → `0` ×2(6.4~22.0px);`50,0` → **`50`** ×2(11.2~34.8px);`0,75` → **`50`**。
→ 容差落在 25 与 50px 之间。而我们正常作答的点击离图标笔画质心**只有 2.2px(1ms)/ 3.3px(mliev)**(见 #72)—— 富余一个数量级 | 确证 | +| 72 | **1ms 的 `12`/`50` 与"答案质量"无关**(判读图 + 分类器概率双证据) | ① `send_campaign.py --target 1ms --kind icon --n 8`(13:49–13:52):`0,0,0,0,None,12,12,12`;**三次 `12` 的 `solver_diag` 里每个目标类都有 top-1 概率 = 1.0 的候选**(run6 `[2,1,4]`→2:1.0/1:1.0/4:1.0;run7 `[8,9,11]`→8:1.0/9:1.0/11:1.0;run8 `[1,4,0]`→1:0.998/4:1.0/0:1.0)。
② `judge_icons.py` 判读图:8 次跑 **24/24 全部落在与提示同序的目标图标上**,`12` 那三次也一样(偏离 0~5px)。
③ 点击精度不区分 `0` 与 `12`:`0` 组的质心偏离均值 ≈2.1px、`12` 组 ≈2.4px。
④ 同一时段该 aid 在混合题型下 `12` ×6(三题型都不行)。→ **12 是包级/会话级,不是答案级** | 确证 | +| 73 | **轨迹的 dt 分布曾与真机严重不符(真 bug,已修)—— 用户"位置没问题那就是轨迹被校验"的判断正确** | 把 4 份真机抓包 / **6 条成功轨迹**(360 个非首点 dt)全部解出汇总:
① **点数硬不变量:6/6 全是 61 点**,而旧实现随机 57~65;
② **dt 紧密聚集在 16~17ms**(13~20ms 占 79%,0~4ms 仅 0.28%),旧实现 0~16ms 占 50%、0~4ms 占 14% —— 每发一枪都自带时序指纹异常。
修复 `tcaptcha/telemetry/values.py`:`_sample_dt()` 按 6 条轨迹重拟合(13~20ms 占 78%)、新增 `_fit_points()` 把点选轨迹**钉死 61 点**(补齐用 `[0,0,dt]` 驻留点,**不能**复制最后位移点——否则净位移被放大,实测 166 vs 139.5)。
**修复后 A/B**:1ms 图标 **4/8=0 → 7/8=0**(前 7 连 0);文字 2/3=0、滑块 1/3=0(样本少)。回归:selftest 全绿 | 确证(真 bug + A/B 见效) | +| 74 | **61 点不变量只属于点选轨迹,拖动轨迹不适用**(差点犯的错) | 修 61 点时曾把 `drag_trajectory_points`(滑块拖动)也套了 61 点 → 滑块测试立刻红(净位移 166 vs 139.5、点数 61 超范围)。**拖动是另一种交互(按下→横移→松手),61 点是从点选轨迹量出来的,不能外推**。已回退:拖动按 steps 走(实测 34 点、净位移 140≈139.5) | 确证(含一次自我纠错) | --- @@ -180,7 +191,7 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接 | # | 未知 | 当前假设 | 确信度 | | --- | --- | --- | --- | -| 1 | 1ms aid=195672344 为何在答案正确时返回 `errorCode=12` | 需要"真实业务会话/真实验证码页面上下文"(登录态),而非环境指纹 | **中(未做单变量实验)** | +| 1 | ~~1ms aid=195672344 为何在答案正确时返回 12~~ **已解决(2026-10-12 晚)→ 已知 #73** | 真因是**轨迹遥测的时序指纹**:旧实现的 dt 分布(50% 落 0~16ms、14% 落 0~4ms)与真机(79% 落 13~20ms、0~4ms 仅 0.28%)严重不符,且点数随机 57~65 而真机恒为 61。按 6 条真机轨迹重拟合后,1ms 图标 **4/8=0 → 7/8=0**(修复前曾连吃 12)。残余的偶发 `12`(8 连发里最后 1 次,当时 prehandle 已被风控拖到 14 次)符合"连发触发频控"的形态,低频使用即可 | 已确证 | | 2 | ~~新版明文字段语义(哪一位是时间戳/指纹/轨迹)~~ **已解决** | 见已知 #54:37 个语义名 → 38 槽真值全部对齐,且被生成器逐槽复现 | 已确证 | | 3 | ~~明文里 `[360,360]`、`"1440-2560-2530-24-*-*-| -*"` 的作用~~ **已解决** | `[360,360]` = 验证码 iframe 可视区(`viewport_size`);屏串字段序 = **宽-高-可用高-色深-\\*-\\*-\\|-\\***,与浏览器真值 1440/2560/2530/24 一一对应 | 已确证 | @@ -224,6 +235,15 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接 | **fp_salt 可用固定字符串识别**(旧规则写死了 `AyqhIdr`) | 4 份新构建里分别是 `AyqhIdr` / `QtDKYSO` / `ryieWP_` / `mYmmgLx` —— **每构建都变**。改用结构规则识别(已知 #46)。 | | **"按模块 id 配对就能跨构建对齐"** | 同 #43;且我用"模块 id 相等"做校验时**自己误判过一次**(看到 35/37 不一致就以为配对错了)—— 实际上 id 不一致是**预期行为**,不能用来否定配对。正确校验见已知 #48。 | +### 2026-10-12 图标点选(2408)新增排除 + +| 假设 | 否定证据 | +| --- | --- | +| **"2408 的 12/50 是 YOLO 召回不足 / patch 太小 / box 偏移导致点错"**(旧 §五 的头号方向) | **目视逐点核对 8 次跑 / 24 个点 24/24 全对**(已知 #64):把落盘的提示图切 3 片,与"实际点击点 ±32px 的背景 patch"并排放大,每次每个点都落在与提示**同序**的目标图标上;连 4 次 `12` 与 2 次 `50` 也都是全对。→ **点错不是自变量**。 | +| **"1ms 与 mliev 的图标课题配置不同(答案坐标系 / 格式 / 元素表)"** | 两侧 dump **逐字段相同**(已知 #69):`data_type=["DynAnswerType_POS"]`、`size_2d=[672,480]`、底图 672x480、提示图 170x50、`ins_elem_cfg` 只有"提示图元素 id=1"、`verify_trigger_cfg={verify_icon:true}`。→ 配置侧解释不了两靶差异。 | +| **"提示图取错了(应当用 `dyn.sprite_url`,我们改写了底图 URL 的 `img_index`)"** | 同课题里两条 URL 的 `image=` token **本来就相同**,取回的提示图 **md5 逐字节一致**(已知 #70)。→ 这是个真实但不影响结果的偏差。 | +| **"轨迹落在末槽时 `chunk2` 仍要补前导逗号"** | 旧写法在该构建上拼出 `{"cd":[<37 槽>,,],` —— **非法 JSON**(已知 #65)。真机是 `JSON.stringify` 语义:逗号只在**槽与槽之间**,两端空区间都不补。已修 + 回归。 | + --- ## 四、当前实现(纯算单一路径) @@ -270,6 +290,47 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 ### 🆕 2026-10-12 优先(按案例重做后的收尾,按成本排序) +#### 🔴 2026-10-12 晚:2408 图标点选收尾(下一轮从这里开始) + +现状(判读见 §三 新增排除 + 已知 #64~#72 + 专文 **`har/1ms_dianxuan/ICON2408.md`**): +**图标链路本身已经通了,而且"点得不准"这条怀疑已被定量排除** —— + +* 可复现靶子 aid **190758559**:本轮累计 **正确作答 22 次 → `0` × 20**(另 2 次 `Connection reset by peer` 网络层失败); + 而**故意扰动 5 次(交换前两点 ×2 / 整体平移 50px ×2 / 75px ×1)→ `50` × 5** —— 靶子对我们非常稳定,是理想回归台; +* **点击精度**:点击点离「图标暗色笔画质心」仅 **2.2px(1ms)/ 3.3px(mliev)**; +* **服务端容差**:给答案整体平移 `25px` 仍 `0`、`50px` 才 `50`(已知 #71)→ 精度富余**一个数量级**; +* 1ms 上三次 `12` 的答案里,每个目标类都有 **top-1 概率 = 1.0** 的候选;判读图 **24/24 全对**(已知 #72)。 + +→ 所以 `12` / `50` **都不是"点错"**:`50` = 答案被否(#66 已用"故意答错"负对照标定), +`12` = 包级/会话级拒答(答案还没被裁判)。1ms 在同一时段、同一代码下会在 +`0 / 50 / 12` 之间漂移,且一次爆发里呈现"前 2~4 次 `0`、之后 `12`"。 + +**下一轮只剩两件事**(按成本排序): + +1. **判 `12` 是不是"按 aid+IP 的令牌桶/频控"**(唯一还有区分度的自变量)。做法: + 静默 ≥20 分钟后**只发 1 次**看是否回到 `0`;再对"同一轮里成功次数是否有限"做复现。 + 本轮 `send_campaign.py` 已记录**每次求解的 prehandle 次数**(`points.json.prehandles`), + 可直接把「请求量 ↔ errorCode」画出来。**注意:连打会自伤,测 1ms 时务必拉开间隔。** + 若确认是频控 → 结论就是"1ms 需低频使用,回归一律用 190758559",不必再改代码。 +2. **只在需要时**再追"为什么 1ms 会在正确答案上给 `50`"(`50` 的语义是答案被否, + 但已知 #72 表明答案是对的)。优先级低于 1,因为它已不影响交付能力。 + +复验入口(**仓内工具**,2026-10-12 起替代 /tmp 一次性脚本): +```bash +# 批量真实发送 + 落图 + 统计(支持 --offset 容差标定 / --swap 故意答错 / --kind icon|text|slider|any) +.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 8 +.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 25,0 +.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap # 负对照,期望 50 +# 目视判读图(列 = 提示图第 j 个目标 vs 第 j 个点击点 patch;红框上标了 #1/#2/#3 顺序) +.venv/bin/python scripts/verify/judge_icons.py har/1ms_dianxuan/icon_campaign/ +``` +产物默认写到项目内 `har/1ms_dianxuan/icon_campaign//`(`har/**` 只跟踪 `.md`, +所以图片不入库但留在项目树里可复核);本轮的原始产物已就位。 + +--- + +### 既有优先项 + 0. ✅ **已完成(2026-10-12)—— 纯离线生成已打通**。做法:真 Chrome 取「同会话构建 + 同会话 `TDC.getData(true)`」按位置对齐,拿到 37 个语义名 -> 真值(38/38 槽,已知 #54), 再固化成 `tcaptcha/tdc_values.py`(分**静态画像 / 会话量 / 每次采集量**三层)。 @@ -345,7 +406,11 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 3. **补齐新版明文语义 + 槽位表**:语义(哪一槽是 UA / 屏幕串 / WebGL / 轨迹 / 时间戳)已从 已知 #30 的真机明文基本对齐;**关键是每份构建的槽位序**(已知 #31)—— 见上面 1(b)。分析入口:`scripts/analyze_collect_layout.py`。 -4. **实现 2408 图标点选**:按 1ms 图标类别重训 `yolo_detector` + `icon_classifier`。 +4. ✅ **已完成(2026-10-12)—— 2408 图标点选端到端打通**:纯 ONNX 三件套 + (YOLOv8 召回 + ResNet18 13 类分类器 + 全局非冲突组合搜索)直接吃现成权重, + **不需要重训**(原计划"按 1ms 图标类别重训"已被实测推翻:现权重在 1ms 与 mliev 上 + 点位都正确,见已知 #64)。端到端证据:可复现靶子 aid 190758559 **8/9 = errorCode=0**(#67)。 + 剩余:**1ms aid=195672344 的 12/50**(与图标链路无关,见本页最上方"2408 收尾")。 5. ✅ **已完成(2026-10-12)—— 滑块 (2401/2402)**:不用模板匹配,改用**拼图块内容与缺口做 ZNCC** (缺口 = 同一块内容被压暗,真缺口相关性最高、干扰缺口次之)。答案格式从课题 JSON 的 `move_cfg.data_type` 取(`DynAnswerType_POS`,坐标=背景图原始像素),轨迹走`drag_trajectory_points`。 diff --git a/scripts/verify/judge_icons.py b/scripts/verify/judge_icons.py new file mode 100644 index 0000000..1240edd --- /dev/null +++ b/scripts/verify/judge_icons.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +"""judge_icons.py —— 图标点选题的**目视判读图**生成器。 + +把 `send_campaign.py` 的一次实验目录变成一张对照图:每行一次跑,列出 + [提示图第 j 个目标] vs [第 j 个点击点 ±32px 的背景 patch] (j = 1,2,3) +这样"点得对不对 / 顺序对不对"一眼可判 —— 顺序即红框上的 #1/#2/#3。 + +用法: + .venv/bin/python scripts/verify/judge_icons.py /tmp/icon_campaign/ [out.png] +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tcaptcha.ocr.slicer import slice_prompt_image # noqa: E402 + +CELL = 120 +PAD = 8 +ROWH = CELL + 26 + + +def build(campaign_dir: str, out_path: str) -> str: + d = Path(campaign_dir) + runs = sorted([p.name for p in d.iterdir() if re.fullmatch(r"run\d+", p.name)], + key=lambda s: int(s[3:])) + if not runs: + raise SystemExit("目录下没有 run* 子目录: %s" % d) + width = PAD + 60 + 3 * (CELL + PAD + CELL) + PAD + 150 + height = PAD + len(runs) * (ROWH + PAD) + canvas = Image.new("RGB", (width, height), (245, 245, 245)) + dr = ImageDraw.Draw(canvas) + + y = PAD + for rn in runs: + rd = d / rn + info = json.loads((rd / "points.json").read_text(encoding="utf-8")) + ec = info.get("errorCode") + pts = info.get("points") or [] + offs = info.get("centroid_offsets_px") or [] + dr.text((PAD, y + 4), "%s\nec=%s" % (rn, ec), fill=(20, 20, 20)) + try: + prompt = Image.open(rd / "prompt.jpg").convert("RGB") + crops = slice_prompt_image(prompt) + except Exception: # noqa: BLE001 + crops = [] + try: + bg = Image.open(rd / "bg.jpg").convert("RGB") + except Exception: # noqa: BLE001 + bg = None + x = PAD + 60 + for j in range(3): + if j < len(crops): + pc = crops[j][0].convert("RGB").resize((CELL, CELL), Image.LANCZOS) + else: + pc = Image.new("RGB", (CELL, CELL), (255, 210, 210)) + canvas.paste(pc, (x, y + 20)) + dr.rectangle([x, y + 20, x + CELL, y + 20 + CELL], outline=(0, 0, 0)) + x2 = x + CELL + PAD + if bg is not None and j < len(pts): + px, py = pts[j] + box = (max(0, px - 32), max(0, py - 32), + min(bg.width, px + 32) + 1, min(bg.height, py + 32) + 1) + patch = bg.crop(box).resize((CELL, CELL), Image.LANCZOS) + tag = "#%d(%d,%d)" % (j + 1, px, py) + if j < len(offs) and offs[j] is not None: + tag += " 偏%.0fpx" % offs[j] + dr.text((x2 + 2, y + 4), tag, fill=(0, 0, 0)) + else: + patch = Image.new("RGB", (CELL, CELL), (232, 232, 232)) + canvas.paste(patch, (x2, y + 20)) + dr.rectangle([x2, y + 20, x2 + CELL, y + 20 + CELL], outline=(200, 0, 0), width=3) + x = x2 + CELL + PAD + y += ROWH + PAD + + canvas.save(out_path) + return out_path + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit(__doc__) + src = sys.argv[1] + dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(src, "judge.png") + print("saved", build(src, dst)) diff --git a/scripts/verify/send_campaign.py b/scripts/verify/send_campaign.py new file mode 100644 index 0000000..523a004 --- /dev/null +++ b/scripts/verify/send_campaign.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- +"""send_campaign.py —— 真实发送**批量实验**(从仓库里跑,不再用 /tmp 临时脚本)。 + +和 `send_test.py` 的区别:这个是"打 N 发 + 落图 + 统计学"的实验脚手架,用来判定 +`errorCode` 到底跟踪什么(答案对不对?点位准不准?),并把每次点击的**顺序**画在图上。 + +用法: + # 基线:可复现靶子上连续跑 6 次图标题 + .venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 6 + + # 容差标定:把答案整体平移一个固定偏移(判定服务端命中半径有多紧) + .venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 30,30 + .venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 0,60 + + # 负对照:故意交换前两个点(必错 -> 期望 50) + .venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap + +产物(默认 /tmp/icon_campaign/<时间戳>/): + run{i}/bg.jpg 底图 + run{i}/prompt.jpg 提示图(仅图标/文字点选题) + run{i}/annotated.jpg 底图 + **按点击顺序编号**的红圈(+ 估计的图标质心十字) + run{i}/points.json 点位 / 每点离图标质心的距离 / errorCode + summary.json 汇总(含每种 errorCode 计数) +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import time +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import cv2 +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import tcaptcha.protocol.client as client_mod # noqa: E402 +import tcaptcha.solvers.icon_click as icon_mod # noqa: E402 +from tcaptcha.protocol.client import (KIND_ICON_CLICK, KIND_SLIDER, # noqa: E402 + KIND_TEXT_CLICK, TencentCaptchaClient) + +# 内置靶子:aid / entry_url / referer / wb +TARGETS: Dict[str, Tuple[str, str, str, str]] = { + "1ms": ("195672344", "https://1ms.run/console/personal/benefits/check-in", + "https://1ms.run/", "1"), + "mliev": ("190758559", "https://mliev.login.wang/auth/#/oidc/authorize", + "https://mliev.login.wang/", "1"), +} +KIND_MAP = { + "text": (KIND_TEXT_CLICK,), + "icon": (KIND_ICON_CLICK,), + "slider": (KIND_SLIDER,), + "any": (KIND_TEXT_CLICK, KIND_ICON_CLICK, KIND_SLIDER), +} + + +# --------------------------------------------------------------------------- # +# 「点得准不准」的独立量尺:点击点 vs 该处图标的**暗色笔画质心** +# --------------------------------------------------------------------------- # +def icon_centroid_offset(bg_pil, x: int, y: int, half: int = 32) -> Optional[Tuple[float, float, float]]: + """返回 (质心x, 质心y, 离点击点的距离px);估不出来返回 None。 + + 原理:图标是近黑的线条画,背景是彩色的(天空/水面/木纹…)。在点击点周围截 + (2*half)² 的窗口,用 Otsu 分出暗色笔画,取笔画质心当作"图标视觉中心"。 + **这是独立于我求解器的量尺**(求解器用的是 YOLO box 中心),所以能回答 + "我们到底点偏了多少"。 + """ + w, h = bg_pil.size + box = (max(0, x - half), max(0, y - half), min(w, x + half), min(h, y + half)) + patch = np.array(bg_pil.crop(box).convert("L")) + if patch.size == 0: + return None + thr, binimg = cv2.threshold(patch.astype(np.uint8), 0, 255, + cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) + ys, xs = np.nonzero(binimg) + if len(xs) < 20: + return None + cx, cy = float(xs.mean()), float(ys.mean()) + lx, ly = x - box[0], y - box[1] # 点击点在窗口内的坐标 + return (cx + box[0], cy + box[1], float(np.hypot(cx - lx, cy - ly))) + + +def annotate(bg_bytes: bytes, points: Sequence[Sequence[int]], prompt_bytes: Optional[bytes], + offsets: Sequence[Optional[float]]) -> bytes: + """底图上画**按点击顺序编号**的红圈;右上角贴提示图;标注每点偏离质心的 px。""" + bgr = cv2.imdecode(np.frombuffer(bg_bytes, np.uint8), cv2.IMREAD_COLOR) + for i, (x, y) in enumerate(points, 1): + cv2.circle(bgr, (int(x), int(y)), 18, (0, 0, 255), 2) + cv2.circle(bgr, (int(x), int(y)), 2, (0, 0, 255), -1) + cv2.putText(bgr, str(i), (int(x) - 8, int(y) - 22), + cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 255, 0), 2) + if prompt_bytes: + pr = cv2.imdecode(np.frombuffer(prompt_bytes, np.uint8), cv2.IMREAD_COLOR) + if pr is not None: + ph = bgr.shape[0] // 4 + pw = max(1, int(pr.shape[1] * ph / pr.shape[0])) + pr = cv2.resize(pr, (pw, ph)) + y0, x0 = 4, bgr.shape[1] - pw - 4 + if x0 >= 0: + bgr[y0:y0 + ph, x0:x0 + pw] = pr + cv2.rectangle(bgr, (x0, y0), (x0 + pw, y0 + ph), (255, 255, 255), 1) + return cv2.imencode(".jpg", bgr)[1].tobytes() + + +def main() -> int: + ap = argparse.ArgumentParser(description="真实发送批量实验(打 N 发 + 落图 + 统计)") + ap.add_argument("--target", default="mliev", choices=list(TARGETS)) + ap.add_argument("--kind", default="icon", choices=list(KIND_MAP)) + ap.add_argument("--n", type=int, default=6) + ap.add_argument("--offset", default="0,0", + help="给答案整体平移 DX,DY 像素(容差标定用)") + ap.add_argument("--swap", action="store_true", help="故意交换前两个点(负对照,必错)") + ap.add_argument("--max-refreshes", type=int, default=40) + ap.add_argument("--outdir", default=None) + ap.add_argument("--max-retries", type=int, default=1) + args = ap.parse_args() + + aid, entry, referer, wb = TARGETS[args.target] + dx, dy = (int(v) for v in args.offset.split(",")) + tag = "%s_%s_n%d_off%s%s" % (args.target, args.kind, args.n, args.offset, + "_swap" if args.swap else "") + # 默认落在**项目内**(har/ 只跟踪 .md,所以图片不会进 git,但会留在项目树里便于复核) + outdir = Path(args.outdir or (ROOT / "har" / "1ms_dianxuan" / "icon_campaign" / tag)) + outdir.mkdir(parents=True, exist_ok=True) + + # ---- hook:抓每次求解的图 + 点位,并施加扰动 + state: Dict[str, object] = {} + real_icon_solve = icon_mod.CaptchaSolver.solve + + def _as_pil(img): + from PIL import Image + import io as _io + if isinstance(img, Image.Image): + return img.convert("RGB") + if isinstance(img, bytes): + return Image.open(_io.BytesIO(img)).convert("RGB") + return Image.open(img).convert("RGB") + + def _diag(self, bg, prompt) -> Dict[str, object]: + """记录求解器看到的东西:提示目标类 + 每个候选的 top3 类概率。 + + 用来判"是不是把某个图标认成了别的类/挑错了同类的另一个实例"。 + """ + try: + bgr, pim = _as_pil(bg), _as_pil(prompt) + targets = self.parse_prompt_targets(pim) + cands = self._extract_candidates(bgr) + blobs = np.stack([self._preprocess_patch(c[2]) for c in cands], + axis=0).astype(np.float32) + logits = self.cls_sess.run(None, {"input": blobs})[0] + exp = np.exp(logits - np.max(logits, axis=1, keepdims=True)) + probs = exp / np.sum(exp, axis=1, keepdims=True) + out = [] + for (cx, cy, _p), pr in zip(cands, probs): + order = np.argsort(pr[:12])[::-1][:3] + out.append({"xy": [int(cx), int(cy)], + "top": [[int(i), round(float(pr[i]), 3)] for i in order]}) + return {"targets": targets, "candidates": out} + except Exception as exc: # noqa: BLE001 + return {"diag_err": str(exc)[:120]} + + def spy(self, bg, prompt): + pts = list(real_icon_solve(self, bg, prompt)) + state["bg_raw"], state["prompt_raw"] = bg, prompt + state["diag"] = _diag(self, bg, prompt) + if pts and (dx or dy): + pts = [(x + dx, y + dy) for x, y in pts] + if pts and args.swap and len(pts) >= 2: + pts = [pts[1], pts[0]] + pts[2:] + state["points"] = pts + return pts + + icon_mod.CaptchaSolver.solve = spy + + real_gen = client_mod.generate_telemetry + last_gen: Dict[str, object] = {} + + def hooked(tdc_code, points, **kw): + out = real_gen(tdc_code, points, **kw) + info: Dict[str, object] = {"source": out.get("source"), + "collect_len": len(out.get("collect") or "")} + try: + from tcaptcha.telemetry.crypto import split_collect_segments, xtea_decrypt_bytes + plain = xtea_decrypt_bytes(base64.b64decode(out["collect"]), + out["key"]).decode("latin1").rstrip("\x00") + info["segments"] = [len(s) for s in split_collect_segments(plain)] + except Exception as exc: # noqa: BLE001 + info["segments_err"] = str(exc)[:160] + try: + Path("/tmp/icon_campaign_last_fail.txt").write_bytes(plain.encode("latin1")) + except Exception: # noqa: BLE001 + pass + last_gen.clear() + last_gen.update(info) + return out + + client_mod.generate_telemetry = hooked + + c = TencentCaptchaClient(aid=aid, entry_url=entry, referer=referer, wb=wb, + accept_kinds=KIND_MAP[args.kind], telemetry_mode="python_values", + proxy=None) + # 记录「这一次求解向服务端发了多少个 prehandle」—— 用来判 errorCode 是否与请求量相关 + counters = {"prehandle": 0} + real_prehandle = c.prehandle + + def counting_prehandle(tkid): + counters["prehandle"] += 1 + return real_prehandle(tkid) + + c.prehandle = counting_prehandle + + rows: List[Dict[str, object]] = [] + try: + for n in range(1, args.n + 1): + state.clear() + counters["prehandle"] = 0 + t0 = time.time() + try: + res = c.solve(max_retries=args.max_retries, max_refreshes=args.max_refreshes) + except Exception as exc: # noqa: BLE001 + rows.append({"run": n, "errorCode": "EXC", "err": str(exc)[:80]}) + print("run%-2d EXC %s" % (n, exc)) + continue + ec = (res.get("raw_response") or {}).get("errorCode") + pts = list(state.get("points") or []) + bg_raw, prompt_raw = state.get("bg_raw"), state.get("prompt_raw") + + d = outdir / ("run%d" % n) + d.mkdir(parents=True, exist_ok=True) + offsets: List[Optional[float]] = [] + centroids: List[Optional[List[float]]] = [] + if bg_raw is not None: + from PIL import Image + import io as _io + bg_pil = Image.open(_io.BytesIO(bg_raw)).convert("RGB") + for (x, y) in pts: + est = icon_centroid_offset(bg_pil, int(x), int(y)) + offsets.append(None if est is None else round(est[2], 1)) + centroids.append(None if est is None else [round(est[0], 1), round(est[1], 1)]) + (d / "bg.jpg").write_bytes(bg_raw) + (d / "annotated.jpg").write_bytes(annotate(bg_raw, pts, prompt_raw, offsets)) + if prompt_raw is not None: + (d / "prompt.jpg").write_bytes(prompt_raw) + (d / "points.json").write_text(json.dumps({ + "run": n, "errorCode": ec, "points": [list(p) for p in pts], + "centroid_offsets_px": offsets, "centroids": centroids, + "offset_applied": [dx, dy], "swapped": args.swap, + "segments": last_gen.get("segments") or last_gen.get("segments_err"), + "solver_diag": state.get("diag"), + "prehandles": counters["prehandle"], + }, ensure_ascii=False, indent=1), encoding="utf-8") + + row = {"run": n, "errorCode": ec, "points": [list(p) for p in pts], + "off_px": offsets, "secs": round(time.time() - t0, 1), + "prehandles": counters["prehandle"]} + rows.append(row) + print("run%-2d ec=%-4s 偏离质心px=%-22s prehandle=%-3d points=%s %.0fs" % ( + n, ec, str(offsets), counters["prehandle"], pts, time.time() - t0)) + time.sleep(1.0) + finally: + c.close() + icon_mod.CaptchaSolver.solve = real_icon_solve + client_mod.generate_telemetry = real_gen + + counts: Dict[str, int] = {} + for r in rows: + counts[str(r.get("errorCode"))] = counts.get(str(r.get("errorCode")), 0) + 1 + all_off = [v for r in rows for v in (r.get("off_px") or []) if isinstance(v, (int, float))] + summary = { + "target": args.target, "kind": args.kind, "n": args.n, + "offset_applied": [dx, dy], "swapped": args.swap, + "errorCode_counts": counts, + "centroid_offset_px": { + "count": len(all_off), + "min": min(all_off) if all_off else None, + "max": max(all_off) if all_off else None, + "mean": round(float(np.mean(all_off)), 1) if all_off else None, + }, + "rows": rows, + } + (outdir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1), + encoding="utf-8") + print("\n" + "=" * 74) + print("靶子=%s 题型=%s 次数=%d 答案平移=%s 交换=%s" % (args.target, args.kind, args.n, + args.offset, args.swap)) + print("errorCode 计数: %s" % json.dumps(counts, ensure_ascii=False)) + if all_off: + print("点击点偏离图标笔画质心: 均值 %.1fpx / 最小 %.1f / 最大 %.1f(%d 个点)" + % (np.mean(all_off), min(all_off), max(all_off), len(all_off))) + print("产物: %s" % outdir) + print("=" * 74) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify/send_test.py b/scripts/verify/send_test.py index 6fc188a..c3d6dc5 100644 --- a/scripts/verify/send_test.py +++ b/scripts/verify/send_test.py @@ -12,7 +12,11 @@ 用法: .venv/bin/python scripts/verify/send_test.py - .venv/bin/python scripts/verify/send_test.py --mode python_keyed # 对照组:旧模板回填 + .venv/bin/python scripts/verify/send_test.py --kind icon # 2408 图标点选 + .venv/bin/python scripts/verify/send_test.py --kind slider # 2401/2402 滑块 + +注: `--mode` 只剩 `python_values` 一种 —— 模板回填 / Node 兜底两条老路已整体删除 +(它们产生的 collect 与本次会话构建不同源,在真实 aid 上必然 errorCode=12)。 """ import argparse import json @@ -42,8 +46,8 @@ KIND_MAP = { def main() -> int: ap = argparse.ArgumentParser(description="1ms 签到页文字点选 · 真实发送测试") - ap.add_argument("--mode", default="python_values", - choices=["python_values", "python_keyed", "python"]) + ap.add_argument("--mode", default="python_values", choices=["python_values"], + help="遥测生成方式(只有 python_values:纯离线按本次会话构建生成)") ap.add_argument("--kind", default="text", choices=list(KIND_MAP), help="只解哪种题(默认 text=文字点选)") ap.add_argument("--max-refreshes", type=int, default=12) @@ -76,6 +80,11 @@ def main() -> int: seen["traj_tail"] = json.loads(traj)[-1] except Exception as e: # noqa: BLE001 seen["segments_err"] = str(e)[:120] + try: # 失败时落盘,便于事后解剖 + open("/tmp/send_test_fail.b64", "w").write(out["collect"]) + open("/tmp/send_test_fail.txt", "w", encoding="latin1").write(plain) + except Exception: # noqa: BLE001 + pass print("[hook] %s" % json.dumps(seen, ensure_ascii=False)) return out diff --git a/tcaptcha/protocol/client.py b/tcaptcha/protocol/client.py index c7b1061..590988a 100644 --- a/tcaptcha/protocol/client.py +++ b/tcaptcha/protocol/client.py @@ -696,9 +696,9 @@ 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_keyed", - choices=["python_keyed", "python"], - help="遥测方式: python_keyed(Node 仅提密钥) / python(纯 Python 固定密钥)") + parser.add_argument("--telemetry", default="python_values", + choices=["python_values"], + help="遥测方式: 只有 python_values(纯离线;模板回填/Node 两条老路已删除)") parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080") parser.add_argument("--proxy-api", default=None, help="代理提取 API (可选; 注意换 IP 并不能解决 errorCode=12)") diff --git a/tcaptcha/telemetry/crypto.py b/tcaptcha/telemetry/crypto.py index 64c1cc9..fccb411 100644 --- a/tcaptcha/telemetry/crypto.py +++ b/tcaptcha/telemetry/crypto.py @@ -219,36 +219,52 @@ def split_collect_segments(plain: str) -> Tuple[str, str, str, str]: sd = '"sd":{...}}' 前三段各用空格 pad 到 24 字节整数倍,sd 不 pad。 + ⚠️ 轨迹落在数组两端时,相邻那段的逗号按 **JSON.stringify 语义** 消失: + k == 0 -> chunk1 = '{"cd":[' (真机成功包实测) + k == n-1 -> chunk2 = '],' (2026-10-12 实测构建) + 做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验 (首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。 校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。 """ data = plain.encode("latin1") cands = _candidate_pads(data) + n_struct = n_json = 0 for (a1, b1), (a2, b2), (a3, b3) in itertools.combinations(cands, 3): if not (b1 <= a2 and b2 <= a3): continue segs = [data[0:a1], data[b1:a2], data[b2:a3], data[b3:]] - # 段边界一定落在 JSON 记号边界上(3 份真机样本一致): - # chunk1 以 ',' 结尾;traj 是**一个**完整 JSON 值;chunk2 以 ',' 开头、以 '],' 结尾 - if not (segs[0].startswith(b'{"cd":[') and segs[0].endswith(b',') - and segs[2].startswith(b',') and segs[2].endswith(b'],') + # 段边界一定落在 JSON 记号边界上: + # chunk1 = '{"cd":[' + 若干槽 + [',' 或 ''] —— 轨迹正好是第 0 槽时**没有那个逗号** + # (真机成功包实测:chunk1 就是 '{"cd":[',随后 17 个空格 pad) + # traj = **一个**完整 JSON 值 + # chunk2 = [',' + 若干槽] + '],' —— 轨迹正好是最后 1 槽时**没有那个前导逗号** + if not (segs[0].startswith(b'{"cd":[') + and (segs[0].endswith(b',') or segs[0] == b'{"cd":[') + and segs[2].endswith(b'],') + and (segs[2].startswith(b',') or segs[2] == b'],') and segs[3].startswith(b'"sd"')): + n_struct += 1 continue try: json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值 except Exception: # noqa: BLE001 + n_struct += 1 continue try: obj = json.loads(b"".join(segs).decode("latin1")) except Exception: # noqa: BLE001 + n_json += 1 continue if "cd" not in obj or "sd" not in obj: + n_json += 1 continue return (segs[0].decode("latin1"), segs[1].decode("latin1"), segs[2].decode("latin1"), segs[3].decode("latin1")) raise ValueError( - f"未能切出「四段加密」结构(候选 pad {len(cands)} 个);这份明文可能不是 4 段格式") + f"未能切出「四段加密」结构(候选 pad {len(cands)} 个," + f"边界结构不符 {n_struct} 组,拼回 JSON 校验失败 {n_json} 组);" + f"这份明文可能不是 4 段格式,或生成侧拼出了非法 JSON") def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes: diff --git a/tcaptcha/telemetry/values.py b/tcaptcha/telemetry/values.py index 3510bf1..1aa6980 100644 --- a/tcaptcha/telemetry/values.py +++ b/tcaptcha/telemetry/values.py @@ -98,17 +98,31 @@ def default_trajectory() -> List[List[int]]: def _sample_dt() -> int: - """采样一个事件间隔(ms)。分布按真机成功样本拟合:浏览器合并事件会出现 0。""" + """采样一个事件间隔(ms)——**按 6 条真机轨迹 / 360 个非首点 dt 实测重拟合**(2026-10-12)。 + + 旧分布(账本里记为"旧拟合")是拿**一条**样本估的,偏差很大:它给了 + ≥50% 概率落在 0~16ms、约 6% 落在 41~200ms。真机实测分布是**紧密聚集在 16~17ms**: + + 0-4 0.28% 5-8 1.39% 9-12 6.39% + 13-16 35.56% 17-20 43.61% 21-25 4.44% + 26-40 2.78% 41-80 1.39% 81-200 2.22% >200 1.94% + (众数 17ms×126 / 16ms×93;均值 25.2,是被极少数长停顿拉高的) + + 判据:真机 **79%** 落在 13~20ms,而我们旧实现只有约 36% —— + "轨迹事件间隔"是服务端最便宜的一类时序特征,别再拿单条样本估分布。 + """ r = random.random() - if r < 0.50: - return random.randint(0, 16) - if r < 0.75: - return random.randint(17, 24) - if r < 0.90: - return random.randint(25, 60) - if r < 0.98: - return random.randint(61, 160) - return random.randint(161, 400) + if r < 0.0628: # ~6%:偏快的一档(9~12ms) + return random.randint(9, 12) + if r < 0.8472: # ~78%:主峰 13~20ms(真机 79%) + return random.randint(13, 20) + if r < 0.9000: # ~5%:21~25ms + return random.randint(21, 25) + if r < 0.9300: # ~3%:26~40ms + return random.randint(26, 40) + if r < 0.9700: # ~4%:41~200ms(偶尔的思考停顿) + return random.randint(41, 200) + return random.randint(201, 600) # ~3%:罕见的长时间歇 def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]: @@ -117,16 +131,38 @@ def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]: return 20 + x * scale, 60 + y * scale +REAL_TRAJECTORY_LEN = 61 # 真机硬不变量:6/6 条成功轨迹都是 61 点(见 _fit_points) + + +def _fit_points(events: List[List[int]]) -> List[List[int]]: + """把轨迹**补齐/截断到真机的 61 点**,末尾再钉上固定收尾 `[1,1,12]`。 + + 为什么点数是硬约束:4 份真机抓包 / **6 条点选成功轨迹**,点数**统统是 61** + (`点选.har` 里 3 次提交也一样),一条都不差 —— 这是服务端最容易查的一类 + 结构特征,而旧实现每次随机 57~65 点,等于每发一枪都自带一个异常值。 + + ⚠️ 补齐必须用 `[0,0,dt]`(驻留/微抖动),**不能**复制最后一个 `[dx,dy,dt]` —— + 复制会把位移量重复计入,净位移被放大(实测 166 vs 期望 139.5)。 + """ + body = [e for e in events if e != [1, 1, 12]] + target = REAL_TRAJECTORY_LEN - 1 + if len(body) >= target: + body = body[:target] + else: + while len(body) < target: # 驻留/微抖动:位移为 0 + body.append([0, 0, _sample_dt()]) + return body + [[1, 1, 12]] + + def trajectory_points(click_points: Sequence[Sequence[int]], img_w: int = 672) -> List[List[int]]: """按点击点生成鼠标轨迹:首点绝对坐标 + 增量点 + 固定收尾 `[1,1,12]`。 - 形状与真机一致(3 份样本逐字核对): - * 首点是**绝对坐标**(含 300~600ms 初始停顿) - * 之后全是**增量** `[dx,dy,dt]` - * **最后一点固定是 `[1,1,12]`** —— 真机成功样本与"没动鼠标"样本都这么收尾 - (没动鼠标时整条轨迹就是 `[[1,1,12]]`) - dt 分布按成功样本实测拟合(旧 crypto._success_style_trajectory 的统计)。 + 形状与真机一致(真机 6 条逐条核对,2026-10-12): + * 首点是**绝对坐标**(含一个"进入前"的停顿,真机实测 7~14766ms) + * 之后全是**增量** `[dx,dy,dt]`,dt 分布见 `_sample_dt` + * **最后一点固定是 `[1,1,12]`** + * **总点数固定 61**(真机 6/6 硬不变量,见 `_fit_points`) """ scale = 320.0 / img_w targets = [(int(round(20 + x * scale)), int(round(60 + y * scale))) @@ -143,8 +179,7 @@ def trajectory_points(click_points: Sequence[Sequence[int]], cur = [int(nx), int(ny)] for _ in range(random.randint(2, 5)): # 命中后短暂驻留 events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()]) - events.append([1, 1, 12]) # 固定收尾(真机如此) - return events + return _fit_points(events) def drag_trajectory_points(start: Sequence[float], end: Sequence[float], @@ -152,7 +187,11 @@ def drag_trajectory_points(start: Sequence[float], end: Sequence[float], """滑块**拖动**轨迹:按下 -> 横向移动 -> 松手。 与 `trajectory_points` 同一套口径(输入是背景图原始像素,输出是 iframe 显示坐标), - 同样以固定 `[1,1,12]` 收尾。速度曲线用 ease-out:人手拖动是先快后慢、末尾微调。 + 同样以固定 `[1,1,12]` 收尾、dt 同一套分布。速度曲线用 ease-out: + 人手拖动是先快后慢、末尾微调。 + + ⚠️ **拖动轨迹不套 61 点**:那个硬不变量是从**点选**轨迹量出来的(6/6), + 拖动是另一种交互(按下→横移→松手),点数按 steps 走。别外推。 """ sx, sy = _to_display(float(start[0]), float(start[1]), img_w) ex, ey = _to_display(float(end[0]), float(end[1]), img_w) @@ -374,9 +413,14 @@ def build_plain(cd: Sequence[Any], rows: Sequence[Dict], head = ",".join(js(v) for v in cd[:k]) tail = ",".join(js(v) for v in cd[k + 1:]) return { - "chunk1": '{"cd":[' + head + ",", + # ⚠️ 逗号是**槽与槽之间**的分隔符(真机是 JSON.stringify 语义),所以两端的空区间 + # 都**不能**再补分隔逗号,否则会拼出 `{"cd":[,` 或 `...,],` 这种非法 JSON。 + # · 轨迹正好是第 0 槽 -> chunk1 就是 '{"cd":['(真机成功包实测,见账本已知 #58) + # · 轨迹正好是最后 1 槽 -> chunk2 就是 '],'(38 槽里 k=37 的构建实测, + # 2026-10-12;此前这里恒补前导逗号 -> 拼出非法 JSON) + "chunk1": '{"cd":[' + (head + "," if head else ""), "trajectory": js(cd[k]), - "chunk2": "," + tail + "],", + "chunk2": ("," + tail + "],") if tail else "],", "sd": '"sd":' + js(sd if sd is not None else {"od": "C"}) + "}", } diff --git a/tcaptcha/tests/test_crypto_core.py b/tcaptcha/tests/test_crypto_core.py index 617ad22..e013ec4 100644 --- a/tcaptcha/tests/test_crypto_core.py +++ b/tcaptcha/tests/test_crypto_core.py @@ -132,14 +132,74 @@ def test_offline_generate_and_roundtrip() -> None: len(plain["chunk2"]), len(plain["sd"])) +def test_build_plain_trajectory_first() -> None: + """轨迹就在第 0 槽时,chunk1 里没有任何槽 —— 不能再补分隔逗号。 + + 实测触发场景:某些构建的清单里轨迹模块排在第 0 位,那样会拼出 `{"cd":[,` + —— 非法 JSON,服务端返回 errorCode=12。 + """ + import json as _json + + from tcaptcha.telemetry.values import build_plain, trajectory_index + + rows = [{"name": "trajectory"}, {"name": "platform"}, {"name": "constant_98k"}] + assert trajectory_index(rows) == 0 + cd = [[[1, 1, 12]], "MacIntel", "98k"] + p = build_plain(cd, rows) + assert p["chunk1"] == '{"cd":[', repr(p["chunk1"]) + assert _json.loads(p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"])["cd"] == cd + + # 轨迹在中间时,chunk1 仍必须以 ',' 结尾(真机实测口径) + rows2 = [{"name": "platform"}, {"name": "trajectory"}, {"name": "constant_98k"}] + p2 = build_plain(["MacIntel", [[1, 1, 12]], "98k"], rows2) + assert p2["chunk1"].endswith(",") and p2["chunk2"].startswith(",") + + +def test_build_plain_trajectory_last() -> None: + """轨迹就在**最后 1 槽**时,chunk2 里没有任何槽 —— 不能再补前导逗号。 + + 实测触发场景(2026-10-12):38 槽里有构建把轨迹排在 k=37(最后), + 旧写法恒拼 `,],` -> 整段变成 `{"cd":[...,],` 非法 JSON。 + 本题同时钉住「切分器必须接受 `],` 这种对称边界」。 + """ + import base64 as _b64 + import json as _json + + from tcaptcha.telemetry.crypto import DEFAULT_KEY + from tcaptcha.telemetry.values import build_plain, trajectory_index + + rows = [{"name": "platform"}, {"name": "constant_98k"}, {"name": "trajectory"}] + assert trajectory_index(rows) == 2 + cd = ["MacIntel", "98k", [[1, 1, 12]]] + p = build_plain(cd, rows) + assert p["chunk2"] == "],", repr(p["chunk2"]) + whole = p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"] + assert _json.loads(whole)["cd"] == cd + + # 旧写法(恒补前导逗号)拼出来的确实是非法 JSON —— 这正是当年 12 的成因之一 + bad = p["chunk1"] + p["trajectory"] + "," + p["chunk2"] + p["sd"] + try: + _json.loads(bad) + raise AssertionError("旧写法应当拼出非法 JSON") + except ValueError: + pass + + # 走完整加密 -> 解密 -> 切分:切分器要吃下 `],` 这种边界 + collect = build_collect(p["chunk1"], p["trajectory"], p["chunk2"], p["sd"], DEFAULT_KEY) + dec = xtea_decrypt_bytes(_b64.b64decode(collect), DEFAULT_KEY).decode("latin1").rstrip("\x00") + assert split_collect_segments(dec) == (p["chunk1"], p["trajectory"], p["chunk2"], p["sd"]) + + def main() -> int: selfcheck() test_split_single_space_pad() test_split_ignores_space_inside_string() test_build_collect_roundtrip_single_space_pad() test_offline_generate_and_roundtrip() + test_build_plain_trajectory_first() + test_build_plain_trajectory_last() print("✅ crypto 纯算法自检通过 (XTEA 往返 + encrypt_string 一致性" - " + 四段切分回归 + 纯离线闭环)") + " + 四段切分回归 + 纯离线闭环 + 轨迹首/末槽边界)") return 0