做了什么:
- 包改为分层: tcaptcha/{protocol,telemetry,solvers,ocr,adapters,models,tests}
* protocol/ client.py transport.py proxy.py
* telemetry/ crypto.py values.py key.py opcodes.py
disasm.py program.py layout.py modules.py
* solvers/ text_click.py icon_click.py icon_click_rotate.py slider.py
* ocr/ engines.py slicer.py
* adapters/ one_ms.py
- tools/ 里原本是**包运行时依赖**的 tdc_{disasm,program,layout,modules}.py 并入 telemetry/
(放在 tools/ 属于分层倒置),tools/ 只留 CLI: tdc_key_pure.py / tdc_profile.py / cdp_*.js
- crypto.py 529 -> 340 行: 切除「成功样本模板回填」与「Node 插桩兜底」两条老路;
generate_telemetry 只剩 python_values 一种模式,传别的会直接报错并说明原因
- 求解器改名分清: solver.py -> solvers/icon_click.py(client 实际在用的 YOLO+ResNet18 ONNX),
icon_click_solver.py -> solvers/icon_click_rotate.py(仅测试在用,留作对照)
- pyproject: 去掉 ultralytics/onnx(训练栈已删),补上真正在用的 onnxruntime
删除了什么:
- 雨云整条线 + dist/rainyun_qinglong(104M 旧快照)
- 模板回填: models/success_collect_template.b64、tdc_worker.js、analyze_collect_layout.py
- 「补环境」时代浏览器 JS: chrome_fetch/manual_captcha/probe_captcha/solve_in_browser/tdc_browser
- 旧 VM 探索工具 10 个(tdc_collectgen/tdc_slotmap/tdc_lift/tdc_vm_run/tdc_query/tdc_probe 等)
+ 8 个基于它们的旧 verify 脚本
- 训练/标注/评估: train_classifier/train_detector/prepare_yolo/web_labeler/collector/
siamese/evaluate/benchmark_verify
- tcaptcha/core_js/(60 个分析产物)、TOOLCHAIN_REPORT.md(整篇在描述已删工具)
- 3 个已无任何引用的 PyTorch 权重:icon_classifier.pt 43M / yolo_detector.pt 5.9M /
siamese_matcher.pt 1.6M —— 线上只跑 onnx,tcaptcha/ 117M -> 66M
为什么:
- 项目后面要专门承载「滑块 / 文字点选 / 图标点选」三种求解器;扁平包 + 运行时依赖混在
tools/ + 两条已废弃的 collect 生成路线,会让后续扩展持续踩坑
怎么验证:
- .venv/bin/python -m tcaptcha.selftest 全绿
- scripts/verify/verify_offline.py 逐槽全等 + 分段几何一致
- scripts/verify/verify_values.py 4 份真机明文 152/152 逐槽全等
- scripts/verify/send_test.py --mode python_values 1ms 签到页文字点选 errorCode=0
- 回滚点: tag backup/pre-refactor-20261012
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""
|
||
腾讯 TDC 遥测 (collect) 纯算法还原 —— 唯一路径: **按本次会话构建纯离线生成** + 教科书 XTEA。
|
||
|
||
设计原则:
|
||
* **纯离线**: 37 个语义名的取值由 `telemetry/values.py` 生成(静态画像 / 会话量 /
|
||
每次采集量三层),collect 的明文组装 / 轨迹 / 时间戳 / XTEA 加密 / 编码全部在
|
||
Python 内完成。零 Node、零真机明文。
|
||
* **密钥也纯 Python**: 从 tdc.js 静态反汇编还原 4 个密钥字(见 `telemetry/key.py`)。
|
||
* **必须用本次会话那一份构建**(实测,账本 #58/#59): 同一 aid 下,
|
||
"按本次构建生成" -> errorCode=0;"拿别处模板回填" -> 恒 12。
|
||
所以历史上有过的两条老路(成功样本模板回填 / Node 插桩兜底)已整体删除。
|
||
|
||
本模块只负责: XTEA 本体 + collect 的四段组装/切分 + 遥测入口。
|
||
"""
|
||
|
||
import base64
|
||
import itertools
|
||
import json
|
||
import logging
|
||
import re
|
||
import struct
|
||
import urllib.parse
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
M32 = 0xFFFFFFFF
|
||
|
||
|
||
logger = logging.getLogger("tcaptcha_crypto")
|
||
|
||
# 样本构建的 XTEA 密钥字 (未加盐)。真实样本的等价密钥 (含两个轮加盐) 见
|
||
DEFAULT_KEY = [1448563793, 1847730015, 1733184827, 1364613714]
|
||
|
||
DEFAULT_USER_AGENT = (
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
|
||
def encrypt_string(plaintext: str, key: List[int] = DEFAULT_KEY) -> str:
|
||
"""把 ASCII/Latin1 明文用教科书 XTEA 加密后转 Base64 (不足 8 字节用 \\x00 补齐)。
|
||
|
||
本模块只有一份 xtea_encrypt_block (教科书实现), 此函数即使用它。
|
||
"""
|
||
data = plaintext.encode("latin1")
|
||
pad_len = (8 - (len(data) % 8)) % 8
|
||
data += b"\x00" * pad_len
|
||
|
||
out = bytearray()
|
||
for i in range(0, len(data), 8):
|
||
v0, v1 = struct.unpack("<II", data[i : i + 8])
|
||
ev0, ev1 = xtea_encrypt_block(v0, v1, key)
|
||
out.extend(struct.pack("<II", ev0, ev1))
|
||
|
||
return base64.b64encode(out).decode("ascii")
|
||
|
||
|
||
def extract_eks(tdc_js_code: str) -> str:
|
||
"""从 tdc.js 中提取会话密钥信封 eks (一个较长的 Base64 字符串)。
|
||
|
||
注意: tdc.js 每次构建都会重命名全局变量 (见 AGENTS.md 附录 A#6), 因此**不能**写死变量名;
|
||
这里匹配任意 `window.<随机名> = '<base64>'`, 并取长度最长的候选。
|
||
"""
|
||
cands = re.findall(r"window\.[A-Za-z_$][\w$]*\s*=\s*'([A-Za-z0-9+/=]{100,})'", tdc_js_code)
|
||
if not cands:
|
||
return ""
|
||
return max(cands, key=len)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 标准 (教科书) XTEA: 32 轮, delta=0x9E3779B9, sum 取 mod 2^32。
|
||
# 这是本模块**唯一**的 XTEA 实现; 需要从每份 tdc.js 里拿到的是那 4 个密钥字。
|
||
# --------------------------------------------------------------------------- #
|
||
XTEA_DELTA = 0x9E3779B9
|
||
|
||
|
||
def xtea_encrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
|
||
"""教科书 XTEA 加密 (32 轮), 全 32 位无符号运算"""
|
||
v0 &= M32
|
||
v1 &= M32
|
||
total = 0
|
||
for _ in range(32):
|
||
v0 = (v0 + (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32
|
||
^ ((total + key[total & 3]) & M32))) & M32
|
||
total = (total + XTEA_DELTA) & M32
|
||
v1 = (v1 + (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32
|
||
^ ((total + key[(total >> 11) & 3]) & M32))) & M32
|
||
return v0, v1
|
||
|
||
|
||
def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
|
||
"""教科书 XTEA 解密 (32 轮), 用于校验提取到的密钥"""
|
||
v0 &= M32
|
||
v1 &= M32
|
||
total = (XTEA_DELTA * 32) & M32
|
||
for _ in range(32):
|
||
v1 = (v1 - (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32
|
||
^ ((total + key[(total >> 11) & 3]) & M32))) & M32
|
||
total = (total - XTEA_DELTA) & M32
|
||
v0 = (v0 - (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32
|
||
^ ((total + key[total & 3]) & M32))) & M32
|
||
return v0, v1
|
||
|
||
|
||
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):
|
||
v0, v1 = struct.unpack("<II", data[i:i + 8])
|
||
e0, e1 = xtea_encrypt_block(v0, v1, key)
|
||
out += struct.pack("<II", e0, e1)
|
||
return bytes(out)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# collect 的「四段加密」结构(按 docs/tdc-案例.md §5.1,真机明文已判决) #
|
||
# --------------------------------------------------------------------------- #
|
||
SEG_ALIGN = 24
|
||
SEG_PAD = b" "
|
||
|
||
# 候选 pad = 一段空格。⚠️ **pad 可以只有 1 个空格**(段内容长 % 24 == 23 时),
|
||
# 2026-10-12 在真机上实测到这种样本(chunk1 = 287 字节 -> pad 1)。
|
||
# 所以**不能**用 ` {2,}` 去找 pad,那个正则会把 `{"cd":[...,0,0,0, [[1,1,12]]...` 里的
|
||
# 单空格 pad 整段漏掉,切段结果直接崩。判据必须用长度模 24 的算术。
|
||
_SpaceRun = re.compile(rb" +")
|
||
|
||
|
||
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 串,没有分隔符。
|
||
实测 pad 长度落在 **1..24**(真机样本里见过 15/8/23 与 1/14/4)——
|
||
注意 **1 个空格的 pad 是真实存在的**,别用 ` {2,}` 去找它(见 split_collect_segments)。
|
||
"""
|
||
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 _inside_json_string(data: bytes, pos: int) -> bool:
|
||
"""判断字节偏移 pos 是否落在 JSON 字符串字面量内部(数未转义的双引号奇偶)。"""
|
||
n = 0
|
||
i = 0
|
||
while i < pos:
|
||
c = data[i]
|
||
if c == 0x5C: # 反斜杠:跳过被转义的下一个字节
|
||
i += 2
|
||
continue
|
||
if c == 0x22: # 双引号
|
||
n += 1
|
||
i += 1
|
||
return n % 2 == 1
|
||
|
||
|
||
def _candidate_pads(data: bytes) -> List[Tuple[int, int]]:
|
||
"""候选 pad 位置:空格串 [a,b) 且满足
|
||
① b % SEG_ALIGN == 0 ② (b-a) == SEG_ALIGN - a % SEG_ALIGN ③ 不落在 JSON 字符串内部
|
||
|
||
为什么要 ③:① ② 单独用**会误报** —— 实测真机 UA 串
|
||
"Mozilla/5.0 (Macintosh…" 里偏移 47 的那个空格恰好满足
|
||
(47 % 24 == 23 -> 期望 pad 1,48 % 24 == 0),而它只是字符串内的普通空格。
|
||
段一定切在 JSON 记号边界上,所以真 pad 处引号计数必为偶数;用它把误报滤掉。
|
||
"""
|
||
out: List[Tuple[int, int]] = []
|
||
for m in _SpaceRun.finditer(data):
|
||
a, b = m.span()
|
||
if b % SEG_ALIGN or (b - a) != SEG_ALIGN - a % SEG_ALIGN:
|
||
continue
|
||
if _inside_json_string(data, a):
|
||
continue
|
||
out.append((a, b))
|
||
return out
|
||
|
||
|
||
def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
|
||
"""把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去掉 pad)。
|
||
|
||
结构(3 份真机样本逐字节核对,2026-10-12):
|
||
chunk1 = '{"cd":[' + 轨迹之前的槽 + ','
|
||
traj = '<轨迹数组>'
|
||
chunk2 = ',' + 轨迹之后的槽 + '],'
|
||
sd = '"sd":{...}}'
|
||
前三段各用空格 pad 到 24 字节整数倍,sd 不 pad。
|
||
|
||
做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验
|
||
(首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。
|
||
校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。
|
||
"""
|
||
data = plain.encode("latin1")
|
||
cands = _candidate_pads(data)
|
||
for (a1, b1), (a2, b2), (a3, b3) in itertools.combinations(cands, 3):
|
||
if not (b1 <= a2 and b2 <= a3):
|
||
continue
|
||
segs = [data[0:a1], data[b1:a2], data[b2:a3], data[b3:]]
|
||
# 段边界一定落在 JSON 记号边界上(3 份真机样本一致):
|
||
# chunk1 以 ',' 结尾;traj 是**一个**完整 JSON 值;chunk2 以 ',' 开头、以 '],' 结尾
|
||
if not (segs[0].startswith(b'{"cd":[') and segs[0].endswith(b',')
|
||
and segs[2].startswith(b',') and segs[2].endswith(b'],')
|
||
and segs[3].startswith(b'"sd"')):
|
||
continue
|
||
try:
|
||
json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
try:
|
||
obj = json.loads(b"".join(segs).decode("latin1"))
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
if "cd" not in obj or "sd" not in obj:
|
||
continue
|
||
return (segs[0].decode("latin1"), segs[1].decode("latin1"),
|
||
segs[2].decode("latin1"), segs[3].decode("latin1"))
|
||
raise ValueError(
|
||
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个);这份明文可能不是 4 段格式")
|
||
|
||
|
||
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||
out = bytearray()
|
||
for i in range(0, len(data) - len(data) % 8, 8):
|
||
v0, v1 = struct.unpack("<II", data[i:i + 8])
|
||
d0, d1 = xtea_decrypt_block(v0, v1, key)
|
||
out += struct.pack("<II", d0, d1)
|
||
return bytes(out)
|
||
|
||
_KEY_CACHE: Dict[str, List[int]] = {}
|
||
|
||
|
||
def extract_xtea_key(tdc_js_code: str, use_cache: bool = True) -> List[int]:
|
||
"""从服务端下发的 tdc.js 里提取本次构建的 XTEA 密钥(4 个 32 位字)。
|
||
|
||
**纯 Python 静态还原**(`tcaptcha/telemetry/key.py`):
|
||
反汇编字节码 -> 定位 XTEA 闭包 -> 回溯它捕获的密钥数组 -> 读出 4 个密钥字
|
||
-> 折入轮加盐。零 Node、零 JS 运行时、零 pc 硬编码
|
||
(opcode 表按构建推导,见 opcodes.py)。
|
||
|
||
密钥按构建固定(同一份脚本多次运行首块完全一致,已实测),故可缓存。
|
||
"""
|
||
if use_cache:
|
||
cached = _KEY_CACHE.get(tdc_js_code)
|
||
if cached:
|
||
return list(cached)
|
||
|
||
from .key import extract_xtea_key as _pure_extract
|
||
|
||
key = [int(k) & M32 for k in _pure_extract(tdc_js_code)]
|
||
if len(key) != 4:
|
||
raise RuntimeError(f"纯 Python 静态还原出来的密钥字不是 4 个: {key!r}")
|
||
if use_cache:
|
||
_KEY_CACHE[tdc_js_code] = list(key)
|
||
return list(key)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 遥测生成入口(纯离线)
|
||
# --------------------------------------------------------------------------- #
|
||
def generate_telemetry(
|
||
tdc_js_code: str,
|
||
click_points: Optional[List[Tuple[int, int]]] = None,
|
||
aid: str = "",
|
||
user_agent: str = DEFAULT_USER_AGENT,
|
||
referrer: str = "https://1ms.run/console/personal/benefits/check-in",
|
||
mode: str = "python_values",
|
||
node_bin: str = "node",
|
||
) -> Dict[str, object]:
|
||
"""统一遥测生成入口 —— **纯离线**(唯一模式 `python_values`)。
|
||
|
||
流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段 XTEA 拼接。
|
||
零 Node、零真机明文;密钥也由纯 Python 静态提取。实现见 `telemetry/values.py`。
|
||
|
||
唯一约束(实测,见 har/1ms_dianxuan/PROGRESS.md 已知 #58/#59):
|
||
collect **必须用本次会话那一份构建**生成 —— 同一 aid 下,
|
||
"按本次构建生成" -> errorCode=0;"拿别处模板回填" -> 恒 12。
|
||
|
||
返回 {"collect", "tlg", "eks", "source", "key"}。
|
||
"""
|
||
if mode != "python_values":
|
||
raise ValueError(
|
||
f"只支持 mode='python_values'(纯离线);收到 {mode!r}。"
|
||
"模板回填 / Node 兜底两条老路已删除 —— 它们产生的 collect 与本次会话构建不同源,"
|
||
"在真实 aid 上必然 errorCode=12。")
|
||
|
||
from .values import generate_collect
|
||
|
||
out = generate_collect(tdc_js_code, click_points, referrer=referrer)
|
||
return {"collect": out["collect"], "tlg": out["tlg"], "eks": out["eks"],
|
||
"source": out["source"], "key": out["key"]}
|
||
|
||
|
||
def selfcheck() -> None:
|
||
"""纯算法自检: XTEA 往返 + encrypt_string 一致性。"""
|
||
for chunk in (b"hello!!", bytes(range(32)), b"x" * 24):
|
||
dec = xtea_decrypt_bytes(xtea_encrypt_bytes(chunk, DEFAULT_KEY), DEFAULT_KEY)
|
||
assert dec[:len(chunk)] == chunk, f"XTEA 往返失败: {chunk!r}"
|
||
ref = base64.b64encode(
|
||
xtea_encrypt_bytes(b"abc\x00\x00\x00\x00\x00", DEFAULT_KEY)).decode("ascii")
|
||
assert encrypt_string("abc") == ref, "encrypt_string 与 xtea_encrypt_bytes 不一致"
|
||
|
||
|
||
if __name__ == "__main__":
|
||
selfcheck()
|
||
print("crypto 自检通过: XTEA 往返 + encrypt_string 一致性")
|