Files
hello_js_reverse_skill/tools/tdc_modules.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

455 lines
20 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_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())