Files
hello_js_reverse_skill/tcaptcha/telemetry/crypto.py
T
yml2213 344eea2194 refactor(结构): 分层重组 + 清除全部废弃路线
做了什么:
- 包改为分层: 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
2026-10-12 12:34:57 +08:00

339 lines
14 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.
"""
腾讯 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 一致性")