feat(反VM): 纯 Python 还原 XTEA 算法 + 从 tdc.js 运行时提取每构建密钥
做了什么 - 确认 collect 的加密算法就是教科书 XTEA (32 轮, delta=0x9E3779B9), 并证明 crypto.py 原有的"带加盐"写法只是把两个密钥字折叠: K' = [k0, k1+salt1, k2+salt2, k3] - tdc_worker.js 新增 captureKey 模式: 正则改写 VM 的 ADD/XOR opcode 为带日志版本, 用反向引用自动识别栈变量名 (样本=g/雨云实时=j/1ms=M, 版本无关); 匹配数不为 1 时显式报错, 不静默失败 - 密钥反推: (sum + K[j]) 是一次 ADD, 两操作数必有一个等于 r*DELTA, 另一个就是密钥字; 再对 Top-N 候选做 4! 排列 + 明文内容特征验证 - 新增 recover_payload_template / generate_collect_from_template: 运行时标定一次明文模板, 之后轨迹生成/组包/加密/编码全部在 Python - 新增 tcaptcha/proxy.py 代理提取, client 支持 --proxy-api 遇 12 自动换 IP 为什么 - 服务端只认 collect 的解密结果, 而密钥是每构建随机的; 把算法做成纯 Python 后, 只有"取密钥"依赖 Node, 抗发版能力最强 怎么验证的 - 纯 Python XTEA 加密结果与真实 tdc.js 产物前 48 字节逐字节一致 - 密钥按构建固定的证据: 同一脚本 3 次运行密文首块均为 f6a24e3e4fb5dd2b - 三份脚本 (样本/雨云实时/1ms实时) 均成功复原密钥 - 雨云 aid 端到端: 纯 Python 出包 -> errorCode=0 + 真实 ticket - 回归测试: tcaptcha/tests/test_xtea_key.py 11 项断言全过, 文字点选求解 8/8; 新增 tcaptcha/selftest.py 一键复验 已知未解 - 1ms aid=195672344 在答案正确时仍返回 errorCode=12; 已排除密钥、 遥测引擎、出口 IP (4 个住宅代理)、短时频控、静默窗口
This commit is contained in:
+458
-33
@@ -8,12 +8,22 @@ telemetry payload serialization.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from typing import List, Tuple
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
M32 = 0xFFFFFFFF
|
||||
|
||||
TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js"
|
||||
TDC_BROWSER_PATH = Path(__file__).resolve().parent / "tdc_browser.js"
|
||||
|
||||
# TDC VM Cryptographic Constants
|
||||
DELTA = 2654435769 # 0x9E3779B9
|
||||
@@ -186,9 +196,12 @@ def generate_trajectory_payload(
|
||||
import json
|
||||
raw_json = json.dumps(events, separators=(",", ":"))
|
||||
|
||||
# Pad with spaces to multiple of 8 bytes (matching target_len, e.g. 600)
|
||||
# 填充到 24 字节对齐 —— 24 是 8 (XTEA 分组) 与 3 (Base64 分组) 的最小公倍数,
|
||||
# 这样每个分块单独 base64 后拼接不会产生 **块内 '=' 填充**。
|
||||
# 实测真实 tdc.js 的 collect 整体只有末尾一个 '==' (见 点选.har),
|
||||
# 块内出现 '=' 会导致服务端解析/解密失败。
|
||||
desired_len = max(len(raw_json), target_len)
|
||||
pad_needed = (8 - (desired_len % 8)) % 8
|
||||
pad_needed = (24 - (desired_len % 24)) % 24
|
||||
total_len = desired_len + pad_needed
|
||||
|
||||
return raw_json.ljust(total_len, " ")
|
||||
@@ -206,36 +219,230 @@ def extract_eks(tdc_js_code: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def generate_collect(
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 标准 XTEA (32 轮, delta=0x9E3779B9, sum 取 mod 2^32) —— 等价于原 crypto.py 的
|
||||
# 自定义形态 + 密钥折叠: K' = [k0, k1 + salt1, k2 + salt2, k3]
|
||||
# 由此「算法」就是教科书 XTEA, 唯一需要从每份 tdc.js 里拿到的是那 4 个密钥字。
|
||||
# --------------------------------------------------------------------------- #
|
||||
XTEA_DELTA = 0x9E3779B9
|
||||
CD_PRE_RAW = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
_CAPTURE_CACHE: Dict[str, Tuple[List[List], bytes]] = {}
|
||||
|
||||
|
||||
def _capture_key_words_cached(tdc_js_code: str, node_bin: str = "node"):
|
||||
cached = _CAPTURE_CACHE.get(tdc_js_code)
|
||||
if cached is None:
|
||||
cached = _capture_key_words(tdc_js_code, node_bin=node_bin)
|
||||
_CAPTURE_CACHE[tdc_js_code] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _capture_key_words(tdc_js_code: str, node_bin: str = "node",
|
||||
timeout: float = 180.0) -> Tuple[List[List], bytes]:
|
||||
"""调用 Node 插桩运行 tdc.js, 返回 (密钥候选, 该脚本产出的 collect 原始字节)"""
|
||||
script = TDC_WORKER_PATH
|
||||
if not script.exists():
|
||||
raise FileNotFoundError(f"缺少 tdc_worker.js: {script}")
|
||||
cmd = [node_bin, str(script), "-", "[[0,0],[0,0],[0,0]]",
|
||||
json.dumps({"aid": "1", "captureKey": True})]
|
||||
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True,
|
||||
timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""})
|
||||
out = (proc.stdout or "").strip().splitlines()
|
||||
if not out:
|
||||
raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}")
|
||||
payload = json.loads(out[-1])
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(f"tdc_worker 执行失败: {payload['error'][:400]}")
|
||||
words = payload.get("key_words") or []
|
||||
if not words:
|
||||
raise RuntimeError("tdc_worker 未返回密钥候选 (captureKey 未生效?)")
|
||||
raw = base64.b64decode(urllib.parse.unquote(payload["collect"]))
|
||||
return words, raw
|
||||
|
||||
|
||||
_KEY_CACHE: Dict[str, List[int]] = {}
|
||||
|
||||
|
||||
def extract_xtea_key(tdc_js_code: str, node_bin: str = "node",
|
||||
top_n: int = 8, use_cache: bool = True) -> List[int]:
|
||||
"""
|
||||
从服务端下发的 tdc.js 里提取本次构建的 XTEA 密钥 (4 个 32 位字)。
|
||||
|
||||
流程 (Node 只负责"跑出密钥", 算法全在 Python):
|
||||
1. Node 在 VM 上下文里把 ADD opcode 换成带日志版本;
|
||||
XTEA 每轮的 (sum + K[j]) 是一次 ADD, 两个操作数必有一个等于 r*DELTA,
|
||||
于是另一个操作数就是密钥字 —— 按频次排序得到无歧义候选。
|
||||
2. Python 取候选 Top-N, 对 4! 个排列逐个用教科书 XTEA 解密
|
||||
tdc.js 自己产出的 collect 前 48 字节, 与常量 cd 前缀比对 —— 命中即真密钥。
|
||||
|
||||
密钥按构建固定 (同一份脚本多次运行首块完全一致, 已实测), 故可缓存。
|
||||
"""
|
||||
import itertools
|
||||
|
||||
if use_cache:
|
||||
cached = _KEY_CACHE.get(tdc_js_code)
|
||||
if cached:
|
||||
return list(cached)
|
||||
|
||||
words, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
|
||||
candidates = [int(k) & M32 for k, *_ in words[:top_n]]
|
||||
# 验证用「内容特征」而非固定明文 (AGENTS.md §5.3 / 附录 A#6):
|
||||
# 实测 tdc.js 至少两代明文布局 ——
|
||||
# 旧版: 4 段 base64 拼接, 首段 = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
|
||||
# 新版: 单个 JSON, 首部 = '{"cd":[1,[],"Apple M1",24,...' / '{"cd":[1791712995,0,...'
|
||||
# 字段序还随构建变化, 故只依赖跨版本稳定的结构特征: '{"cd":[' 开头 + 首块全可打印。
|
||||
blocks = [struct.unpack("<II", raw[i:i + 8]) for i in range(0, 48, 8)]
|
||||
|
||||
for perm in itertools.permutations(candidates, 4):
|
||||
key = list(perm)
|
||||
head = b"".join(struct.pack("<II", *xtea_decrypt_block(v0, v1, key))
|
||||
for v0, v1 in blocks)
|
||||
if head[:7] == b'{"cd":[' and all(32 <= b < 127 for b in head[:48]):
|
||||
if use_cache:
|
||||
_KEY_CACHE[tdc_js_code] = key
|
||||
return key
|
||||
raise RuntimeError(
|
||||
f"未能从 tdc.js 复原 XTEA 密钥 (候选 {len(candidates)} 个, 排列全试失败)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §5.8 标定式交付: 运行时取一次明文模板, 之后全部由 Python 回填
|
||||
# (新版 tdc.js 的明文是单个 JSON, 且**字段序随构建变化** —— 实测两份同时期脚本
|
||||
# 首部一个是 '{"cd":[1,[],"Apple M1",24,...', 另一个是 '{"cd":[1791712995,0,...',
|
||||
# 所以不能硬编码模板, 只能每次标定一份。)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_TRAJ_RE = re.compile(r"\[\[\d+,-?\d+,\d+\](?:,\[-?\d+,-?\d+,\d+\])+\]")
|
||||
_EPOCH_RE = re.compile(r"(?<![\d.])(1[6-9]\d{8})(?![\d.])")
|
||||
|
||||
|
||||
def recover_payload_template(tdc_js_code: str, node_bin: str = "node"):
|
||||
"""
|
||||
标定: 跑一次 tdc.js, 提取本次构建的 XTEA 密钥并解密出**明文模板**。
|
||||
返回 (template_text, key)。
|
||||
"""
|
||||
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
|
||||
_, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
|
||||
plain = xtea_decrypt_bytes(raw, key).rstrip(b"\x00")
|
||||
return plain.decode("latin1"), key
|
||||
|
||||
|
||||
def generate_collect_from_template(
|
||||
template: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
key: List[int],
|
||||
refresh_time: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
纯 Python 回填出 collect:
|
||||
1. 用 Python 生成的鼠标轨迹替换模板里的轨迹数组
|
||||
2. 刷新模板里的秒级时间戳
|
||||
3. 空格补齐到 8 字节倍数 -> XTEA 加密 -> base64 -> TDC URL 编码
|
||||
加密与组包 100% 在 Python, Node 只提供密钥与一次性模板。
|
||||
"""
|
||||
text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template
|
||||
|
||||
traj = generate_trajectory_payload(click_points, target_len=0).rstrip()
|
||||
m = _TRAJ_RE.search(text)
|
||||
if not m:
|
||||
raise RuntimeError("模板里未找到轨迹数组, 无法回填")
|
||||
text = text[:m.start()] + traj + text[m.end():]
|
||||
|
||||
if refresh_time:
|
||||
now = int(time.time())
|
||||
text = _EPOCH_RE.sub(str(now), text)
|
||||
|
||||
data = text.encode("latin1")
|
||||
data += b" " * ((8 - len(data) % 8) % 8) # 与真实 tdc.js 一致: 空格补齐
|
||||
return urllib.parse.quote_from_bytes(
|
||||
base64.b64encode(xtea_encrypt_bytes(data, key)), safe="")
|
||||
|
||||
|
||||
def generate_collect_pure(
|
||||
tdc_js_code: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
renderer: str = DEFAULT_RENDERER,
|
||||
vendor: str = DEFAULT_VENDOR,
|
||||
canvas_fp: str = DEFAULT_CANVAS_FP,
|
||||
) -> str:
|
||||
node_bin: str = "node",
|
||||
) -> Tuple[str, List[int]]:
|
||||
"""
|
||||
Generates the complete encrypted telemetry payload (`collect`) in 100% pure Python.
|
||||
**纯 Python 出包入口**: Node 只负责「跑出密钥 + 一次性标定明文模板」,
|
||||
轨迹生成 / 组包 / XTEA 加密 / 编码 全部由 Python 完成。
|
||||
|
||||
Assembles:
|
||||
1. cd prefix (48 bytes, space padded)
|
||||
2. mouse trajectory (space padded to 8-byte multiple)
|
||||
3. cd post-body (504 bytes, space padded)
|
||||
4. sd suffix (16 bytes)
|
||||
Encrypts each chunk with the 32-round custom XTEA algorithm, concatenates
|
||||
the Base64 results, and applies TDC URL encoding (%2B, %2F, %3D).
|
||||
两次 Node 调用均按脚本内容缓存 (同一构建只付一次成本)。
|
||||
"""
|
||||
try:
|
||||
template, key = recover_payload_template(tdc_js_code, node_bin=node_bin)
|
||||
return generate_collect_from_template(template, click_points, key), key
|
||||
except Exception:
|
||||
# 模板不适用 (例如两段式旧结构) -> 退回旧版结构 + 同一把密钥
|
||||
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
|
||||
return generate_collect_keyed(click_points, key, aid=aid, user_agent=user_agent,
|
||||
renderer=renderer, vendor=vendor,
|
||||
canvas_fp=canvas_fp), key
|
||||
|
||||
|
||||
def _collect_segments(click_points: List[Tuple[int, int]], aid: str, user_agent: str,
|
||||
renderer: str, vendor: str, canvas_fp: str) -> List[bytes]:
|
||||
"""组装 collect 的 4 个明文分块 (与真实 tdc.js 一致)"""
|
||||
now_sec = int(time.time())
|
||||
rand_salt = random.randint(100000000, 999999999)
|
||||
|
||||
# 1. cd prefix (48 bytes)
|
||||
cd_pre_raw = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
|
||||
cd_pre = cd_pre_raw.ljust(48, " ")
|
||||
cd_pre = CD_PRE_RAW.ljust(48, " ")
|
||||
|
||||
# 2. Mouse trajectory (600 bytes)
|
||||
traj_payload = generate_trajectory_payload(click_points, target_len=600)
|
||||
|
||||
# 3. cd post-body (504 bytes)
|
||||
cd_post_inner = (
|
||||
f',"",'
|
||||
f'"undefined?rand=1519713624347",'
|
||||
@@ -272,25 +479,243 @@ def generate_collect(
|
||||
)
|
||||
cd_post = cd_post_inner.ljust(504, " ")
|
||||
|
||||
# 4. sd suffix (16 bytes)
|
||||
sd = '"sd":{"od":"C"}}'
|
||||
return [s.encode("latin1") for s in (cd_pre, traj_payload, cd_post, sd)]
|
||||
|
||||
# Encrypt all 4 parts
|
||||
enc_cd_pre = encrypt_string(cd_pre)
|
||||
enc_traj = encrypt_string(traj_payload)
|
||||
enc_cd_post = encrypt_string(cd_post)
|
||||
enc_sd = encrypt_string(sd)
|
||||
|
||||
combined_b64 = enc_cd_pre + enc_traj + enc_cd_post + enc_sd
|
||||
def _assemble_collect(segments: List[bytes], key: Optional[List[int]] = None) -> str:
|
||||
"""4 段分别加密 + base64 + 拼接 + TDC URL 编码。
|
||||
|
||||
# TDC URL encoding
|
||||
collect = (
|
||||
combined_b64
|
||||
.replace("+", "%2B")
|
||||
.replace("/", "%2F")
|
||||
.replace("=", "%3D")
|
||||
)
|
||||
return collect
|
||||
每段长度都是 3 的倍数 (48 / 24k / 504), 末段 16 字节 -> 只有末尾一个 '==',
|
||||
与真实样本结构一致 (块内出现 '=' 会导致服务端解析失败)。
|
||||
"""
|
||||
if key is None:
|
||||
enc = [encrypt_string(s.decode("latin1")) for s in segments]
|
||||
else:
|
||||
enc = [base64.b64encode(xtea_encrypt_bytes(s, key)).decode("ascii") for s in segments]
|
||||
return "".join(enc).replace("+", "%2B").replace("/", "%2F").replace("=", "%3D")
|
||||
|
||||
|
||||
def generate_collect_keyed(
|
||||
click_points: List[Tuple[int, int]],
|
||||
key: List[int],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
renderer: str = DEFAULT_RENDERER,
|
||||
vendor: str = DEFAULT_VENDOR,
|
||||
canvas_fp: str = DEFAULT_CANVAS_FP,
|
||||
) -> str:
|
||||
"""用「从 tdc.js 提取到的本次构建密钥」纯 Python 生成 collect (标准 XTEA)"""
|
||||
segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp)
|
||||
return _assemble_collect(segments, key=key)
|
||||
|
||||
|
||||
def generate_collect(
|
||||
click_points: List[Tuple[int, int]],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
renderer: str = DEFAULT_RENDERER,
|
||||
vendor: str = DEFAULT_VENDOR,
|
||||
canvas_fp: str = DEFAULT_CANVAS_FP,
|
||||
) -> str:
|
||||
"""
|
||||
用 crypto.py 内置的固定密钥 (DEFAULT_KEY + 两个历史加盐) 生成 collect。
|
||||
等价于标准 XTEA + K' = [k0, k1+salt1, k2+salt2, k3]。
|
||||
|
||||
兼容性: 实测雨云 aid 直接接受 (errorCode=0), 说明服务端并不校验 collect 与 eks
|
||||
的同源性; 需要严格场景请用 `generate_collect_keyed()` + `extract_xtea_key()`。
|
||||
"""
|
||||
segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp)
|
||||
return _assemble_collect(segments, key=None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 真实 tdc.js 执行路径 (推荐): 在 Node VM 沙箱中跑当前会话的 tdc.js
|
||||
# --------------------------------------------------------------------------- #
|
||||
def generate_telemetry_node(
|
||||
tdc_js_code: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
referrer: str = "https://app.rainyun.com/",
|
||||
frame_size: Tuple[int, int] = (360, 360),
|
||||
display_scale: Optional[float] = None,
|
||||
node_bin: str = "node",
|
||||
timeout: float = 60.0,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
在 Node 沙箱里执行真实 tdc.js, 产出与浏览器一致的 collect / tlg / eks。
|
||||
|
||||
为什么必须走这条路
|
||||
------------------
|
||||
tdc.js 每次加载都会重新混淆, 且其 XTEA 会话密钥并非固定常量 —— 用
|
||||
`har/1ms_dianxuan/live/tdc_live.js` 产出的真实 collect 用 DEFAULT_KEY 解密
|
||||
得到的是随机字节 (见 ANALYSIS.md 验证记录)。因此纯 Python 硬编码密钥的实现
|
||||
只对当初逆向的那一份 tdc.js 有效, 换会话即失效; 而 Node 沙箱直接执行服务端
|
||||
下发的 tdc.js, 拿到的是服务端自己认可的密文。
|
||||
|
||||
返回: {"collect": str, "tlg": str, "eks": str, "source": "node"}
|
||||
"""
|
||||
script = TDC_WORKER_PATH
|
||||
if not script.exists():
|
||||
raise FileNotFoundError(f"缺少 tdc_worker.js: {script}")
|
||||
|
||||
options = {
|
||||
"aid": str(aid),
|
||||
"referrer": referrer,
|
||||
"userAgent": user_agent,
|
||||
"screenWidth": 1920,
|
||||
"screenHeight": 1080,
|
||||
"availHeight": 1055,
|
||||
"frameWidth": frame_size[0],
|
||||
"frameHeight": frame_size[1],
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
}
|
||||
if display_scale is not None:
|
||||
options["displayScale"] = float(display_scale)
|
||||
|
||||
cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]),
|
||||
json.dumps(options)]
|
||||
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True,
|
||||
timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""})
|
||||
out = (proc.stdout or "").strip().splitlines()
|
||||
if not out:
|
||||
raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}")
|
||||
try:
|
||||
payload = json.loads(out[-1])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"tdc_worker 输出无法解析: {out[-1][:300]}") from exc
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(f"tdc_worker 执行 tdc.js 失败: {payload['error'][:400]}")
|
||||
collect = payload.get("collect") or ""
|
||||
if not collect:
|
||||
raise RuntimeError("tdc_worker 未产出 collect")
|
||||
return {
|
||||
"collect": collect,
|
||||
"tlg": str(payload.get("tlg") or len(collect)),
|
||||
"eks": payload.get("eks") or "",
|
||||
"source": "node",
|
||||
}
|
||||
|
||||
|
||||
def generate_telemetry_browser(
|
||||
tdc_js_code: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
referrer: str = "https://app.rainyun.com/",
|
||||
frame_size: Tuple[int, int] = (360, 360),
|
||||
display_scale: Optional[float] = None,
|
||||
chrome_path: Optional[str] = None,
|
||||
headless: bool = True,
|
||||
node_bin: str = "node",
|
||||
timeout: float = 120.0,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
用 **真实 Chrome** (CDP 驱动) 执行 tdc.js, 产出环境指纹真实度最高的 collect / eks。
|
||||
|
||||
与 `generate_telemetry_node` 的区别: 后者在 Node VM 里伪造 DOM/BOM/Canvas,
|
||||
实测答题正确后仍会被服务端判定为环境异常 (errorCode 12); 本函数把 tdc.js 放进
|
||||
真实浏览器, 用 Input.dispatchMouseEvent 派发可信鼠标事件, 再读回 TDC.getData()。
|
||||
"""
|
||||
script = TDC_BROWSER_PATH
|
||||
if not script.exists():
|
||||
raise FileNotFoundError(f"缺少 tdc_browser.js: {script}")
|
||||
|
||||
options = {
|
||||
"aid": str(aid),
|
||||
"referer": referrer,
|
||||
"userAgent": user_agent,
|
||||
"frameWidth": frame_size[0],
|
||||
"frameHeight": frame_size[1],
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"headless": headless,
|
||||
}
|
||||
if chrome_path:
|
||||
options["chromePath"] = chrome_path
|
||||
if display_scale is not None:
|
||||
options["displayScale"] = float(display_scale)
|
||||
|
||||
cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]),
|
||||
json.dumps(options)]
|
||||
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True, timeout=timeout)
|
||||
out = (proc.stdout or "").strip().splitlines()
|
||||
if not out:
|
||||
raise RuntimeError(f"tdc_browser 无输出 (rc={proc.returncode}): {proc.stderr[:600]}")
|
||||
try:
|
||||
payload = json.loads(out[-1])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"tdc_browser 输出无法解析: {out[-1][:300]}") from exc
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(f"真实 Chrome 执行 tdc.js 失败: {payload['error'][:600]}")
|
||||
collect = payload.get("collect") or ""
|
||||
if not collect:
|
||||
raise RuntimeError("真实 Chrome 未产出 collect")
|
||||
return {"collect": collect, "tlg": str(payload.get("tlg") or len(collect)),
|
||||
"eks": payload.get("eks") or "", "source": "browser"}
|
||||
|
||||
|
||||
def generate_telemetry(
|
||||
tdc_js_code: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
referrer: str = "https://app.rainyun.com/",
|
||||
mode: str = "python",
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
统一遥测生成入口。
|
||||
|
||||
mode="python" : 纯 Python 算法还原 (内置固定密钥; 默认, 无 Node 依赖, ~1ms)
|
||||
mode="python_keyed": **Node 仅提取本次构建的 XTEA 密钥, 加密全部在 Python**
|
||||
mode="node" : Node VM 沙箱执行服务端下发的真实 tdc.js (~0.1s)
|
||||
mode="browser" : 真实 Chrome + CDP 执行真实 tdc.js (~50s)
|
||||
mode="auto" : python -> node -> browser 逐级回退
|
||||
|
||||
为什么默认仍然是纯 Python
|
||||
------------------------
|
||||
实测(2026-10-11)证明服务端 **并不按 tdc.js 的会话密钥解密 collect**:
|
||||
雨云 aid=2039519451 + 纯 Python(硬编码 DEFAULT_KEY) 的 collect + 正确答案
|
||||
→ errorCode=0 并返回真实 ticket (tr03jvqlp225MjNBxjDj_-mnM1VTS8O9)
|
||||
而 `DEFAULT_KEY` 只对仓库里那份 tdc_sample.js 有效(能解出明文
|
||||
`{"cd":[[],"1920-1080..."`), 对当天实时抓的同一 aid 的脚本已失效 —— 说明
|
||||
tdc.js 的密码学参数(4 个密钥字 + 2829068/328986 两个轮加盐)是**每次构建随机**的,
|
||||
但服务端并不校验收到的 collect 是否与 eks 同源。
|
||||
|
||||
因此纯 Python 方案在协议层完全够用; 需要 node/browser 的场景是目标 aid 启用了
|
||||
更严格的风控(例如 1ms.run 的 aid=195672344 在答案正确时返回 errorCode=12),
|
||||
详见 har/1ms_dianxuan/ANALYSIS.md。
|
||||
"""
|
||||
chain = {"python": ["python"], "python_keyed": ["python_keyed"],
|
||||
"node": ["node"], "browser": ["browser"],
|
||||
"auto": ["python", "python_keyed", "node", "browser"],
|
||||
"auto_keyed": ["python_keyed", "node", "python"]}.get(mode, ["python"])
|
||||
errors = []
|
||||
for kind in chain:
|
||||
try:
|
||||
if kind == "browser":
|
||||
return generate_telemetry_browser(tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent, referrer=referrer)
|
||||
if kind == "node":
|
||||
return generate_telemetry_node(tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent, referrer=referrer)
|
||||
if kind == "python_keyed":
|
||||
# Node 只负责跑出 XTEA 密钥 + 标定一次明文模板; 加密与组包全在 Python
|
||||
collect, key = generate_collect_pure(tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent)
|
||||
return {"collect": collect, "tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
|
||||
"key": key}
|
||||
collect = generate_collect(click_points, aid=aid, user_agent=user_agent)
|
||||
return {"collect": collect, "tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code), "source": "python"}
|
||||
except Exception as exc:
|
||||
errors.append(f"{kind}: {exc}")
|
||||
if mode != "auto":
|
||||
raise
|
||||
raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user