Files
hello_js_reverse_skill/tcaptcha/telemetry/crypto.py
T
yml2213 74a863c8b3 feat(icon): 轨迹时序指纹修复 —— 1ms 图标 4/8→7/8=0,errorCode 语义与轨迹对拍收口
- 真因:dt 分布与真机严重不符(旧 50% 落 0~16ms,真机 79% 落 13~20ms)+ 点数
  随机 57~65(真机 6/6 恒 61)。按 6 条真机轨迹重拟合 _sample_dt,新增 _fit_points
  钉死 61 点(补齐用 [0,0,dt] 驻留点,不能复制位移点——净位移会被放大)。
- A/B:1ms 图标 0×4→0×7(前 7 连 0);残余偶发 12 为连发频控形态,低频使用即可。
- errorCode 语义标定:50=答案被否(容差实测 25~40px,我们点偏仅 2~3px),
  12=包级拒答;判点位只看 50/0。
- 顺带修 build_plain:轨迹落末槽(k=37)时 chunk2 恒补前导逗号 → 非法 JSON。
- 新增 scripts/verify/send_campaign.py(批量真发+落图+统计)与 judge_icons.py
  (提示目标 vs 点击点并排判读);账本 #64~#74 + 专文 ICON2408.md。

复验:selftest 全绿;判读图 24/24 点位全对;mliev 图标 9/10=0。
2026-10-12 14:32:14 +08:00

357 lines
15 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。
⚠️ 轨迹落在数组两端时,相邻那段的逗号按 **JSON.stringify 语义** 消失:
k == 0 -> chunk1 = '{"cd":[' (真机成功包实测)
k == n-1 -> chunk2 = '],' (2026-10-12 实测构建)
做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验
(首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。
校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。
"""
data = plain.encode("latin1")
cands = _candidate_pads(data)
n_struct = n_json = 0
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 记号边界上:
# chunk1 = '{"cd":[' + 若干槽 + [',' 或 ''] —— 轨迹正好是第 0 槽时**没有那个逗号**
# (真机成功包实测:chunk1 就是 '{"cd":[',随后 17 个空格 pad)
# traj = **一个**完整 JSON 值
# chunk2 = [',' + 若干槽] + '],' —— 轨迹正好是最后 1 槽时**没有那个前导逗号**
if not (segs[0].startswith(b'{"cd":[')
and (segs[0].endswith(b',') or segs[0] == b'{"cd":[')
and segs[2].endswith(b'],')
and (segs[2].startswith(b',') or segs[2] == b'],')
and segs[3].startswith(b'"sd"')):
n_struct += 1
continue
try:
json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值
except Exception: # noqa: BLE001
n_struct += 1
continue
try:
obj = json.loads(b"".join(segs).decode("latin1"))
except Exception: # noqa: BLE001
n_json += 1
continue
if "cd" not in obj or "sd" not in obj:
n_json += 1
continue
return (segs[0].decode("latin1"), segs[1].decode("latin1"),
segs[2].decode("latin1"), segs[3].decode("latin1"))
raise ValueError(
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个,"
f"边界结构不符 {n_struct} 组,拼回 JSON 校验失败 {n_json} 组);"
f"这份明文可能不是 4 段格式,或生成侧拼出了非法 JSON")
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",
trajectory: Optional[List[List[int]]] = None,
) -> 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,
trajectory=trajectory)
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 一致性")