- 真因: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。
357 lines
15 KiB
Python
357 lines
15 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。
|
||
|
||
⚠️ 轨迹落在数组两端时,相邻那段的逗号按 **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 一致性")
|