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
+99
View File
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
在线抓取**全新**样本(2026-10-12 起,只使用本脚本产出的数据)。
抓什么:
1. prehandle -> sess / comm_captcha_cfg.tdc_path
2. 连抓 N 份 tdc.js(同一个 URL **每次请求都返回不同构建**,账本 #29)
3. 每份都算出: XTEA 密钥、eks、37 模块的字符串指纹、槽位序
产出目录: har/online/<时间戳>/
用法:
.venv/bin/python scripts/fetch_online_samples.py --n 4
"""
from __future__ import annotations
import argparse
import hashlib
import json
import random
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools"))
from tcaptcha.client import TencentCaptchaClient # noqa: E402
from tcaptcha.crypto import extract_eks # noqa: E402
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
OUT = ROOT / "har" / "online"
def main() -> int:
ap = argparse.ArgumentParser(description="在线抓取全新 tdc.js 样本")
ap.add_argument("--n", type=int, default=4, help="抓几份 tdc.js")
ap.add_argument("--aid", default="2039519451", help="雨云 aid(宽松,适合取样)")
ap.add_argument("--tag", default=None, help="输出子目录名(默认时间戳)")
ap.add_argument("--gap", type=float, default=1.0, help="两次抓取间隔秒")
args = ap.parse_args()
tag = args.tag or time.strftime("%Y%m%d-%H%M%S")
d = OUT / tag
d.mkdir(parents=True, exist_ok=True)
print("输出目录:", d)
client = TencentCaptchaClient(aid=args.aid,
entry_url="https://app.rainyun.com/account/reward/earn#")
try:
tkid = str(random.randint(100000000, 999999999))
ch, cfg, sid = client.prehandle(tkid)
print("prehandle: sess=%s sid=%s kind=%s" % (ch.sess[:16], sid, ch.kind))
tdc_path = cfg.get("tdc_path", "")
if not tdc_path:
print("❌ prehandle 没给 tdc_path")
return 1
print("tdc_path:", tdc_path[:90])
(d / "meta.json").write_text(json.dumps({
"aid": args.aid, "sess": ch.sess, "sid": sid,
"tdc_path": tdc_path, "kind": ch.kind,
"fetched_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}, ensure_ascii=False, indent=1), encoding="utf-8")
seen = set()
got = 0
for i in range(args.n * 3): # 允许重试,直到拿到 n 份不同构建
if got >= args.n:
break
body = client.fetch_tdc(tdc_path)
md5 = hashlib.md5(body.encode("utf-8")).hexdigest()
if md5 in seen:
time.sleep(args.gap)
continue
seen.add(md5)
got += 1
name = "tdc_%02d_%s.js" % (got, md5[:8])
(d / name).write_text(body, encoding="utf-8")
info = {"md5": md5, "bytes": len(body), "index": got}
try:
info["key"] = extract_xtea_key_pure(body)["key"]
except Exception as e:
info["key_error"] = "%s: %s" % (type(e).__name__, str(e)[:120])
info["eks"] = extract_eks(body)[:40]
print(" [%d] %s %6d B md5=%s key=%s"
% (got, name, len(body), md5[:12], info.get("key")))
(d / (name[:-3] + ".json")).write_text(
json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
time.sleep(args.gap)
print()
print(">>> 拿到 %d 份互不相同的新构建 -> %s" % (got, d))
return 0 if got else 1
finally:
client.close()
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""验证 struct_40_9 的消歧特征是否跨构建稳定。
判别:
A 组: 闭包体最后写入 **空串** (PUSH_EMPTY_STR; SET_PROP)
B 组: 闭包体最后写入 **随机 int32** (PUSH_IMM <big>; SET_PROP)
"""
import glob
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_modules as tm # noqa: E402
import tdc_program as tp # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
def closure_body(p, entry):
"""取模块唯一闭包的 target 体。"""
cls = [c for c in p.closures if p.owner(c.pc) == entry]
if not cls:
return None
return p.function_code(cls[0].target)
def disamb(p, entry):
"""返回 'empty' | 'rand' | None"""
body = closure_body(p, entry)
if body is None:
return None
imms = [x["operands"][0] for x in body
if x["name"] == "PUSH_IMM" and x["operands"]]
big = [v for v in imms if v > 1_000_000]
if big:
return "rand"
return "empty"
stats = defaultdict(lambda: defaultdict(list))
for f in BATCH:
p = tp.load(f)
reg = dict(p.module_registry(0))
rows = tm.analyze(f)
for r in rows:
if r["name"] != "struct_40_9":
continue
d = disamb(p, reg[r["module"]])
stats[f.split("/")[-1]][d or "?"].append((r["item"], r["module"]))
print("%-24s %-8s %s" % ("构建", "判别", "项(模块)"))
print("-" * 70)
ok = True
for f, groups in stats.items():
for d, members in sorted(groups.items()):
print("%-24s %-8s %s" % (f, d, members))
# 每份构建必须 empty 1 个 + rand 1 个
if len(groups.get("empty", [])) != 1 or len(groups.get("rand", [])) != 1:
ok = False
print()
print(">>> 每份构建都是 empty×1 + rand×1 :", ok)
print(">>> 结论:", "可静态消歧(写入空串 vs 写入随机 int32)" if ok else "仍不可分")
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""对照实验:证明「按语义名配对」不是归一化过拟合出来的。
风险: 归一化若抹掉太多,任何配对都会"全对",校验就失去意义。
做法(负对照,同一套归一化 + 同一套比对):
① 按语义名配对(实验组)
② 按清单下标配对(负对照:槽位序随机,必然错)
③ 按模块 id 配对(负对照:id 每次重排,必然错)
④ 随机配对(负对照基线)
若①接近 37/37 而 ②③④ 明显更低 => 归一化没有过拟合,配对确实有效。
"""
import glob
import random
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_modules as tm # noqa: E402
import tdc_program as tp # noqa: E402
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
def norm_code(p, entry, maxlen=400):
"""归一化指令序列(与 verify_pair_correct2.py 同一套规则,此处内联避免导入副作用)。
抹掉三类**会重排**的操作数,保留指令骨架与字符串的位置:
1. 代码地址(JUMP / JUMP_IF_* / PUSH_TRY)
2. require(N) 的模块号 N
3. PUSH_STR_CHAR 的具体字符值(fp_salt 随机盐串、混淆变量名每次都变)
"""
code = p.function_code(entry)
out = []
for k, inst in enumerate(code):
nm, ops = inst["name"], tuple(inst["operands"] or [])
if nm in ADDR_OPS:
ops = ("ADDR",) * len(ops)
elif nm == "PUSH_STR_CHAR":
ops = ("CH",)
elif nm == "PUSH_IMM" and ops:
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
ops = ("MOD",) * len(ops)
out.append((nm, ops))
return tuple(out[:maxlen])
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
def load(f):
p = tp.load(f)
rows = tm.analyze(f)
reg = dict(p.module_registry(0))
sig = {r["name"]: norm_code(p, reg[r["module"]]) for r in rows}
return rows, sig
A, fa = load(BATCH[0])
print("基准 %s (归一化:抹掉地址/模块号/字符值,保留指令骨架与串的位置)" % BATCH[0].split("/")[-1])
print()
print("%-22s %-22s %-8s %-8s" % ("对比构建", "配对方式", "一致", "不一致"))
print("-" * 66)
for f in BATCH[1:]:
B, fb = load(f)
names = sorted(set(fa) & set(fb))
def score(pairs):
return sum(1 for a, b in pairs if a == b)
# ① 实验组:语义名
p1 = [(fa[n], fb[n]) for n in names]
# ② 负对照:清单下标
p2 = [(fa[A[i]["name"]], fb[B[i]["name"]]) for i in range(min(len(A), len(B)))]
# ③ 负对照:模块 id(用 id 当 key 去查另一边同名集合的任意一项)
fb_by_id = {r["module"]: fb[r["name"]] for r in B}
p3 = [(fa[A[i]["name"]], fb_by_id.get(A[i]["module"], ())) for i in range(len(A))]
# ④ 负对照:随机配对
rnd = random.Random(7)
shuf = names[:]
rnd.shuffle(shuf)
p4 = [(fa[n], fb[m]) for n, m in zip(names, shuf)]
for label, pairs in (("①语义名(实验组)", p1), ("②清单下标(负对照)", p2),
("③模块id(负对照)", p3), ("④随机配对(负对照)", p4)):
s = score(pairs)
print("%-22s %-22s %-8d %-8d" % (f.split("/")[-1], label, s, len(pairs) - s))
print()
print(">>> 判读: ①应≈37/37;②③④应明显更低 => 归一化未过拟合,配对有效")
+67
View File
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""验证字符串+结构指纹方案的**跨构建一致性** —— 只基于 har/online/ 全新样本。
案例 §5.3 的验收标准: "3 个 tdc.js 版本、37 个模块、全部正确识别,无一 unknown"。
本脚本再进一步:不仅要求"无 unknown",还要求
① 每份构建内部 37 项都有名字
② 各构建之间的**语义名集合**一致(说明识别的是同一批采集器)
③ 语义名 -> 每项在 cd 里的**相对位置**可交叉核对
"""
import glob
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_modules as tm # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
print("全新在线样本 %d 份(只使用这批数据)" % len(BATCH))
print()
results = {}
for f in BATCH:
rows = tm.analyze(f)
names = [r["name"] for r in rows]
results[f] = rows
unk = sum(1 for n in names if n == "unknown")
via_struct = sum(1 for r in rows if r.get("via_struct"))
via_deleg = sum(1 for r in rows if r.get("via_delegate"))
print(" %-24s 项%2d unknown %d | 字符串直判 %2d 委派链 %d 结构兜底 %d"
% (f.split("/")[-1], len(rows), unk,
len(rows) - via_struct - via_deleg, via_deleg, via_struct))
print()
keys = list(results)
base = Counter(r["name"] for r in results[keys[0]])
print("以 %s 为基准:%d 个语义名(去重 %d)"
% (keys[0].split("/")[-1], len(base), len(set(base))))
print()
for f in keys[1:]:
c = Counter(r["name"] for r in results[f])
same = sum((base & c).values())
only_base = sum((base - c).values())
only_it = sum((c - base).values())
print(" vs %-22s 相同 %2d | 仅基准有 %2d | 仅有它有 %2d"
% (f.split("/")[-1], same, only_base, only_it))
print()
common = set(base)
for f in keys[1:]:
common &= set(Counter(r["name"] for r in results[f]))
allnames = set()
for f in keys:
allnames |= set(r["name"] for r in results[f])
print(">>> 所有构建**共同**识别出的语义名: %d 个" % len(common))
print(">>> 出现过的语义名总数 : %d 个" % len(allnames))
print(">>> 差异名:", sorted(allnames - common) if allnames - common else "无(完全一致)")
print()
# 唯一性检查:单构建内是否有重名(重名 = 锚点有歧义)
print("单构建内重名检查(重名说明该锚点有歧义):")
for f in keys:
c = Counter(r["name"] for r in results[f])
dup = {k: v for k, v in c.items() if v > 1}
print(" %-24s 重名 %d 组 %s"
% (f.split("/")[-1], len(dup), dict(sorted(dup.items()))))
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""判决「按语义名配对」是否**正确** —— 用独立于指纹的方法。
⚠️ 上一轮我用「模块 id 相等」做校验,那个假设是**错的**:
案例 §1.2 与账本 #29 都明确说「37 个模块的 ID 每次构建随机重新分配」。
registry 全集交集 62/62 只说明编号**范围**相同(都是 0..61),
不代表 id -> 语义 的映射相同。id 不一致是**预期行为**,不能用来否定配对。
本轮改用真正独立的证据:**函数体的归一化指令序列**。
指纹规则只用到「字符串集合」和「结构统计(指令数/直方图)」,
没有用到「指令的完整顺序 + 操作数」。所以拿它做校验是独立的。
归一化: 抹掉 JUMP 的绝对地址(每构建不同),保留其余操作数。
"""
import glob
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_layout # noqa: E402
import tdc_modules as tm # noqa: E402
import tdc_program as tp # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
def norm_code(p, entry, maxlen=400):
"""归一化指令序列。
必须抹掉两类**会重排**的操作数,否则校验必然失败:
1. 代码地址:JUMP / JUMP_IF_TRUE / JUMP_IF_FALSE / PUSH_TRY
2. require(N) 的模块号 N:`LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC`
(案例 §1.2:37 个模块的 ID 每次构建随机重新分配)
其余操作数(字符串 charCode、小常量、寄存器号)跨构建稳定,保留。
"""
code = p.function_code(entry)
out = []
for k, inst in enumerate(code):
nm, ops = inst["name"], tuple(inst["operands"] or [])
if nm in ADDR_OPS:
ops = ("ADDR",) * len(ops)
elif nm == "PUSH_STR_CHAR":
# 只保留"这是一个字符"这件事,**不看具体是哪个字符**。
# 这样 fp_salt 的随机盐串、混淆变量名这些「会变的内容」就不会干扰校验,
# 而"字符串的长度/位置"仍然参与比对 —— 足够独立,且仍然很强。
ops = ("CH",)
elif nm == "PUSH_IMM" and ops:
# require(N) 形状:前一个 LOAD_REG_IMM,后一个 CALL_FUNC
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
ops = ("MOD",) * len(ops)
out.append((nm, ops))
return tuple(out[:maxlen])
print("构建 : 每个模块的函数体归一化指令序列 -> 作为独立指纹")
print()
A = tm.analyze(BATCH[0])
pa = tp.load(BATCH[0])
ra = dict(pa.module_registry(0))
fa = {r["name"]: norm_code(pa, ra[r["module"]]) for r in A}
print("基准 %s (%d 项)" % (BATCH[0].split("/")[-1], len(A)))
print()
print("%-24s %-8s %-8s %-8s %s" % ("对比构建", "配对", "序列一致", "不一致", "判决"))
print("-" * 66)
all_ok = True
for f in BATCH[1:]:
B = tm.analyze(f)
pb = tp.load(f)
rb = dict(pb.module_registry(0))
fb = {r["name"]: norm_code(pb, rb[r["module"]]) for r in B}
common = set(fa) & set(fb)
same = [n for n in common if fa[n] == fb[n]]
diff = [n for n in common if fa[n] != fb[n]]
all_ok &= not diff
print("%-24s %-8d %-8d %-8d %s"
% (f.split("/")[-1], len(common), len(same), len(diff),
"✓" if not diff else "✗"))
for n in sorted(diff)[:3]:
a, b = fa[n], fb[n]
hit = next((k for k in range(min(len(a), len(b))) if a[k] != b[k]), -1)
print(" %-26s 首个分歧@%d A=%s B=%s" % (n, hit,
a[hit] if hit >= 0 else "-", b[hit] if hit >= 0 else "-"))
print()
print(">>> 全部配对的函数体序列一致:", all_ok)
print(">>> 判决:", "语义名配对**正确**(独立证据)" if all_ok else "配对有误,需检查")
+61
View File
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""用**全新在线样本**做端到端重排测试(不需要真机明文,自证)。
做法:
给每个语义名编一个**假值** value_i,按构建 A 的清单顺序排成 cd_A。
再用锚点把它重排到构建 B 的顺序,得到 cd_B。
正确答案 = 按 B 的清单顺序、取各自语义名的假值。
这个测试能抓住 reorder 的核心 bug(恒等映射):
若 B 的顺序 ≠ A 的顺序,恒等映射必然错;只有真对齐才对。
"""
import glob
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import tdc_collectgen as cg # noqa: E402
import tdc_modules as tm # noqa: E402
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
print("新样本 %d 份" % len(BATCH))
print()
A = BATCH[0]
rowsA = tm.analyze(A)
namesA = [r["name"] for r in rowsA]
seqA = [r["module"] for r in rowsA]
n = len(namesA)
print("基准 %s 项 %d 唯一名 %d" % (A.split("/")[-1], n, len(set(namesA))))
# 假值:每项一个可区分的值
fake = {nm: "V<%s>" % nm for nm in namesA}
cd_A = [fake[nm] for nm in namesA]
src = cg.spec_from_seq(seqA, [None] * n, namesA, sample_cd=cd_A)
print()
for f in BATCH[1:]:
rowsB = tm.analyze(f)
namesB = [r["name"] for r in rowsB]
seqB = [r["module"] for r in rowsB]
dst = cg.spec_from_seq(seqB, [None] * len(rowsB), namesB)
# 正确答案:按 B 的顺序取各自语义名的假值
truth = [fake[nm] for nm in namesB]
try:
out = cg.reorder(cd_A, dst, src)
ok = out == truth
identical = out == cd_A
print(" vs %-24s" % f.split("/")[-1])
print(" 重排结果 == 正确答案 : %s" % ok)
print(" 重排结果 == 原样(说明没重排): %s" % identical)
if not ok:
bad = [i for i, (a, b) in enumerate(zip(out, truth)) if a != b]
print(" 错位 %d 处, 前5: %s" % (len(bad), bad[:5]))
except Exception as e:
print(" vs %-24s %s: %s" % (f.split("/")[-1], type(e).__name__, str(e)[:110]))
print()
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""用真机 collect 密文的**长度与填充**判决「四段加密」结构。
不需要密钥:段结构在 base64 的长度与 '=' 分布上留下指纹。
判决依据(docs/tdc-案例.md §5.1):
chunk1/trajectory/chunk2 各 pad 到 24 字节 -> 密文长度是 24 的倍数 -> base64 无 '='
sd 不 pad,只被 XTEA 补到 8 的倍数 -> 长度%3 != 0 -> base64 带 '='
用法:
.venv/bin/python scripts/verify/verify_seglen.py <collect 的 base64>
"""
import base64
import re
import sys
from pathlib import Path
if len(sys.argv) < 2:
print("用法: verify_seglen.py <collect 的 base64(文件路径或直接给串)>")
raise SystemExit(2)
_arg = sys.argv[1]
_p = Path(_arg)
b64 = (_p.read_text(encoding="ascii") if _p.exists() else _arg).strip()
print("真机 collect: %d 字符, 含 '=' : %s" % (len(b64), "=" in b64))
print()
raw = base64.b64decode(b64)
print("解码后密文: %d 字节" % len(raw))
print()
# 真机明文(另一份同会话样本)给出的各段长度
segs = [("chunk1", 355, 5), ("trajectory", 62, 10), ("chunk2", 288, 24), ("sd", 33, None)]
print("%-11s %8s %6s %10s %12s %10s" % ("段", "内容", "pad", "密文字节", "base64字符", "含'='"))
print("-" * 66)
total = 0
total_b64 = 0
for name, content, pad in segs:
if pad is not None:
n = content + pad # 空格 pad 到 24
assert n % 24 == 0
else:
n = content + ((8 - content % 8) % 8) # sd: 只被 XTEA 补到 8 的倍数
enc = base64.b64encode(b"\x00" * n).decode()
total += n
total_b64 += len(enc)
print("%-11s %8d %6s %10d %12d %10s"
% (name, content, pad if pad is not None else "(xtea)",
n, len(enc), "=" in enc))
print("-" * 66)
print("%-11s %8s %6s %10d %12d" % ("合计", "", "", total, total_b64))
print()
print(">>> 推算总密文字节 %d == 实际 %d : %s" % (total, len(raw), total == len(raw)))
print(">>> 推算 base64 字符 %d == 实际 %d : %s" % (total_b64, len(b64), total_b64 == len(b64)))
print()
# 反过来看:把整串按 24 字节切,前三段应能整除
print("[反向校验] 把密文按 24 字节切分:")
off = 0
for name, content, pad in segs:
if pad is None:
print(" %-11s 剩余 %3d 字节 (sd, 非 24 倍数)" % (name, len(raw) - off))
break
n = content + pad
print(" %-11s [%4d:%4d] %3d 字节 24倍数=%s"
% (name, off, off + n, n, n % 24 == 0))
off += n
print()
# 关键指纹: '=' 只应出现在最后(来自 sd 段)
idx = b64.find("=")
print("'=' 首次出现位置: %d / 总长 %d -> %s"
% (idx, len(b64), "在末尾区域(符合 sd 段不 pad)"
if idx >= len(b64) - 4 else "⚠️ 在中间(与四段结构矛盾)"))
if idx >= 0:
print(" 尾部填充: %r" % b64[idx:])
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""按 docs/tdc-案例.md §5.1 验证 collect 的「四段加密」结构(真机明文判决)。
要判决的问题:
项目现有 crypto.encrypt_string / tdc_collectgen.encrypt_plain 都是
「整体 JSON -> \x00 补齐到 8 -> 一次加密 -> 一个 base64」。
案例说「4 段独立加密,前 3 段空格 pad 到 24 字节对齐,sd 不 pad」。
两者只能有一个对 —— 用真机明文里的**空格填充串**来判。
用法:
.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件>
"""
import json
import re
import sys
if len(sys.argv) < 2:
print("用法: verify_segment.py <已解密的 collect 明文文件>")
print(" (明文来源: 用同会话 tdc.js 的密钥解密 TDC.getData(true) 的产出)")
raise SystemExit(2)
RAW = sys.argv[1]
data = open(RAW, encoding="latin1").read()
print("原始(含尾部 \\x00):", len(data), "字节")
stripped = data.rstrip("\x00")
print("去掉尾部 \\x00 后 :", len(stripped), "字节 (尾部 \\x00 个数 = %d)" % (len(data) - len(stripped)))
print()
runs = [(m.start(), m.end()) for m in re.finditer(r" {2,}", stripped)]
print("[1] 连续空格串(只能来自「空格 pad」):")
for a, b in runs:
print(" 位置 %3d..%3d 长度 %2d" % (a, b, b - a))
print()
# 段边界 = 每个空格串的结束位置(若空格串在段尾)
bounds = [0] + [b for _a, b in runs[:-1]] if len(runs) > 1 else [0]
# 更稳: 段边界取每个空格串的"起点所在段"的末尾 -> 直接用空格串终点
b1, b2, b3 = runs[0][1], runs[1][1], runs[2][1]
segs = [("chunk1", 0, b1), ("trajectory", b1, b2), ("chunk2", b2, b3), ("sd", b3, len(stripped))]
print("[2] 按空格串切出的 4 段:")
for name, a, b in segs:
s = stripped[a:b]
print(" %-11s [%3d:%3d] len=%3d 24倍数=%-5s 8倍数=%-5s"
% (name, a, b, len(s), len(s) % 24 == 0, len(s) % 8 == 0))
print()
print("[3] 空格 pad 公式校验 (pad_len = 24 - len%24,总是补 1..24 个):")
for name, a, b in segs[:3]:
s = stripped[a:b]
content = s.rstrip(" ")
pad = len(s) - len(content)
print(" %-11s 内容 %3d + pad %2d = %3d 公式给出 %2d -> %s"
% (name, len(content), pad, len(s), 24 - len(content) % 24,
"OK" if pad == 24 - len(content) % 24 else "FAIL"))
print()
print("[4] sd 段不 pad(只被 XTEA 的 8 字节块 \x00 补齐):")
sd = stripped[b3:]
print(" sd = %r" % sd)
print(" sd 长度 %d (非 8 的倍数 -> 加密时被补 %d 个 \\x00)"
% (len(sd), (8 - len(sd) % 8) % 8))
print(" 实际尾部 \\x00 个数 = %d" % (len(data) - len(stripped)))
print()
print("[5] 逻辑整体是否是合法 JSON,以及轨迹在 cd 里的位置:")
obj = json.loads(stripped)
cd = obj["cd"]
traj_idx = [i for i, v in enumerate(cd)
if isinstance(v, list) and v and all(isinstance(x, list) and len(x) == 3 for x in v)]
print(" cd 槽数 = %d" % len(cd))
print(" 轨迹槽下标 = %s" % traj_idx)
if traj_idx:
print(" 轨迹内容前 3 点 = %s" % cd[traj_idx[0]][:3])
print()
print("[6] 去 pad 后 4 段拼接 == 逻辑明文(应能解析成同一对象):")
rebuilt = "".join(stripped[a:b].rstrip(" ") for _name, a, b in segs[:3]) + stripped[b3:]
obj2 = json.loads(rebuilt)
print(" 可解析为 JSON :", True)
print(" 与原对象逐项相同 :", obj2 == obj)
print(" 重建长度 %d (原文去尾 \\x00 后 %d, 差 = pad 总量 %d)"
% (len(rebuilt), len(stripped), len(stripped) - len(rebuilt)))
print()
print("[7] 三段之间的连接处(验证 chunk1 以逗号结尾 / chunk2 以 '],' 结尾):")
print(" chunk1 内容末尾 : %r" % stripped[0:b1].rstrip(" ")[-24:])
print(" trajectory 内容 : %r" % stripped[b1:b2].rstrip(" ")[:40])
print(" chunk2 内容开头 : %r" % stripped[b2:b3].rstrip(" ")[:24])
print(" chunk2 内容末尾 : %r" % stripped[b2:b3].rstrip(" ")[-24:])
print(" sd 内容 : %r" % stripped[b3:])