Files
hello_js_reverse_skill/tcaptcha/tests/test_collectgen.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

181 lines
7.6 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 -*-
"""
按构建生成 collect 的回归测试(承 PROGRESS.md #34/#35/#36;AGENTS.md §5.9)。
固化的性质:
1. `outer_commas`: 只数**括号外**的逗号 —— 方括号内的逗号不构成顶层分隔
('[]' / '[360,360]' / '["zh-CN","zh"]' 拼回去仍是一个值);
2. `SlotSpec` 推算出的槽数 == 真机明文 cd 的实际槽数;
3. 重排是**置换**:值集合不变(排序后逐项相同);
4. 加密/解密**回环一致**(明文逐字节还原);
5. 槽数不匹配的样本**必须报错**,不能静默错位。
样本:
* tcaptcha/tests/fixtures/tdc_slotmap_case.json —— 合成用例(已知正确答案)
* har/online/<时间戳>/tdc_*.js —— **在线抓取的全新构建**(真机数据)
运行:
.venv/bin/python -m tcaptcha.tests.test_collectgen
"""
from __future__ import annotations
import base64
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
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"))
from tcaptcha.crypto import xtea_decrypt_bytes # noqa: E402
import tdc_collectgen as cg # noqa: E402
FIX = ROOT / "tcaptcha" / "tests" / "fixtures" / "tdc_slotmap_case.json"
# 固定密钥,仅用于回环自洽(不代表任何真实构建)
KEY = [1497711441, 1213874789, 1799903316, 1835232858]
_fails: list = []
def check(name: str, cond: bool, detail: str = "") -> None:
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
if not cond:
_fails.append(name)
def main() -> int:
if not FIX.exists():
print(f"缺少 fixture: {FIX}")
return 1
case = json.loads(FIX.read_text(encoding="utf-8"))
seq, kinds, v0s, sample_cd = case["seq"], case["kinds"], case["v0s"], case["sample_cd"]
print(f"fixture: 项 {len(seq)} / kind {len(kinds)} / v0 {len(v0s)} / 真机 cd {len(sample_cd)}")
print("\n[1] outer_commas: 只数括号外的逗号")
check("'[]' -> 0", cg.outer_commas("[]") == 0)
check("'[360,360]' -> 0", cg.outer_commas("[360,360]") == 0)
check("'[\"zh-CN\",\"zh\"]' -> 0", cg.outer_commas('["zh-CN","zh"]') == 0)
check("'641547489,1791732001' -> 1", cg.outer_commas("641547489,1791732001") == 1)
check("'a,b,c' -> 2", cg.outer_commas("a,b,c") == 2)
print("\n[2] 槽数推算 == 真机明文槽数")
spec = cg.SlotSpec(seq, kinds, v0s)
print(f" {spec.describe()}")
check("槽数一致", spec.n_slots == len(sample_cd),
f"{spec.n_slots} vs {len(sample_cd)}")
check("fixture 覆盖 kind=1", any(k == 1 for k in kinds))
check("fixture 覆盖 kind=2", any(k == 2 for k in kinds))
print("\n[3] 重排是置换(值集合不变)")
out = cg.reorder(sample_cd, spec)
key = lambda v: json.dumps(v, sort_keys=True, ensure_ascii=False)
check("长度不变", len(out) == len(sample_cd), f"{len(out)} vs {len(sample_cd)}")
check("值集合相同(排序后逐项相等)",
sorted(map(key, out)) == sorted(map(key, sample_cd)))
print("\n[4] 加密/解密回环")
plain = cg.build_plain(out)
b64 = cg.encrypt_plain(plain, KEY)
back = xtea_decrypt_bytes(base64.b64decode(b64), KEY).decode("latin1").rstrip("\x00").strip()
check("明文逐字节还原", back == plain)
back_obj = json.loads(back)
check("cd 还原", back_obj["cd"] == out)
check("sd 还原", back_obj["sd"] == {"od": "C", "ft": "qf_7Pf__H"})
print("\n[5] 槽数不匹配必须报错")
raised = False
try:
cg.reorder(sample_cd[:-1], spec) # src=None: 契约是"同序"
except ValueError:
raised = True
check("少一槽 -> ValueError", raised)
# ---------------------------------------------------------------- #
# [6] 强断言:重排**必须真的重排**(旧实现的 bug 就是被"是置换"放过的)
# ---------------------------------------------------------------- #
print("\n[6] reorder 不是恒等映射(抓旧 bug)")
print(" 旧实现的循环是 sample[slot:slot+n],其中 slot 是**目标**槽号,")
print(" 两份构建顺序不同时全盘错位;而'值集合不变'这个弱断言抓不住它。")
# 把清单顺序打乱,构造"另一份构建"
import random
idx = list(range(len(seq)))
random.Random(11).shuffle(idx)
seq2 = [seq[i] for i in idx]
kinds2 = [kinds[i] for i in idx]
v0s2 = [v0s[i] for i in idx]
# 给每项一个唯一语义名(真实场景由 tdc_modules 的指纹提供)
names1 = ["n%d" % i for i in range(len(seq))]
names2 = ["n%d" % i for i in idx]
src = cg.spec_from_seq(seq, kinds, names1, sample_cd=sample_cd, v0s=v0s)
dst = cg.spec_from_seq(seq2, kinds2, names2, v0s=v0s2)
# 正确答案:按 src 的 assign 把每项的值搬到目标顺序
truth: list = []
for i in idx:
_i, _m, sl, nn = src.assign[i]
truth.extend(sample_cd[sl:sl + nn])
try:
out2 = cg.reorder(sample_cd, dst, src)
check("重排结果 == 已知正确答案", out2 == truth)
check("重排结果 != 原样(证明真的重排了)", out2 != list(sample_cd))
except cg.UnmappedSlotsError as exc:
check("重排结果 == 已知正确答案", False, f"抛错: {str(exc)[:80]}")
# ---------------------------------------------------------------- #
# [7] 用在线抓取的**全新构建**做端到端重排(真机数据,非合成)
# ---------------------------------------------------------------- #
online = sorted((ROOT / "har" / "online").glob("*/tdc_*.js")) if (
ROOT / "har" / "online").exists() else []
print("\n[7] 在线新构建 + 语义名锚点的端到端重排")
if len(online) < 2:
print(" (跳过: 需要 ≥2 份 har/online/*/tdc_*.js;"
"用 scripts/fetch_online_samples.py --n 4 抓取)")
else:
try:
import tdc_modules
except Exception as exc:
check("导入 tdc_modules", False, str(exc)[:80])
online = []
if len(online) >= 2:
def names_of(f):
return [r["name"] for r in tdc_modules.analyze(f)]
na = names_of(online[0])
n = len(na)
check("新构建清单 37 项", n == 37, "实际 %d" % n)
check("语义名全部唯一(37/37)", len(set(na)) == n,
"唯一 %d/%d" % (len(set(na)), n))
check("无 unknown", not any(x == "unknown" for x in na))
# 给每个语义名一个假值,按 A 顺序排成 cd_A,重排到 B 顺序
fake = {nm: "V<%s>" % nm for nm in na}
cd_a = [fake[nm] for nm in na]
seqa = [r["module"] for r in tdc_modules.analyze(online[0])]
srca = cg.spec_from_seq(seqa, [None] * n, na, sample_cd=cd_a)
all_ok = True
for f in online[1:]:
nb = names_of(f)
seqb = [r["module"] for r in tdc_modules.analyze(f)]
dstb = cg.spec_from_seq(seqb, [None] * len(nb), nb)
truthb = [fake[nm] for nm in nb]
try:
got = cg.reorder(cd_a, dstb, srca)
good = got == truthb and got != cd_a
except Exception:
good = False
all_ok &= good
print(" %-28s 重排正确=%s" % (f.parent.name + "/" + f.name[:12], good))
check("全部新构建重排正确且非恒等", all_ok)
print(f"\n{'=' * 62}")
if _fails:
print(f"❌ collectgen 测试失败: {_fails}")
return 1
print("✅ collectgen 测试全部通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())