Files
hello_js_reverse_skill/tcaptcha/tdc_values.py
T
yml2213 51dff870cf feat(collect): 纯离线生成打通 1ms 签到文字点选 —— errorCode=0 收口
做了什么:
- 新增 tcaptcha/tdc_values.py: 37 个语义名的取值生成器(静态画像 / 会话量 /
  每次采集量三层)+ build_plain 四段组装 + generate_collect 纯离线出包
- crypto: 新增 generate_telemetry(mode="python_values");轨迹生成统一到
  trajectory_points()(首点绝对坐标 + 增量 + 固定收尾 [1,1,12])
- crypto.split_collect_segments 重写: pad 只有 1 个空格时旧 ` {2,}` 判据会漏段,
  且 UA 串里的普通空格会假阳性 —— 改为「模 24 算术 + 不落在 JSON 字符串内部 + 结构校验」
- tools/tdc_modules: trajectory 指纹原先指向只做 JSON.stringify 的模块,改为
  mouseMoveEvent/touchStartEvent(真轨迹模块),37/37 不变
- client 默认 telemetry_mode 改为 python_values
- 新增 scripts/verify/{align_session,audit_names,verify_values,verify_offline,send_test}.py
  与 tools/{cdp_collect,cdp_dump_scripts,tdc_profile}.py

为什么:
- 账本 #40「谁在发 HTTP 是 12 的剩余自变量」被推翻。同 aid/同客户端/同求解器、
  答案都对,只换 collect 构造方式: 本次会话构建生成 -> 0 (4/4),
  旧模板回填 -> 12 (2/2)。真因是 collect 必须与本次会话构建同源。

怎么验证:
- .venv/bin/python -m tcaptcha.selftest  全绿(含新增四段切分与纯离线闭环回归)
- scripts/verify/verify_values.py        4 份真机明文 152/152 逐槽全等
- scripts/verify/verify_offline.py       逐槽全等 + 分段几何一致
- scripts/verify/send_test.py --mode python_values  1ms 签到页文字点选 errorCode=0 x4
2026-10-12 03:38:18 +08:00

390 lines
16 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 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)]]
def dt() -> int:
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)
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], dt()])
cur = [int(nx), int(ny)]
for _ in range(random.randint(2, 5)): # 命中后短暂驻留
events.append([random.randint(-1, 1), random.randint(-1, 1), 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)
import tdc_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) -> Dict[str, Any]:
"""**纯离线**生成一颗 collect:只吃 tdc.js 源码,不需要任何真机明文。
流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密拼接。
密钥默认用纯 Python 静态提取(`tcaptcha.tdc_key`),零 Node。
"""
from tcaptcha.crypto import build_collect, extract_eks # 局部导入避免环形依赖
from tcaptcha.tdc_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 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()
import tdc_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())