Files
hello_js_reverse_skill/tcaptcha/telemetry/layout.py
T
yml2213 344eea2194 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
2026-10-12 12:34:57 +08:00

346 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
tdc_layout.py —— 静态导出 `collect` 明文的 **cd 槽位表**(纯算, 不跑 JS, 不跑环境)。
背景(这条链是 2026-10-11 排查 errorCode=12 时打通的, 见 har/1ms_dianxuan/PROGRESS.md #31/#32):
真机成功会话的 collect 明文解出来后, 发现 **5 份同日样本的 `cd` 槽位数(38)、取值集合、`sd`
全都一样, 但槽位顺序 5/5 互不相同** —— 也就是"哪些字段排在哪一槽"是**按构建随机化**的。
仓库里的 success_collect_template.b64 是**某一次**的顺序, 拿它发的 collect 会被服务端
按"本次构建的槽位表"读成自相矛盾的环境 -> errorCode=12。
槽位序从哪来(程序结构, 见 tools/tdc_program.py):
1. 顶层 @0 先把 62 个**模块工厂**按 id 填进一个数组 (registry[id] = function(module, exports, require));
2. 其中一个模块(下称"清单模块")返回一个**数组**, 它的构造方式是一串
r6[r6.length] = require(N)
也就是 **`cd` 的槽位顺序 = 这串 require 的顺序**;
3. 另一个模块负责把这些采集器按 `.on` / `.reset` / `.get` 分到三个数组, 再另有一个模块
遍历"有 .get() 的那些", 依次取 `get()` 的值拼成 `{"cd":[...],"sd":{...}}`。
所以本工具做的事:
* 找清单模块(= push(require(N)) 次数最多的那个模块);
* 读出它的 require 序列 -> 槽位表 [(槽位号, 模块 id, 工厂 entry), ...];
* 把每个槽位子模块(含其内部闭包)里的**环境属性字面量**列出来, 作为该槽的语义线索;
* 可选: 读一份已解密的真机 collect 明文, 与槽位表并排打印, 人工/程序核对。
用法:
# 只看槽位表
.venv/bin/python python -m tcaptcha.telemetry.layout tcaptcha/core_js/tdc_sample.js
# 和真机明文并排核对 (明文是怎么来的: 同会话 tdc.js 密钥解密 verify 的 collect)
.venv/bin/python python -m tcaptcha.telemetry.layout <tdc.js> --plain /tmp/real_collect_plain.txt
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
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"))
from tcaptcha.telemetry import program as tp # noqa: E402
# 采集器用来读环境的属性名(用于给槽位打语义标签, 不是判定依据)
ENV_KEYS = [
"userAgent", "platform", "languages", "language", "hardwareConcurrency", "deviceMemory",
"colorDepth", "pixelDepth", "availHeight", "availWidth", "deviceXDPI", "logicalXDPI",
"fontSmoothingEnabled", "appVersion", "appName", "vendor", "cookieEnabled", "timezone",
"getTimezoneOffset", "toDataURL", "getParameter", "getExtension", "TCaptchaReferrer",
"webdriver", "referrer", "href", "width", "height", "innerWidth", "innerHeight",
"devicePixelRatio", "characterSet", "MAX_TEXTURE_SIZE", "WEBGL_debug_renderer_info",
]
def _fold_str_before(code: List[dict], end: int, span: int = 40) -> Optional[str]:
"""从 end 往前折叠一个字面串(PUSH_EMPTY_STR + 连续 PUSH_STR_CHAR)。"""
chars: List[str] = []
i = end
while i >= 0 and i > end - span and code[i]["name"] == "PUSH_STR_CHAR":
chars.append(chr(code[i]["operands"][0] & 0xFF))
i -= 1
if i >= 0 and code[i]["name"] == "PUSH_EMPTY_STR":
return "".join(reversed(chars))
return None
def slot_value_expr(p: tp.Program, entry: int) -> Optional[str]:
"""**精确**取一个槽位模块"这一槽的值是什么"。
规则(实测形状): 采集器模块最后做
r3.exports.get = function () { ... return <被捕获的那个寄存器> ... }
也就是 `get` 闭包**只返回它捕获的寄存器**。所以:
1. 在工厂里找到被赋给属性 `get` 的那个 CREATE_CLOSURE;
2. 从它的 caps 里取出**父寄存器号**;
3. 该寄存器在工厂里的赋值表达式, 就是这一槽的语义。
例: 模块 18 -> caps=[r3=r6], 工厂里 `r6 = c.navigator.userAgent`。
"""
code = p.function_code(entry)
assigns = dict(p.symbolic_assignments(entry, limit=60))
for k, inst in enumerate(code):
if inst["name"] != "CREATE_CLOSURE":
continue
# 形状: <"get" 字面量>; REVERSE_TOP2; JUMP -> @site; @site: CREATE_CLOSURE
j = k - 1
if j >= 0 and code[j]["name"] == "JUMP":
j -= 1
if _fold_str_before(code, j) != "get":
continue
site = next((c for c in p.closures if c.pc == inst["pc"]), None)
if site is None:
continue
for _child, parent in site.caps: # caps 是 (子寄存器, 父寄存器)
expr = assigns.get(f"r{parent}")
if expr:
return expr
# 兜底(覆盖更广): 采集器模块里通常**只有一个闭包**,它捕获的那个父寄存器就是取值寄存器
for c in p.closures:
if p.owner(c.pc) != entry or len(c.caps) != 1:
continue
_child, parent = c.caps[0]
expr = assigns.get(f"r{parent}")
if expr:
return expr
return None
def slot_semantics(p: tp.Program, reg: Dict[int, int], mid: int, depth: int = 2) -> List[str]:
"""还原某个槽位子模块"到底取什么值"。
三条线索合并(都来自 tdc_program 的符号求值, 不跑 JS):
1. 模块工厂自身的赋值链, 例如 `r6 = c.navigator.userAgent`;
2. **委派**: 有些模块只是 `r6 = r5(N)`(require 另一个模块再导出),要跟进去;
3. **`get` 闭包内部**: canvas/WebGL 这类是延迟取值, 真正读环境在它内部创建的闭包里。
"""
exprs: List[str] = []
seen: set = set()
def visit(m: int, d: int) -> None:
if m in seen or d < 0 or m not in reg:
return
seen.add(m)
entry = reg[m]
for _r, v in p.symbolic_assignments(entry, limit=14):
v = v.strip()
if not v:
continue
exprs.append(v)
mm = re.match(r"r\d+\((\d+)\)$", v) # 纯委派 r6 = r5(N)
if mm:
visit(int(mm.group(1)), d - 1)
for c in p.closures: # 该模块内部创建的闭包(含 get 实现)
if p.owner(c.pc) != entry:
continue
for _r, v in p.symbolic_assignments(c.target, limit=10):
if v.strip():
exprs.append(v.strip())
mm = re.match(r"r\d+\((\d+)\)$", v.strip())
if mm:
visit(int(mm.group(1)), d - 1)
visit(mid, depth)
uniq, out = set(), []
for e in exprs:
if e not in uniq:
uniq.add(e)
out.append(e)
return out
_ENV_RE = re.compile(r"c\.|screen|navigator|document|window")
_DELEG_RE = re.compile(r"r\d+\(\d*\)\s*$")
def _is_delegation(e: str) -> bool:
"""纯模块委派 `r5(22)` / `r5()` —— 括号里的模块号每份构建重排,不能进指纹。"""
return bool(_DELEG_RE.fullmatch(e))
def static_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
depth: int = 4, keep: int = 3) -> str:
"""模块的**静态语义指纹** —— 跨构建对齐的主锚点(2026-10-12 实测)。
为什么不用模块 id(实测证伪):模块 id 是编译器序号,每份构建重新洗牌。
两份构建清单交集的 28 个模块里 `id -> 语义` 完全相同的只有 **1/11**
(模块9 在 A 是 canvas,在 B 是 webkitRTCPeerConnection)。按 id 对齐必错位。
构造规则(两条,都是为了抹掉"会重排"的部分、留下"不会变"的部分):
1. 丢掉纯模块委派 `r5(22)` —— 括号里的模块号会重排;
2. 其余符号表达式**排序**后取前 keep 条 —— 排序消除遍历顺序差异。
实测(har/1ms_dianxuan/tdc.js vs live/tdc_fresh.js):
* 覆盖 29/37 项(比只取"环境路径"的 24/37 高不少 —— 常量槽、结构槽也有稳定形状)
* 两份构建指纹**多重集交集 27/29**
* 单构建内 **27/29 唯一**(只有 "Not supported | createElement(canvas)" 这一组重复)
"""
keepers: List[str] = []
for e in slot_semantics(p, reg, mid, depth=depth):
e = e.strip()
if not e or _is_delegation(e):
continue
keepers.append(e)
return " | ".join(sorted(set(keepers))[:keep])
def env_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
depth: int = 4, keep: int = 3) -> str:
"""只保留**读环境**的表达式(`c.`/screen/navigator/document/window)。
覆盖比 `static_fingerprint` 低(24/37 vs 29/37),仅在需要"这一槽读什么环境"
这种**人类可读标签**时使用。做**对齐**请用 static_fingerprint。
"""
keepers: List[str] = []
for e in slot_semantics(p, reg, mid, depth=depth):
e = e.strip()
if not e or _is_delegation(e) or not _ENV_RE.search(e):
continue
keepers.append(e)
return " | ".join(sorted(set(keepers))[:keep])
def opcode_histogram(p: tp.Program, reg: Dict[int, int], mid: int) -> str:
"""模块的**指令直方图** —— 覆盖 37/37、跨构建多重集交集 37/37 的兜底锚点。
有些采集器(常量槽、纯闭包槽)的符号求值拿不到表达式(实测 8/37 项返回空串),
但它们**函数体的 opcode 分布**是稳定的:编译器把同一份源码编成同一串指令,
只是模块 id 换了。实测两份构建 37/37 全部匹配。
缺点:唯一性差一些(单构建内 20/37 唯一),所以只能当**第三级**锚点,
在静态指纹匹配不上时兜底,并且同样要求"双向唯一"才配对。
"""
entry = reg.get(mid)
if entry is None:
return ""
counts = Counter(inst["name"] for inst in p.function_code(entry))
return "|".join(f"{k}:{v}" for k, v in sorted(counts.items()))
def item_fingerprints(p: tp.Program, seq: Sequence[int]) -> List[str]:
"""按清单顺序, 给每一项算出静态语义指纹(与 seq 等长)。"""
reg = dict(p.module_registry(0))
return [static_fingerprint(p, reg, m) for m in seq]
def item_histograms(p: tp.Program, seq: Sequence[int]) -> List[str]:
"""按清单顺序, 给每一项算出指令直方图(与 seq 等长, 兜底锚点)。"""
reg = dict(p.module_registry(0))
return [opcode_histogram(p, reg, m) for m in seq]
def find_manifest_module(p: tp.Program) -> Tuple[int, int, List[int]]:
"""找"清单模块": 体内 `push(require(N))` 次数最多者。
返回 (模块 id, 工厂 entry, require 序列)。
"""
reg = dict(p.module_registry(0))
best = (0, -1, [])
for mid, entry in reg.items():
code = p.function_code(entry)
seq: List[int] = []
for k, inst in enumerate(code):
if inst["name"] != "PUSH_IMM" or not inst["operands"]:
continue
if k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM" \
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC":
seq.append(int(inst["operands"][0]))
if len(seq) > len(best[2]):
best = (mid, entry, seq)
return best
def transitive_strings(p: tp.Program, entry: int, depth: int = 3) -> List[str]:
"""模块自身 + 它内部创建的闭包(递归 depth 层)里的字面串。"""
seen, out = set(), []
frontier = {entry}
for _ in range(depth):
nxt = set()
for e in frontier:
if e in seen:
continue
seen.add(e)
for pc, s in p.strings:
if p.owner(pc) == e:
out.append(s)
for c in p.closures:
if p.owner(c.pc) == e:
nxt.add(c.target)
frontier = nxt
return out
def env_hints(strings: List[str]) -> List[str]:
hits = []
for s in strings:
for k in ENV_KEYS:
if k.lower() in s.lower() and k not in hits:
hits.append(k)
return hits
def main() -> int:
ap = argparse.ArgumentParser(description="静态导出 collect 的 cd 槽位表")
ap.add_argument("path")
ap.add_argument("--plain", default=None, help="已解密的真机 collect 明文 (json 文本)")
ap.add_argument("--json", default=None, help="把槽位表写成 JSON")
args = ap.parse_args()
p = tp.load(args.path)
reg = dict(p.module_registry(0))
mid, entry, seq = find_manifest_module(p)
print(f"文件 : {args.path}")
print(f"模块总数 : {len(reg)}")
print(f"清单模块 : registry[{mid}] = @{entry}")
print(f"槽位序列长度 : {len(seq)} (真机 cd 槽数 = 38, 差的部分是字面量槽)")
print(f"require 序列 : {seq}")
plain_cd: Optional[list] = None
if args.plain:
txt = open(args.plain, encoding="latin1").read().rstrip("\x00").strip()
obj = json.loads(txt)
plain_cd = obj.get("cd")
print(f"真机明文 cd 槽数: {len(plain_cd)}")
table = []
print("\n槽位 模块id 工厂entry 取值语义(静态还原) 真机值")
print("-" * 112)
for i, m in enumerate(seq):
tgt = reg.get(m)
expr = slot_value_expr(p, tgt) if tgt else None
# 有些模块只是"再 require 一层"(r6 = r5(M)),跟进一层
mm = re.match(r"r\d+\((\d+)\)$", (expr or "").strip())
if mm and int(mm.group(1)) in reg:
sub = slot_value_expr(p, reg[int(mm.group(1))])
if sub:
expr = f"{sub} [via require({mm.group(1)})]"
if not expr:
expr = "; ".join(slot_semantics(p, reg, m)[:2]) if tgt else ""
val = ""
if plain_cd is not None and i < len(plain_cd):
v = repr(plain_cd[i])
val = v if len(v) <= 30 else v[:30] + "..."
sem = expr or ""
if len(sem) > 44:
sem = sem[:44] + "..."
table.append({"slot": i, "module": m, "entry": tgt, "expr": expr})
print(f" {i:2d} {m:4d} @{tgt:<7} {sem:<46} {val}")
if plain_cd is not None:
print(f"\n(真机 cd 里还有 {len(plain_cd) - len(seq)} 个槽不来自 require —— "
f"通常是字面量, 例如 cd[0] 的空串)")
if args.json:
Path(args.json).write_text(json.dumps({"manifestModule": mid, "entry": entry,
"slots": table}, ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"槽位表 -> {args.json}")
return 0
if __name__ == "__main__":
raise SystemExit(main())