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)。
This commit is contained in:
yml2213
2026-10-12 02:28:08 +08:00
parent 50734a854b
commit 80a122245a
18 changed files with 2512 additions and 40 deletions
+268 -19
View File
@@ -31,6 +31,7 @@ from __future__ import annotations
import argparse
import base64
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
@@ -50,6 +51,202 @@ import tdc_program as tp # noqa: E402
# --------------------------------------------------------------------------- #
# 槽位映射 #
# --------------------------------------------------------------------------- #
def value_sig(v: Any) -> str:
"""值的**结构签名**(用于在没有环境指纹时当次级锚点)。
只描述"形状 + 常量",抹掉随时间/会话变化的具体数值:
1791736322 -> "int:epochish" [[34,96,3284],...] -> "list:pts"
"MacIntel" -> "str:MacIntel" [360,360] -> "list:2"
易变槽(时间戳/轨迹/随机串)的签名在两份样本里**类型一致**,数值不同 —— 正好
用来把它们彼此对应起来,再由调用方决定"沿用样本值"还是"重新生成"。
"""
if v is None:
return "null"
if isinstance(v, bool):
return f"bool:{v}"
if isinstance(v, int):
return "int:epochish" if 1_700_000_000 <= v <= 2_000_000_000 else f"int:{v}"
if isinstance(v, float):
return "float"
if isinstance(v, str):
if len(v) > 200:
return "str:big"
if _RANDSTR_RE.fullmatch(v):
return "str:rand"
return f"str:{v[:24]}"
if isinstance(v, list):
if v and all(isinstance(x, list) and len(x) == 3 for x in v):
return "list:pts"
return f"list:{len(v)}"
return type(v).__name__
_RANDSTR_RE = re.compile(r"[A-Za-z0-9+/=]{20,}")
class _Unmapped:
"""哨兵:标记"这个目标槽没找到源槽"。
为什么不能用 None:真机 cd 里本身就有 null 值(`out += "null"` 分支,
见 PROGRESS.md #36),用 None 当占位符会把合法的 null 槽误判成"没对齐"。
"""
__slots__ = ()
def __repr__(self) -> str: # pragma: no cover - 仅用于调试输出
return "<unmapped>"
_UNMAPPED = _Unmapped()
def build_anchor_map(src: "SlotSpec", dst: "SlotSpec") -> List[Tuple[int, int]]:
"""把「源构建的槽」对齐到「目标构建的槽」,返回 [(src_slot, dst_slot), ...]。
这是重排的核心。三层锚点,逐层降级(都必须**双向唯一**,否则宁可不对齐):
① **环境语义指纹**(主锚点,跨构建稳定,实测多重集交集 23/24)
源项 i 与目标项 j 的 env_fingerprint 相同 -> 对齐。
② **结构签名**(次锚点,用于常量槽 / 读不到环境的项)
value_sig 相同 -> 对齐。
③ 剩余未对齐的 -> 由调用方显式处理(不猜)。
为什么不能按项下标或模块 id 对齐(两个都已实测证伪):
* 项下标:两次构建的清单顺序**就是**不同的东西,按下标对齐 = 恒等映射;
* 模块 id:编译器每次重新分配,两份构建交集 28 个模块里 id->语义 相同的
只有 1/11(2026-10-12 实测)。
"""
def bipartite(keys_src: List[Any], keys_dst: List[Any]) -> Dict[int, int]:
"""只在**双向唯一**时配对,避免多对一的歧义错位。"""
from collections import defaultdict
by_dst: Dict[Any, List[int]] = defaultdict(list)
by_src: Dict[Any, List[int]] = defaultdict(list)
for i, k in enumerate(keys_src):
if k:
by_src[k].append(i)
for j, k in enumerate(keys_dst):
if k:
by_dst[k].append(j)
pair: Dict[int, int] = {}
for k, srcs in by_src.items():
dsts = by_dst.get(k) or []
if len(srcs) == 1 and len(dsts) == 1:
pair[srcs[0]] = dsts[0]
return pair
pairs: Dict[int, int] = {}
# 锚点优先级(2026-10-12 实测,4 份全新在线构建):
# 锚点 跨构建唯一可配对
# ① 语义名(案例§5.3 指纹) 35/37 <- 主锚点
# ② 静态语义指纹 fp 29/37
# ③ 指令直方图 hist 20/37
# ④ 结构签名 value_sig (仅双方都有实测值时)
# 所以:语义名先用,再逐级降级。每一级都要求**双向唯一**才配对。
# ① 语义名(案例 §5.3:字符串指纹 + 结构兜底 + 重名消歧)
pairs.update(bipartite([n or "" for n in src.names],
[n or "" for n in dst.names]))
# ②③ 降级:静态语义指纹 -> 指令直方图
for key in (lambda s: [f or "" for f in s.fps],
lambda s: [h or "" for h in s.hists]):
for i, j in bipartite(key(src), key(dst)).items():
pairs.setdefault(i, j)
# ④ 末级:结构签名(仅在双方都有实测值时可用)
sig_pair = bipartite([value_sig(v) for v in src.sample_values],
[value_sig(v) for v in dst.sample_values])
for i, j in sig_pair.items():
pairs.setdefault(i, j)
out: List[Tuple[int, int]] = []
for i, (_i, _m, sslot, n) in enumerate(src.assign):
j = pairs.get(i)
if j is None or j >= len(dst.assign):
continue
_dj, _dm, dslot, dn = dst.assign[j]
if dn != n:
continue # 槽数形状不一致 -> 拒绝猜,跳过
for k in range(n):
out.append((sslot + k, dslot + k))
return out
def reorder(sample: Sequence[Any], dst: "SlotSpec",
src: Optional["SlotSpec"] = None,
fill: Optional[Dict[int, Any]] = None) -> List[Any]:
"""把「源样本」的 cd 值重排成**目标构建**的槽位序。
⚠️ 旧实现的 bug(2026-10-12 修复):
旧版签名是 `reorder(sample, spec)`,其中 `spec` 只是**目标构建**的规格,
而循环里 `sample[slot:slot+n]` 用的 `slot` 恰恰是**目标构建自己的槽下标**。
两次构建的槽位序不同(#31),所以这等价于"按目标下标切源数组" —— 顺序不同
时全盘错位,顺序相同时才碰巧对。**必须同时给出源构建的规格**才能对齐。
参数:
sample - 源构建产出的 cd 值数组(真机明文)
dst - 目标构建的槽位规格(本次要发包的那个构建)
src - 源构建的槽位规格。**不传则退化为旧行为**(仅当两份构建同序时才正确,
此时本函数会先校验再放行,不再静默错位)。
fill - {目标槽号: 值},用于补齐锚点匹配不上的槽。
锚点能力上限(2026-12 实测,见 tdc_layout.static_fingerprint / opcode_histogram):
静态锚点组合 `fp+hist` 单构建内唯一 **29/37**,跨构建交集 35。
剩下 8 项(分成 2 组)**静态不可区分**:它们的函数体只有一处 JUMP 目标不同,
语义藏在跳转目标里,符号求值取不到。这些槽必须由调用方用 `fill` 显式给值。
未给 fill 且仍对不齐的槽 -> 抛 `UnmappedSlotsError`(宁可暴露"不知道",
也不要静默填错值 —— 旧实现正是静默填错,导致多轮实验建立在坏数据上)。
"""
if src is None:
# 契约:不提供源构建规格 = 调用方**承诺**样本与目标构建同序。
# 此时重排只能是恒等映射;先把槽数校验掉,再直接返回副本。
if len(sample) != dst.n_slots:
raise ValueError(
f"样本槽数 {len(sample)} != 目标构建槽数 {dst.n_slots};"
f"未提供源构建规格时不跨构建重排(见 PROGRESS.md #31)")
out: List[Any] = [_UNMAPPED] * dst.n_slots
for d in range(dst.n_slots):
out[d] = sample[d]
for d, v in (fill or {}).items():
if 0 <= d < dst.n_slots:
out[d] = v
return out
if len(sample) != src.n_slots:
raise ValueError(
f"样本槽数 {len(sample)} != 源构建槽数 {src.n_slots};"
f"样本必须来自 `src` 描述的那个构建")
pairs = build_anchor_map(src, dst)
# 用哨兵而不是 None 标记"未映射":真机 cd 里本身就有 null 值,
# 拿 None 当占位符会把合法的 null 槽误判成"没对齐"。
out: List[Any] = [_UNMAPPED] * dst.n_slots
for s_slot, d_slot in pairs:
out[d_slot] = sample[s_slot]
unmapped = [d for d, v in enumerate(out) if v is _UNMAPPED]
for d, v in (fill or {}).items():
if 0 <= d < dst.n_slots:
out[d] = v
if d in unmapped:
unmapped.remove(d)
if unmapped:
raise UnmappedSlotsError(unmapped, dst, src)
return out
class UnmappedSlotsError(RuntimeError):
"""有目标槽找不到源槽与之对应(锚点匹配失败)。"""
def __init__(self, slots: List[int], dst: "SlotSpec", src: "SlotSpec") -> None:
self.slots = slots
super().__init__(
f"目标构建有 {len(slots)} 个槽无法与源样本对齐(槽号 {slots[:12]}"
f"{'...' if len(slots) > 12 else ''})。"
f"锚点不足时宁可报错也不要静默填错值 —— 请用 `--values` 显式提供这些槽的取值,"
f"或检查两份构建是否来自同一采集器集合。")
def outer_commas(s: str) -> int:
"""数**不在**任何 `[]` / `{}` 内的逗号个数(方括号内的逗号不构成顶层分隔)。"""
depth = 0
@@ -69,11 +266,27 @@ class SlotSpec:
def __init__(self, seq: Sequence[int],
kinds: Optional[Sequence[Any]] = None,
v0s: Optional[Sequence[str]] = None) -> None:
v0s: Optional[Sequence[str]] = None,
fps: Optional[Sequence[Optional[str]]] = None,
hists: Optional[Sequence[str]] = None,
names: Optional[Sequence[Optional[str]]] = None) -> None:
self.seq = list(seq)
self.kinds = list(kinds) if kinds is not None else [None] * len(seq)
# v0s[i] = 该项 get()[0] 的字符串化结果;kind=1 时按它的逗号数切出额外槽
self.v0s = list(v0s) if v0s is not None else [""] * len(seq)
# fps[i] = 该项的**环境语义指纹**(跨构建稳定, 见 tdc_layout.env_fingerprint);
# None/空串 表示该项读不到环境路径(常量槽 / 轨迹槽),需退到值签名或直接填值
self.fps: List[Optional[str]] = list(fps) if fps is not None else [None] * len(seq)
# hists[i] = 该项的**指令直方图**(兜底锚点,覆盖 37/37,见 tdc_layout.opcode_histogram)
self.hists: List[str] = list(hists) if hists is not None else [""] * len(seq)
# names[i] = 该项的**语义名**——案例 §5.3 的字符串+结构指纹(主锚点)。
# 实测 4 份全新在线构建:37/37 识别、0 unknown、跨构建完全一致、**35/37 唯一可配对**。
# 明显优于本项目早期自创的 fp/hist 锚点(29/37),所以放在最前面用。
self.names: List[Optional[str]] = (
list(names) if names is not None else [None] * len(seq))
# 该构建下的一份**实测 cd 值**(源侧重排时要用它算结构签名)。
# 目标侧可以留空;只有当成"源规格"参与 build_anchor_map 时才必须给。
self.sample_values: List[Any] = []
self.assign: List[Tuple[int, int, int, int]] = [] # (item, module, first_slot, n_slots)
self._build()
@@ -130,20 +343,40 @@ def load_sample_cd(path: str) -> List[Any]:
return raw["cd"] if isinstance(raw, dict) else raw
def reorder(sample: Sequence[Any], spec: SlotSpec) -> List[Any]:
"""把"上一份 cd"的值按**本次构建**的槽位序重排。
def spec_from_js(path: str, kinds_path: Optional[str] = None,
sample_cd: Optional[Sequence[Any]] = None) -> "SlotSpec":
"""从一份 tdc.js 直接构造该构建的槽位规格(含环境语义指纹)。"""
prog = tp.load(path)
_mid, _entry, seq = tdc_layout.find_manifest_module(prog)
kinds, v0s = load_shape(kinds_path, seq) if kinds_path else (None, None)
fps = tdc_layout.item_fingerprints(prog, seq)
hists = tdc_layout.item_histograms(prog, seq)
# 主锚点:案例 §5.3 的字符串+结构指纹语义名(实测 35/37 唯一可配对)
# 复用已加载的 prog(analyze_prog),避免把同一份 tdc.js 再解析一遍。
# 不做静默降级(AGENTS.md 附录 A#8):锚点算不出来就抛错,
# 让调用方明确知道"这次没有主锚点",而不是悄悄退化到较弱的锚点上。
import tdc_modules
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
spec = SlotSpec(seq, kinds, v0s, fps, hists, names)
if sample_cd is not None:
spec.sample_values = list(sample_cd)
return spec
原理(#31):同一台机器上的多份样本,**取值集合相同**、只有顺序不同;
所以只要样本与本次构建的"槽位数一致",就能按"项 -> 槽"的对应关系做置换。
def spec_from_seq(seq: Sequence[int],
kinds: Optional[Sequence[Any]] = None,
names: Optional[Sequence[Optional[str]]] = None,
sample_cd: Optional[Sequence[Any]] = None,
v0s: Optional[Sequence[str]] = None) -> "SlotSpec":
"""只用清单序列构造规格(**不需要 tdc.js**,供测试/合成用例使用)。
纯算重排只需要「每一项占几槽」+「每项的锚点名」;
tdc.js 只用来**导出**这两样东西,本身不是重排的输入。
"""
if len(sample) != spec.n_slots:
raise ValueError(
f"样本槽数 {len(sample)} != 本次构建槽数 {spec.n_slots};"
f"两者必须来自同一环境(见 PROGRESS.md #31)")
out: List[Any] = []
for (_i, _m, slot, n) in spec.assign:
out.extend(sample[slot:slot + n])
return out
spec = SlotSpec(seq, kinds, v0s, names=names)
if sample_cd is not None:
spec.sample_values = list(sample_cd)
return spec
# --------------------------------------------------------------------------- #
@@ -162,7 +395,10 @@ def encrypt_plain(plain: str, key: Sequence[int]) -> str:
def main() -> int:
ap = argparse.ArgumentParser(description="按本次构建生成 collect")
ap.add_argument("path", help="本次会话的 tdc.js")
ap.add_argument("path", help="本次会话的 tdc.js(**目标构建**)")
ap.add_argument("--src-build", default=None,
help="源样本所属的那份 tdc.js。**跨构建重排必须给**;"
"不给则假定源样本与目标构建同序(并会校验槽数)")
ap.add_argument("--from-sample", default=None, help="一份已解密的 cd 数组(json)")
ap.add_argument("--values", default=None, help="语义名->值的 json 字典(实验性)")
ap.add_argument("--out", default=None, help="输出 collect(base64) 的文件")
@@ -176,24 +412,37 @@ def main() -> int:
key = extract_xtea_key_pure(open(args.path, encoding="utf-8", errors="replace").read())["key"]
kinds, v0s = load_shape(args.kinds, seq) if args.kinds else (None, None)
spec = SlotSpec(seq, kinds, v0s)
fps = tdc_layout.item_fingerprints(prog, seq)
hists = tdc_layout.item_histograms(prog, seq)
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
dst = SlotSpec(seq, kinds, v0s, fps, hists, names)
anchored = sum(1 for f in fps if f)
print(f"文件 : {args.path}")
print(f"清单模块 : registry[{_mid}] 项数 {len(seq)}")
print(f"槽位规格 : {spec.describe()}")
print(f"槽位规格 : {dst.describe()}")
print(f"锚点覆盖 : 语义名 {sum(1 for n in names if n and n != 'unknown')}/{len(seq)}"
f" 静态指纹 {anchored}/{len(seq)}")
print(f"XTEA 密钥 : {key}")
if args.from_sample:
sample = load_sample_cd(args.from_sample)
if args.src_build:
src = spec_from_js(args.src_build, args.kinds, sample)
else:
src = None
try:
cd = reorder(sample, spec)
except ValueError as exc:
cd = reorder(sample, dst, src)
except (ValueError, UnmappedSlotsError) as exc:
print(f"❌ {exc}")
return 1
print(f"样本槽数 : {len(sample)} -> 重排后 {len(cd)}")
if args.src_build:
# 自证:锚点对齐后,值集合应当守恒(易变槽数值允许不同)
print(f"对齐方式 : 环境语义指纹 + 结构签名(源={args.src_build})")
elif args.values:
vals = json.load(open(args.values, encoding="utf-8"))
cd = []
for (_i, m, _slot, n) in spec.assign:
for (_i, m, _slot, n) in dst.assign:
v = vals[str(m)]
cd.extend(v[:n] if isinstance(v, list) else [v])
print(f"按语义字典 : {len(cd)} 槽")
+82 -1
View File
@@ -35,8 +35,9 @@ import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Sequence, Tuple
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
@@ -152,6 +153,86 @@ def slot_semantics(p: tp.Program, reg: Dict[int, int], mid: int, depth: int = 2)
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))` 次数最多者。
+454
View File
@@ -0,0 +1,454 @@
# -*- coding: utf-8 -*-
"""
tdc_modules.py —— **按案例 §5.3 的字符串指纹识别 37 个采集器模块**。
> "从 v1 的全部错位到指纹方案的全部正确,关键不是更复杂的分析,
> 而是换了一个更好的问题 —— 不再问『这个模块返回什么值』,
> 而是问『这个模块的代码长什么样』。" —— docs/tdc-案例.md §5.3
为什么必须做这件事(2026-10-12 实测,推翻项目早期假设):
* **模块 id 每份构建重新分配**。两份构建清单交集的 28 个模块里,
`id -> 语义` 完全相同的只有 **1/11**(模块 8 在 A 是 `navigator.platform`,
在 B 是 canvas 指纹)。按 id 对齐必然错位。
* 但**模块代码里的字符串集合跨构建完全不变**:采集 userAgent 的模块
一定含 `'userAgent'`;采集时区的一定含 `'getTimezoneOffset'`。这就是不变量。
本模块做三件事:
1. `module_strings()` —— 取一个模块的**可达**字符串集合(含内部闭包,递归委派)
2. `resolve_delegate()` —— 解析**委派链**(见下)
3. `identify()` —— 用指纹规则表把模块判成语义名
委派链(本项目新发现,案例未提但实测必需):
清单里有 6 个模块**自身不采集任何东西**,身体只是 `exports.get = require(N)`:
项8 模块21 -> require(22)
项9 模块24 -> require(25)
项27 模块49 -> require(31)
项31 模块53 -> require(0)
它们的字符串集合只有 {'Array','Object','exports','get'} —— **零区分性**,
必须顺着委派链跟到真正的采集器才能识别。这 6 个就是案例 §5.2 说的
"6 个无 .get 模块"的来源之一。
用法:
from tdc_modules import analyze
info = analyze("har/1ms_dianxuan/tdc.js")
for row in info:
print(row["item"], row["module"], row["name"], row["strings"][:4])
"""
from __future__ import annotations
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Set, 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_layout # noqa: E402
import tdc_program as tp # noqa: E402
# --------------------------------------------------------------------------- #
# 通用字符串(每个模块都有,无区分性,进指纹前必须剔除) #
# --------------------------------------------------------------------------- #
NOISE: Set[str] = {
"Array", "Object", "exports", "get", "Math", "RegExp", "String", "Number",
"JSON", "set", "reset", "on", "length", "slice", "prototype", "constructor",
"push", "apply", "call", "bind", "toString", "valueOf", "hasOwnProperty",
}
# --------------------------------------------------------------------------- #
# 指纹规则表(案例 §5.3)—— 每个语义名对应一组「必须出现的字符串」 #
# --------------------------------------------------------------------------- #
# 形式: 语义名 -> (必需串集合, 权重提示)
# 判定: 模块的区分串集合 **包含** 某个规则的必需串 -> 命中该规则
# 形式: (语义名, 必需串, 禁用串)
# 判定: required ⊆ strings 且 forbidden ∩ strings == ∅
#
# ⚠️ 规则必须**按 specificity 从高到低排**(必需串多的在前)。
# 早期版本只按列表顺序取第一个命中,结果 webdriver_detect 被误判成 user_agent
# —— 两者都只要求 1 个串,而 user_agent 恰好排在前面。
# 给特征明显的规则补上更多必需串(如 webdriver 要求 2 个自动化标记),
# 它们就会天然压过 user_agent 这类单串规则。
#
# fp_salt 之所以需要 forbidden: 它和 iframe_detect 的串集合几乎相同
# (都是 appendChild/body/createElement/document),唯一区别是 iframe_detect
# 多一个 contentWindow。用禁用串把两者分开。
FINGERPRINTS: List[Tuple[str, Set[str], Set[str]]] = [
# ---- 复合指纹(多必需串,天然压过单串规则)----
("screen_fingerprint", {"screen", "width", "height", "availHeight", "colorDepth"}, set()),
("webrtc_ip", {"RTCPeerConnection", "createDataChannel", "createOffer"}, set()),
("webgl_renderer", {"UNMASKED_RENDERER_WEBGL", "WEBGL_debug_renderer_info"}, set()),
("webgl_vendor", {"UNMASKED_VENDOR_WEBGL", "WEBGL_debug_renderer_info"}, set()),
("webdriver_detect", {"$cdc_asdjflasutopfhvcZLmcfl_", "__webdriver_script_fn"}, set()),
("canvas_fingerprint", {"ClientJS,org <canvas> 1.0", "#069"}, set()),
("dom_compliance", {"::marker", "CSS"}, set()),
("template_url", {"?rand=*", "URL"}, set()),
("charset", {"characterSet", "charset"}, set()),
("viewport_size", {"clientWidth", "clientHeight"}, set()),
("time_since_load", {"getTime", "load"}, set()),
# ---- 单串规则(放最后,避免压过上面的复合规则)----
("user_agent", {"userAgent"}, set()),
("platform", {"platform"}, set()),
("languages", {"languages"}, set()),
("hardware_concurrency", {"hardwareConcurrency"}, set()),
("cookie_enabled", {"cookieEnabled"}, set()),
("timezone_offset", {"getTimezoneOffset"}, set()),
("itoken", {"TDC_itoken"}, set()),
("guid_token", {r"^\d+:\d+$"}, set()),
("referrer", {"TCaptchaReferrer"}, set()),
("location_href", {"href"}, {"?rand=*", "TCaptchaReferrer"}),
("iframe_detect", {"contentWindow"}, set()),
("console_detect", {"console"}, set()),
("touch_detect", {"ontouchstart"}, set()),
("window_time", {"getTime"}, set()),
("constant_98k", {"98k"}, set()),
("trajectory", {"stringify"}, set()),
("screen_width", {"screen", "width"}, set()),
("screen_height", {"screen", "height"}, set()),
("screen_color_depth", {"colorDepth"}, set()),
]
# --------------------------------------------------------------------------- #
# 结构兜底规则(案例 §5.3: "对于没有区分性字符串的通用模块,
# 用 .get 函数的指令数 + entry 函数的指令数 + 特定指令来区分")
# --------------------------------------------------------------------------- #
# 形式: (语义名, 判定函数) —— 只在**字符串规则全落空**时启用。
#
# ⚠️ 命名纪律:下面这些名字**只描述「观测到的结构分组」,不猜测业务语义**。
# 早期版本把 entry48/get9/call1 那组臆断成 "itoken_relay" —— 那是没有证据的猜测。
# 现在统一用 "struct_<entry>_<get>[_call]" 这种**纯结构标签**:
# 它诚实地表达「我们只知道它长这样,还不知道它采集什么」,
# 同时又因为跨构建稳定,仍然可以当**对齐锚点**用。
#
# 实测(4 份全新在线构建 har/online/20261012-01*,2026-10-12):
# entry135/get121/… -> 每份构建恰好 1 个 => fp_salt(随机串每次都变,只能靠结构)
# entry46/get9/… -> 每份构建恰好 1 个 => 稳定分组
# entry48/get9/call1 -> 每份构建恰好 1 个 => 稳定分组
# entry40/get9/… -> 每份构建 **2 个** => 组内无法再分,需运行时取样才能定
STRUCT_RULES = [
("fp_salt", lambda s: s["entry_instr"] >= 120 and s["get_instr"] >= 100),
("struct_46_9", lambda s: s["entry_instr"] == 46 and s["get_instr"] == 9),
("struct_48_9_call", lambda s: s["entry_instr"] == 48 and s["get_instr"] == 9
and s["call_func"]),
("struct_40_9", lambda s: s["entry_instr"] == 40 and s["get_instr"] == 9),
]
def struct_signature(p: tp.Program, reg: Dict[int, int], mid: int) -> Dict:
"""取一个模块的**结构签名**(案例 §5.3 兜底用)。
entry_instr = 模块工厂函数体的指令数
get_instr = `.get` 闭包(JUMP 目标)函数体的指令数
"""
entry = reg.get(mid)
if entry is None:
return {"entry_instr": -1, "get_instr": -1, "call_func": False,
"closure_kind": ""}
code = p.function_code(entry)
ops = Counter(inst["name"] for inst in code)
get_n = -1
for inst in code:
if inst["name"] == "JUMP" and inst["operands"]:
try:
get_n = len(p.function_code(inst["operands"][0]))
break
except Exception:
continue
return {
"entry_instr": len(code),
"get_instr": get_n,
"call_func": ops.get("CALL_FUNC", 0) > 0,
"closure_kind": _closure_kind(p, entry),
}
def _closure_kind(p: tp.Program, entry: int) -> str:
"""模块唯一闭包**最后写入什么** —— 用于拆开结构完全相同的模块。
背景(2026-10-12,4 份全新在线构建):
有 2 个模块 entry 都是 40 条、.get 都是 9 条、指令逐条相同,
只差一处 JUMP 绝对地址(每构建不同,不能当特征)。
真正的区别在**闭包体**(CREATE_CLOSURE 的 target,不是 JUMP 目标):
A: new Array()[0] = "" (PUSH_EMPTY_STR; SET_PROP)
B: new Array()[0] = 1558325408 (PUSH_IMM <随机int32>; SET_PROP)
实测每份构建恰好 empty×1 + rand×1 —— 稳定、可静态消歧。
返回: "empty" | "rand" | "const" | ""(无闭包)
"""
cls = [c for c in p.closures if p.owner(c.pc) == entry]
if not cls:
return ""
try:
body = p.function_code(cls[0].target)
except Exception:
return ""
imms = [inst["operands"][0] for inst in body
if inst["name"] == "PUSH_IMM" and inst["operands"]]
if any(v > 1_000_000 for v in imms):
return "rand"
if any(inst["name"] == "PUSH_EMPTY_STR" for inst in body):
return "empty"
return "const" if imms else ""
def _identify_struct(p: tp.Program, reg: Dict[int, int], mid: int) -> Optional[str]:
"""字符串规则落空时的兜底识别。"""
sig = struct_signature(p, reg, mid)
for name, pred in STRUCT_RULES:
try:
if pred(sig):
return name
except Exception:
continue
return None
def _clean(s: str) -> str:
return s.strip()
def module_strings(p: tp.Program, reg: Dict[int, int], mid: int,
depth: int = 3) -> Set[str]:
"""取一个模块的**可达**字符串集合(模块自身 + 内部闭包,递归 depth 层)。"""
entry = reg.get(mid)
if entry is None:
return set()
by_owner: Dict[int, Set[str]] = defaultdict(set)
for pc, s in p.strings:
by_owner[p.owner(pc)].add(s)
seen: Set[int] = set()
frontier = {entry}
out: Set[str] = set()
for _ in range(depth):
nxt: Set[int] = set()
for e in frontier:
if e in seen:
continue
seen.add(e)
out |= by_owner.get(e, set())
for c in p.closures:
if p.owner(c.pc) == e and c.target not in seen:
nxt.add(c.target)
frontier = nxt
if not frontier:
break
return out
def resolve_delegate(p: tp.Program, reg: Dict[int, int], mid: int,
max_hops: int = 6) -> List[int]:
"""解析模块 m 的委派链: `exports.X = require(N)` 里的 N。
返回 [m, N1, N2, ...](含自身)。取不到委派时返回 [m]。
形状(实测): 模块体里出现 `LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC` 且 N 在
registry 里 -> 该模块委派给 N。有些模块 require 的是自己,要防环。
"""
chain: List[int] = [mid]
seen = {mid}
cur = mid
for _ in range(max_hops):
entry = reg.get(cur)
if entry is None:
break
code = p.function_code(entry)
nxt: Optional[int] = None
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":
cand = int(inst["operands"][0])
if cand in reg and cand not in seen:
nxt = cand
break
if nxt is None:
break
chain.append(nxt)
seen.add(nxt)
cur = nxt
return chain
def _has(strings: Set[str], token: str) -> bool:
"""判断 token 是否出现在字符串集合里。
两种模式:
* 普通 token -> **精确**相等
* `前缀*` 结尾 -> **子串/前缀**匹配
为什么需要后者(踩过的坑): `?rand=` 在真机里是 `?rand=1519713624347`
(带会话随机数),精确匹配永远命中不了,导致 template_url 被误判成
location_href。凡是"带运行时随机后缀"的串都必须用前缀匹配。
"""
if token in strings:
return True
if token.endswith("*"):
stem = token[:-1]
return any(stem in s for s in strings)
return False
def _matches(strings: Set[str]) -> List[Tuple[str, int, Set[str]]]:
"""列出所有命中的规则,按 specificity(必需串个数)降序。"""
hits: List[Tuple[str, int, Set[str]]] = []
for name, required, forbidden in FINGERPRINTS:
if not all(_has(strings, t) for t in required):
continue
if any(_has(strings, t) for t in forbidden):
continue
hits.append((name, len(required), required))
hits.sort(key=lambda x: -x[1])
return hits
def _identify(strings: Set[str]) -> Tuple[str, Set[str]]:
"""用指纹规则判定语义名。返回 (名字, 命中的必需串)。
取**必需串最多**的那条规则(specificity 优先),而不是列表里第一条 ——
这样 `webdriver_detect`(2 个必需串)不会被 `user_agent`(1 个)抢走。
"""
hits = _matches(strings)
if not hits:
return "unknown", set()
return hits[0][0], hits[0][2]
def disambiguate(rows: List[Dict]) -> None:
"""给**重名**的项加稳定后缀,使 37 个锚点尽可能唯一(原地修改 name)。
为什么需要这一步(2026-10-12 实测,只看 har/online/ 全新样本):
字符串规则会把不同采集器判成同一个名字。跨 4 份新构建统计,重名是**稳定的**:
user_agent x2 -> entry48(委派链) / entry68(直判) 每份各 1 个 ✓ 可消歧
window_time x3 -> entry279 / entry40 / entry48(委派链) 每份各 1 个 ✓ 可消歧
struct_40_9 x2 -> 结构完全相同 每份各 2 个 ✗ 静态不可分
消歧后缀用**结构签名**(entry/get 指令数 + 是否含 CALL_FUNC),因为它是
跨构建稳定的实测分组依据,而不是猜的语义。
⚠️ struct_40_9 那组每份构建固定 2 个、结构完全一致 —— 这是**静态识别的能力上限**。
要靠运行时取样(TDC.getData 对照)才能分开。本函数会如实保留重名,
并在 row["ambiguous_static"] 上标记,调用方据此决定是否需要运行时标定。
"""
groups: Dict[str, List[Dict]] = defaultdict(list)
for r in rows:
groups[r["name"]].append(r)
for name, members in groups.items():
if len(members) <= 1:
continue
for r in members:
s = r.get("struct") or {}
tag = "e%s_g%s%s%s" % (s.get("entry_instr", "-"),
s.get("get_instr", "-"),
"_call" if s.get("call_func") else "",
# 结构完全相同时,用"闭包最后写入什么"再拆一层
("_" + s["closure_kind"]) if s.get("closure_kind") else "")
# 同组内的 tag 是否唯一?唯一才能消歧
r["_tag"] = tag
tags = [r["_tag"] for r in members]
can_split = len(set(tags)) == len(tags)
for r in members:
r["ambiguous_static"] = not can_split
r["name_unique"] = "%s#%s" % (name, r["_tag"]) if can_split else name
if can_split:
for r in members:
r["name"] = r["name_unique"]
# --------------------------------------------------------------------------- #
# 主入口 #
# --------------------------------------------------------------------------- #
def analyze_prog(p: tp.Program, depth: int = 3) -> List[Dict]:
"""分析一个**已加载**的 Program(供调用方复用,避免重复解析 tdc.js)。"""
reg = dict(p.module_registry(0))
_mid, _entry, seq = tdc_layout.find_manifest_module(p)
rows: List[Dict] = []
for i, m in enumerate(seq):
own = module_strings(p, reg, m, depth=depth)
chain = resolve_delegate(p, reg, m)
# 顺着委派链往下找,直到能识别出语义(或链走完)
name, hit = _identify(own - NOISE)
used_chain = False
if name == "unknown" and len(chain) > 1:
for deeper in chain[1:]:
s = module_strings(p, reg, deeper, depth=depth)
n2, h2 = _identify(s - NOISE)
if n2 != "unknown":
name, hit = n2, h2
used_chain = True
break
# 字符串规则全落空 -> 启用案例 §5.3 的结构兜底(按指令数识别)
via_struct = False
if name == "unknown":
n3 = _identify_struct(p, reg, m)
if n3:
name, hit = n3, set()
via_struct = True
sig = sorted((own - NOISE))
rows.append({
"item": i,
"module": m,
"name": name,
"hit": sorted(hit),
"delegate": chain if len(chain) > 1 else [],
"via_delegate": used_chain,
"via_struct": via_struct,
"struct": struct_signature(p, reg, m),
"ambiguous_static": False,
"strings": sig,
})
disambiguate(rows)
return rows
def analyze(path: str, depth: int = 3) -> List[Dict]:
"""分析一份 tdc.js,返回清单每一项的识别结果(按清单顺序)。"""
return analyze_prog(tp.load(path), depth=depth)
def main() -> int:
import argparse
import json
ap = argparse.ArgumentParser(description="按字符串指纹识别 37 个采集器模块")
ap.add_argument("path")
ap.add_argument("--json", default=None, help="把结果写成 JSON")
ap.add_argument("--only-unknown", action="store_true", help="只列出未识别的")
args = ap.parse_args()
rows = analyze(args.path)
unknown = [r for r in rows if r["name"] == "unknown"]
print("文件 : %s" % args.path)
print("清单项 : %d 已识别 %d 未识别 %d"
% (len(rows), len(rows) - len(unknown), len(unknown)))
print()
print("项 模块 语义名 委派链 区分串")
print("-" * 108)
for r in rows:
if args.only_unknown and r["name"] != "unknown":
continue
dele = "->".join(str(x) for x in r["delegate"]) if r["delegate"] else ""
mark = " *" if r["via_delegate"] else " "
print("%2d %-5d %s%-21s %-17s %s"
% (r["item"], r["module"], mark, r["name"], dele,
r["strings"][:5]))
if args.json:
Path(args.json).write_text(
json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
print("\n结果 -> %s" % args.json)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+19 -14
View File
@@ -193,6 +193,11 @@ class Program:
def top() -> Tuple[str, str]:
return st[-1] if st else ("sym", "?")
# 栈上元素形如 ("reg", int) | ("sym", str):`[1]` 可能是 int,
# 插值进 f-string 前必须统一转 str(早期版本在 CALL_* 处直接 join 会 TypeError)。
def txt(x: Tuple[str, object]) -> str:
return str(x[1]) if isinstance(x, tuple) else str(x)
def popn(n: int) -> List[Tuple[str, str]]:
r = st[-n:] if n else []
del st[len(st) - n:]
@@ -224,44 +229,44 @@ class Program:
st.append(("sym", regs.get(int(v), f"r{v}") if str(v).isdigit() else v))
elif n == "BIND_THIS":
k, v = st.pop() if st else ("sym", "?")
st.append(("sym", f"c.{v}"))
st.append(("sym", f"c.{txt(('sym', v))}"))
elif n == "MAKE_PROP_REF":
key = top()[1]
key = txt(top())
popn(1)
base = top()[1]
base = txt(top())
popn(1)
st.append(("sym", f"{base}.{key}"))
elif n == "MAKE_REG_PROP_REF":
key = top()[1]
key = txt(top())
popn(1)
k, v = st.pop() if st else ("sym", "?")
st.append(("sym", f"{regs.get(v, f'r{v}')}.{key}"))
elif n == "GET_GLOBAL_PROP":
st[-1] = ("sym", f"c.{top()[1]}")
st[-1] = ("sym", f"c.{txt(top())}")
elif n in ("GET_PROP", "GET_ELEM"):
pass
elif n in ("ADD", "SUB", "MUL", "DIV", "MOD", "BIT_AND", "BIT_OR", "BIT_XOR",
"SHL", "SHR", "SHR_U", "EQ", "EQ_STRICT", "GREATER"):
if len(st) >= 2:
b = top()[1]
b = txt(top())
popn(1)
a = top()[1]
a = txt(top())
popn(1)
st.append(("sym", f"({a} {n} {b})"))
elif n in ("NEW_FUNC", "NEW_METHOD"):
args = popn(int(ops[0]) if ops else 0)
fn = st.pop()[1] if st else "?"
st.append(("sym", f"new {fn}({','.join(x[1] for x in args)})"))
fn = txt(st.pop()) if st else "?"
st.append(("sym", f"new {fn}({','.join(txt(x) for x in args)})"))
elif n == "CALL_FUNC":
args = popn(int(ops[0]) if ops else 0)
fn = st.pop()[1] if st else "?"
st.append(("sym", f"{fn}({','.join(x[1] for x in args)})"))
fn = txt(st.pop()) if st else "?"
st.append(("sym", f"{fn}({','.join(txt(x) for x in args)})"))
elif n == "CALL_METHOD":
args = popn(int(ops[0]) if ops else 0)
tgt = st.pop() if st else ("sym", "?")
st.append(("sym", f"{tgt[1]}({','.join(x[1] for x in args)})"))
tgt = txt(st.pop()) if st else "?"
st.append(("sym", f"{tgt}({','.join(txt(x) for x in args)})"))
elif n == "STORE_REG":
v = st.pop()[1] if st else "?"
v = txt(st.pop()) if st else "?"
k, ref = st.pop() if st else ("sym", "?")
if k == "reg":
regs[ref] = v