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
This commit is contained in:
+101
-57
@@ -13,6 +13,7 @@
|
||||
"""
|
||||
|
||||
import base64
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -123,7 +124,11 @@ def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||||
SEG_ALIGN = 24
|
||||
SEG_PAD = b" "
|
||||
|
||||
_collect_seg_re = re.compile(rb" {2,}")
|
||||
# 候选 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],
|
||||
@@ -132,7 +137,8 @@ def xtea_encrypt_segment(text: str, key: List[int],
|
||||
|
||||
为什么要 pad 到 24(而不是 8):24 字节 -> 32 个 Base64 字符,且**不会产生 `=` 填充**。
|
||||
4 段密文的 Base64 直接拼接后看起来就是一个完整的 Base64 串,没有分隔符。
|
||||
(真机明文里能直接看到这些空格:chunk1 +5、trajectory +10、chunk2 +24。)
|
||||
实测 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 字节、
|
||||
@@ -174,27 +180,81 @@ def build_collect(chunk1: str, trajectory: str, chunk2: str, sd: str,
|
||||
return b64
|
||||
|
||||
|
||||
def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
|
||||
"""把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去 pad)。
|
||||
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
|
||||
|
||||
用于从真机样本反解出 chunk1/trajectory/chunk2/sd,做对拍或标定。
|
||||
|
||||
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。
|
||||
|
||||
做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验
|
||||
(首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。
|
||||
校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。
|
||||
"""
|
||||
data = plain.encode("latin1")
|
||||
bounds = [0]
|
||||
for m in _collect_seg_re.finditer(data):
|
||||
bounds.append(m.end())
|
||||
bounds.append(len(data))
|
||||
if len(bounds) != 5:
|
||||
raise ValueError(
|
||||
f"期望 3 个空格填充串(4 段结构),实际切出 {len(bounds) - 1} 段;"
|
||||
f"这份明文可能不是「四段加密」格式")
|
||||
out = []
|
||||
for i in range(4):
|
||||
seg = data[bounds[i]:bounds[i + 1]]
|
||||
if i < 3:
|
||||
seg = seg.rstrip(SEG_PAD) # 前三段去掉空格 pad
|
||||
out.append(seg.decode("latin1"))
|
||||
return out[0], out[1], out[2], out[3]
|
||||
cands = _candidate_pads(data)
|
||||
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 记号边界上(3 份真机样本一致):
|
||||
# chunk1 以 ',' 结尾;traj 是**一个**完整 JSON 值;chunk2 以 ',' 开头、以 '],' 结尾
|
||||
if not (segs[0].startswith(b'{"cd":[') and segs[0].endswith(b',')
|
||||
and segs[2].startswith(b',') and segs[2].endswith(b'],')
|
||||
and segs[3].startswith(b'"sd"')):
|
||||
continue
|
||||
try:
|
||||
json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(b"".join(segs).decode("latin1"))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if "cd" not in obj or "sd" not in obj:
|
||||
continue
|
||||
return (segs[0].decode("latin1"), segs[1].decode("latin1"),
|
||||
segs[2].decode("latin1"), segs[3].decode("latin1"))
|
||||
raise ValueError(
|
||||
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个);这份明文可能不是 4 段格式")
|
||||
|
||||
|
||||
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||||
@@ -331,38 +391,13 @@ _SUCCESS_TPL_PATH = Path(__file__).resolve().parent / "models" / "success_collec
|
||||
|
||||
def _success_style_trajectory(click_points: List[Tuple[int, int]],
|
||||
img_w: int = 672) -> str:
|
||||
"""
|
||||
按「成功签到样本」实测统计生成轨迹 (61 点, dt 均值 30.6ms):
|
||||
* 首点为绝对坐标(含 300~600ms 初始停顿), 之后为增量
|
||||
* 依次经过 3 个目标(验证码 iframe 360x360 显示坐标系), 沿途高频采样
|
||||
* dt: 50% 在 0~16ms(浏览器合并事件会出现 0), 25% 17~24, 15% 25~60,
|
||||
8% 60~160, 2% 160~400 —— 与成功样本的实测分布一致
|
||||
* 移动样本的增量可达 ±30px(快速换目标), 驻留时为 0/±1px
|
||||
"""
|
||||
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 = [[cur[0], cur[1], random.randint(300, 600)]]
|
||||
"""轨迹生成已统一到 `tcaptcha/tdc_values.trajectory_points()`(保留本函数兼容旧调用点)。
|
||||
|
||||
def dt():
|
||||
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()])
|
||||
return json.dumps(events, separators=(",", ":"))
|
||||
那里按真机样本把形状定死了:首点绝对坐标 + 增量点 + **固定收尾 `[1,1,12]`**。
|
||||
"""
|
||||
from tcaptcha.tdc_values import trajectory_points
|
||||
return json.dumps(trajectory_points(click_points, img_w=img_w),
|
||||
separators=(",", ":"))
|
||||
|
||||
|
||||
def generate_collect_success(
|
||||
@@ -435,10 +470,12 @@ def generate_telemetry(
|
||||
) -> Dict[str, str]:
|
||||
"""统一遥测生成入口 —— **只走纯算**, 不再有任何伪造环境的执行后端。
|
||||
|
||||
mode="python_keyed" (默认): Node 只做一件事 —— 从当前 tdc.js 提取本次构建的
|
||||
XTEA 密钥字 (密钥由 VM 运行时算出, 不是字面量); 明文组装 / 轨迹 / 时间戳 /
|
||||
加密 / 编码全部在 Python 内完成。Node 不可用时自动退回 DEFAULT_KEY。
|
||||
mode="python": 完全不调用 Node, 直接用内置 DEFAULT_KEY。
|
||||
mode="python_values" (推荐, 2026-10-12 新增): **纯离线**生成整颗 collect ——
|
||||
tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密。
|
||||
零 Node、零真机明文(密钥也由纯 Python 静态提取)。见 tcaptcha/tdc_values.py。
|
||||
mode="python_keyed": 明文仍来自仓库里的**成功样本模板回填**
|
||||
(generate_collect_success),Node 只负责从当前 tdc.js 提取 XTEA 密钥字。
|
||||
mode="python": 完全不调用 Node, 用内置 DEFAULT_KEY + 同样的模板回填。
|
||||
|
||||
返回 {"collect", "tlg", "eks", "source"[, "key"]}。
|
||||
|
||||
@@ -446,8 +483,15 @@ def generate_telemetry(
|
||||
换 IP / 加长静默同样无效), 见 har/1ms_dianxuan/ANALYSIS.md §7。**不要再用"换环境"解释它**,
|
||||
把精力放在账本 §7.5 的单变量实验 (登录态/真页面上下文) 上。
|
||||
"""
|
||||
if mode not in ("python_keyed", "python"):
|
||||
raise ValueError(f"未知 telemetry mode: {mode!r} (只支持 'python_keyed' / 'python')")
|
||||
if mode not in ("python_values", "python_keyed", "python"):
|
||||
raise ValueError(f"未知 telemetry mode: {mode!r} "
|
||||
"(只支持 'python_values' / 'python_keyed' / 'python')")
|
||||
|
||||
if mode == "python_values":
|
||||
from tcaptcha.tdc_values import generate_collect
|
||||
out = generate_collect(tdc_js_code, click_points, referrer=referrer)
|
||||
return {"collect": out["collect"], "tlg": out["tlg"], "eks": out["eks"],
|
||||
"source": out["source"], "key": out["key"]}
|
||||
|
||||
key: Optional[List[int]] = None
|
||||
source = "python"
|
||||
|
||||
Reference in New Issue
Block a user