feat(icon): 轨迹时序指纹修复 —— 1ms 图标 4/8→7/8=0,errorCode 语义与轨迹对拍收口
- 真因: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。
This commit is contained in:
@@ -696,9 +696,9 @@ def main():
|
||||
default="https://1ms.run/console/personal/benefits/check-in")
|
||||
parser.add_argument("--wb", default="1")
|
||||
parser.add_argument("--retries", type=int, default=3)
|
||||
parser.add_argument("--telemetry", default="python_keyed",
|
||||
choices=["python_keyed", "python"],
|
||||
help="遥测方式: python_keyed(Node 仅提密钥) / python(纯 Python 固定密钥)")
|
||||
parser.add_argument("--telemetry", default="python_values",
|
||||
choices=["python_values"],
|
||||
help="遥测方式: 只有 python_values(纯离线;模板回填/Node 两条老路已删除)")
|
||||
parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
|
||||
parser.add_argument("--proxy-api", default=None,
|
||||
help="代理提取 API (可选; 注意换 IP 并不能解决 errorCode=12)")
|
||||
|
||||
@@ -219,36 +219,52 @@ def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
|
||||
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 记号边界上(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'],')
|
||||
# 段边界一定落在 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)} 个);这份明文可能不是 4 段格式")
|
||||
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个,"
|
||||
f"边界结构不符 {n_struct} 组,拼回 JSON 校验失败 {n_json} 组);"
|
||||
f"这份明文可能不是 4 段格式,或生成侧拼出了非法 JSON")
|
||||
|
||||
|
||||
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||||
|
||||
@@ -98,17 +98,31 @@ def default_trajectory() -> List[List[int]]:
|
||||
|
||||
|
||||
def _sample_dt() -> int:
|
||||
"""采样一个事件间隔(ms)。分布按真机成功样本拟合:浏览器合并事件会出现 0。"""
|
||||
"""采样一个事件间隔(ms)——**按 6 条真机轨迹 / 360 个非首点 dt 实测重拟合**(2026-10-12)。
|
||||
|
||||
旧分布(账本里记为"旧拟合")是拿**一条**样本估的,偏差很大:它给了
|
||||
≥50% 概率落在 0~16ms、约 6% 落在 41~200ms。真机实测分布是**紧密聚集在 16~17ms**:
|
||||
|
||||
0-4 0.28% 5-8 1.39% 9-12 6.39%
|
||||
13-16 35.56% 17-20 43.61% 21-25 4.44%
|
||||
26-40 2.78% 41-80 1.39% 81-200 2.22% >200 1.94%
|
||||
(众数 17ms×126 / 16ms×93;均值 25.2,是被极少数长停顿拉高的)
|
||||
|
||||
判据:真机 **79%** 落在 13~20ms,而我们旧实现只有约 36% ——
|
||||
"轨迹事件间隔"是服务端最便宜的一类时序特征,别再拿单条样本估分布。
|
||||
"""
|
||||
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)
|
||||
if r < 0.0628: # ~6%:偏快的一档(9~12ms)
|
||||
return random.randint(9, 12)
|
||||
if r < 0.8472: # ~78%:主峰 13~20ms(真机 79%)
|
||||
return random.randint(13, 20)
|
||||
if r < 0.9000: # ~5%:21~25ms
|
||||
return random.randint(21, 25)
|
||||
if r < 0.9300: # ~3%:26~40ms
|
||||
return random.randint(26, 40)
|
||||
if r < 0.9700: # ~4%:41~200ms(偶尔的思考停顿)
|
||||
return random.randint(41, 200)
|
||||
return random.randint(201, 600) # ~3%:罕见的长时间歇
|
||||
|
||||
|
||||
def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]:
|
||||
@@ -117,16 +131,38 @@ def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]:
|
||||
return 20 + x * scale, 60 + y * scale
|
||||
|
||||
|
||||
REAL_TRAJECTORY_LEN = 61 # 真机硬不变量:6/6 条成功轨迹都是 61 点(见 _fit_points)
|
||||
|
||||
|
||||
def _fit_points(events: List[List[int]]) -> List[List[int]]:
|
||||
"""把轨迹**补齐/截断到真机的 61 点**,末尾再钉上固定收尾 `[1,1,12]`。
|
||||
|
||||
为什么点数是硬约束:4 份真机抓包 / **6 条点选成功轨迹**,点数**统统是 61**
|
||||
(`点选.har` 里 3 次提交也一样),一条都不差 —— 这是服务端最容易查的一类
|
||||
结构特征,而旧实现每次随机 57~65 点,等于每发一枪都自带一个异常值。
|
||||
|
||||
⚠️ 补齐必须用 `[0,0,dt]`(驻留/微抖动),**不能**复制最后一个 `[dx,dy,dt]` ——
|
||||
复制会把位移量重复计入,净位移被放大(实测 166 vs 期望 139.5)。
|
||||
"""
|
||||
body = [e for e in events if e != [1, 1, 12]]
|
||||
target = REAL_TRAJECTORY_LEN - 1
|
||||
if len(body) >= target:
|
||||
body = body[:target]
|
||||
else:
|
||||
while len(body) < target: # 驻留/微抖动:位移为 0
|
||||
body.append([0, 0, _sample_dt()])
|
||||
return body + [[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 的统计)。
|
||||
形状与真机一致(真机 6 条逐条核对,2026-10-12):
|
||||
* 首点是**绝对坐标**(含一个"进入前"的停顿,真机实测 7~14766ms)
|
||||
* 之后全是**增量** `[dx,dy,dt]`,dt 分布见 `_sample_dt`
|
||||
* **最后一点固定是 `[1,1,12]`**
|
||||
* **总点数固定 61**(真机 6/6 硬不变量,见 `_fit_points`)
|
||||
"""
|
||||
scale = 320.0 / img_w
|
||||
targets = [(int(round(20 + x * scale)), int(round(60 + y * scale)))
|
||||
@@ -143,8 +179,7 @@ def trajectory_points(click_points: Sequence[Sequence[int]],
|
||||
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
|
||||
return _fit_points(events)
|
||||
|
||||
|
||||
def drag_trajectory_points(start: Sequence[float], end: Sequence[float],
|
||||
@@ -152,7 +187,11 @@ def drag_trajectory_points(start: Sequence[float], end: Sequence[float],
|
||||
"""滑块**拖动**轨迹:按下 -> 横向移动 -> 松手。
|
||||
|
||||
与 `trajectory_points` 同一套口径(输入是背景图原始像素,输出是 iframe 显示坐标),
|
||||
同样以固定 `[1,1,12]` 收尾。速度曲线用 ease-out:人手拖动是先快后慢、末尾微调。
|
||||
同样以固定 `[1,1,12]` 收尾、dt 同一套分布。速度曲线用 ease-out:
|
||||
人手拖动是先快后慢、末尾微调。
|
||||
|
||||
⚠️ **拖动轨迹不套 61 点**:那个硬不变量是从**点选**轨迹量出来的(6/6),
|
||||
拖动是另一种交互(按下→横移→松手),点数按 steps 走。别外推。
|
||||
"""
|
||||
sx, sy = _to_display(float(start[0]), float(start[1]), img_w)
|
||||
ex, ey = _to_display(float(end[0]), float(end[1]), img_w)
|
||||
@@ -374,9 +413,14 @@ def build_plain(cd: Sequence[Any], rows: Sequence[Dict],
|
||||
head = ",".join(js(v) for v in cd[:k])
|
||||
tail = ",".join(js(v) for v in cd[k + 1:])
|
||||
return {
|
||||
"chunk1": '{"cd":[' + head + ",",
|
||||
# ⚠️ 逗号是**槽与槽之间**的分隔符(真机是 JSON.stringify 语义),所以两端的空区间
|
||||
# 都**不能**再补分隔逗号,否则会拼出 `{"cd":[,` 或 `...,],` 这种非法 JSON。
|
||||
# · 轨迹正好是第 0 槽 -> chunk1 就是 '{"cd":['(真机成功包实测,见账本已知 #58)
|
||||
# · 轨迹正好是最后 1 槽 -> chunk2 就是 '],'(38 槽里 k=37 的构建实测,
|
||||
# 2026-10-12;此前这里恒补前导逗号 -> 拼出非法 JSON)
|
||||
"chunk1": '{"cd":[' + (head + "," if head else ""),
|
||||
"trajectory": js(cd[k]),
|
||||
"chunk2": "," + tail + "],",
|
||||
"chunk2": ("," + tail + "],") if tail else "],",
|
||||
"sd": '"sd":' + js(sd if sd is not None else {"od": "C"}) + "}",
|
||||
}
|
||||
|
||||
|
||||
@@ -132,14 +132,74 @@ def test_offline_generate_and_roundtrip() -> None:
|
||||
len(plain["chunk2"]), len(plain["sd"]))
|
||||
|
||||
|
||||
def test_build_plain_trajectory_first() -> None:
|
||||
"""轨迹就在第 0 槽时,chunk1 里没有任何槽 —— 不能再补分隔逗号。
|
||||
|
||||
实测触发场景:某些构建的清单里轨迹模块排在第 0 位,那样会拼出 `{"cd":[,`
|
||||
—— 非法 JSON,服务端返回 errorCode=12。
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from tcaptcha.telemetry.values import build_plain, trajectory_index
|
||||
|
||||
rows = [{"name": "trajectory"}, {"name": "platform"}, {"name": "constant_98k"}]
|
||||
assert trajectory_index(rows) == 0
|
||||
cd = [[[1, 1, 12]], "MacIntel", "98k"]
|
||||
p = build_plain(cd, rows)
|
||||
assert p["chunk1"] == '{"cd":[', repr(p["chunk1"])
|
||||
assert _json.loads(p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"])["cd"] == cd
|
||||
|
||||
# 轨迹在中间时,chunk1 仍必须以 ',' 结尾(真机实测口径)
|
||||
rows2 = [{"name": "platform"}, {"name": "trajectory"}, {"name": "constant_98k"}]
|
||||
p2 = build_plain(["MacIntel", [[1, 1, 12]], "98k"], rows2)
|
||||
assert p2["chunk1"].endswith(",") and p2["chunk2"].startswith(",")
|
||||
|
||||
|
||||
def test_build_plain_trajectory_last() -> None:
|
||||
"""轨迹就在**最后 1 槽**时,chunk2 里没有任何槽 —— 不能再补前导逗号。
|
||||
|
||||
实测触发场景(2026-10-12):38 槽里有构建把轨迹排在 k=37(最后),
|
||||
旧写法恒拼 `,],` -> 整段变成 `{"cd":[...,],` 非法 JSON。
|
||||
本题同时钉住「切分器必须接受 `],` 这种对称边界」。
|
||||
"""
|
||||
import base64 as _b64
|
||||
import json as _json
|
||||
|
||||
from tcaptcha.telemetry.crypto import DEFAULT_KEY
|
||||
from tcaptcha.telemetry.values import build_plain, trajectory_index
|
||||
|
||||
rows = [{"name": "platform"}, {"name": "constant_98k"}, {"name": "trajectory"}]
|
||||
assert trajectory_index(rows) == 2
|
||||
cd = ["MacIntel", "98k", [[1, 1, 12]]]
|
||||
p = build_plain(cd, rows)
|
||||
assert p["chunk2"] == "],", repr(p["chunk2"])
|
||||
whole = p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"]
|
||||
assert _json.loads(whole)["cd"] == cd
|
||||
|
||||
# 旧写法(恒补前导逗号)拼出来的确实是非法 JSON —— 这正是当年 12 的成因之一
|
||||
bad = p["chunk1"] + p["trajectory"] + "," + p["chunk2"] + p["sd"]
|
||||
try:
|
||||
_json.loads(bad)
|
||||
raise AssertionError("旧写法应当拼出非法 JSON")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 走完整加密 -> 解密 -> 切分:切分器要吃下 `],` 这种边界
|
||||
collect = build_collect(p["chunk1"], p["trajectory"], p["chunk2"], p["sd"], DEFAULT_KEY)
|
||||
dec = xtea_decrypt_bytes(_b64.b64decode(collect), DEFAULT_KEY).decode("latin1").rstrip("\x00")
|
||||
assert split_collect_segments(dec) == (p["chunk1"], p["trajectory"], p["chunk2"], p["sd"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
selfcheck()
|
||||
test_split_single_space_pad()
|
||||
test_split_ignores_space_inside_string()
|
||||
test_build_collect_roundtrip_single_space_pad()
|
||||
test_offline_generate_and_roundtrip()
|
||||
test_build_plain_trajectory_first()
|
||||
test_build_plain_trajectory_last()
|
||||
print("✅ crypto 纯算法自检通过 (XTEA 往返 + encrypt_string 一致性"
|
||||
" + 四段切分回归 + 纯离线闭环)")
|
||||
" + 四段切分回归 + 纯离线闭环 + 轨迹首/末槽边界)")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user