refactor(结构): 分层重组 + 清除全部废弃路线
做了什么:
- 包改为分层: tcaptcha/{protocol,telemetry,solvers,ocr,adapters,models,tests}
* protocol/ client.py transport.py proxy.py
* telemetry/ crypto.py values.py key.py opcodes.py
disasm.py program.py layout.py modules.py
* solvers/ text_click.py icon_click.py icon_click_rotate.py slider.py
* ocr/ engines.py slicer.py
* adapters/ one_ms.py
- tools/ 里原本是**包运行时依赖**的 tdc_{disasm,program,layout,modules}.py 并入 telemetry/
(放在 tools/ 属于分层倒置),tools/ 只留 CLI: tdc_key_pure.py / tdc_profile.py / cdp_*.js
- crypto.py 529 -> 340 行: 切除「成功样本模板回填」与「Node 插桩兜底」两条老路;
generate_telemetry 只剩 python_values 一种模式,传别的会直接报错并说明原因
- 求解器改名分清: solver.py -> solvers/icon_click.py(client 实际在用的 YOLO+ResNet18 ONNX),
icon_click_solver.py -> solvers/icon_click_rotate.py(仅测试在用,留作对照)
- pyproject: 去掉 ultralytics/onnx(训练栈已删),补上真正在用的 onnxruntime
删除了什么:
- 雨云整条线 + dist/rainyun_qinglong(104M 旧快照)
- 模板回填: models/success_collect_template.b64、tdc_worker.js、analyze_collect_layout.py
- 「补环境」时代浏览器 JS: chrome_fetch/manual_captcha/probe_captcha/solve_in_browser/tdc_browser
- 旧 VM 探索工具 10 个(tdc_collectgen/tdc_slotmap/tdc_lift/tdc_vm_run/tdc_query/tdc_probe 等)
+ 8 个基于它们的旧 verify 脚本
- 训练/标注/评估: train_classifier/train_detector/prepare_yolo/web_labeler/collector/
siamese/evaluate/benchmark_verify
- tcaptcha/core_js/(60 个分析产物)、TOOLCHAIN_REPORT.md(整篇在描述已删工具)
- 3 个已无任何引用的 PyTorch 权重:icon_classifier.pt 43M / yolo_detector.pt 5.9M /
siamese_matcher.pt 1.6M —— 线上只跑 onnx,tcaptcha/ 117M -> 66M
为什么:
- 项目后面要专门承载「滑块 / 文字点选 / 图标点选」三种求解器;扁平包 + 运行时依赖混在
tools/ + 两条已废弃的 collect 生成路线,会让后续扩展持续踩坑
怎么验证:
- .venv/bin/python -m tcaptcha.selftest 全绿
- scripts/verify/verify_offline.py 逐槽全等 + 分段几何一致
- scripts/verify/verify_values.py 4 份真机明文 152/152 逐槽全等
- scripts/verify/send_test.py --mode python_values 1ms 签到页文字点选 errorCode=0
- 回滚点: tag backup/pre-refactor-20261012
This commit is contained in:
@@ -1,182 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
collect 明文「字段布局」分析器 —— 回答一个关键问题:
|
||||
|
||||
`collect` 解密后的 `cd` 数组, 槽位顺序到底是"按 tdc.js 版本固定"还是"按会话/构建随机"?
|
||||
|
||||
为什么要问这个 (2026-10-11 排查 errorCode=12):
|
||||
tcaptcha/models/success_collect_template.b64 存的是**明文**模板 (base64 封装),
|
||||
generate_collect_success() 只回填 轨迹 / entry_url / 几个时间戳, 其余字段原样保留。
|
||||
真机成功样本解密后发现: **同一批值, 顺序完全不同** —— 若服务端按"本次构建的槽位表"
|
||||
解释 cd, 那么我们用旧槽位序发出的 collect 会被读成"自相矛盾的环境" -> 12。
|
||||
|
||||
本脚本把多份样本 (HAR + CDP 抓包 + 仓库模板) 的 cd 槽位打成类型指纹并对比顺序。
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/analyze_collect_layout.py # 默认样本集
|
||||
.venv/bin/python scripts/analyze_collect_layout.py --json /tmp/cap2.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tcaptcha.crypto import xtea_decrypt_bytes # noqa: E402
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
|
||||
HARS = [
|
||||
("har/原始抓包/签到成功-1.har", "签到成功-1"),
|
||||
("har/原始抓包/点选.har", "点选"),
|
||||
("har/login_checkin.har", "login_checkin"),
|
||||
]
|
||||
|
||||
|
||||
def _decrypt_collect(collect_b64: str, tdc_js: str) -> Optional[dict]:
|
||||
key = extract_xtea_key_pure(tdc_js)["key"]
|
||||
pt = xtea_decrypt_bytes(base64.b64decode(collect_b64), key)
|
||||
text = pt.decode("latin1").rstrip("\x00").strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def from_har(path: str) -> List[Tuple[str, dict]]:
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
data = json.load(open(path, encoding="utf-8"))
|
||||
tdcs: Dict[str, str] = {}
|
||||
sess2sid: Dict[str, str] = {}
|
||||
verifies: List[dict] = []
|
||||
for e in data["log"]["entries"]:
|
||||
url = e["request"]["url"]
|
||||
content = (e["response"].get("content") or {}).get("text") or ""
|
||||
if "tdc.js" in url and content:
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
|
||||
tdcs[(q.get("app_data") or [""])[0]] = content
|
||||
elif "cap_union_prehandle" in url and content:
|
||||
m = re.search(r"^[^(]*\((.*)\)\s*$", content, re.S)
|
||||
if m:
|
||||
try:
|
||||
j = json.loads(m.group(1))
|
||||
sess2sid[j.get("sess", "")] = str(j.get("sid", ""))
|
||||
except Exception:
|
||||
pass
|
||||
elif "cap_union_new_verify" in url:
|
||||
pd = (e["request"].get("postData") or {}).get("text") or ""
|
||||
if pd:
|
||||
verifies.append(dict(urllib.parse.parse_qsl(pd, keep_blank_values=True)))
|
||||
out = []
|
||||
for i, v in enumerate(verifies):
|
||||
js = tdcs.get(sess2sid.get(v.get("sess", ""), ""))
|
||||
if not js:
|
||||
continue
|
||||
j = _decrypt_collect(v["collect"], js)
|
||||
if j is not None:
|
||||
out.append((f"{Path(path).name}#{i}", j))
|
||||
return out
|
||||
|
||||
|
||||
def from_cdp_capture(path: str) -> List[Tuple[str, dict]]:
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
d = json.load(open(path, encoding="utf-8"))
|
||||
recs = d.get("records") or []
|
||||
tdcs = [(r["url"], r.get("body") or "") for r in recs if "tdc.js" in r["url"] and r.get("body")]
|
||||
vers = [r for r in recs if "verify" in r["url"] and r.get("postData")]
|
||||
out = []
|
||||
for i, v in enumerate(vers):
|
||||
form = dict(urllib.parse.parse_qsl(v["postData"], keep_blank_values=True))
|
||||
sess = form.get("sess", "")
|
||||
# sess 前 2 段与 tdc.js 的 app_data(sid) 无直接映射, 这里按时间就近取**最后一份**构建
|
||||
js = tdcs[-1][1] if tdcs else ""
|
||||
if not js:
|
||||
continue
|
||||
j = _decrypt_collect(form["collect"], js)
|
||||
if j is not None:
|
||||
out.append((f"{Path(path).name}#{i}", j))
|
||||
return out
|
||||
|
||||
|
||||
def slot_tags(cd: List) -> List[str]:
|
||||
tags = []
|
||||
for v in cd:
|
||||
if isinstance(v, str):
|
||||
if v.startswith("http"):
|
||||
tags.append("url")
|
||||
elif "Mozilla" in v:
|
||||
tags.append("UA")
|
||||
elif v.startswith("GgoAAA"):
|
||||
tags.append("png")
|
||||
elif re.fullmatch(r"[-+]\d\d", v):
|
||||
tags.append("tz")
|
||||
elif v == "":
|
||||
tags.append("empty")
|
||||
elif len(v) <= 6 and v.isupper():
|
||||
tags.append("enc:" + v)
|
||||
else:
|
||||
tags.append("str:" + v[:10])
|
||||
elif isinstance(v, list):
|
||||
if len(v) == 2 and all(isinstance(x, int) for x in v):
|
||||
tags.append("vec2")
|
||||
elif v and isinstance(v[0], list):
|
||||
tags.append("traj")
|
||||
else:
|
||||
tags.append("list%d" % len(v))
|
||||
elif isinstance(v, bool):
|
||||
tags.append("bool")
|
||||
else:
|
||||
tags.append("num")
|
||||
return tags
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="collect 明文字段布局分析")
|
||||
ap.add_argument("--json", action="append", default=[], help="额外加入的 cdp_capture JSON")
|
||||
args = ap.parse_args()
|
||||
|
||||
samples: List[Tuple[str, dict]] = []
|
||||
for p in args.json:
|
||||
samples += from_cdp_capture(p)
|
||||
for p, tag in HARS:
|
||||
for name, j in from_har(str(ROOT / p)):
|
||||
samples.append((f"{tag}#{name.split('#')[-1]}", j))
|
||||
tpl_path = ROOT / "tcaptcha" / "models" / "success_collect_template.b64"
|
||||
if tpl_path.exists():
|
||||
tpl = json.loads(base64.b64decode(tpl_path.read_text(encoding="ascii"))
|
||||
.decode("latin1").rstrip("\x00").strip())
|
||||
samples.append(("TEMPLATE(仓库在用)", tpl))
|
||||
|
||||
if not samples:
|
||||
print("没有可用样本")
|
||||
return 1
|
||||
|
||||
for name, j in samples:
|
||||
tags = slot_tags(j["cd"])
|
||||
print(f"\n[{name}] len(cd)={len(j['cd'])} sd={j.get('sd')}")
|
||||
print(" " + " ".join(f"{i}:{t}" for i, t in enumerate(tags)))
|
||||
|
||||
print("\n=== 槽位指纹两两对比 (看顺序是否随会话变化) ===")
|
||||
sigs = [(n, slot_tags(j["cd"])) for n, j in samples]
|
||||
base_name, base = sigs[0]
|
||||
print(f" 基准 = {base_name}")
|
||||
for n, s in sigs[1:]:
|
||||
diff = [i for i in range(max(len(base), len(s)))
|
||||
if (base[i] if i < len(base) else None) != (s[i] if i < len(s) else None)]
|
||||
print(f" {n:22s} 顺序相同={s == base} 不同槽位数={len(diff)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -10,8 +10,8 @@
|
||||
1. 用 CDP 在真实页面里刷新腾讯验证码 widget (`__cap.refresh()`);
|
||||
2. 读 widget 内的题型/题面/几何 (滑块: slideBg + 拼图块 + 拖动柄; 点选: 提示图 + 背景图);
|
||||
3. 求解:
|
||||
* 滑块 -> tcaptcha.slider_solver.SliderSolver (ZNCC 找真缺口)
|
||||
* 图文点选 -> tcaptcha.icon_click_solver.IconClickSolver (形状模板 + 旋转 IoU)
|
||||
* 滑块 -> tcaptcha.solvers.slider.SliderSolver (ZNCC 找真缺口)
|
||||
* 图文点选 -> tcaptcha.solvers.icon_click.IconClickSolver (形状模板 + 旋转 IoU)
|
||||
4. 用 CDP `Input.dispatchMouseEvent` 派发**真实(trusted)鼠标事件** (点击 / 拖动);
|
||||
5. 读回 widget 回调的 {ret, ticket, randstr}。
|
||||
|
||||
@@ -38,9 +38,9 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
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
|
||||
from tcaptcha.solvers.text_click import TextClickSolver # noqa: E402
|
||||
from tcaptcha.solvers.icon_click_rotate import IconClickSolver # noqa: E402
|
||||
from tcaptcha.solvers.slider import SliderSolver, sprite_region # noqa: E402
|
||||
|
||||
NODE = "node"
|
||||
CDP_EVAL = str(ROOT / "tools" / "cdp_eval.js")
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
真机 aid 冒烟测试 —— 用**可反复测**的真实 aid 回归"纯算链路是否完好"。
|
||||
|
||||
背景 (har/1ms_dianxuan/PROGRESS.md 已知 #26/#27):
|
||||
aid 195672344 (1ms.run) 对本客户端恒返回 errorCode=12 (aid 级风控, 与请求侧无关),
|
||||
且 1ms 签到每天只能试一次 —— 不适合做回归。
|
||||
而抓包 `har/原始抓包/登录抓包-点选.har` 里的 aid **190758559** (mliev.login.wang 短信登录)
|
||||
可反复测: 本客户端 (纯算, 零 Node) 稳定拿到 errorCode=0 + 真 ticket。
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/check_real_tencent_aid.py # 默认 190758559
|
||||
.venv/bin/python scripts/check_real_tencent_aid.py --aid 195672344 # 对照: 1ms 会得到 12
|
||||
|
||||
注意: 只调用腾讯天御的验证码接口 (prehandle/tdc.js/getcapbysig/verify),
|
||||
**不会**触发任何短信/签到等业务副作用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tcaptcha.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402
|
||||
|
||||
# (aid, entry_url, referer, wb)
|
||||
TARGETS = {
|
||||
"190758559": ("https://mliev.login.wang/auth/#/oidc/authorize", "https://mliev.login.wang/", "1"),
|
||||
"195672344": ("https://1ms.run/console/personal/benefits/check-in", "https://1ms.run/", "1"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="真机 aid 冒烟: 纯算链路端到端")
|
||||
ap.add_argument("--aid", default="190758559", choices=sorted(TARGETS))
|
||||
ap.add_argument("--transport", default="httpx", choices=["httpx", "curl"])
|
||||
ap.add_argument("--refreshes", type=int, default=25)
|
||||
args = ap.parse_args()
|
||||
|
||||
entry_url, referer, wb = TARGETS[args.aid]
|
||||
client = TencentCaptchaClient(aid=args.aid, entry_url=entry_url, referer=referer, wb=wb,
|
||||
accept_kinds=(KIND_TEXT_CLICK,), telemetry_mode="python_keyed",
|
||||
transport=args.transport)
|
||||
try:
|
||||
result = client.solve(max_retries=2, max_refreshes=args.refreshes)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
raw = result.get("raw_response") or {}
|
||||
print("\n=== 结果 ===")
|
||||
print("aid :", args.aid)
|
||||
print("transport :", args.transport)
|
||||
print("success :", result.get("success"))
|
||||
print("errorCode :", raw.get("errorCode"))
|
||||
print("ticket :", (raw.get("ticket") or "")[:32])
|
||||
print("randstr :", raw.get("randstr"))
|
||||
print("telemetry :", result.get("raw_response", {}).get("sess") and "python_keyed(纯算)")
|
||||
return 0 if result.get("success") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -35,7 +35,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tcaptcha.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402
|
||||
from tcaptcha.protocol.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402
|
||||
|
||||
|
||||
def run_once(tag: str, cookies, args) -> dict:
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# -*- 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())
|
||||
@@ -34,8 +34,8 @@ if str(ROOT) not in sys.path:
|
||||
if str(ROOT / "tools") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_layout # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
from tcaptcha.telemetry import layout # noqa: E402
|
||||
from tcaptcha.telemetry import program as tp # noqa: E402
|
||||
|
||||
TRAP_JS = r"""(() => {
|
||||
if (window.__reqTrapInstalled) return {already: true};
|
||||
|
||||
@@ -37,7 +37,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tcaptcha.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402
|
||||
from tcaptcha.protocol.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402
|
||||
|
||||
TRANSPORTS = {"a": ("httpx", None), "b": ("curl", "chrome")}
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from tcaptcha.client import TencentCaptchaClient # noqa: E402
|
||||
from tcaptcha.crypto import extract_eks # noqa: E402
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.protocol.client import TencentCaptchaClient # noqa: E402
|
||||
from tcaptcha.telemetry.crypto import extract_eks # noqa: E402
|
||||
from tcaptcha.telemetry.key import extract_xtea_key_pure # noqa: E402
|
||||
|
||||
OUT = ROOT / "har" / "online"
|
||||
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
cron: 30 8 * * *
|
||||
new Env('雨云每日签到');
|
||||
|
||||
雨云 (Rainyun) 自动登录与每日签到脚本 (适用于青龙面板)
|
||||
账号配置规范:
|
||||
环境变量 / 本地 .env 统一使用 RAINYUN_ACCOUNT="账号#密码"
|
||||
多账号分割:
|
||||
- 青龙环境变量: 使用 & 分割 (如 RAINYUN_ACCOUNT="13754650804#pwd1&13800000000#pwd2")
|
||||
- 本地 .env 文件: 支持 & 分割或换行分割
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
# 将项目根目录加入 sys.path 以便加载本地模块
|
||||
CURRENT_DIR = Path(__file__).resolve().parent
|
||||
if str(CURRENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(CURRENT_DIR))
|
||||
|
||||
# 配置日志格式
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("rainyun_checkin")
|
||||
|
||||
# 青龙通知模块导入
|
||||
try:
|
||||
from sendNotify import send
|
||||
except Exception:
|
||||
try:
|
||||
from notify import send
|
||||
except Exception:
|
||||
def send(title: str, content: str):
|
||||
pass
|
||||
|
||||
|
||||
# 默认配置
|
||||
RAINYUN_API_BASE = "https://api.v2.rainyun.com"
|
||||
TENCENT_AID = "2039519451"
|
||||
DEFAULT_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# 积分提现比例: 2000 积分 = 1 元现金 (支持直接提现)
|
||||
POINTS_PER_CNY = 2000.0
|
||||
|
||||
|
||||
def format_points(points: int) -> str:
|
||||
"""格式化积分及等值提现金额 (2000积分 = 1元)"""
|
||||
cny = points / POINTS_PER_CNY
|
||||
return f"{points} 积分 (≈ {cny:.2f} 元)"
|
||||
|
||||
|
||||
def mask_field(val: str) -> str:
|
||||
"""脱敏账号信息"""
|
||||
if not val:
|
||||
return ""
|
||||
if len(val) <= 4:
|
||||
return "***"
|
||||
if len(val) == 11 and val.isdigit():
|
||||
return f"{val[:3]}****{val[7:]}"
|
||||
return f"{val[:2]}****{val[-2:]}"
|
||||
|
||||
|
||||
class CaptchaManager:
|
||||
"""验证码求解调度器:支持本地 tcaptcha 模块或远程 HTTP 接口"""
|
||||
|
||||
def __init__(self, api_url: Optional[str] = None):
|
||||
self.api_url = api_url or os.getenv("TCAPTCHA_API_URL", "").strip()
|
||||
self.local_client = None
|
||||
|
||||
# 尝试初始化本地客户端
|
||||
if not self.api_url:
|
||||
try:
|
||||
from tcaptcha.client import TencentCaptchaClient
|
||||
self.local_client = TencentCaptchaClient(aid=TENCENT_AID)
|
||||
logger.info("✓ 成功加载本地 tcaptcha 纯算法求解引擎")
|
||||
except Exception as e:
|
||||
logger.warning(f"未能加载本地 tcaptcha 求解引擎 ({e}),将尝试远程接口或环境变量配置")
|
||||
|
||||
def get_ticket(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""获取腾讯天御点选验证码票据 (ticket, randstr)"""
|
||||
# 1. 优先使用远程 API (若已配置)
|
||||
if self.api_url:
|
||||
logger.info(f"正在调用远程验证码求解接口: {self.api_url} ...")
|
||||
try:
|
||||
with httpx.Client(timeout=30.0, trust_env=False) as client:
|
||||
resp = client.post(self.api_url, json={"aid": TENCENT_AID})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# 兼容直接返回或嵌套在 data 中
|
||||
res_data = data.get("data", data)
|
||||
ticket = res_data.get("ticket")
|
||||
randstr = res_data.get("randstr")
|
||||
if ticket and randstr:
|
||||
logger.info(f"✓ 远程验证码求解成功 (randstr: {randstr})")
|
||||
return ticket, randstr
|
||||
else:
|
||||
logger.error(f"远程验证码接口未返回有效票据: {data}")
|
||||
else:
|
||||
logger.error(f"远程验证码接口响应异常: {resp.status_code} {resp.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"调用远程验证码接口失败: {e}")
|
||||
|
||||
# 2. 本地求解引擎
|
||||
if self.local_client:
|
||||
logger.info("正在调用本地 tcaptcha AI 纯协议求解器...")
|
||||
try:
|
||||
res = self.local_client.solve()
|
||||
if res.get("success"):
|
||||
logger.info(f"✓ 本地求解成功 (耗时: {res.get('elapsed_ms')}ms, randstr: {res.get('randstr')})")
|
||||
return res.get("ticket"), res.get("randstr")
|
||||
else:
|
||||
logger.error(f"本地求解失败: {res.get('error')}")
|
||||
except Exception as e:
|
||||
logger.error(f"本地求解器执行异常: {e}")
|
||||
|
||||
logger.error("未找到可用的验证码求解方案!请确认部署了 tcaptcha 本地模型或配置了 TCAPTCHA_API_URL 环境变量")
|
||||
return None, None
|
||||
|
||||
|
||||
class RainyunClient:
|
||||
"""雨云 API 客户端"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user: str,
|
||||
password: str,
|
||||
captcha_mgr: Optional[CaptchaManager] = None,
|
||||
):
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.captcha_mgr = captcha_mgr or CaptchaManager()
|
||||
|
||||
self.account_name = mask_field(user) if user else "未知账号"
|
||||
self.points_before = 0
|
||||
self.points_after = 0
|
||||
|
||||
# 初始化 HTTP 客户端
|
||||
self.session = httpx.Client(
|
||||
base_url=RAINYUN_API_BASE,
|
||||
timeout=15.0,
|
||||
follow_redirects=True,
|
||||
trust_env=False, # 屏蔽环境代理干扰以兼容各类青龙网络
|
||||
)
|
||||
self._update_common_headers()
|
||||
|
||||
def _update_common_headers(self):
|
||||
rys = f"{uuid.uuid4().hex}{time.time():.3f}"
|
||||
self.session.headers.update({
|
||||
"User-Agent": DEFAULT_UA,
|
||||
"Origin": "https://app.rainyun.com",
|
||||
"Referer": "https://app.rainyun.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"rys": rys,
|
||||
})
|
||||
|
||||
def login(self) -> bool:
|
||||
"""执行登录并获取凭据"""
|
||||
if not self.user or not self.password:
|
||||
logger.error("缺少账号或密码!")
|
||||
return False
|
||||
|
||||
logger.info(f"正在登录雨云账号: {self.account_name} ...")
|
||||
self._update_common_headers()
|
||||
|
||||
payload = {
|
||||
"field": self.user,
|
||||
"password": self.password,
|
||||
"verifyCode": "",
|
||||
}
|
||||
|
||||
resp = self.session.post("/user/login", json=payload)
|
||||
res_json = {}
|
||||
try:
|
||||
res_json = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否需要验证码
|
||||
if resp.status_code == 400 and res_json.get("code") in [10004, 10005]:
|
||||
logger.info("雨云登录要求完成人机验证,正在调用验证码求解器...")
|
||||
ticket, randstr = self.captcha_mgr.get_ticket()
|
||||
if not ticket or not randstr:
|
||||
logger.error("登录验证码获取失败,无法继续登录")
|
||||
return False
|
||||
payload["vticket"] = ticket
|
||||
payload["vrandstr"] = randstr
|
||||
resp = self.session.post("/user/login", json=payload)
|
||||
try:
|
||||
res_json = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if resp.status_code == 200 and res_json.get("code") == 200:
|
||||
# 同步更新 CSRF Token
|
||||
csrf = self.session.cookies.get("X-CSRF-Token")
|
||||
if csrf:
|
||||
self.session.headers["x-csrf-token"] = csrf
|
||||
logger.info(f"✓ 账号 {self.account_name} 登录成功!")
|
||||
return True
|
||||
|
||||
err_msg = res_json.get("message") or resp.text
|
||||
logger.error(f"❌ 账号 {self.account_name} 登录失败: {err_msg}")
|
||||
return False
|
||||
|
||||
def fetch_user_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""获取当前用户信息(积分、用户名等)"""
|
||||
self._update_common_headers()
|
||||
try:
|
||||
resp = self.session.get("/user/?no_cache=true")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("code") == 200:
|
||||
info = data.get("data", {})
|
||||
self.account_name = info.get("Name") or self.account_name
|
||||
self.points_before = info.get("Points", 0)
|
||||
return info
|
||||
except Exception as e:
|
||||
logger.warning(f"获取用户信息异常: {e}")
|
||||
return None
|
||||
|
||||
def fetch_tasks(self) -> List[Dict[str, Any]]:
|
||||
"""获取任务列表"""
|
||||
self._update_common_headers()
|
||||
try:
|
||||
resp = self.session.get("/user/reward/tasks")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("code") == 200:
|
||||
return data.get("data", [])
|
||||
except Exception as e:
|
||||
logger.error(f"获取任务列表失败: {e}")
|
||||
return []
|
||||
|
||||
def claim_daily_checkin(self) -> Dict[str, Any]:
|
||||
"""
|
||||
执行每日签到核心业务逻辑:
|
||||
1. 检查任务列表与签到状态
|
||||
2. 若 Status == 2: 今日已完成,直接跳过
|
||||
3. 若 Status == 1: 待领取,获取验证码并提交
|
||||
"""
|
||||
user_info = self.fetch_user_info()
|
||||
user_display = f"{self.account_name} (ID: {user_info.get('ID') if user_info else '未知'})"
|
||||
|
||||
tasks = self.fetch_tasks()
|
||||
checkin_task = None
|
||||
for t in tasks:
|
||||
if t.get("Name") == "每日签到":
|
||||
checkin_task = t
|
||||
break
|
||||
|
||||
if not checkin_task:
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "error",
|
||||
"message": "未在任务列表中找到【每日签到】任务",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
task_status = checkin_task.get("Status")
|
||||
# Status 2: 今日已领取
|
||||
if task_status == 2:
|
||||
logger.info(f"✨ 账号【{user_display}】今日已完成每日签到,当前总资产: {format_points(self.points_before)}。")
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "already_done",
|
||||
"message": "今日已签到 (无需重复领取)",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
# Status 1: 待签到
|
||||
logger.info(f"🎯 账号【{user_display}】今日尚未签到,开始启动签到流程...")
|
||||
|
||||
# 1. 求解验证码
|
||||
ticket, randstr = self.captcha_mgr.get_ticket()
|
||||
if not ticket or not randstr:
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "fail",
|
||||
"message": "验证码求解失败,签到中止",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
# 2. 提交签到请求
|
||||
self._update_common_headers()
|
||||
payload = {
|
||||
"task_name": "每日签到",
|
||||
"verifyCode": "",
|
||||
"vticket": ticket,
|
||||
"vrandstr": randstr,
|
||||
}
|
||||
|
||||
resp = self.session.post("/user/reward/tasks", json=payload)
|
||||
res_json = {}
|
||||
try:
|
||||
res_json = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if resp.status_code == 200 and res_json.get("code") == 200:
|
||||
reward_data = res_json.get("data", {})
|
||||
earned_points = reward_data.get("points", 500)
|
||||
earned_cny = earned_points / POINTS_PER_CNY
|
||||
|
||||
# 刷新最新积分
|
||||
latest_info = self.fetch_user_info()
|
||||
self.points_after = latest_info.get("Points", self.points_before + earned_points) if latest_info else self.points_before + earned_points
|
||||
|
||||
msg = f"签到成功!获得 +{earned_points} 积分 (≈ {earned_cny:.2f} 元)"
|
||||
logger.info(f"🎉 账号【{user_display}】{msg},当前总资产: {format_points(self.points_after)}")
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "success",
|
||||
"message": msg,
|
||||
"earned_points": earned_points,
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_after,
|
||||
}
|
||||
else:
|
||||
err_msg = res_json.get("message") or resp.text
|
||||
logger.error(f"❌ 账号【{user_display}】签到失败: {err_msg}")
|
||||
return {
|
||||
"user": user_display,
|
||||
"status": "fail",
|
||||
"message": f"签到请求失败: {err_msg}",
|
||||
"points_before": self.points_before,
|
||||
"points_after": self.points_before,
|
||||
}
|
||||
|
||||
|
||||
def load_dotenv(env_path: Optional[Path] = None):
|
||||
"""自动加载本地 .env 文件到 os.environ,方便本地直接调试运行"""
|
||||
search_paths = [
|
||||
env_path,
|
||||
CURRENT_DIR / ".env",
|
||||
Path.cwd() / ".env",
|
||||
]
|
||||
for p in search_paths:
|
||||
if p and p.is_file():
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||
v = v[1:-1]
|
||||
if k not in os.environ or not os.environ[k]:
|
||||
os.environ[k] = v
|
||||
logger.info(f"✓ 成功加载本地环境变量: {p}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 .env 异常: {e}")
|
||||
|
||||
|
||||
def parse_accounts() -> List[Dict[str, str]]:
|
||||
"""
|
||||
解析雨云账号配置 (优先加载本地 .env):
|
||||
仅支持 RAINYUN_ACCOUNT="账号#密码" 格式
|
||||
多账号使用 & 分割 (青龙面板推荐) 或 换行分割 (本地 .env 推荐)
|
||||
例如:
|
||||
RAINYUN_ACCOUNT="13754650804#pwd1&13800000000#pwd2"
|
||||
"""
|
||||
load_dotenv()
|
||||
accounts = []
|
||||
|
||||
acc_env = os.getenv("RAINYUN_ACCOUNT", "").strip()
|
||||
if not acc_env:
|
||||
return []
|
||||
|
||||
# 去除外层引号
|
||||
if (acc_env.startswith('"') and acc_env.endswith('"')) or (acc_env.startswith("'") and acc_env.endswith("'")):
|
||||
acc_env = acc_env[1:-1].strip()
|
||||
|
||||
# 按照 & 或 换行 分割多账号
|
||||
raw_list = re.split(r"[&\n]", acc_env)
|
||||
for item in raw_list:
|
||||
item = item.strip()
|
||||
if not item or "#" not in item:
|
||||
continue
|
||||
u, p = item.split("#", 1)
|
||||
u, p = u.strip(), p.strip()
|
||||
if u and p and not any(a.get("user") == u for a in accounts):
|
||||
accounts.append({"user": u, "password": p})
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" 🚀 雨云 (Rainyun) 自动登录与每日签到任务启动")
|
||||
print("=" * 60)
|
||||
|
||||
accounts = parse_accounts()
|
||||
if not accounts:
|
||||
msg = (
|
||||
"未检测到雨云账号配置!请在青龙环境变量或本地 .env 中配置 RAINYUN_ACCOUNT\n"
|
||||
"格式: 账号#密码 (多账号用 & 或换行分割)\n"
|
||||
"例如: RAINYUN_ACCOUNT=\"13754650804#pwd1&13800000000#pwd2\"\n"
|
||||
"如需远程验证码服务,可添加 TCAPTCHA_API_URL (如 http://x.x.x.x:8000/solve)"
|
||||
)
|
||||
logger.warning(msg)
|
||||
send("雨云每日签到异常", msg)
|
||||
return
|
||||
|
||||
logger.info(f"已成功加载 {len(accounts)} 个雨云账号配置")
|
||||
captcha_mgr = CaptchaManager()
|
||||
|
||||
results = []
|
||||
for idx, acc in enumerate(accounts, 1):
|
||||
logger.info(f"\n--- [正在处理账号 {idx}/{len(accounts)}] ---")
|
||||
client = RainyunClient(
|
||||
user=acc["user"],
|
||||
password=acc["password"],
|
||||
captcha_mgr=captcha_mgr,
|
||||
)
|
||||
|
||||
login_ok = client.login()
|
||||
if not login_ok:
|
||||
results.append({
|
||||
"user": client.account_name,
|
||||
"status": "login_failed",
|
||||
"message": "登录失败",
|
||||
"points_before": 0,
|
||||
"points_after": 0,
|
||||
})
|
||||
continue
|
||||
|
||||
res = client.claim_daily_checkin()
|
||||
results.append(res)
|
||||
|
||||
# 多账号之间随机休眠 2~4 秒
|
||||
if idx < len(accounts):
|
||||
sleep_sec = random.uniform(2.0, 4.0)
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
# 统计汇总
|
||||
print("\n" + "=" * 60)
|
||||
print(" 📊 任务执行结果统计报表")
|
||||
print("=" * 60)
|
||||
|
||||
success_cnt = sum(1 for r in results if r["status"] in ["success", "already_done"])
|
||||
summary_lines = []
|
||||
total_points = 0
|
||||
|
||||
for idx, r in enumerate(results, 1):
|
||||
status_icon = "✅" if r["status"] in ["success", "already_done"] else "❌"
|
||||
pts = r.get("points_after", 0)
|
||||
total_points += pts
|
||||
cny_str = format_points(pts)
|
||||
line = f"[{idx}] {status_icon} 账号: {r['user']} | 结果: {r['message']} | 当前账户: {cny_str}"
|
||||
summary_lines.append(line)
|
||||
print(line)
|
||||
|
||||
total_cny = total_points / POINTS_PER_CNY
|
||||
total_summary = f"💰 全部账号总资产: {total_points} 积分 (可提现 ≈ {total_cny:.2f} 元)"
|
||||
summary_lines.append(total_summary)
|
||||
print(total_summary)
|
||||
print("=" * 60)
|
||||
|
||||
# 推送青龙系统通知
|
||||
notify_title = f"雨云签到: 成功 {success_cnt}/{len(results)} | 总资产 ≈ {total_cny:.2f}元"
|
||||
notify_content = "\n".join(summary_lines)
|
||||
send(notify_title, notify_content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,9 +23,9 @@ from urllib.parse import unquote
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
from tcaptcha.crypto import split_collect_segments, xtea_decrypt_bytes # noqa: E402
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.telemetry import modules as tm # noqa: E402
|
||||
from tcaptcha.telemetry.crypto import split_collect_segments, xtea_decrypt_bytes # noqa: E402
|
||||
from tcaptcha.telemetry.key import extract_xtea_key_pure # noqa: E402
|
||||
|
||||
BUILD = "/tmp/1ms_bridge/session_build.js"
|
||||
SESS = "/tmp/1ms_bridge/session_collect.json"
|
||||
|
||||
@@ -30,10 +30,10 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_modules as tm # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
from tcaptcha.crypto import xtea_decrypt_bytes # noqa: E402
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.telemetry import modules as tm # noqa: E402
|
||||
from tcaptcha.telemetry import program as tp # noqa: E402
|
||||
from tcaptcha.telemetry.crypto import xtea_decrypt_bytes # noqa: E402
|
||||
from tcaptcha.telemetry.key import extract_xtea_key_pure # noqa: E402
|
||||
|
||||
# 用于「这个模块到底像在采什么」的旁证关键词
|
||||
HINTS = {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全量配平:37 个语义名 <-> 真机明文的 38 个槽。
|
||||
|
||||
约束(这些是硬证据,不是猜):
|
||||
1. 值内容唯一确定语义名(MacIntel/UTF-8/ANGLE/GgoA.../98k/iframe...)
|
||||
2. screen_fingerprint `"1440-2560-2530-24-*-*-|-*"` 反解出
|
||||
screen_height=1440 / screen_width=2560 / screen_color_depth=24
|
||||
3. itoken 有 `^\\d+:\\d+$` 特征 -> 占 **2 槽**(`641547489,1791732001`)
|
||||
4. 37 项 -> 38 槽(仅 itoken 多占 1 槽),可与明文槽数对账
|
||||
|
||||
输出: 每个语义名的取值 + 置信度,供人工核对。
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
|
||||
RAW = "/tmp/1ms_bridge/real_collect_plain.txt"
|
||||
raw = open(RAW, encoding="latin1").read().rstrip("\x00")
|
||||
cd = json.loads(re.sub(r" {2,}", "", raw))["cd"]
|
||||
print("明文 38 槽:")
|
||||
for i, v in enumerate(cd):
|
||||
print(" %2d %s" % (i, json.dumps(v, ensure_ascii=False)[:78]))
|
||||
print()
|
||||
|
||||
rows = tm.analyze("har/1ms_dianxuan/tdc.js")
|
||||
names = [r["name"] for r in rows]
|
||||
print("语义名 %d 个" % len(names))
|
||||
print()
|
||||
|
||||
# ---- 按内容给每个槽打标签(不依赖位置)----
|
||||
def tags(v):
|
||||
t = []
|
||||
if isinstance(v, str):
|
||||
if "Mozilla" in v: t.append("user_agent")
|
||||
if v == "MacIntel": t.append("platform")
|
||||
if re.fullmatch(r"[+-]\d\d", v): t.append("timezone_offset")
|
||||
if v.upper() == "UTF-8": t.append("charset")
|
||||
if "ANGLE" in v: t.append("webgl_renderer")
|
||||
if "Inc." in v: t.append("webgl_vendor")
|
||||
if v.startswith("GgoA"): t.append("canvas_fingerprint")
|
||||
if "rand=" in v and "captcha" in v: t.append("template_url")
|
||||
if v == "98k": t.append("constant_98k")
|
||||
if v == "iframe": t.append("iframe_detect")
|
||||
if "*" in v and "-" in v: t.append("screen_fingerprint")
|
||||
if v.startswith("http") and "1ms.run" in v: t.append("location_href")
|
||||
elif isinstance(v, list):
|
||||
if v and all(isinstance(x, list) and len(x) == 3 for x in v): t.append("trajectory")
|
||||
if v and all(isinstance(x, str) for x in v): t.append("languages")
|
||||
elif isinstance(v, int) and not isinstance(v, bool):
|
||||
if 1_600_000_000 <= v <= 2_100_000_000: t.append("timestamp?")
|
||||
elif v > 1_000_000: t.append("bigint?")
|
||||
else: t.append("smallint?")
|
||||
elif isinstance(v, str) and v == "":
|
||||
t.append("emptystr?")
|
||||
return t
|
||||
|
||||
print("逐槽标签:")
|
||||
for i, v in enumerate(cd):
|
||||
print(" %2d %-22s %s" % (i, ",".join(tags(v)), json.dumps(v, ensure_ascii=False)[:50]))
|
||||
print()
|
||||
|
||||
# ---- 反解 screen_fingerprint 的约束 ----
|
||||
fp = next((v for v in cd if isinstance(v, str) and "*" in v and "-" in v), None)
|
||||
if fp:
|
||||
p = fp.split("-")
|
||||
print("screen_fingerprint = %r -> 高%s 宽%s 色深%s" % (fp, p[0], p[1], p[3]))
|
||||
for target, want in (("screen_height", int(p[0])), ("screen_width", int(p[1])),
|
||||
("screen_color_depth", int(p[3]))):
|
||||
idxs = [i for i, v in enumerate(cd) if v == want]
|
||||
print(" %-20s 期望 %s -> 明文里出现在槽 %s" % (target, want, idxs))
|
||||
@@ -1,138 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把真机明文的值,与「语义名」精确配对。
|
||||
|
||||
难点:真机明文的槽顺序 = **它自己那次构建**的 require 顺序,
|
||||
而我手上只有 har/online/ 等若干构建;不知道明文属于哪一份。
|
||||
|
||||
解法(自洽性打分):把明文的值**按每个候选构建的语义名顺序**标注一遍,
|
||||
再看标注结果是否语义自洽(platform 的位置该是 "MacIntel"、user_agent 该含
|
||||
"Mozilla"、charset 该是 "UTF-8" …)。自洽度最高者即为它所属的构建;
|
||||
若都不匹配,也能从"哪些语义名被正确喂到"反推出每个名字的真实取值。
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/verify/label_plaintext.py <明文文件> <若干 tdc.js>
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
|
||||
# ---- 语义名 -> 期望值特征(这就是"画像"的定义雏形)----
|
||||
def likes(name: str, v) -> str:
|
||||
"""值是否与语义名相容。返回 OK / NO / ??(?? = 该名字无法从值判断)"""
|
||||
s = json.dumps(v, ensure_ascii=False)
|
||||
if name.startswith("user_agent"):
|
||||
return "OK" if isinstance(v, str) and "Mozilla" in v else "NO"
|
||||
if name == "platform":
|
||||
return "OK" if isinstance(v, str) and v and " " not in v else "NO"
|
||||
if name == "languages":
|
||||
return "OK" if isinstance(v, list) and v and isinstance(v[0], str) else "NO"
|
||||
if name == "hardware_concurrency":
|
||||
return "OK" if isinstance(v, int) and 2 <= v <= 64 else "NO"
|
||||
if name == "screen_width":
|
||||
return "OK" if isinstance(v, int) and v > 500 else "NO"
|
||||
if name == "screen_height":
|
||||
return "OK" if isinstance(v, int) and v > 300 else "NO"
|
||||
if name == "screen_color_depth":
|
||||
return "OK" if isinstance(v, int) and 16 <= v <= 48 else "NO"
|
||||
if name == "screen_fingerprint":
|
||||
return "OK" if isinstance(v, str) and "-" in v and "*" in v else "NO"
|
||||
if name == "timezone_offset":
|
||||
return "OK" if isinstance(v, str) and re.fullmatch(r"[+-]\d\d", v) else "NO"
|
||||
if name == "charset":
|
||||
return "OK" if isinstance(v, str) and "UTF" in v.upper() else "NO"
|
||||
if name == "webgl_renderer":
|
||||
return "OK" if isinstance(v, str) and ("ANGLE" in v or "Renderer" in v) else "NO"
|
||||
if name == "webgl_vendor":
|
||||
return "OK" if isinstance(v, str) and ("Inc." in v or "Google" in v) else "NO"
|
||||
if name == "canvas_fingerprint":
|
||||
return "OK" if isinstance(v, str) and len(v) > 40 else "NO"
|
||||
if name == "constant_98k":
|
||||
return "OK" if v == "98k" else "NO"
|
||||
if name == "template_url":
|
||||
return "OK" if isinstance(v, str) and "captcha" in v and "rand=" in v else "NO"
|
||||
if name in ("location_href", "referrer"):
|
||||
return "OK" if isinstance(v, str) and v.startswith("http") else "??"
|
||||
if name == "trajectory":
|
||||
return "OK" if (isinstance(v, list) and v
|
||||
and all(isinstance(x, list) and len(x) == 3 for x in v)) else "NO"
|
||||
return "??"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
return 2
|
||||
plain = open(sys.argv[1], encoding="latin1").read().rstrip("\x00")
|
||||
logical = re.sub(r" {2,}", "", plain)
|
||||
cd = json.loads(logical)["cd"]
|
||||
print("明文 cd 槽数: %d" % len(cd))
|
||||
print()
|
||||
|
||||
cands = []
|
||||
for a in sys.argv[2:]:
|
||||
cands += sorted(glob.glob(a))
|
||||
if not cands:
|
||||
print("没有候选构建")
|
||||
return 1
|
||||
|
||||
def assign(names, nslots):
|
||||
"""按语义名顺序把 nslots 个槽分配给各名字(交错推进)。"""
|
||||
out = {}
|
||||
k = 0
|
||||
for nm in names:
|
||||
if k >= nslots:
|
||||
break
|
||||
out.setdefault(nm, []).append(cd[k])
|
||||
k += 1
|
||||
return out
|
||||
|
||||
best = None
|
||||
for f in cands:
|
||||
rows = tm.analyze(f)
|
||||
names = [r["name"] for r in rows]
|
||||
# 假设除 itoken(占 2 槽)外都是 1 槽
|
||||
itok = [i for i, n in enumerate(names) if n.startswith("itoken")]
|
||||
n_extra = 1 if itok else 0
|
||||
# 逐个候选:把 cd 的槽按名字顺序切(itoken 那两个槽合并看第一个)
|
||||
mapping = {}
|
||||
k = 0
|
||||
for i, nm in enumerate(names):
|
||||
if k >= len(cd):
|
||||
break
|
||||
if i in itok:
|
||||
mapping[nm] = cd[k:k + 2]
|
||||
k += 2
|
||||
else:
|
||||
mapping[nm] = [cd[k]]
|
||||
k += 1
|
||||
ok = no = unk = 0
|
||||
for nm, vals in mapping.items():
|
||||
st = likes(nm, vals[0])
|
||||
ok += st == "OK"
|
||||
no += st == "NO"
|
||||
unk += st == "??"
|
||||
score = ok - no
|
||||
print(" %-46s OK=%2d NO=%2d ??=%2d 分=%d"
|
||||
% (Path(f).name, ok, no, unk, score))
|
||||
if best is None or score > best[0]:
|
||||
best = (score, f, mapping, ok, no)
|
||||
|
||||
print()
|
||||
print(">>> 最自洽的构建: %s (OK=%d NO=%d)" % (Path(best[1]).name, best[3], best[4]))
|
||||
print()
|
||||
print("该假设下的 name -> 值 标注:")
|
||||
for nm, vals in best[2].items():
|
||||
st = likes(nm, vals[0])
|
||||
mark = {"OK": " ", "NO": "!", "??": "?"}[st]
|
||||
print(" %s %-40s %s" % (mark, nm, json.dumps(vals, ensure_ascii=False)[:70]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -24,8 +24,8 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tcaptcha.client as client_mod # noqa: E402
|
||||
from tcaptcha.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402
|
||||
import tcaptcha.protocol.client as client_mod # noqa: E402
|
||||
from tcaptcha.protocol.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402
|
||||
|
||||
AID = "195672344"
|
||||
ENTRY = "https://1ms.run/console/personal/benefits/check-in"
|
||||
@@ -56,7 +56,7 @@ def main() -> int:
|
||||
# 段几何(新链路才有;旧模板回填没有 segments)
|
||||
if out.get("source") == "python_values":
|
||||
try:
|
||||
from tcaptcha.crypto import split_collect_segments, xtea_decrypt_bytes
|
||||
from tcaptcha.telemetry.crypto import split_collect_segments, xtea_decrypt_bytes
|
||||
import base64
|
||||
plain = xtea_decrypt_bytes(base64.b64decode(out["collect"]),
|
||||
out["key"]).decode("latin1").rstrip("\x00")
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证 struct_40_9 的消歧特征是否跨构建稳定。
|
||||
|
||||
判别:
|
||||
A 组: 闭包体最后写入 **空串** (PUSH_EMPTY_STR; SET_PROP)
|
||||
B 组: 闭包体最后写入 **随机 int32** (PUSH_IMM <big>; SET_PROP)
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
|
||||
|
||||
def closure_body(p, entry):
|
||||
"""取模块唯一闭包的 target 体。"""
|
||||
cls = [c for c in p.closures if p.owner(c.pc) == entry]
|
||||
if not cls:
|
||||
return None
|
||||
return p.function_code(cls[0].target)
|
||||
|
||||
|
||||
def disamb(p, entry):
|
||||
"""返回 'empty' | 'rand' | None"""
|
||||
body = closure_body(p, entry)
|
||||
if body is None:
|
||||
return None
|
||||
imms = [x["operands"][0] for x in body
|
||||
if x["name"] == "PUSH_IMM" and x["operands"]]
|
||||
big = [v for v in imms if v > 1_000_000]
|
||||
if big:
|
||||
return "rand"
|
||||
return "empty"
|
||||
|
||||
|
||||
stats = defaultdict(lambda: defaultdict(list))
|
||||
for f in BATCH:
|
||||
p = tp.load(f)
|
||||
reg = dict(p.module_registry(0))
|
||||
rows = tm.analyze(f)
|
||||
for r in rows:
|
||||
if r["name"] != "struct_40_9":
|
||||
continue
|
||||
d = disamb(p, reg[r["module"]])
|
||||
stats[f.split("/")[-1]][d or "?"].append((r["item"], r["module"]))
|
||||
|
||||
print("%-24s %-8s %s" % ("构建", "判别", "项(模块)"))
|
||||
print("-" * 70)
|
||||
ok = True
|
||||
for f, groups in stats.items():
|
||||
for d, members in sorted(groups.items()):
|
||||
print("%-24s %-8s %s" % (f, d, members))
|
||||
# 每份构建必须 empty 1 个 + rand 1 个
|
||||
if len(groups.get("empty", [])) != 1 or len(groups.get("rand", [])) != 1:
|
||||
ok = False
|
||||
print()
|
||||
print(">>> 每份构建都是 empty×1 + rand×1 :", ok)
|
||||
print(">>> 结论:", "可静态消歧(写入空串 vs 写入随机 int32)" if ok else "仍不可分")
|
||||
@@ -1,94 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""对照实验:证明「按语义名配对」不是归一化过拟合出来的。
|
||||
|
||||
风险: 归一化若抹掉太多,任何配对都会"全对",校验就失去意义。
|
||||
|
||||
做法(负对照,同一套归一化 + 同一套比对):
|
||||
① 按语义名配对(实验组)
|
||||
② 按清单下标配对(负对照:槽位序随机,必然错)
|
||||
③ 按模块 id 配对(负对照:id 每次重排,必然错)
|
||||
④ 随机配对(负对照基线)
|
||||
|
||||
若①接近 37/37 而 ②③④ 明显更低 => 归一化没有过拟合,配对确实有效。
|
||||
"""
|
||||
import glob
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
|
||||
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
|
||||
|
||||
|
||||
def norm_code(p, entry, maxlen=400):
|
||||
"""归一化指令序列(与 verify_pair_correct2.py 同一套规则,此处内联避免导入副作用)。
|
||||
|
||||
抹掉三类**会重排**的操作数,保留指令骨架与字符串的位置:
|
||||
1. 代码地址(JUMP / JUMP_IF_* / PUSH_TRY)
|
||||
2. require(N) 的模块号 N
|
||||
3. PUSH_STR_CHAR 的具体字符值(fp_salt 随机盐串、混淆变量名每次都变)
|
||||
"""
|
||||
code = p.function_code(entry)
|
||||
out = []
|
||||
for k, inst in enumerate(code):
|
||||
nm, ops = inst["name"], tuple(inst["operands"] or [])
|
||||
if nm in ADDR_OPS:
|
||||
ops = ("ADDR",) * len(ops)
|
||||
elif nm == "PUSH_STR_CHAR":
|
||||
ops = ("CH",)
|
||||
elif nm == "PUSH_IMM" and ops:
|
||||
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
|
||||
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
|
||||
ops = ("MOD",) * len(ops)
|
||||
out.append((nm, ops))
|
||||
return tuple(out[:maxlen])
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
|
||||
|
||||
def load(f):
|
||||
p = tp.load(f)
|
||||
rows = tm.analyze(f)
|
||||
reg = dict(p.module_registry(0))
|
||||
sig = {r["name"]: norm_code(p, reg[r["module"]]) for r in rows}
|
||||
return rows, sig
|
||||
|
||||
|
||||
A, fa = load(BATCH[0])
|
||||
print("基准 %s (归一化:抹掉地址/模块号/字符值,保留指令骨架与串的位置)" % BATCH[0].split("/")[-1])
|
||||
print()
|
||||
print("%-22s %-22s %-8s %-8s" % ("对比构建", "配对方式", "一致", "不一致"))
|
||||
print("-" * 66)
|
||||
|
||||
for f in BATCH[1:]:
|
||||
B, fb = load(f)
|
||||
names = sorted(set(fa) & set(fb))
|
||||
|
||||
def score(pairs):
|
||||
return sum(1 for a, b in pairs if a == b)
|
||||
|
||||
# ① 实验组:语义名
|
||||
p1 = [(fa[n], fb[n]) for n in names]
|
||||
# ② 负对照:清单下标
|
||||
p2 = [(fa[A[i]["name"]], fb[B[i]["name"]]) for i in range(min(len(A), len(B)))]
|
||||
# ③ 负对照:模块 id(用 id 当 key 去查另一边同名集合的任意一项)
|
||||
fb_by_id = {r["module"]: fb[r["name"]] for r in B}
|
||||
p3 = [(fa[A[i]["name"]], fb_by_id.get(A[i]["module"], ())) for i in range(len(A))]
|
||||
# ④ 负对照:随机配对
|
||||
rnd = random.Random(7)
|
||||
shuf = names[:]
|
||||
rnd.shuffle(shuf)
|
||||
p4 = [(fa[n], fb[m]) for n, m in zip(names, shuf)]
|
||||
|
||||
for label, pairs in (("①语义名(实验组)", p1), ("②清单下标(负对照)", p2),
|
||||
("③模块id(负对照)", p3), ("④随机配对(负对照)", p4)):
|
||||
s = score(pairs)
|
||||
print("%-22s %-22s %-8d %-8d" % (f.split("/")[-1], label, s, len(pairs) - s))
|
||||
print()
|
||||
|
||||
print(">>> 判读: ①应≈37/37;②③④应明显更低 => 归一化未过拟合,配对有效")
|
||||
@@ -1,67 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证字符串+结构指纹方案的**跨构建一致性** —— 只基于 har/online/ 全新样本。
|
||||
|
||||
案例 §5.3 的验收标准: "3 个 tdc.js 版本、37 个模块、全部正确识别,无一 unknown"。
|
||||
本脚本再进一步:不仅要求"无 unknown",还要求
|
||||
① 每份构建内部 37 项都有名字
|
||||
② 各构建之间的**语义名集合**一致(说明识别的是同一批采集器)
|
||||
③ 语义名 -> 每项在 cd 里的**相对位置**可交叉核对
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
print("全新在线样本 %d 份(只使用这批数据)" % len(BATCH))
|
||||
print()
|
||||
|
||||
results = {}
|
||||
for f in BATCH:
|
||||
rows = tm.analyze(f)
|
||||
names = [r["name"] for r in rows]
|
||||
results[f] = rows
|
||||
unk = sum(1 for n in names if n == "unknown")
|
||||
via_struct = sum(1 for r in rows if r.get("via_struct"))
|
||||
via_deleg = sum(1 for r in rows if r.get("via_delegate"))
|
||||
print(" %-24s 项%2d unknown %d | 字符串直判 %2d 委派链 %d 结构兜底 %d"
|
||||
% (f.split("/")[-1], len(rows), unk,
|
||||
len(rows) - via_struct - via_deleg, via_deleg, via_struct))
|
||||
|
||||
print()
|
||||
keys = list(results)
|
||||
base = Counter(r["name"] for r in results[keys[0]])
|
||||
print("以 %s 为基准:%d 个语义名(去重 %d)"
|
||||
% (keys[0].split("/")[-1], len(base), len(set(base))))
|
||||
print()
|
||||
for f in keys[1:]:
|
||||
c = Counter(r["name"] for r in results[f])
|
||||
same = sum((base & c).values())
|
||||
only_base = sum((base - c).values())
|
||||
only_it = sum((c - base).values())
|
||||
print(" vs %-22s 相同 %2d | 仅基准有 %2d | 仅有它有 %2d"
|
||||
% (f.split("/")[-1], same, only_base, only_it))
|
||||
|
||||
print()
|
||||
common = set(base)
|
||||
for f in keys[1:]:
|
||||
common &= set(Counter(r["name"] for r in results[f]))
|
||||
allnames = set()
|
||||
for f in keys:
|
||||
allnames |= set(r["name"] for r in results[f])
|
||||
print(">>> 所有构建**共同**识别出的语义名: %d 个" % len(common))
|
||||
print(">>> 出现过的语义名总数 : %d 个" % len(allnames))
|
||||
print(">>> 差异名:", sorted(allnames - common) if allnames - common else "无(完全一致)")
|
||||
print()
|
||||
|
||||
# 唯一性检查:单构建内是否有重名(重名 = 锚点有歧义)
|
||||
print("单构建内重名检查(重名说明该锚点有歧义):")
|
||||
for f in keys:
|
||||
c = Counter(r["name"] for r in results[f])
|
||||
dup = {k: v for k, v in c.items() if v > 1}
|
||||
print(" %-24s 重名 %d 组 %s"
|
||||
% (f.split("/")[-1], len(dup), dict(sorted(dup.items()))))
|
||||
@@ -26,11 +26,11 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_modules as tm # noqa: E402
|
||||
from tcaptcha.crypto import (build_collect, split_collect_segments, # noqa: E402
|
||||
from tcaptcha.telemetry import modules as tm # noqa: E402
|
||||
from tcaptcha.telemetry.crypto import (build_collect, split_collect_segments, # noqa: E402
|
||||
xtea_decrypt_bytes)
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.tdc_values import Session, build_plain, generate_cd, trajectory_index # noqa: E402
|
||||
from tcaptcha.telemetry.key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.telemetry.values import Session, build_plain, generate_cd, trajectory_index # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""判决「按语义名配对」是否**正确** —— 用独立于指纹的方法。
|
||||
|
||||
⚠️ 上一轮我用「模块 id 相等」做校验,那个假设是**错的**:
|
||||
案例 §1.2 与账本 #29 都明确说「37 个模块的 ID 每次构建随机重新分配」。
|
||||
registry 全集交集 62/62 只说明编号**范围**相同(都是 0..61),
|
||||
不代表 id -> 语义 的映射相同。id 不一致是**预期行为**,不能用来否定配对。
|
||||
|
||||
本轮改用真正独立的证据:**函数体的归一化指令序列**。
|
||||
指纹规则只用到「字符串集合」和「结构统计(指令数/直方图)」,
|
||||
没有用到「指令的完整顺序 + 操作数」。所以拿它做校验是独立的。
|
||||
|
||||
归一化: 抹掉 JUMP 的绝对地址(每构建不同),保留其余操作数。
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_layout # noqa: E402
|
||||
import tdc_modules as tm # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
|
||||
|
||||
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
|
||||
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
|
||||
|
||||
|
||||
def norm_code(p, entry, maxlen=400):
|
||||
"""归一化指令序列。
|
||||
|
||||
必须抹掉两类**会重排**的操作数,否则校验必然失败:
|
||||
1. 代码地址:JUMP / JUMP_IF_TRUE / JUMP_IF_FALSE / PUSH_TRY
|
||||
2. require(N) 的模块号 N:`LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC`
|
||||
(案例 §1.2:37 个模块的 ID 每次构建随机重新分配)
|
||||
其余操作数(字符串 charCode、小常量、寄存器号)跨构建稳定,保留。
|
||||
"""
|
||||
code = p.function_code(entry)
|
||||
out = []
|
||||
for k, inst in enumerate(code):
|
||||
nm, ops = inst["name"], tuple(inst["operands"] or [])
|
||||
if nm in ADDR_OPS:
|
||||
ops = ("ADDR",) * len(ops)
|
||||
elif nm == "PUSH_STR_CHAR":
|
||||
# 只保留"这是一个字符"这件事,**不看具体是哪个字符**。
|
||||
# 这样 fp_salt 的随机盐串、混淆变量名这些「会变的内容」就不会干扰校验,
|
||||
# 而"字符串的长度/位置"仍然参与比对 —— 足够独立,且仍然很强。
|
||||
ops = ("CH",)
|
||||
elif nm == "PUSH_IMM" and ops:
|
||||
# require(N) 形状:前一个 LOAD_REG_IMM,后一个 CALL_FUNC
|
||||
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
|
||||
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
|
||||
ops = ("MOD",) * len(ops)
|
||||
out.append((nm, ops))
|
||||
return tuple(out[:maxlen])
|
||||
|
||||
|
||||
print("构建 : 每个模块的函数体归一化指令序列 -> 作为独立指纹")
|
||||
print()
|
||||
|
||||
A = tm.analyze(BATCH[0])
|
||||
pa = tp.load(BATCH[0])
|
||||
ra = dict(pa.module_registry(0))
|
||||
fa = {r["name"]: norm_code(pa, ra[r["module"]]) for r in A}
|
||||
|
||||
print("基准 %s (%d 项)" % (BATCH[0].split("/")[-1], len(A)))
|
||||
print()
|
||||
print("%-24s %-8s %-8s %-8s %s" % ("对比构建", "配对", "序列一致", "不一致", "判决"))
|
||||
print("-" * 66)
|
||||
all_ok = True
|
||||
for f in BATCH[1:]:
|
||||
B = tm.analyze(f)
|
||||
pb = tp.load(f)
|
||||
rb = dict(pb.module_registry(0))
|
||||
fb = {r["name"]: norm_code(pb, rb[r["module"]]) for r in B}
|
||||
common = set(fa) & set(fb)
|
||||
same = [n for n in common if fa[n] == fb[n]]
|
||||
diff = [n for n in common if fa[n] != fb[n]]
|
||||
all_ok &= not diff
|
||||
print("%-24s %-8d %-8d %-8d %s"
|
||||
% (f.split("/")[-1], len(common), len(same), len(diff),
|
||||
"✓" if not diff else "✗"))
|
||||
for n in sorted(diff)[:3]:
|
||||
a, b = fa[n], fb[n]
|
||||
hit = next((k for k in range(min(len(a), len(b))) if a[k] != b[k]), -1)
|
||||
print(" %-26s 首个分歧@%d A=%s B=%s" % (n, hit,
|
||||
a[hit] if hit >= 0 else "-", b[hit] if hit >= 0 else "-"))
|
||||
|
||||
print()
|
||||
print(">>> 全部配对的函数体序列一致:", all_ok)
|
||||
print(">>> 判决:", "语义名配对**正确**(独立证据)" if all_ok else "配对有误,需检查")
|
||||
@@ -1,61 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用**全新在线样本**做端到端重排测试(不需要真机明文,自证)。
|
||||
|
||||
做法:
|
||||
给每个语义名编一个**假值** value_i,按构建 A 的清单顺序排成 cd_A。
|
||||
再用锚点把它重排到构建 B 的顺序,得到 cd_B。
|
||||
正确答案 = 按 B 的清单顺序、取各自语义名的假值。
|
||||
|
||||
这个测试能抓住 reorder 的核心 bug(恒等映射):
|
||||
若 B 的顺序 ≠ A 的顺序,恒等映射必然错;只有真对齐才对。
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_collectgen as cg # noqa: E402
|
||||
import tdc_modules as tm # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
print("新样本 %d 份" % len(BATCH))
|
||||
print()
|
||||
|
||||
|
||||
A = BATCH[0]
|
||||
rowsA = tm.analyze(A)
|
||||
namesA = [r["name"] for r in rowsA]
|
||||
seqA = [r["module"] for r in rowsA]
|
||||
n = len(namesA)
|
||||
print("基准 %s 项 %d 唯一名 %d" % (A.split("/")[-1], n, len(set(namesA))))
|
||||
|
||||
# 假值:每项一个可区分的值
|
||||
fake = {nm: "V<%s>" % nm for nm in namesA}
|
||||
cd_A = [fake[nm] for nm in namesA]
|
||||
|
||||
src = cg.spec_from_seq(seqA, [None] * n, namesA, sample_cd=cd_A)
|
||||
|
||||
print()
|
||||
for f in BATCH[1:]:
|
||||
rowsB = tm.analyze(f)
|
||||
namesB = [r["name"] for r in rowsB]
|
||||
seqB = [r["module"] for r in rowsB]
|
||||
dst = cg.spec_from_seq(seqB, [None] * len(rowsB), namesB)
|
||||
|
||||
# 正确答案:按 B 的顺序取各自语义名的假值
|
||||
truth = [fake[nm] for nm in namesB]
|
||||
|
||||
try:
|
||||
out = cg.reorder(cd_A, dst, src)
|
||||
ok = out == truth
|
||||
identical = out == cd_A
|
||||
print(" vs %-24s" % f.split("/")[-1])
|
||||
print(" 重排结果 == 正确答案 : %s" % ok)
|
||||
print(" 重排结果 == 原样(说明没重排): %s" % identical)
|
||||
if not ok:
|
||||
bad = [i for i, (a, b) in enumerate(zip(out, truth)) if a != b]
|
||||
print(" 错位 %d 处, 前5: %s" % (len(bad), bad[:5]))
|
||||
except Exception as e:
|
||||
print(" vs %-24s %s: %s" % (f.split("/")[-1], type(e).__name__, str(e)[:110]))
|
||||
print()
|
||||
@@ -1,75 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用真机 collect 密文的**长度与填充**判决「四段加密」结构。
|
||||
|
||||
不需要密钥:段结构在 base64 的长度与 '=' 分布上留下指纹。
|
||||
|
||||
判决依据(docs/tdc-案例.md §5.1):
|
||||
chunk1/trajectory/chunk2 各 pad 到 24 字节 -> 密文长度是 24 的倍数 -> base64 无 '='
|
||||
sd 不 pad,只被 XTEA 补到 8 的倍数 -> 长度%3 != 0 -> base64 带 '='
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/verify/verify_seglen.py <collect 的 base64>
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: verify_seglen.py <collect 的 base64(文件路径或直接给串)>")
|
||||
raise SystemExit(2)
|
||||
_arg = sys.argv[1]
|
||||
_p = Path(_arg)
|
||||
b64 = (_p.read_text(encoding="ascii") if _p.exists() else _arg).strip()
|
||||
print("真机 collect: %d 字符, 含 '=' : %s" % (len(b64), "=" in b64))
|
||||
print()
|
||||
|
||||
raw = base64.b64decode(b64)
|
||||
print("解码后密文: %d 字节" % len(raw))
|
||||
print()
|
||||
|
||||
# 真机明文(另一份同会话样本)给出的各段长度
|
||||
segs = [("chunk1", 355, 5), ("trajectory", 62, 10), ("chunk2", 288, 24), ("sd", 33, None)]
|
||||
print("%-11s %8s %6s %10s %12s %10s" % ("段", "内容", "pad", "密文字节", "base64字符", "含'='"))
|
||||
print("-" * 66)
|
||||
total = 0
|
||||
total_b64 = 0
|
||||
for name, content, pad in segs:
|
||||
if pad is not None:
|
||||
n = content + pad # 空格 pad 到 24
|
||||
assert n % 24 == 0
|
||||
else:
|
||||
n = content + ((8 - content % 8) % 8) # sd: 只被 XTEA 补到 8 的倍数
|
||||
enc = base64.b64encode(b"\x00" * n).decode()
|
||||
total += n
|
||||
total_b64 += len(enc)
|
||||
print("%-11s %8d %6s %10d %12d %10s"
|
||||
% (name, content, pad if pad is not None else "(xtea)",
|
||||
n, len(enc), "=" in enc))
|
||||
print("-" * 66)
|
||||
print("%-11s %8s %6s %10d %12d" % ("合计", "", "", total, total_b64))
|
||||
print()
|
||||
print(">>> 推算总密文字节 %d == 实际 %d : %s" % (total, len(raw), total == len(raw)))
|
||||
print(">>> 推算 base64 字符 %d == 实际 %d : %s" % (total_b64, len(b64), total_b64 == len(b64)))
|
||||
print()
|
||||
|
||||
# 反过来看:把整串按 24 字节切,前三段应能整除
|
||||
print("[反向校验] 把密文按 24 字节切分:")
|
||||
off = 0
|
||||
for name, content, pad in segs:
|
||||
if pad is None:
|
||||
print(" %-11s 剩余 %3d 字节 (sd, 非 24 倍数)" % (name, len(raw) - off))
|
||||
break
|
||||
n = content + pad
|
||||
print(" %-11s [%4d:%4d] %3d 字节 24倍数=%s"
|
||||
% (name, off, off + n, n, n % 24 == 0))
|
||||
off += n
|
||||
print()
|
||||
|
||||
# 关键指纹: '=' 只应出现在最后(来自 sd 段)
|
||||
idx = b64.find("=")
|
||||
print("'=' 首次出现位置: %d / 总长 %d -> %s"
|
||||
% (idx, len(b64), "在末尾区域(符合 sd 段不 pad)"
|
||||
if idx >= len(b64) - 4 else "⚠️ 在中间(与四段结构矛盾)"))
|
||||
if idx >= 0:
|
||||
print(" 尾部填充: %r" % b64[idx:])
|
||||
@@ -1,89 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""按 docs/tdc-案例.md §5.1 验证 collect 的「四段加密」结构(真机明文判决)。
|
||||
|
||||
要判决的问题:
|
||||
项目现有 crypto.encrypt_string / tdc_collectgen.encrypt_plain 都是
|
||||
「整体 JSON -> \x00 补齐到 8 -> 一次加密 -> 一个 base64」。
|
||||
案例说「4 段独立加密,前 3 段空格 pad 到 24 字节对齐,sd 不 pad」。
|
||||
两者只能有一个对 —— 用真机明文里的**空格填充串**来判。
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件>
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: verify_segment.py <已解密的 collect 明文文件>")
|
||||
print(" (明文来源: 用同会话 tdc.js 的密钥解密 TDC.getData(true) 的产出)")
|
||||
raise SystemExit(2)
|
||||
RAW = sys.argv[1]
|
||||
|
||||
data = open(RAW, encoding="latin1").read()
|
||||
print("原始(含尾部 \\x00):", len(data), "字节")
|
||||
stripped = data.rstrip("\x00")
|
||||
print("去掉尾部 \\x00 后 :", len(stripped), "字节 (尾部 \\x00 个数 = %d)" % (len(data) - len(stripped)))
|
||||
print()
|
||||
|
||||
runs = [(m.start(), m.end()) for m in re.finditer(r" {2,}", stripped)]
|
||||
print("[1] 连续空格串(只能来自「空格 pad」):")
|
||||
for a, b in runs:
|
||||
print(" 位置 %3d..%3d 长度 %2d" % (a, b, b - a))
|
||||
print()
|
||||
|
||||
# 段边界 = 每个空格串的结束位置(若空格串在段尾)
|
||||
bounds = [0] + [b for _a, b in runs[:-1]] if len(runs) > 1 else [0]
|
||||
# 更稳: 段边界取每个空格串的"起点所在段"的末尾 -> 直接用空格串终点
|
||||
b1, b2, b3 = runs[0][1], runs[1][1], runs[2][1]
|
||||
segs = [("chunk1", 0, b1), ("trajectory", b1, b2), ("chunk2", b2, b3), ("sd", b3, len(stripped))]
|
||||
print("[2] 按空格串切出的 4 段:")
|
||||
for name, a, b in segs:
|
||||
s = stripped[a:b]
|
||||
print(" %-11s [%3d:%3d] len=%3d 24倍数=%-5s 8倍数=%-5s"
|
||||
% (name, a, b, len(s), len(s) % 24 == 0, len(s) % 8 == 0))
|
||||
print()
|
||||
|
||||
print("[3] 空格 pad 公式校验 (pad_len = 24 - len%24,总是补 1..24 个):")
|
||||
for name, a, b in segs[:3]:
|
||||
s = stripped[a:b]
|
||||
content = s.rstrip(" ")
|
||||
pad = len(s) - len(content)
|
||||
print(" %-11s 内容 %3d + pad %2d = %3d 公式给出 %2d -> %s"
|
||||
% (name, len(content), pad, len(s), 24 - len(content) % 24,
|
||||
"OK" if pad == 24 - len(content) % 24 else "FAIL"))
|
||||
print()
|
||||
|
||||
print("[4] sd 段不 pad(只被 XTEA 的 8 字节块 \x00 补齐):")
|
||||
sd = stripped[b3:]
|
||||
print(" sd = %r" % sd)
|
||||
print(" sd 长度 %d (非 8 的倍数 -> 加密时被补 %d 个 \\x00)"
|
||||
% (len(sd), (8 - len(sd) % 8) % 8))
|
||||
print(" 实际尾部 \\x00 个数 = %d" % (len(data) - len(stripped)))
|
||||
print()
|
||||
|
||||
print("[5] 逻辑整体是否是合法 JSON,以及轨迹在 cd 里的位置:")
|
||||
obj = json.loads(stripped)
|
||||
cd = obj["cd"]
|
||||
traj_idx = [i for i, v in enumerate(cd)
|
||||
if isinstance(v, list) and v and all(isinstance(x, list) and len(x) == 3 for x in v)]
|
||||
print(" cd 槽数 = %d" % len(cd))
|
||||
print(" 轨迹槽下标 = %s" % traj_idx)
|
||||
if traj_idx:
|
||||
print(" 轨迹内容前 3 点 = %s" % cd[traj_idx[0]][:3])
|
||||
print()
|
||||
|
||||
print("[6] 去 pad 后 4 段拼接 == 逻辑明文(应能解析成同一对象):")
|
||||
rebuilt = "".join(stripped[a:b].rstrip(" ") for _name, a, b in segs[:3]) + stripped[b3:]
|
||||
obj2 = json.loads(rebuilt)
|
||||
print(" 可解析为 JSON :", True)
|
||||
print(" 与原对象逐项相同 :", obj2 == obj)
|
||||
print(" 重建长度 %d (原文去尾 \\x00 后 %d, 差 = pad 总量 %d)"
|
||||
% (len(rebuilt), len(stripped), len(stripped) - len(rebuilt)))
|
||||
print()
|
||||
print("[7] 三段之间的连接处(验证 chunk1 以逗号结尾 / chunk2 以 '],' 结尾):")
|
||||
print(" chunk1 内容末尾 : %r" % stripped[0:b1].rstrip(" ")[-24:])
|
||||
print(" trajectory 内容 : %r" % stripped[b1:b2].rstrip(" ")[:40])
|
||||
print(" chunk2 内容开头 : %r" % stripped[b2:b3].rstrip(" ")[:24])
|
||||
print(" chunk2 内容末尾 : %r" % stripped[b2:b3].rstrip(" ")[-24:])
|
||||
print(" sd 内容 : %r" % stripped[b3:])
|
||||
@@ -27,10 +27,10 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_modules as tm # noqa: E402
|
||||
from tcaptcha.crypto import split_collect_segments, xtea_decrypt_bytes # noqa: E402
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.tdc_values import Session, generate_cd # noqa: E402
|
||||
from tcaptcha.telemetry import modules as tm # noqa: E402
|
||||
from tcaptcha.telemetry.crypto import split_collect_segments, xtea_decrypt_bytes # noqa: E402
|
||||
from tcaptcha.telemetry.key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.telemetry.values import Session, generate_cd # noqa: E402
|
||||
|
||||
|
||||
def slot_names(rows):
|
||||
|
||||
Reference in New Issue
Block a user