# -*- 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 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()), # 轨迹采集器:真机实测其字符串集是鼠标/触摸事件名(2026-10-12 修正)。 # 旧规则写成 {"stringify"} —— 那抓到的是另一个只做 JSON.stringify 的模块, # 结果「空数组」被记成 trajectory,真正的轨迹却在 window_time 名下。 ("trajectory", {"mouseMoveEvent", "touchStartEvent"}, set()), ("struct_76_33", {"stringify"}, 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()), ("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__[_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())