feat(诊断): 用真机复现解出 1ms 成功会话的 collect 明文 —— 归因改写为「cd 槽位序按会话随机」

结论 (已知 #29~#32, 推翻旧 #25/#27 与"collect 逐字节同构"这条否定实验):
  * 同一个 tdc.js URL 每次请求返回不同构建 (4/4 md5 + XTEA 密钥全不同)
  * 同会话同时抓 tdc.js + verify -> 解密出真机 collect 明文 (784B/38 槽, 可打印率 .991)
  * 5 份同日样本 (真机 cap2 / 签到成功-1 / 点选 / login_checkin / 仓库模板) 全部 38 槽、
    同一批取值、同一个 sd, 但槽位序 5/5 互不相同
  * 仓库在用的模板槽位序 == 签到成功-1 那一次; generate_collect_success() 只回填
    轨迹/时间戳/entry_url, 槽位序仍是那一次的

=> 头号可证伪假设 H-LAYOUT: 服务端按"本次会话构建"的槽位表解释 cd, 用别的会话的槽位序
   会被读成自相矛盾的环境 -> 12 (答案错理应是 50/9)。这也解释了为何换 IP / 换遥测后端 /
   换传输指纹 / 换 cookie / 长静默全都无效 —— 它们都不动槽位序。

新增:
  * scripts/analyze_collect_layout.py —— collect 槽位序对比 (复现 #31)
  * tcaptcha/slider_solver.py + test —— 滑块缺口求解 (ZNCC, 真机首轮命中 ret=0)
  * tcaptcha/icon_click_solver.py + test —— 图文点选形状模板求解
  * scripts/bridge_1ms_captcha.py —— 真机桥接三模式 (slider/icon/text) + --probe 题型侦察
  * tools/cdp_eval.js / cdp_click.js / cdp_shot.js / cdp_net_capture.js / cdp_capture.js
  * selftest 纳入 icon_click / slider 两个套件

账本: §一 修正 #25/#27、新增 #29~#32; §三 标注"collect 逐字节同构"否定实验有缺陷;
      §五 重写 12 的归因与下一步 (决定性实验 A + 取槽位表两条路)
This commit is contained in:
yml2213
2026-10-11 23:59:42 +08:00
parent bc8cc5e124
commit c55f833c66
17 changed files with 1716 additions and 13 deletions
+390
View File
@@ -0,0 +1,390 @@
# -*- coding: utf-8 -*-
"""
1ms 验证码「真机桥接」驱动 —— 在**真实 Chrome 页面**里完成腾讯滑块/点选, 拿回 ticket。
为什么需要桥接 (har/1ms_dianxuan/PROGRESS.md 已知 #26/#27):
aid=195672344 对该 aid 之外的会话恒返回 errorCode=12 (aid 级风控), 纯协议侧无解;
但**真实浏览器页面**里能拿到 ec=0。所以这一层只负责"过验证码", 业务提交仍走纯 HTTPS。
它做什么 (每轮):
1. 用 CDP 在真实页面里刷新腾讯验证码 widget (`__cap.refresh()`);
2. 读 widget 内的题型/题面/几何 (滑块: slideBg + 拼图块 + 拖动柄; 点选: 提示图 + 背景图);
3. 求解:
* 滑块 -> tcaptcha.slider_solver.SliderSolver (ZNCC 找真缺口)
* 图文点选 -> tcaptcha.icon_click_solver.IconClickSolver (形状模板 + 旋转 IoU)
4. 用 CDP `Input.dispatchMouseEvent` 派发**真实(trusted)鼠标事件** (点击 / 拖动);
5. 读回 widget 回调的 {ret, ticket, randstr}。
前置:
Chrome 以 `--remote-debugging-port=9222` 启动且已登录 1ms;
页面里已注入 widget 实例: `window.__cap = new TencentCaptcha(aid, cb, {}); __cap.show()`。
用法:
.venv/bin/python scripts/bridge_1ms_captcha.py --rounds 12
.venv/bin/python scripts/bridge_1ms_captcha.py --only slider --rounds 20
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import cv2 # noqa: E402
from tcaptcha.glyph_solver import TextClickSolver # noqa: E402
from tcaptcha.icon_click_solver import IconClickSolver # noqa: E402
from tcaptcha.slider_solver import SliderSolver, sprite_region # noqa: E402
NODE = "node"
CDP_EVAL = str(ROOT / "tools" / "cdp_eval.js")
CDP_CLICK = str(ROOT / "tools" / "cdp_click.js")
TMP = Path("/tmp/1ms_bridge")
PAGE = "1ms.run"
IFRAME = "drag_ele"
# 页面重载会丢掉注入的实例; 这个表达式负责"有就刷新, 没有就重建"
EXPR_ENSURE = """(() => {
try {
if (window.__cap && typeof window.__cap.refresh === 'function') {
window.__capres = null; window.__cap.refresh();
return {mode: 'refresh'};
}
if (typeof window.TencentCaptcha !== 'function') return {err: 'TencentCaptcha 未加载'};
window.__capres = null;
window.__cap = new window.TencentCaptcha('%AID%',
function (res) { window.__capres = res; }, {});
window.__cap.show();
return {mode: 'inject'};
} catch (e) { return {err: String(e)}; }
})()""".replace("%AID%", "195672344")
EXPR_RESULT = "(()=>({res: window.__capres||null}))()"
# 页面里可能有多个 tcaptcha iframe (隐藏的预加载 iframe 在 y=-1000000), 必须挑**可见**那个
EXPR_FRAME = """(() => {
const cands = [...document.querySelectorAll('iframe[id^="tcaptcha"], iframe[src*="captcha"]')];
const f = cands.find(e => {
const r = e.getBoundingClientRect();
return r.width > 100 && r.height > 100 && r.top > -1000;
});
if (!f) return {found:false, count: cands.length};
const r = f.getBoundingClientRect();
return {found:true, x:r.x, y:r.y, w:r.width, h:r.height};
})()"""
EXPR_GEO = """(() => {
const R = el => { const r = el.getBoundingClientRect();
return {x:r.x, y:r.y, w:r.width, h:r.height}; };
const urlOf = el => { const bi = getComputedStyle(el).backgroundImage || '';
const m = /url\\(["']?([^"')]+)["']?\\)/.exec(bi); return m ? m[1] : null; };
const out = {
instruction: ((document.getElementById('instructionText') || {}).textContent || '').trim(),
mode: null,
};
// --- 图文点选: 提示条是 <img> 里 img_index=0, 背景是 img_index=1 ---
const all = [...document.querySelectorAll('img')];
if (/依次点击|点击/.test(out.instruction)) {
const pr = all.map(i => i.src).find(s => (s || '').includes('img_index=0'));
const bgs = [...document.querySelectorAll('*')].filter(e => {
const bi = getComputedStyle(e).backgroundImage || '';
return bi.includes('img_index=1') && e.getBoundingClientRect().width > 200;
});
const bgEl = bgs[0];
// 可见的「确定」按钮: 逐层找叶子文本为 确定/确认 且尺寸非 0
const btn = [...document.querySelectorAll('div,span,button,a')].find(e => {
const t = (e.textContent || '').trim();
const r = e.getBoundingClientRect();
return (t === '确定' || t === '确认' || t === '提交') && r.width > 10 && r.height > 10;
});
out.mode = pr ? 'icon' : 'text'; // 有提示图 = 图文点选; 纯文字题面 = 文字点选
out.promptUrl = pr || null;
out.bgUrl = bgEl ? urlOf(bgEl) : null;
out.bgRect = bgEl ? R(bgEl) : null;
out.bgNatural = bgEl ? bgEl.getAttribute('style') : null;
out.okBtn = btn ? R(btn) : null;
return out;
}
// --- 滑块: slideBg 有 img_index=1 背景, 拼图块元素有 img_index=0 背景 ---
const bgEl = document.getElementById('slideBg');
if (bgEl && urlOf(bgEl) && bgEl.getBoundingClientRect().width > 150) {
const st = getComputedStyle(bgEl);
out.mode = 'slider';
out.bgUrl = urlOf(bgEl);
out.bgRect = R(bgEl);
out.bgCssSize = st.backgroundSize;
const pe = [...document.querySelectorAll('div')].find(e => {
const r = e.getBoundingClientRect();
return (getComputedStyle(e).backgroundImage || '').includes('img_index=0')
&& r.height > 30 && r.height < 140 && r.width > 30 && r.width < 140;
});
if (pe) {
const ps = getComputedStyle(pe);
out.pieceRect = R(pe);
out.pieceBgPos = ps.backgroundPosition;
out.pieceBgSize = ps.backgroundSize;
out.pieceUrl = urlOf(pe);
}
const he = document.querySelector('.tc-slider-normal') || document.querySelector('.tc-fg-item.tc-slider-normal');
if (he) out.handleRect = R(he);
return out;
}
return out;
})()"""
def cdp_eval(expr: str, match: str) -> dict:
p = subprocess.run([NODE, CDP_EVAL, "--port", "9222", "--match", match],
input=expr, capture_output=True, text=True, timeout=90)
if p.returncode != 0:
raise RuntimeError(f"cdp_eval 失败: {p.stderr.strip()[:300]}")
return json.loads(p.stdout)
def cdp_click(clicks) -> None:
spec = ";".join("%d,%d" % (round(x), round(y)) for x, y in clicks)
p = subprocess.run([NODE, CDP_CLICK, "--port", "9222", "--match", PAGE,
"--clicks", spec], capture_output=True, text=True, timeout=180)
if p.returncode != 0:
raise RuntimeError(f"cdp_click 失败: {p.stderr.strip()[:300]}")
def cdp_drag(x1: float, y1: float, x2: float, y2: float, steps: int = 34) -> None:
spec = "%.2f,%.2f,%.2f,%.2f,%d" % (x1, y1, x2, y2, steps)
p = subprocess.run([NODE, CDP_CLICK, "--port", "9222", "--match", PAGE,
"--drag", spec], capture_output=True, text=True, timeout=180)
if p.returncode != 0:
raise RuntimeError(f"cdp_drag 失败: {p.stderr.strip()[:300]}")
def download(url: str, path: Path) -> bool:
p = subprocess.run(["curl", "-s", "--max-time", "30", "-o", str(path),
"-H", "Referer: https://turing.captcha.gtimg.com/1/template/drag_ele.html",
url], capture_output=True, text=True)
ok = p.returncode == 0 and path.exists() and path.stat().st_size > 1000
return ok
def parse_px(value: str) -> tuple:
"""把 '345.06px 313.69px' / '-70.8333px -247.917px' 解析成 (x, y)。"""
nums = []
for tok in str(value).replace("px", " ").split():
try:
nums.append(float(tok))
except ValueError:
pass
return (nums[0], nums[1]) if len(nums) >= 2 else (0.0, 0.0)
def solve_slider(geo: dict, tmp: Path, solver: SliderSolver):
"""滑块: 返回 (from_xy, to_xy) —— 均为**顶层页面视口**坐标。"""
bg_path, sp_path = tmp / "sl_img1", tmp / "sl_img0"
if not download(geo["bgUrl"], bg_path) or not download(geo["pieceUrl"], sp_path):
return None, "图片下载失败"
bg = cv2.imread(str(bg_path))
sprite = cv2.imread(str(sp_path), cv2.IMREAD_UNCHANGED)
if bg is None or sprite is None:
return None, "图片解码失败"
bg_rect, piece_rect = geo["bgRect"], geo["pieceRect"]
scale = bg_rect["w"] / bg.shape[1] # 显示宽度 / 原图宽度
piece_x_image = (piece_rect["x"] - bg_rect["x"]) / scale
piece_y_image = (piece_rect["y"] - bg_rect["y"]) / scale
px, py = parse_px(geo["pieceBgPos"])
sw, sh = parse_px(geo["pieceBgSize"])
bx, by, bw, bh = sprite_region((px, py), (sw, sh),
(piece_rect["w"], piece_rect["h"]), sprite.shape[:2])
crop = sprite[by:by + bh, bx:bx + bw]
if crop.size == 0:
return None, f"精灵图裁剪越界 {(bx, by, bw, bh)} vs {sprite.shape}"
res = solver.solve(bg, crop, piece_x_image=piece_x_image, scale=scale,
piece_y_image=piece_y_image)
print(f" 滑块求解: ok={res.ok} {res.describe()}")
if not res.ok:
return None, "求解不可靠"
cv2.imwrite(str(tmp / "slider_annot.png"), SliderSolver.annotate(bg, res, bw, bh))
handle = geo.get("handleRect")
if not handle:
return None, "未找到拖动柄"
hx = handle["x"] + handle["w"] / 2.0
hy = handle["y"] + handle["h"] / 2.0
return (hx, hy, hx + res.dx_display, hy), None
def solve_icon(geo: dict, tmp: Path, solver: IconClickSolver):
"""图文点选: 返回 (clicks, ) —— 均为**iframe 内**坐标, 由调用方加偏移。"""
pr_path, bg_path = tmp / "ic_prompt.jpg", tmp / "ic_bg.jpg"
if not download(geo["promptUrl"], pr_path) or not download(geo["bgUrl"], bg_path):
return None, "图片下载失败"
pr = cv2.imread(str(pr_path))
bg = cv2.imread(str(bg_path))
if pr is None or bg is None:
return None, "图片解码失败"
res = solver.solve(bg, pr)
print(f" 点选求解: ok={res.ok} {res.describe()}")
if not res.ok:
return None, "求解不可靠"
cv2.imwrite(str(tmp / "icon_annot.png"), solver.annotate(bg, res))
bg_rect = geo["bgRect"]
sx, sy = bg_rect["w"] / bg.shape[1], bg_rect["h"] / bg.shape[0]
clicks = [(bg_rect["x"] + x * sx, bg_rect["y"] + y * sy) for x, y in res.points]
return clicks, None
def solve_text(geo: dict, tmp: Path, solver: TextClickSolver):
"""文字点选 (汉字 2404): 题面自带目标字, 返回 iframe 内点击坐标。"""
bg_path = tmp / "tx_bg.jpg"
if not download(geo["bgUrl"], bg_path):
return None, "图片下载失败"
bg = cv2.imread(str(bg_path))
if bg is None:
return None, "图片解码失败"
res = solver.solve_ex(bg, geo["instruction"])
print(f" 文字点选求解: ok={res.ok} {res.describe()}")
print(f" points={res.points} targets={res.targets}")
if not res.ok:
return None, "求解不可靠"
bg_rect = geo["bgRect"]
sx, sy = bg_rect["w"] / bg.shape[1], bg_rect["h"] / bg.shape[0]
clicks = [(bg_rect["x"] + x * sx, bg_rect["y"] + y * sy) for x, y in res.points]
return clicks, None
def probe(rounds: int) -> int:
"""只刷新不点击, 统计目标站点实际下发的题型分布 (侦察用)。"""
dist: dict = {}
for rnd in range(1, rounds + 1):
ens = cdp_eval(EXPR_ENSURE, PAGE)["result"]
if ens.get("err"):
print(f" 无法唤起 widget: {ens['err']}")
return 2
time.sleep(3.0)
geo = cdp_eval(EXPR_GEO, IFRAME)["result"]
mode = geo.get("mode") or "unknown"
dist[mode] = dist.get(mode, 0) + 1
print(f" [{rnd:02d}] {mode:8s} {geo.get('instruction')!r}")
print(f"\n题型分布 ({rounds} 次刷新): {dist}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="1ms 验证码真机桥接 (CDP)")
ap.add_argument("--aid", default="195672344")
ap.add_argument("--rounds", type=int, default=12, help="最多尝试几轮 (1ms 滑块下发最多)")
ap.add_argument("--solves", type=int, default=1, help="拿到几个 ticket 后停止 (用于统计成功率)")
ap.add_argument("--probe", type=int, default=0, help="只刷新 N 次统计题型分布, 不点击")
ap.add_argument("--only", choices=["slider", "icon", "text", "auto"], default="auto")
args = ap.parse_args()
TMP.mkdir(parents=True, exist_ok=True)
if args.probe:
return probe(args.probe)
slider, icon = SliderSolver(), IconClickSolver()
text_solver = None
skipped = {"slider": 0, "icon": 0, "text": 0}
stats = {"attempts": 0, "ok": 0, "fail": 0, "timeout": 0, "unsolved": 0, "skip": 0}
tickets: list = []
for rnd in range(1, args.rounds + 1):
if stats["ok"] >= args.solves:
break
print(f"\n=== 第 {rnd}/{args.rounds} 轮 (成功 {stats['ok']}/{args.solves}) ===")
ens = cdp_eval(EXPR_ENSURE, PAGE)["result"]
if ens.get("err"):
print(f" 无法唤起 widget: {ens['err']}")
return 2
print(f" widget: {ens.get('mode')}")
time.sleep(3.0)
geo = cdp_eval(EXPR_GEO, IFRAME)["result"]
mode = geo.get("mode")
print(f" 题型={mode} 题面={geo.get('instruction')!r}")
if mode is None:
print(" 未就绪 -> 下一轮")
continue
if args.only != "auto" and mode != args.only:
skipped[mode] += 1
stats["skip"] += 1
print(f" 跳过 (--only {args.only})")
continue
fr = cdp_eval(EXPR_FRAME, PAGE)["result"]
if not fr.get("found"):
print(" 未找到可见的 captcha iframe -> 下一轮")
continue
ox, oy = fr["x"], fr["y"]
if mode == "slider":
plan, err = solve_slider(geo, TMP, slider)
if err:
print(f" {err} -> 下一轮")
stats["unsolved"] += 1
continue
x1, y1, x2, y2 = plan
print(" 页面坐标拖动: (%.0f,%.0f) -> (%.0f,%.0f) dx=%.1f" % (x1, y1, x2, y2, x2 - x1))
cdp_drag(ox + x1, oy + y1, ox + x2, oy + y2)
else:
if mode == "text":
if text_solver is None:
text_solver = TextClickSolver()
clicks, err = solve_text(geo, TMP, text_solver)
else:
clicks, err = solve_icon(geo, TMP, icon)
if err:
print(f" {err} -> 下一轮")
stats["unsolved"] += 1
continue
pts = [(ox + x, oy + y) for x, y in clicks]
btn = geo.get("okBtn")
if btn:
pts.append((ox + btn["x"] + btn["w"] / 2, oy + btn["y"] + btn["h"] / 2))
else:
print(" ⚠️ 未找到可见的「确定」按钮")
print(" 页面坐标点击: %s" % [("%d,%d" % (round(a), round(b))) for a, b in pts])
cdp_click(pts)
stats["attempts"] += 1
got = None
for _ in range(25):
time.sleep(0.6)
got = cdp_eval(EXPR_RESULT, PAGE)["result"]["res"]
if got:
break
if not got:
print(" 等待回调超时 -> 下一轮")
stats["timeout"] += 1
continue
print("\n >>> widget 回调: %s" % json.dumps(got, ensure_ascii=False)[:300])
print(" >>> ret=%s ticket=%s randstr=%s" %
(got.get("ret"), (got.get("ticket") or "")[:32], got.get("randstr")))
if got.get("ret") == 0:
stats["ok"] += 1
tickets.append(got.get("ticket"))
else:
stats["fail"] += 1
print(f"\n{'=' * 62}")
print("统计: %s" % stats)
print("跳过: %s" % skipped)
if tickets:
print("最后 ticket: %s" % tickets[-1][:48])
if stats["ok"] >= args.solves:
print("✅ 全部达成 (%d/%d)" % (stats["ok"], args.solves))
return 0
print("❌ 未达成 (%d/%d)" % (stats["ok"], args.solves))
return 1
if __name__ == "__main__":
raise SystemExit(main())