diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md index c4348fa..d6f97fc 100644 --- a/har/1ms_dianxuan/PROGRESS.md +++ b/har/1ms_dianxuan/PROGRESS.md @@ -68,6 +68,8 @@ | 35 | **运行时反证通过:静态导出的 require 序 == 运行时真实 require 序(逐项一致)** | 在真机 iframe(`tdc.js` 实际执行的上下文,`window.TDC.getData` 就在这里)里包一层 `Function.prototype.apply`,模块工厂调用形状 = **4 参 `[object,object,object,function]`**(require 是最后一个),按工厂实例分组记账。触发一次刷新后取运行时串,用 `tools/cdp_script_source.js`(Debugger.scriptParsed + getScriptSource)取到**运行时真正执行的那份 tdc.js**(exactly-once,不能用重新 fetch 代替——#29 每次返回不同构建),两边串:
静态 = 运行时 = `[8,9,10,13,14,15,16,17,18,19,20,21,23,24,25,30,31,32,34,36,37,1,38,39,40,41,42,43,44,47,48,49,50,51,56,57,58]`(37 项,逐位相同)
→ 由此可判定:之前"部分槽语义对不上"是**标注启发式**的问题,**顺序本身正确**。工具:`scripts/experiment_require_order.py` | 确证 | | 36 | **「清单项 → cd 槽」的精确规则(同一构建端到端校验确立)** | 装配循环(线上构建 `@4362`,370 条)分支树已读通:
`if(!item) continue;` `v,w,kind = item.get();`
· **`kind===2`(`@4820`)**:把 `r3` 清空转存 `r4`(`@4860`),`r4` 在收尾 `@4905` 二次加工后**拼回**,因此**仍产出 1 槽**(实测第 0 项,随机串 `vXc29RxhvT34Q…`)
· `v===null/undefined`(`@4799`)→ `out += "null"`
· `typeof v==="number"`(`@4738`)→ `out += (v||0)`
· **`kind===1`(`@4724`)→ `out += v` 原样拼**:返回值是 JSON 碎片串;**只按括号外的逗号**切槽 —— `'[]'`/`'[360,360]'`/`'["zh-CN","zh"]'` 内的逗号不构成新槽,而 `"641547489,1791732001"` → 2 槽
· 其余(`@4703`)→ `out += '"' + v + '"'`
· 收尾 `@4753`:`typeof w === "number"` 才再 +1 槽(实测 kind=2 的第二元素是 null,不触发;此路径本次未命中)
→ **37 项 → 38 槽**,仅第 33 项(模块 1)占 2 槽。工具:`tools/tdc_slotmap.py`(`--items` 运行时取样 + `--plain` 明文对拍) | 确证 | | 37 | **按构建生成 collect 的工具链已跑通(含回环自测)** | `tools/tdc_collectgen.py`:静态导出槽位序 → 按 #36 规则算「项→槽」→ 重排 → 该构建密钥加密。
实测(同一构建):37 项 → **38 槽**,明文 686B / collect 920 字符;**解密回环逐字节一致**,cd 与真机样本逐槽相同。
规则修正(重要):`kind=1` 的串**只按括号外逗号**切槽 —— `'[]'`/`'[360,360]'`/`'["zh-CN","zh"]'` 内的逗号不构成新槽;`kind=2` 的项**仍产出 1 槽**(转存 r4 后在收尾 @4905 拼回)。
回归:`tcaptcha/tests/test_collectgen.py`(已并入 selftest) | 确证 | +| 39 | **12 只在"答案正确"之后才判**(排查方法学上的关键事实)| 答错一律 50/9:本轮滑块题型下(我们按点选坐标答,必错)两组都是 50;换成真解(text/icon)后立刻全是 12。→ **任何用"答错"做的对照实验都没有区分度**,这解释了早期多轮对照为何无效。今后做 12 的 A/B 必须 `--solve` 真解题 | 确证 | +| 40 | **"谁在发 HTTP"是 12 的剩余唯一自变量**(结论,待最后一步坐实)| 证据链:① 槽位序证伪(§三);② 包内容证伪(本会话真实 collect 由 Python 发仍 12);③ 已知 #26:宽松 aid 190758559 上 **httpx + 无 cookie + 无头保真** 也能 0 —— 说明"请求侧细节"对宽松 aid 无所谓,但对 195672344 不够。④ 真机同 aid 只出 50/9/0(#25)。→ 剩余差在**会话↔请求方绑定**。**收口实验**:把同一 sess/eks/tkid + 同一 collect,分别由浏览器发与 Python 发,比较 errorCode | 结论(待收口) | | 38 | **纯离线生成还没打通(诚实标注)** | 当前 `--from-sample` 依赖**一份同机的真机明文**做重排;`--values`(语义名→值)需要调用方提供取值字典。**仍缺**:纯算地为每个语义名算出值(多数是静态环境量可配置,但 canvas/WebGL 指纹只能取自真实环境)。这一步完成前,不能声称"纯离线过风控"。 | 状态说明 | --- @@ -101,6 +103,9 @@ | **"密钥字必须靠 Node 插桩才能拿到"** | `tcaptcha/tdc_key.py` 纯 Python 零 Node 复算,6/6 构建与 Node 逐字一致(已知 #22)→ Node 降级为兜底 | | **H3(captcha 域腾讯云登录 cookie)能解决 12** | 用**抓包里的真实 cookie 值**(uin/skey/qcmainCSRFToken/intl)重跑,仍 12(`scripts/experiment_h3_cookie_ab.py --only b`,2026-10-11)→ 与早期结论一致,H3 确凿排除 | | **HTTP/TLS 传输指纹是 12 的自变量** | `curl_cffi`(impersonate=chrome: JA3/JA4 + HTTP/2 + Chrome 头序) + 真实 cookie,仍 12(`scripts/experiment_transport_ab.py --only b`)→ 传输层排除 | +| **collect 的"内容/取值"是 12 的自变量**(2026-10-12 **证伪**)| 用**本会话真机真实产物** (`TDC.getData(true)`,1012 字符,取值/顺序/密钥全对)原样交给 Python 发:**4/4 全是 12**。→ 包内容**不是**自变量;只剩 **"谁在发这个 HTTP"**(会话↔请求方绑定)| +| **H-LAYOUT:collect 的 `cd` 槽位序是 12 的自变量**(2026-10-11 晚提出,2026-10-12 **证伪**)| +两个独立实验都**否定**它:
① A/B(`scripts/experiment_layout_verify.py --solve`):按本次构建槽位序生成的 collect vs 仓库旧模板,**两组 9/9 全是 12**;
② **严格对照**(决定性):同一批取值、**同一长度 920**、只把槽位序**完全颠倒**,两组 **6/6 全是 12**。
→ **槽位序不影响 verify 结果**。已知 #31(槽位序按构建随机化)与 #34/#35/#36/#37(可静态导出)**仍然成立且是有价值的资产**,但它们**不是 12 的原因**。| | **请求头保真度是 12 的自变量** | 按真实抓包逐 endpoint 保真:资源 GET 用 `Sec-Fetch-Dest=script/image` + `Mode=no-cors` + `Storage-Access=active` + cookie,POST 用 `empty/cors` + Origin 且**不带** cookie;仍 12 | | **collect 明文结构/取值是 12 的自变量** | ~~我们的 collect 明文与 1ms 真实成功样本逐字节同构(同 1353B、同字段序、同取值,仅回填轨迹/时间戳/盐),仍 12~~ ⚠️ **该否定实验本身有缺陷(2026-10-11 晚推翻)**:所谓"同字段序"只是与**模板来源的那一次**样本比,而 `cd` 槽位序**按会话随机**(已知 #31),所以它没检验到真正会变的维度。修正后重开为:**"cd 槽位序必须与本次会话构建一致"是 12 的头号可疑自变量**(已知 #32)| | **H5:1ms 后端用 `identity_context` 把会话标记为可信** | 拿抓包里**签名过的** `identity_context`(599B `emctx1.*`) 先调 `POST /api/v1/captcha/params`(响应与不带时**完全相同**,无额外 token)再解题,仍 12 → H5 排除 | @@ -147,29 +152,27 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 ④ 仓库在用的模板槽位序 == `签到成功-1.har` 那一次(已知 #32),而 `generate_collect_success()` 只回填轨迹/时间戳/entry_url —— **槽位序仍是那一次的**。 - * **H-LAYOUT(头号假设,可证伪)**:服务端按"**本次会话构建**"的槽位表解释 `cd`。 - 我们用**别的会话的槽位序**发出的 collect 会被读成"自相矛盾的环境" - (如 `cd[1]` 该是 `[360,360]` 却是 `"MacIntel"`)→ 风控判 **12**,与答案对错无关 - (答案错理应是 50/9)。 - 这条同时解释了旧账本的所有负结果:换 IP / 换遥测后端 / 换传输指纹 / 换 cookie / 长静默 - **都不动槽位序**;而宽松 aid(190758559 / 雨云)**不交叉校验布局**,所以 0。 + * ~~**H-LAYOUT(头号假设)**:槽位序错导致 12~~ → **已证伪(2026-10-12,见 §三)**: + 按构建槽位序 vs 旧模板 **9/9 都 12**;同一批值只颠倒顺序(同长度 920)**6/6 都 12**。 + **槽位序不是 12 的自变量。** + + * **当前最可能的自变量(按成本排序)**: + (a) **collect 的内容/取值本身**(不是顺序):真机那份 38 槽里含 canvas/WebGL/时间戳等 + **当次会话真实产物**;我们用"旧模板的值"无论怎么排都不是**本会话**的值。 + 判定实验:**在真机 iframe 里直接 `TDC.getData(true)` 拿到本会话的 collect, + 原样交给 Python 发**(不重排、不改值)——若 Python 也 0,则是"值"的问题; + 若仍 12,则排除包内容,只剩"请求方/会话绑定"。 + (b) **会话↔请求方绑定**:sess/eks/tkid 与"发出这个 HTTP 的一端"存在绑定 + (同包浏览器发 0、Python 发 12)。判定实验 = 上面 (a) 的同一发即可区分。 + + 建议下一步(一条命令,能同时判定 a 与 b): + 真机 iframe: `TDC.getData(true)` -> collect (本会话真实产物) + 同一 sess/eks/tkid, 分别用浏览器发 / 用 Python 发, 比较 errorCode。 + + * 附带事实(重要,改变了排查方向):**12 只在"答案正确"之后才判**。 + 答错一律 50/9(本轮两组在滑块题型下都是 50)——所以任何拿"答错"跑出来的对照都无效。 + 这解释了为什么早期很多对照实验没有区分度。 - * 下一步三件事(按顺序做): - (a) **决定性实验 A(最便宜,先做)**:真机页面里 hook 掉 `cap_union_new_verify` 的发送, - 让 widget 把 collect **组好但不发**;再把这一包原样交给 **Python** 发。 - 若 Python 也 0 → "谁在发 HTTP"彻底无关,12 只剩"包内容"一个自变量 → 坐实 H-LAYOUT。 - 若 Python 仍 12(而同一包在浏览器里是 0)→ 说明还有"会话↔连接"绑定,需换实验 A'。 - (b) **拿到本次构建的槽位表** —— 已打通静态提取(已知 #33/#34): - 顶层 62 个模块工厂注册表 → 清单模块 `arr[arr.length] = require(N)` 的**顺序即槽位序** - → `tools/tdc_layout.py ` 直接导出。 - ⚠️ 残留:逐槽**语义标签**还没全对齐(已精确命中 `cd[3]←c.navigator.userAgent`、 - `cd[5]←c.navigator.languages`,其余若干槽仍对不上)。 - 两个待收口点:① `get` 闭包"取值寄存器"的判定规则要覆盖全部模块形状; - ② **运行时反证**:在真机页面里包一层 `require`,把清单模块实际按顺序请求的 id 打出来, - 与静态导出串对拍(一条命令就能证伪/证实静态提取)。 - (c) **验证**:collect 按 (b) 的槽位序重排后重发 195672344,期望 **12 → 0**。 - 注意:既然 tdc.js 按请求随机(#29),Python 客户端必须用**它自己那次 prehandle 拿到 sid - 之后请求的那份 tdc.js** 的槽位表,不能用缓存/模板。 2. **可复现靶子(新增资产)**:aid 190758559 / `mliev.login.wang` 短信登录 —— **可反复测**, 用来回归"纯算链路是否完好"(1ms 每天只能试一次,不适合做回归): ```bash diff --git a/scripts/experiment_layout_verify.py b/scripts/experiment_layout_verify.py new file mode 100644 index 0000000..f98737f --- /dev/null +++ b/scripts/experiment_layout_verify.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +""" +实验 C —— 用「按本次构建生成的 collect」打一次完整 verify,检验 12 是否消失。 + +背景(PROGRESS.md #31/#32/#34/#35/#36/#37): + 槽位序按构建随机化;仓库里的模板是**某一次**的顺序; + `tools/tdc_collectgen.py` 已能按本次构建重排并加密(回环自洽)。 + +这一发要回答: + * 用「本次构建的槽位序」发出的 collect,aid 195672344 是否 **12 -> 0**? + - 若 0 -> H-LAYOUT 成立(12 的根因就是槽位序),这条链彻底闭合 + - 若仍 12 -> 槽位序**不是** 12 的自变量,H-LAYOUT 需要改, + 但我们已经知道该查什么(转去查请求方/会话绑定,即实验 A) + +⚠️ 样本要求(#31): 重排需要一份**同机**的真机明文(取值集合相同、只有顺序不同)。 + 本脚本的 --sample 就是它;本次样本恰好与本次构建同序,因此这一发是"原序复现"。 + +用法: + .venv/bin/python scripts/experiment_layout_verify.py \\ + --build /tmp/build.js --sample /tmp/sample_cd.json --kinds /tmp/items3.json +""" +from __future__ import annotations + +import argparse +import json +import random +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)) +if str(ROOT / "tools") not in sys.path: + sys.path.insert(0, str(ROOT / "tools")) + +import tdc_collectgen as cg # noqa: E402 +import tdc_layout # noqa: E402 +import tdc_program as tp # noqa: E402 +from tcaptcha.client import TencentCaptchaClient # noqa: E402 +from tcaptcha.crypto import extract_eks, extract_xtea_key # noqa: E402 + + +def one_verify(client, ch, tkid, collect: str, eks: str, pts) -> dict: + ans = json.dumps([{"elem_id": i + 1, "type": "DynAnswerType_POS", + "data": f"{p[0]},{p[1]}"} for i, p in enumerate(pts)], + separators=(",", ":")) + body = {"ans": ans, "collect": collect, "tlg": str(len(collect)), + "eks": eks, "sess": ch.sess, "tkid": tkid, + "pow_answer": "", "pow_calc_time": "1"} + r = client.http_client.post( + "/cap_union_new_verify", data=body, + headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Accept": "application/json, text/javascript, */*; q=0.01", + "Origin": "https://turing.captcha.gtimg.com", + "Referer": "https://turing.captcha.gtimg.com/"}) + return r.json() + + +def main() -> int: + ap = argparse.ArgumentParser(description="用按构建生成的 collect 打 verify(带对照组)") + ap.add_argument("--build", required=True, help="本次会话的 tdc.js 路径") + ap.add_argument("--sample", required=True, help="同机的真机 cd(json: {cd:[...]} 或 [...])") + ap.add_argument("--kinds", default=None, help="运行时取样的 items json(提供 kind/v0)") + ap.add_argument("--aid", default="195672344") + ap.add_argument("--entry-url", default="https://1ms.run/console/personal/benefits/check-in") + ap.add_argument("--n", type=int, default=12, help="每种 collect 各打几次(统计用)") + ap.add_argument("--solve", action="store_true", + help="真解题: 按题型用本地求解器算答案(12 只在答案正确时才判, 见说明)") + args = ap.parse_args() + + js = open(args.build, encoding="utf-8", errors="replace").read() + prog = tp.load(args.build) + _mid, _entry, seq = tdc_layout.find_manifest_module(prog) + key = extract_xtea_key(js) + eks = extract_eks(js) + + kinds, v0s = cg.load_shape(args.kinds, seq) if args.kinds else (None, None) + spec = cg.SlotSpec(seq, kinds, v0s) + sample = cg.load_sample_cd(args.sample) + cd = cg.reorder(sample, spec) + collect = cg.encrypt_plain(cg.build_plain(cd), key) + + print(f"[1] 构建 : {args.build}") + print(f" 清单模块 : registry[{_mid}] 项 {len(seq)}") + print(f" 槽位规格 : {spec.describe()}") + print(f" 密钥 : {key}") + print(f" eks : {len(eks)} 字符") + print(f"[2] collect : {len(collect)} 字符 (tlg={len(collect)}) 槽数 {len(cd)}") + + # 走一次完整协议: prehandle -> tdc(已在手) -> getcapbysig -> 解 -> verify, 但**替换 collect** + tkid = str(random.randint(100000000, 999999999)) + client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url, + referer="https://1ms.run/") + # ⚠️ 12 是"答案正确之后"的风控判定, 答错一律 50/9, 两组就没区分度。 + # 所以 --solve 时按题型真解; 只统计**可解**的轮次。 + def solve_points(ch): + if not args.solve: + return [(120, 90), (180, 150), (240, 210)] + if ch.kind in ("text", "text_click"): + res = client.text_solver.solve_ex(client.fetch_image(ch.img_url, 1), + ch.instruction) + return res.points if res.ok else None + if ch.kind in ("icon", "icon_click"): + bg = client.fetch_image(ch.img_url, 1) + pr = client.fetch_image(ch.img_url, 0) + if bg and pr: + return client.icon_solver.solve(bg, pr) or None + return None + return None # 滑块: 本脚本不处理(用 --no-solve 时才有意义) + # 对照组: 仓库旧模板(某一次的槽位序), 同一份构建/同一 session 流程 + from tcaptcha.crypto import generate_telemetry + # 对照组只需"仓库旧模板的槽位序", 轨迹用固定点即可(不影响槽位序这一自变量) + tm = generate_telemetry(js, [(120, 90), (180, 150), (240, 210)], + aid=args.aid, user_agent=client.user_agent, + referrer=client.referer) + + tally = {"new(按构建)": {}, "old(旧模板)": {}} + try: + solved = 0 + for rnd in range(1, args.n + 1): + ch, _cfg, _sid = client.prehandle(tkid) + pts = solve_points(ch) + if not pts: + print(f" [{rnd:02d}] 跳过 (kind={ch.kind}, 本脚本不处理)") + continue + solved += 1 + print(f" [{rnd:02d}] kind={ch.kind} pts={pts}") + for tag, collect in (("new(按构建)", collect), ("old(旧模板)", tm["collect"])): + v = one_verify(client, ch, tkid, collect, eks, pts) + code = str(v.get("errorCode")) + tally[tag][code] = tally[tag].get(code, 0) + 1 + print(f" [{rnd:02d}] {tag:12s} -> {code}") + print(f"\n (有效轮次: {solved})") + for tag, d in tally.items(): + print(f" {tag:12s} 分布: {d}") + new12 = tally["new(按构建)"].get("12", 0) + old12 = tally["old(旧模板)"].get("12", 0) + print() + if new12 == 0 and old12 > 0: + print(">>> ✅ 按构建生成后 12 消失(对照组仍有 12) -> H-LAYOUT 成立, 槽位序就是根因") + elif new12 and old12: + print(">>> ❌ 两组都有 12 -> 槽位序不是 12 的自变量, H-LAYOUT 需修正") + elif not new12 and not old12: + print(">>> ⚠️ 两组都没有 12(多为 50/9=答案未命中) -> 本轮题型/条件下风控未介入, " + "需要换成能答对的题型或加大样本再判") + return 0 + finally: + client.close() + + +if __name__ == "__main__": + raise SystemExit(main())