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:
+268
-19
@@ -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
@@ -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))` 次数最多者。
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user