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
+4 -2
View File
@@ -26,6 +26,7 @@ import logging
import random
import re
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -405,9 +406,10 @@ class TencentCaptchaClient:
if not eks:
logger.warning("未从 tdc.js 中提取到 eks, 继续尝试")
# 无论是预期的还是非预期题型,第一时间下载原图落盘到本地 scratch/captcha_debug/,辅助用户肉眼判断
# 无论是预期的还是非预期题型,第一时间下载原图落盘到系统临时目录,辅助用户肉眼判断。
# 注意: 别再写回仓库内的 scratch/ —— 该目录已清理且不入库(.gitignore)。
bg_bytes: Optional[bytes] = None
debug_dir = Path("scratch/captcha_debug")
debug_dir = Path(tempfile.gettempdir()) / "captcha_debug"
debug_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
safe_inst = re.sub(r"[^\w\u4e00-\u9fff]+", "_", challenge.instruction).strip("_") or "no_inst"
+81
View File
@@ -107,6 +107,7 @@ def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
"""XTEA 加密一段字节;不足 8 字节用 \\x00 补齐(XTEA 块对齐所需)。"""
data = data + b"\x00" * ((8 - len(data) % 8) % 8)
out = bytearray()
for i in range(0, len(data), 8):
@@ -116,6 +117,86 @@ def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
return bytes(out)
# --------------------------------------------------------------------------- #
# collect 的「四段加密」结构(按 docs/tdc-案例.md §5.1,真机明文已判决) #
# --------------------------------------------------------------------------- #
SEG_ALIGN = 24
SEG_PAD = b" "
_collect_seg_re = re.compile(rb" {2,}")
def xtea_encrypt_segment(text: str, key: List[int],
align: int = SEG_ALIGN, pad: bytes = SEG_PAD) -> bytes:
"""加密 collect 的一个**数据段**:空格 pad 到 align 字节对齐后 XTEA + Base64。
为什么要 pad 到 24(而不是 8):24 字节 -> 32 个 Base64 字符,且**不会产生 `=` 填充**。
4 段密文的 Base64 直接拼接后看起来就是一个完整的 Base64 串,没有分隔符。
(真机明文里能直接看到这些空格:chunk1 +5、trajectory +10、chunk2 +24。)
"""
data = text.encode("latin1")
# 注意:即使 len%align==0 也要补一整个 align(真机 chunk2 内容 288 字节、
# 288%24==0,却补了 24 个空格 -> pad 长度恒为 align - len%align,落在 1..align)
data += pad * (align - len(data) % align)
return base64.b64encode(xtea_encrypt_bytes(data, key))
def xtea_encrypt_segment_raw(text: str, key: List[int]) -> bytes:
"""加密 **sd 段**:不 pad 到 24,只由 XTEA 的 8 字节块补齐(真机实测如此)。"""
data = text.encode("latin1")
return base64.b64encode(xtea_encrypt_bytes(data, key))
def build_collect(chunk1: str, trajectory: str, chunk2: str, sd: str,
key: List[int], url_encode: bool = False) -> str:
"""按案例 §5.1 组装 collect —— **4 段密文直接拼接,无分隔符**。
collect = b64(xtea(chunk1 + 空格pad到24))
+ b64(xtea(traj + 空格pad到24))
+ b64(xtea(chunk2 + 空格pad到24))
+ b64(xtea(sd)) # sd 不 pad
段的内容边界(案例 §5.5,与真机明文逐字吻合):
chunk1 = '{"cd":[' + 轨迹之前的指纹值 + ','
trajectory = '[[x,y,t],[dx,dy,dt],...,[1,1,12]]'
chunk2 = ',' + 轨迹之后的指纹值 + '],'
sd = '"sd":{"od":"C","ft":"..."}}'
"""
parts = [
xtea_encrypt_segment(chunk1, key),
xtea_encrypt_segment(trajectory, key),
xtea_encrypt_segment(chunk2, key),
xtea_encrypt_segment_raw(sd, key),
]
b64 = b"".join(parts).decode("ascii")
if url_encode:
return urllib.parse.quote(b64, safe="")
return b64
def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
"""把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去 pad)。
用于从真机样本反解出 chunk1/trajectory/chunk2/sd,做对拍或标定。
"""
data = plain.encode("latin1")
bounds = [0]
for m in _collect_seg_re.finditer(data):
bounds.append(m.end())
bounds.append(len(data))
if len(bounds) != 5:
raise ValueError(
f"期望 3 个空格填充串(4 段结构),实际切出 {len(bounds) - 1} 段;"
f"这份明文可能不是「四段加密」格式")
out = []
for i in range(4):
seg = data[bounds[i]:bounds[i + 1]]
if i < 3:
seg = seg.rstrip(SEG_PAD) # 前三段去掉空格 pad
out.append(seg.decode("latin1"))
return out[0], out[1], out[2], out[3]
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
out = bytearray()
for i in range(0, len(data) - len(data) % 8, 8):
+78 -3
View File
@@ -10,8 +10,9 @@
4. 加密/解密**回环一致**(明文逐字节还原);
5. 槽数不匹配的样本**必须报错**,不能静默错位。
样本: tcaptcha/tests/fixtures/tdc_slotmap_case.json
= {seq, kinds, v0s, sample_cd},取自 2026-10-11 真机会话(37 项 -> 38 槽)。
样本:
* tcaptcha/tests/fixtures/tdc_slotmap_case.json —— 合成用例(已知正确答案)
* har/online/<时间戳>/tdc_*.js —— **在线抓取的全新构建**(真机数据)
运行:
.venv/bin/python -m tcaptcha.tests.test_collectgen
@@ -88,11 +89,85 @@ def main() -> int:
print("\n[5] 槽数不匹配必须报错")
raised = False
try:
cg.reorder(sample_cd[:-1], spec)
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}")