Files
hello_js_reverse_skill/tools/tdc_layout.py
T
yml2213 80a122245a feat(collect): 按 docs/tdc-案例.md 重做主链路 —— 四段加密 + 字符串指纹识别 37/37
做的什么:
  1. 判决并修正 collect 结构: 不是「整体一次加密」, 而是
     chunk1 / trajectory / chunk2 / sd **四段独立加密**, 前 3 段
     空格 pad 到 24 字节对齐、sd 不 pad, 4 段 base64 直接拼接。
  2. 按案例 §5.3 实现字符串+结构指纹的模块识别器, 把 37 个采集器
     全部识别且跨构建唯一。
  3. 修 reorder 的恒等映射 bug, 并补强断言。
  4. 在线抓取全新样本作为唯一数据源, 清理探索性脚本。

为什么:
  * 旧「整体加密 + \x00 补齐」与真机数据矛盾 —— 导致多轮实验
    建立在结构错误的 collect 上(这正是此前反复"乱碰"的根因)。
  * 模块 id 每份构建重排, 不能当跨构建锚点; 但模块的**代码**
    (字符串集合、结构)不变 —— 这是案例指明的不变量。
  * reorder 旧实现用「目标槽下标」切「源数组」, 顺序不同即全盘错位;
    原测试只断言"是置换", 恒等函数也满足, 所以 bug 被放过了。

怎么验证:
  * 四段结构(3 条独立证据, 其中一条不需密钥):
      - 真机明文 3 处连续空格串 [+5,+10,+24], 且 pad == 24-len%24 全中
      - 密文长度算术 480+96+416+56 = 1048 == 真实 1048 字符
      - '=' 只出现在 1046/1048, 对应 sd 段 33B→补 7→40B
  * 指纹: 4 份全新在线构建 37/37 识别、0 unknown、跨构建语义名集合
    完全一致、单构建内 0 重名
  * 配对正确性: 独立证据 37/37 一致; 负对照(清单下标 / 模块 id /
    随机配对)均只有 2-3/37 —— 证明归一化没有过拟合
  * 端到端重排 3/3 正确且非恒等
  * selftest 全绿; 强断言自查: 旧实现过不了新断言

新增:
  tools/tdc_modules.py            字符串+结构指纹识别(案例 §5.3)
  scripts/fetch_online_samples.py 在线抓取全新 tdc.js
  scripts/verify/*.py             7 个可复现的验证脚本
  docs/tdc-案例.md                案例原文(看雪, 作者执着的猫)

修改:
  tcaptcha/crypto.py          +build_collect / split_collect_segments(四段)
  tools/tdc_collectgen.py     reorder 改为跨构建锚点对齐 + spec_from_seq
  tools/tdc_layout.py         +static_fingerprint / opcode_histogram
  tools/tdc_program.py        修符号求值插值类型崩溃
  tcaptcha/tests/test_collectgen.py  补「非恒等」强断言 + 在线新样本端到端
  tcaptcha/client.py          debug 目录改到系统临时目录(不再写仓库内 scratch/)
  har/1ms_dianxuan/PROGRESS.md      新增已知 #41~#49, 显式修正被推翻的旧结论
  .gitignore                  +scratch/  +har/online/

删除:
  scratch/(含 10-11 遗留的一次性脚本, 已确认不可恢复后清理)

注意:
  * 账本记录了一次自我纠错: 曾据案例 §5.5 误判 kind=2「不进 cd」,
    实测(cd 38 槽、轨迹在第 10 槽)否定后已撤回。
  * 仍未打通: 纯离线生成需要每个语义名的取值来源(已知 #38)。
2026-10-12 02:28:08 +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 tools/tdc_layout.py tcaptcha/core_js/tdc_sample.js
# 和真机明文并排核对 (明文是怎么来的: 同会话 tdc.js 密钥解密 verify 的 collect)
.venv/bin/python tools/tdc_layout.py <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"))
import tdc_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())