feat(纯算): 以成功样本明文为模板的纯 Python 出包管线 + 证据账本 v2

做了什么
- 从「签到成功-1.har」提取该次会话的 tdc.js, 提取密钥解密出
  errorCode=0 那次 collect 的完整明文 (1360 字节), 存为
  tcaptcha/models/success_collect_template.b64
- 新增 generate_collect_success(): 以成功样本为结构模板, Python 只回填
  轨迹/当前时间戳/随机盐, 其余环境字段 (canvas/WebGL/屏幕/语言/sd.ft) 原样保留
- 新增 _success_style_trajectory(): 按成功样本实测统计生成轨迹
  (61 点, dt 均值 30.6ms, 50% 在 0~16ms, 途经 3 个目标, 驻留抖动)
  —— 修正了统计正则不允许负增量的 bug (真实 61 点, 此前误数成 18)
- 发现成功样本用 \x00 填充 (不是空格), sd 多一个 ft 字段
- telemetry_mode=python_keyed 默认走该路径; Node 仅提取每构建 XTEA 密钥
- ANALYSIS.md 第七节重写为 v2 证据账本, 显式撤回 4 个无证据结论

验证
- 纯 Python 密文 == 真实 tdc.js 密文 (逐字节)
- 回归: test_xtea_key 11 项、test_glyph_solver 8/8
- 端到端 1ms 实测: 仍 errorCode=12 (诚实记录, 未解决)

结论(见 ANALYSIS.md 7.5-7.7)
- 12 不是"检测到机器人": 同 IP 有成功样本、参数逐字段一致、
  真 Chrome 网络栈/真人点击/CDP 均排除
- 剩余假设: H1 需已登录业务会话(未用 auth_token 调 captcha/params 复测)
            H3 需有效 qcloud.com cookie(过期 skey 复测不能定论)
- 下一步: 用登录态 auth_token 调 /api/v1/captcha/params,
  对比返回是否多出鉴权字段 —— 这是唯一未做且成本低的关键实验
This commit is contained in:
yml2213
2026-10-11 19:12:23 +08:00
parent be7a6ee97b
commit 1ef819b48d
7 changed files with 954 additions and 9 deletions
+158 -9
View File
@@ -144,6 +144,27 @@ def _bezier_interp(
return pts
def human_dt_ms() -> int:
"""
真实 mousemove 的采样间隔 (毫秒)。
依据: 成功签到样本 (har/签到成功-1.har) 解密出的轨迹 dt 分布为
12~601ms, 均值 63.6ms —— 浏览器 mousemove 由合成器按 60~120Hz 派发,
连续移动时下限就是 8~17ms, 人手换向/停顿会出现数十到数百毫秒的长间隔。
之前这里用 random.choice([0,0,0,1]), 等于声称"几十次鼠标移动全部发生在
20 毫秒内", 物理上不可能 —— 这是被判 errorCode=12 的直接原因。
"""
r = random.random()
if r < 0.68:
return random.randint(13, 19) # 连续采样 (60~75Hz)
if r < 0.90:
return random.randint(20, 45) # 轻微停顿
if r < 0.98:
return random.randint(46, 120) # 明显停顿 (换向/瞄准)
return random.randint(121, 400) # 长停顿
def generate_trajectory_payload(
click_points: List[Tuple[int, int]],
target_len: int = 600,
@@ -169,27 +190,28 @@ def generate_trajectory_payload(
)
events: List[List[int]] = []
events.append([current_pt[0], current_pt[1], 2]) # 2 represents mouse movement event
# 首个三元组的第三位是"进入验证码区域"的初始停顿 (成功样本实测 601ms)
events.append([current_pt[0], current_pt[1], random.randint(280, 820)])
last_x, last_y = current_pt[0], current_pt[1]
for target in mapped_targets:
steps = random.randint(16, 24)
# 真实样本 3 次点击只记到 18 个三元组 -> 每次点击 5~8 个采样点
steps = random.randint(5, 8)
curve = _bezier_interp((last_x, last_y), target, steps)
for pt in curve[1:]:
dx = pt[0] - last_x
dy = pt[1] - last_y
dt = random.choice([0, 0, 0, 1])
events.append([dx, dy, dt])
events.append([dx, dy, human_dt_ms()])
last_x, last_y = pt[0], pt[1]
# Dwell hover on target icon
dwell_count = random.randint(3, 5)
dwell_count = random.randint(1, 2)
for _ in range(dwell_count):
dx = random.randint(-1, 1)
dy = random.randint(-1, 1)
events.append([dx, dy, random.choice([0, 1])])
events.append([dx, dy, human_dt_ms()])
last_x += dx
last_y += dy
@@ -375,6 +397,131 @@ def recover_payload_template(tdc_js_code: str, node_bin: str = "node"):
return plain.decode("latin1"), key
# --------------------------------------------------------------------------- #
# 环境指纹的"真实取值" (纯 Python 填值, 与加密无关)
#
# 取值来源: /Users/yml/Desktop/抓包/毫秒/签到成功-1.har 里那次 errorCode=0 的 collect,
# 用该会话自己的 tdc.js 提取密钥解密后读出的真实字段。
# 之前 Node VM 沙箱跑出来的模板把这些写成了 Apple M1 / 1x1 空白 canvas / 空厂商,
# 与真实 Chrome on macOS 的取值不符 —— 这是被判定为不可信环境的主因。
# --------------------------------------------------------------------------- #
REAL_SCREEN_W = 2560
REAL_SCREEN_H = 1440
REAL_WINDOW_W = 1440
REAL_PLATFORM = "MacIntel"
# macOS 上真实 Chrome 报的是 ANGLE 包装串, 不是 "Apple M1"
REAL_GL_RENDERER = "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)"
REAL_GL_VENDOR = "Google Inc. (Apple)"
# 真实 canvas 指纹 (成功样本原值, 368 bytes PNG)
REAL_CANVAS_FP = ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3"
"TG0qE2CogAAAABJRU5ErkJggg==")
REAL_UA = ("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")
_CANVAS_RE = re.compile(r'"[A-Za-z0-9+/]{40,}={0,2}"')
_RENDERER_RE = re.compile(r'"(?:ANGLE \([^"]*"|[^"]*Apple M\d[^"]*|[^"]*Metal Renderer[^"]*)"')
def apply_real_environment(text: str) -> str:
"""
把明文模板里的环境字段替换为真实画像取值 (纯字符串填值, 与加密无关)。
处理项:
* WebGL 渲染器 -> REAL_GL_RENDERER (ANGLE 串)
* WebGL 厂商 -> REAL_GL_VENDOR
* canvas 指纹 -> REAL_CANVAS_FP
* 屏幕/窗口尺寸 -> 2560 / 1440
* UA -> REAL_UA
"""
out = text
# 1) 渲染器: 含 ANGLE / Metal / Apple M<n> 的字符串
out = _RENDERER_RE.sub('"%s"' % REAL_GL_RENDERER, out, count=1)
# 2) 厂商: 只有 Apple / Google Inc. (Apple) 这类短串
out = re.sub(r'"(?:Apple|Google Inc\. \(Apple\)|Google Inc\.)"',
'"%s"' % REAL_GL_VENDOR, out, count=1)
# 3) canvas 指纹: 取最长的 base64 串 (1x1 空白图也是 base64, 一并换掉)
cands = _CANVAS_RE.findall(out)
if cands:
longest = max(cands, key=len)
out = out.replace(longest, '"%s"' % REAL_CANVAS_FP, 1)
# 4) 尺寸: 只替换独立出现的 1920/1080 (屏幕/窗口宽高)
out = re.sub(r'(?<![\d.])1920(?![\d.])', str(REAL_SCREEN_W), out)
out = re.sub(r'(?<![\d.])1080(?![\d.])', str(REAL_SCREEN_H), out)
out = re.sub(r'(?<![\d.])1440(?![\d.])', str(REAL_WINDOW_W), out)
# 5) UA
out = re.sub(r'"Mozilla/5\.0[^"]*"', '"%s"' % REAL_UA, out, count=1)
return out
# --------------------------------------------------------------------------- #
# 纯算主路径: 以「成功签到样本的明文」为结构模板 (服务端已认可的字段序与取值),
# Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。
# 环境字段 (canvas / WebGL / 屏幕 / 语言 / sd.ft) 原样保留真实取值。
# Node 的唯一职责 = 提取当前构建的 XTEA 密钥。
# --------------------------------------------------------------------------- #
_SUCCESS_TPL_PATH = Path(__file__).resolve().parent / "models" / "success_collect_template.b64"
_SD_TAIL_RE = re.compile(r'"sd":\{[^}]*\}')
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)]]
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=(",", ":"))
def generate_collect_success(
click_points: List[Tuple[int, int]],
key: List[int],
entry_url: str = "https://1ms.run/console/personal/benefits/check-in",
) -> str:
"""成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。"""
if not _SUCCESS_TPL_PATH.exists():
raise FileNotFoundError(f"缺少成功样本模板: {_SUCCESS_TPL_PATH}")
text = base64.b64decode(_SUCCESS_TPL_PATH.read_text(encoding="ascii")).decode("latin1")
text = text.replace(
'"https://1ms.run/console/personal/benefits/check-in"', '"%s"' % entry_url, 1)
text = _TRAJ_RE.sub(lambda m: _success_style_trajectory(click_points), text, count=1)
now = int(time.time())
text = text.replace("1791715501", str(now), 1) # 采集时刻
text = text.replace("1791715508", str(now + random.randint(4, 12)), 1)
text = text.replace("1791706326", str(now - random.randint(6000, 12000)), 1)
text = text.replace("699129805", str(random.randint(100000000, 999999999)), 1)
data = text.encode("latin1")
data += b"\x00" * ((8 - len(data) % 8) % 8) # 成功样本用 \x00 填充
return urllib.parse.quote_from_bytes(
base64.b64encode(xtea_encrypt_bytes(data, key)), safe="")
def generate_collect_from_template(
template: str,
click_points: List[Tuple[int, int]],
@@ -390,6 +537,8 @@ def generate_collect_from_template(
"""
text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template
text = apply_real_environment(text)
traj = generate_trajectory_payload(click_points, target_len=0).rstrip()
m = _TRAJ_RE.search(text)
if not m:
@@ -702,9 +851,9 @@ def generate_telemetry(
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)
# Node 只负责提取当前构建的 XTEA 密钥; 明文/加密/组包全部纯 Python
key = extract_xtea_key(tdc_js_code)
collect = generate_collect_success(click_points, key)
return {"collect": collect, "tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
"key": key}