Files
hello_js_reverse_skill/tcaptcha/telemetry/values.py
T
yml2213 6a8b3b3ce4 feat(slider): 滑块 (2401/2402) 端到端打通 —— errorCode=0
做了什么:
- solvers/slider.py: 新增 SliderChallenge(把课题 JSON 解析成解题参数)+ piece_from_sprite
  (按 sprite_pos/size_2d 裁拼图块)+ track_limit 解析 / clamp_x / answer(按 data_type 组答案)
- telemetry/values.py: 新增 drag_trajectory_points(拖动轨迹:按下->横移->松手,
  首点绝对、其余增量、尾点固定 [1,1,12]);_sample_dt 抽成模块级供两条轨迹复用
- telemetry/{values,crypto}.py: generate_collect / generate_telemetry 接受 trajectory=
- protocol/client.py: 新增滑块分支(拉底图 + 精灵图 -> 裁块 -> ZNCC -> 组答案 -> 拖动轨迹),
  accept_kinds 默认扩为 文字点选 + 图标点选 + 滑块
- scripts/verify/send_test.py: 加 --kind text|slider|icon|any

关键结论(账本 #60~#63):
- 滑块答案是 DynAnswerType_POS,但坐标用**背景图原始像素**(不是页面显示像素):
  所以解题用 scale=1.0 直接提交 target_x_image;elem_id / data_type 均取自 move_cfg
- 拼图块可从课题配置直接裁,与旧 DOM 换算结果**逐像素相同**(测试已加交叉断言)
- **ddddocr 的滑块距离识别不可用**:fixture 偏 59px、实时偏 60px。判定手段是把拼图块
  alpha 轮廓叠到背景上目视 —— 本项目 ZNCC 与缺口描边严丝合缝,ddddocr 整体右偏
- 低置信度(margin=0.024 < 0.08)那次被正确拒答并换题,换题后立刻成功,安全网有效

怎么验证:
- .venv/bin/python -m tcaptcha.tests.test_slider_solver   全绿(新增 [6]/[7] 两组断言)
- .venv/bin/python -m tcaptcha.selftest                   全绿
- .venv/bin/python scripts/verify/send_test.py --kind slider   3/3 errorCode=0
- 文字点选回归不受影响:--kind text 仍为 errorCode=0
2026-10-12 12:46:35 +08:00

427 lines
18 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.
# -*- coding: utf-8 -*-
"""tcaptcha/tdc_values.py —— 为每个采集器**语义名**提供取值生成器(纯离线)。
背景(账本 §五 第 0 项,har/1ms_dianxuan/PROGRESS.md):
「纯离线生成 collect」的唯一阻塞点 = 不知道每个语义名该填什么值。
本文件把 2026-10-12 实测到的**同会话真值**固化成生成规则。
真值来源(不是猜的):
真 Chrome + 真 1ms 页面 -> Debugger 取**同会话** tdc.js(77,954 字节,
含 __TENCENT_CHAOS_VM)-> iframe 内 TDC.getData(true) -> 同会话 XTEA 密钥解密
-> 按「require 序 == 槽位序」对齐,38/38 槽全覆盖。
脚本: scripts/verify/align_session.py、scripts/verify/audit_names.py
会话 vs 每次采集(连续 4 次 getData 实测,这一条决定了生成器怎么分层):
| 语义名 | 第1次 | 第2次 | 第3次 | 第4次 | 结论 |
|---------------------------|-------------|-------------|-------------|-------------|---------------|
| itoken | 641547489 | 641547489 | 641547489 | 641547489 | **会话内固定** |
| struct_40_9 (#.._rand) | 1591912482 | 1591912482 | 1591912482 | 1591912482 | **会话内固定** |
| window_time (#e48 委派) | 1791745224 | 1791745224 | 1791745224 | 1791745224 | **会话内固定** |
| window_time (#e40) | 1791745331 | 1791745548 | 1791745813 | 1791745814 | 每次刷新 |
| struct_46_9 | 1 | 2 | 3 | 4 | **每次 +1 的计数器** |
| trajectory 点数 | 1 | 61 | 1 | 1 | 取完即清空 |
三类取值:
* 静态画像 DEVICE_PROFILE —— UA / 屏幕 / WebGL / Canvas / 语言 / 时区 / 核数 …
(已用浏览器真值交叉校验: screen.width/height/availHeight/colorDepth、
hardwareConcurrency、timezoneOffset、languages 全部一一吻合)
* 会话量 Session —— itoken / 会话随机盐 / 页面加载时刻
* 每次采集 Session.next_call() —— 当前时刻、采集计数器、轨迹
"""
from __future__ import annotations
import random
import time
from typing import Any, Dict, List, Optional, Sequence
# --------------------------------------------------------------------------- #
# 设备画像:与真机逐项核对过(见模块 docstring 的交叉校验说明) #
# --------------------------------------------------------------------------- #
DEVICE_PROFILE: Dict[str, Any] = {
"name": "Mac / Chrome 155 / 1440x2560",
"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"),
"platform": "MacIntel",
"languages": ["zh-CN", "zh"],
"timezone_offset": "+08", # 真机 Date.getTimezoneOffset() == -480
"charset": "UTF-8",
"hardware_concurrency": 10, # 真机 navigator.hardwareConcurrency
# ⚠️ 方向别弄反:真机 screen.width == 1440、screen.height == 2560
# (1440x2560 竖屏;浏览器实测 screen.availHeight == 2530)
"screen_width": 1440,
"screen_height": 2560,
"screen_color_depth": 24,
# 真机 screen_fingerprint 串;字段序 = 宽-高-可用高-色深-*-*-|-*
"screen_fingerprint": "1440-2560-2530-24-*-*-|-*",
"webgl_vendor": "Google Inc. (Apple)",
"webgl_renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)",
"canvas_fingerprint": ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1"
"PQinPL7Bi3TG0qE2CogAAAABJRU5ErkJggg=="),
"viewport_size": [360, 360], # 验证码 iframe 的可视区
"cookie_enabled": 1,
"touch_detect": 2, # 真机实测值(该机 maxTouchPoints == 0)
"iframe_detect": "iframe",
"webrtc_ip": "", # 未取到候选时为空串
"dom_compliance": 1023,
"constant_98k": "98k",
"template_url": ("https://turing.captcha.gtimg.com/1/template/"
"drag_ele.html?rand=1519713624347"),
}
# 真机里恒为这些常量的槽(连续 4 次抽样一致)
CONSTANTS: Dict[str, Any] = {
"time_since_load": 0,
"webdriver_detect": 0,
"console_detect": 0,
"fp_salt": 0,
"struct_48_9_call": 0,
"location_href": "",
"struct_40_9#empty": "",
}
# 真机里是空数组的槽(与轨迹区分:轨迹至少含一个点)
EMPTY_LIST_NAME = "struct_76_33"
DEFAULT_REFERRER = "https://1ms.run/console/personal/benefits/check-in"
def _rand_int32() -> int:
"""[0, 2^31) 的随机整数 —— 真机观测值 1591912482 / 699129805 / 641547489 都落在这里。"""
return random.randint(0, (1 << 31) - 1)
def default_trajectory() -> List[List[int]]:
"""无鼠标输入时的轨迹(真机实测就是 [[1,1,12]],看着像个"无位移"哨兵点)。"""
return [[1, 1, 12]]
def _sample_dt() -> int:
"""采样一个事件间隔(ms)。分布按真机成功样本拟合:浏览器合并事件会出现 0。"""
r = random.random()
if r < 0.50:
return random.randint(0, 16)
if r < 0.75:
return random.randint(17, 24)
if r < 0.90:
return random.randint(25, 60)
if r < 0.98:
return random.randint(61, 160)
return random.randint(161, 400)
def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]:
"""背景图原始像素 -> iframe 显示坐标(与真机点击轨迹同一套口径)。"""
scale = 320.0 / img_w
return 20 + x * scale, 60 + y * scale
def trajectory_points(click_points: Sequence[Sequence[int]],
img_w: int = 672) -> List[List[int]]:
"""按点击点生成鼠标轨迹:首点绝对坐标 + 增量点 + 固定收尾 `[1,1,12]`。
形状与真机一致(3 份样本逐字核对):
* 首点是**绝对坐标**(含 300~600ms 初始停顿)
* 之后全是**增量** `[dx,dy,dt]`
* **最后一点固定是 `[1,1,12]`** —— 真机成功样本与"没动鼠标"样本都这么收尾
(没动鼠标时整条轨迹就是 `[[1,1,12]]`)
dt 分布按成功样本实测拟合(旧 crypto._success_style_trajectory 的统计)。
"""
scale = 320.0 / img_w
targets = [(int(round(20 + x * scale)), int(round(60 + y * scale)))
for x, y in click_points]
cur = [random.randint(140, 220), random.randint(300, 340)]
events: List[List[int]] = [[cur[0], cur[1], random.randint(300, 600)]]
for tx, ty in targets:
seg = random.randint(14, 20)
for i in range(1, seg + 1):
nx = cur[0] + (tx - cur[0]) * i / seg + random.randint(-1, 1)
ny = cur[1] + (ty - cur[1]) * i / seg + random.randint(-1, 1)
events.append([int(nx) - cur[0], int(ny) - cur[1], _sample_dt()])
cur = [int(nx), int(ny)]
for _ in range(random.randint(2, 5)): # 命中后短暂驻留
events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()])
events.append([1, 1, 12]) # 固定收尾(真机如此)
return events
def drag_trajectory_points(start: Sequence[float], end: Sequence[float],
img_w: int = 672, steps: int = 30) -> List[List[int]]:
"""滑块**拖动**轨迹:按下 -> 横向移动 -> 松手。
与 `trajectory_points` 同一套口径(输入是背景图原始像素,输出是 iframe 显示坐标),
同样以固定 `[1,1,12]` 收尾。速度曲线用 ease-out:人手拖动是先快后慢、末尾微调。
"""
sx, sy = _to_display(float(start[0]), float(start[1]), img_w)
ex, ey = _to_display(float(end[0]), float(end[1]), img_w)
cur = [int(round(sx)), int(round(sy))]
events: List[List[int]] = [[cur[0], cur[1], random.randint(300, 600)]] # 按下前停顿
for i in range(1, steps + 1):
t = i / steps
e = 1.0 - (1.0 - t) ** 3
nx = int(round(sx + (ex - sx) * e)) + random.randint(-1, 1)
ny = int(round(sy + (ey - sy) * e)) + random.randint(-1, 1)
events.append([nx - cur[0], ny - cur[1], _sample_dt()])
cur = [nx, ny]
for _ in range(random.randint(2, 4)): # 落点附近的微调
events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()])
events.append([1, 1, 12])
return events
def rows_from_source(js_text: str) -> List[Dict]:
"""分析 tdc.js **源码**(tdc_modules 的入口收的是路径,这里落临时文件再分析)。"""
import os
import tempfile
fd, path = tempfile.mkstemp(suffix=".js", prefix="tdc_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(js_text)
return _analyze(path)
finally:
try:
os.unlink(path)
except OSError:
pass
def _analyze(path: str) -> List[Dict]:
"""惰性导入 tools/tdc_modules(它不在包里,且只做静态分析)。"""
import sys
from pathlib import Path as _P
tools = str(_P(__file__).resolve().parents[1] / "tools")
if tools not in sys.path:
sys.path.insert(0, tools)
from tcaptcha.telemetry import modules as tm
return tm.analyze(path)
def generate_collect(tdc_js_code: str,
click_points: Optional[Sequence[Sequence[int]]] = None,
*,
key: Optional[Sequence[int]] = None,
sd: Optional[Dict] = None,
session: Optional[Session] = None,
img_w: int = 672,
referrer: Optional[str] = None,
trajectory: Optional[Sequence[Sequence[int]]] = None) -> Dict[str, Any]:
"""**纯离线**生成一颗 collect:只吃 tdc.js 源码,不需要任何真机明文。
流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密拼接。
密钥默认用纯 Python 静态提取(`tcaptcha.telemetry.key`),零 Node。
trajectory 直接给轨迹点(滑块用 `drag_trajectory_points`);不给则按 click_points 推。
"""
from tcaptcha.telemetry.crypto import build_collect, extract_eks # 局部导入避免环形依赖
from tcaptcha.telemetry.key import extract_xtea_key_pure
rows = rows_from_source(tdc_js_code)
if key is None:
key = extract_xtea_key_pure(tdc_js_code)["key"]
sess = session or Session(referrer=referrer or DEFAULT_REFERRER)
if trajectory is not None:
sess.set_trajectory(trajectory)
elif click_points:
sess.set_trajectory(trajectory_points(click_points, img_w=img_w))
cd = generate_cd(rows, sess)
plain = build_plain(cd, rows, sd)
collect = build_collect(plain["chunk1"], plain["trajectory"],
plain["chunk2"], plain["sd"], list(key))
return {
"collect": collect,
"tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code),
"key": list(key),
"source": "python_values",
"cd": cd,
"segments": {k: len(v) for k, v in plain.items()},
}
class Session:
"""一次验证码会话的状态。
会话内固定的量(itoken / 随机盐 / 页面加载时刻)在 __init__ 里定下来,
每次采集会变的量(当前时刻 / 计数器 / 轨迹)由 next_call() 推进。
"""
def __init__(self, now: Optional[float] = None, entry_age: int = 60,
referrer: str = DEFAULT_REFERRER, profile: Optional[Dict] = None):
self.t0 = int(now if now is not None else time.time())
# 页面加载时刻先于会话建立(真机两次采样差 ~107 秒)
self.loaded_at = self.t0 - entry_age
self.itoken = [_rand_int32(), self.loaded_at] # 会话内固定,实测 4 次完全一致
self.session_rand = _rand_int32() # struct_40_9 的"随机"槽
self.referrer = referrer
self.profile = dict(profile or DEVICE_PROFILE)
self.calls = 0
self.now = self.t0
self.trajectory: List[List[int]] = default_trajectory()
def next_call(self, now: Optional[float] = None) -> "Session":
"""进入下一次 TDC.getData():计数器 +1、刷新当前时刻、轨迹复位由调用方决定。"""
self.calls += 1
self.now = int(now if now is not None else time.time())
return self
def set_trajectory(self, points: Sequence[Sequence[int]]) -> "Session":
self.trajectory = [list(p) for p in points]
return self
# --------------------------------------------------------------------------- #
# 语义名 -> 取值生成器 #
# --------------------------------------------------------------------------- #
def value_for(base: str, row: Dict, sess: Session) -> Any:
"""按**基础语义名**(去掉 #结构后缀)产出一个槽的值。
同一基础名在一个构建里可能出现多次(user_agent×2 / window_time×2 /
struct_40_9×2),这时用行上的**结构证据**分流,而不是靠出现次序 ——
次序会随构建变,结构不会(见 tdc_modules.STRUCT_RULES)。
"""
st = row.get("struct") or {}
p = sess.profile
# ---- 同组多成员:用结构 / 委派证据分流 ----
if base == "user_agent":
# 直采的那个返回 UA 串;走委派链的那个返回 0(真机实测)
return 0 if row.get("via_delegate") else p["user_agent"]
if base == "struct_40_9":
# 闭包最后写入 "" vs 随机 int32(账本 #47 的静态消歧依据)
return "" if st.get("closure_kind") == "empty" else sess.session_rand
if base == "window_time":
# 委派的那个是页面加载时刻(会话内固定),直采的那个是当前时刻(每次变)
return sess.loaded_at if row.get("via_delegate") else sess.now
# ---- 会话量 / 每次采集量 ----
if base == "itoken":
return None # 2 槽,见 generate_cd
if base == "trajectory":
return sess.trajectory
if base == "struct_46_9":
return sess.calls # 实测 1,2,3,4 —— 每次采集 +1
if base == "referrer":
return sess.referrer
# ---- 常量 / 画像 ----
if base in CONSTANTS:
return CONSTANTS[base]
if base == EMPTY_LIST_NAME:
return []
if base in p:
return p[base]
raise KeyError("没有 %r 的取值规则(新语义名? 需要补规则)" % base)
def generate_cd(rows: Sequence[Dict], sess: Optional[Session] = None,
now: Optional[float] = None) -> List[Any]:
"""按**清单顺序**生成 cd 数组(37 项 -> 38 槽,itoken 占 2 槽)。
rows 来自 tools.tdc_modules.analyze()(每项含 name / struct / via_delegate)。
每次调用会推进 sess.next_call()(模拟一次 TDC.getData)。
"""
s = sess or Session(now=now)
if now is not None:
s.next_call(now)
elif s.calls == 0:
s.next_call(s.t0)
out: List[Any] = []
for r in rows:
base = str(r["name"]).split("#")[0]
if base == "itoken":
out.extend(s.itoken) # 真机就是 2 个连续槽
continue
out.append(value_for(base, r, s))
return out
def trajectory_index(rows: Sequence[Dict]) -> int:
"""轨迹在 cd 中的下标 —— 它就是 4 段加密里第 2 段的分界点。"""
k = 0
for r in rows:
base = str(r["name"]).split("#")[0]
if base == "trajectory":
return k
k += 2 if base == "itoken" else 1
raise KeyError("清单里没有 trajectory 项")
def build_plain(cd: Sequence[Any], rows: Sequence[Dict],
sd: Optional[Dict] = None) -> Dict[str, str]:
"""把 cd 组装成 4 段明文(chunk1 / trajectory / chunk2 / sd)。
与真机**逐字节一致**的分段写法(3 份样本核对,2026-10-12):
chunk1 = '{"cd":[' + 轨迹之前的槽(逗号连接)+ ','
traj = '<轨迹槽的 JSON>'
chunk2 = ',' + 轨迹之后的槽(逗号连接)+ '],'
sd = '"sd":' + json(sd) + '}'
分隔符一律是**无空格的逗号**(真机 JSON.stringify 风格)。
"""
import json
def js(v: Any) -> str:
return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
k = trajectory_index(rows)
head = ",".join(js(v) for v in cd[:k])
tail = ",".join(js(v) for v in cd[k + 1:])
return {
"chunk1": '{"cd":[' + head + ",",
"trajectory": js(cd[k]),
"chunk2": "," + tail + "],",
"sd": '"sd":' + js(sd if sd is not None else {"od": "C"}) + "}",
}
def main() -> int:
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
ap = argparse.ArgumentParser(description="按语义名生成 cd 取值(纯离线)")
ap.add_argument("tdc_js", help="一份 tdc.js(用来取清单顺序与语义名)")
ap.add_argument("--json", default=None)
args = ap.parse_args()
from tcaptcha.telemetry import modules as tm
rows = tm.analyze(args.tdc_js)
sess = Session()
cd = generate_cd(rows, sess)
print("项 语义名 取值")
print("-" * 78)
k = 0
for r in rows:
base = str(r["name"]).split("#")[0]
n = 2 if base == "itoken" else 1
val = cd[k:k + n] if n > 1 else cd[k]
k += n
print("%-3d %-36s %s" % (r["item"], r["name"],
json.dumps(val, ensure_ascii=False)[:34]))
print()
print("槽数 %d / %d 项" % (len(cd), len(rows)))
plain = build_plain(cd, rows)
print("分段: chunk1=%d traj=%d chunk2=%d sd=%d"
% (len(plain["chunk1"]), len(plain["trajectory"]),
len(plain["chunk2"]), len(plain["sd"])))
if args.json:
Path(args.json).write_text(json.dumps(cd, ensure_ascii=False, indent=1),
encoding="utf-8")
print(">>> %s" % args.json)
return 0
if __name__ == "__main__":
raise SystemExit(main())