白泽图

  • 文章
    • Unity渲染
    • Unity项目开发
    • 工具
    • 数学
    • 算法
    • 网站搭建
    • 网络&操作系统
个人知识库
互联网技术研究与经验总结
  1. 首页
  2. 网络&操作系统
  3. 正文

当 Cloudflare Turnstile 遇上 CDP:一次自动登录的完整踩坑实录

2026-09-24 9点热度 0人点赞 0条评论

2026-09-24 · 前端安全 · 逆向工程 · 自动化

名词解释

  • CDP(Chrome DevTools Protocol):Chrome 浏览器提供的一套 WebSocket 协议,允许外部程序通过代码控制浏览器——导航页面、执行 JavaScript、读取 DOM 等。你在 Chrome 里按 F12 打开的开发者工具,底层用的就是这套协议。通过 --remote-debugging-port 参数启动 Chrome 后,程序可以用 WebSocket 连接到浏览器,发送 JSON 格式的命令(如 Page.navigate、Runtime.evaluate),并接收响应。
  • Turnstile:Cloudflare 提供的人机验证服务,类似 reCAPTCHA 但对用户更无感。它有 visible 模式(显示一个验证框)和 invisible 模式(页面加载后自动在后台完成验证,用户无感知)。验证通过后会生成一个 token,写入页面的隐藏 中,后端登录接口校验这个 token。
  • SPA(Single Page Application):单页应用。页面 HTML 只加载一次,后续所有交互都由 JavaScript 动态渲染。站点 B 的前端就是一个 SPA bundle(约 191KB 的 JS 文件),配置信息通过 HTML 内联的 window.APP_CONFIG 注入。
  • Headless 模式:Chrome 浏览器的无界面运行模式(--headless=new 参数),程序后台运行 Chrome 但不显示任何浏览器窗口。优点是不占用屏幕、适合服务器环境;缺点是缺少真实的窗口句柄和 GPU 渲染管线,容易被自动化检测识别。本文中 headless 模式下 Turnstile 拒绝生成 token,切换到非 headless + 窗口移到屏幕外后恢复正常。

背景

手头有个自动签到工具,管着两个站点的账号。站点 A 一直跑得好好的——验证码 API 返回 provider=none,直接邮箱+密码登录就完事。但站点 B 卡住了:它用了 Cloudflare Turnstile 人机验证。

问题很简单:token 过期了,怎么自动重新登录?


环境前提

  • Windows 10/11,已安装 Google Chrome
  • Python 3.10+,安装依赖:pip install websocket-client
  • 有目标站点的邮箱和密码

踩坑过程

第一次尝试:直接调 API(天真版)

POST https://example.com/api/v1/auth/login
{"email": "...", "password": "..."}

返回:

{"code": 400, "message": "turnstile verification failed", "reason": "TURNSTILE_VERIFICATION_FAILED"}

好的,后端确实校验 Turnstile token。那我加个 turnstile_token 字段传个假值呢?

for field in ['turnstile_token', 'cf_turnstile_response', 'turnstile', 'captcha_token', ...]:
    # 全部 400,TURNSTILE_VERIFICATION_FAILED

结论:必须拿到真实的 Turnstile token。

第二次尝试:Chrome Headless + CDP

既然需要真实 token,那就用浏览器渲染页面,等 Turnstile 自己生成。

proc = subprocess.Popen([
    CHROME, "--headless=new", "--disable-gpu",
    f"--remote-debugging-port={CDP_PORT}", ...
])

等了 30 秒,结果:

{
  "turnstileInput": "empty",
  "hasIframe": false,
  "turnstileDivs": 2
}

Turnstile 的 div 容器渲染了,但 iframe 没出来,token 更是没有。当时第一反应:CDP 被检测了。

第三次尝试:非 Headless Chrome + CDP

把 --headless=new 去掉,改用 --window-position=-32000,-32000 把窗口移到屏幕外。

proc = subprocess.Popen([
    CHROME,
    "--disable-gpu",
    "--window-position=-32000,-32000",
    "--window-size=1280,800",
    f"--remote-debugging-port={CDP_PORT}",
    ...
])

每 10 秒检查一次,30 秒内检查了三次:

// 第 10 秒
{ "webdriver": false, "turnstileInput": "empty", "hasIframe": false }
// 第 20 秒
{ "webdriver": false, "turnstileInput": "empty", "hasIframe": false }
// 第 30 秒
{ "webdriver": false, "turnstileInput": "empty", "hasIframe": false }

还是不行。iframe 压根没创建。当时我确信非 headless + CDP 也不行。

但这个结论是错的。 原因见后文。

第四次尝试:反检测补丁

在页面加载前注入经典的反检测脚本:

// 删除 navigator.webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });

// 伪造 chrome.runtime
window.chrome = { runtime: {}, app: {}, csi: () => {} };

// 伪造 plugins
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });

// WebGL 指纹
WebGLRenderingContext.prototype.getParameter = function(p) {
    if (p === 37445) return 'Intel Inc.';
    if (p === 37446) return 'Intel(R) Iris(TM) Plus Graphics';
    return getParameter.call(this, p);
};

通过 CDP 的 Page.addScriptToEvaluateOnNewDocument 注入。结果?完全无效。 Turnstile 连 iframe 都懒得创建。

第五次尝试:Chrome 扩展(Content Script)

换个思路:不用 CDP,用 Chrome 扩展的 content script 来操作页面。扩展不受页面 CSP 限制——等等,不对,content script 的 fetch 受页面 CSP 约束。

看了一下目标站点的 CSP:

connect-src 'self' https://turing.captcha.qcloud.com ... https:;

https: 允许所有 HTTPS 请求,但我的本地服务器是 http://127.0.0.1:17890,被 CSP 拦了。

改用 background service worker 做网络请求?可以,background 不受页面 CSP 限制。但扩展压根没触发——content script 没有任何 HTTP 请求到达本地服务器。

第六次尝试:仔细查看 SPA 源码

下载 SPA 的 JS bundle(191KB),搜索 turnstile:

turnstile_enabled: !1,  // false
turnstile_site_key: ""

等等,Turnstile 在客户端是关闭的?!

再去看 HTML 源码中的内联配置:

<script nonce="...">
window.__APP_CONFIG__ = {
  ...
  "turnstile_enabled": true,
  "turnstile_site_key": "0x4AAAAAAA...(已脱敏)",
  ...
}
</script>

服务端返回的配置里 Turnstile 是开启的! SPA bundle 里的 !1 只是 fallback 默认值,实际配置由 window.__APP_CONFIG__ 覆盖。

第七次尝试:CDP + 高频轮询

回过头来想:第三次尝试和这次用的配置完全一样——非 headless Chrome + CDP + 窗口移到屏幕外。区别在于检查方式。

第三次每 10 秒检查一次,而这次每 2 秒轮询一次:

while time.time() - start < MAX_WAIT:
    time.sleep(2)  # ← 关键:2 秒一次,不是 10 秒
    js = "document.querySelector('[name=cf-turnstile-response]')?.value || ''"
    result = cdp_send(ws, 10, "Runtime.evaluate", {
        "expression": js, "returnByValue": True
    })
    token = result["result"]["result"]["value"]
    if len(token) > 10:
        break

结果:

[2s]  tokenLen=0     ← 页面还没加载完
[4s]  tokenLen=0     ← Turnstile 脚本刚开始加载
[6s]  tokenLen=0     ← Turnstile 在后台验证中
[8s]  tokenLen=0     ← 验证即将完成
[10s] tokenLen=730   ← token 生成!

Turnstile token 生成成功了!730 字符!

为什么第三次失败、第七次成功?

两件事同时变了:

  1. 轮询间隔从 10s 改成 2s。 第三次在 10s/20s/30s 三个时间点检查,但 Turnstile token 是在页面加载后约 8-10 秒生成的——如果刚好在 token 生成之前检查(比如第 8 秒),看到的就是空值。而 2 秒间隔能把生成瞬间捕捉到。更关键的是,第三次的检查代码先查 iframe 再查 input,iframe 始终不存在(invisible 模式不创建 iframe),导致检查逻辑提前返回了"无 token"。

  2. 检查逻辑从"先查 iframe"改成"直接查 hidden input 的 value"。 Turnstile invisible 模式不创建 iframe,它直接在后台完成验证并把 token 写入 <input name="cf-turnstile-response">。第三次的代码先检查 hasIframe,发现为 false 就认为 Turnstile 没工作,实际上 token 可能已经在隐藏 input 里了。

第三次(错误):
  hasIframe? → No → 判定 Turnstile 没工作 → 跳过 token 检查

第七次(正确):
  hidden input 有值? → 有 → 拿到 token

最终方案

CDP 通信基础

CDP(Chrome DevTools Protocol)通过 WebSocket 通信。完整流程是:

  1. 启动 Chrome 时加 --remote-debugging-port=9230
  2. GET http://127.0.0.1:9230/json 拿到所有标签页的 webSocketDebuggerUrl
  3. 用 WebSocket 连接这个 URL
  4. 发送 {"id": 1, "method": "Runtime.evaluate", "params": {...}} 格式的命令
  5. 接收 {"id": 1, "result": {...}} 格式的响应

封装代码:

import json
import urllib.request
import websocket

def get_cdp_tab(port):
    """获取一个可用的 CDP 标签页,返回其 WebSocket URL"""
    tabs = json.loads(urllib.request.urlopen(
        f"http://127.0.0.1:{port}/json", timeout=5).read())
    # 找已有页面标签页,没有就新建一个
    page_tabs = [t for t in tabs if t.get("type") == "page"]
    if page_tabs:
        return page_tabs[0]
    # 新建标签页
    return json.loads(urllib.request.urlopen(urllib.request.Request(
        f"http://127.0.0.1:{port}/json/new?about:blank", method="PUT"),
        timeout=10).read())

def cdp_send(ws, msg_id, method, params=None):
    """发送 CDP 命令并等待对应 id 的响应。

    CDP 会持续推送事件消息(没有 id 字段或 id 不匹配),
    必须跳过它们,只返回匹配 id 的响应。
    """
    ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
    while True:
        msg = json.loads(ws.recv())
        if msg.get("id") == msg_id:
            return msg
        # 跳过事件消息(Page.frameNavigated、Runtime.executionContextCreated 等)

完整登录脚本

"""auto_login_cdp.py — CDP 自动登录流程"""
import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.error
import websocket

sys.stdout.reconfigure(encoding="utf-8", errors="replace")

CDP_PORT = 9230
CHROME = r"C:Program FilesGoogleChromeApplicationchrome.exe"
PROFILE = os.path.join(os.environ["LOCALAPPDATA"], "chrome-autologin")
LOGIN_URL = "https://example.com/login"
API_URL = "https://example.com/api/v1/auth/login"
MAX_TURNSTILE_WAIT = 45  # 秒

email = sys.argv[1]
password = sys.argv[2]

# ─── 1. 启动 Chrome ──────────────────────────────────────

import shutil
if os.path.isdir(PROFILE):
    shutil.rmtree(PROFILE, ignore_errors=True)
os.makedirs(PROFILE, exist_ok=True)

proc = subprocess.Popen([
    CHROME, "--no-first-run", "--no-default-browser-check",
    f"--remote-debugging-port={CDP_PORT}", "--remote-allow-origins=*",
    f"--user-data-dir={PROFILE}",
    "--window-position=-32000,-32000", "--window-size=1280,800",
    "about:blank",
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

# 等待 CDP 就绪
for _ in range(15):
    try:
        urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json/version", timeout=3)
        break
    except Exception:
        time.sleep(1)

# ─── 2. 连接 CDP,导航到登录页 ──────────────────────────

tab = get_cdp_tab(CDP_PORT)
ws = websocket.create_connection(tab["webSocketDebuggerUrl"], timeout=30, suppress_origin=True)

cdp_send(ws, 1, "Page.enable")
cdp_send(ws, 2, "Runtime.enable")
cdp_send(ws, 3, "Page.navigate", {"url": LOGIN_URL})

# ─── 3. 轮询等待 Turnstile token ────────────────────────

print("等待 Turnstile token...", file=sys.stderr)
token = ""
start = time.time()

while time.time() - start < MAX_TURNSTILE_WAIT:
    time.sleep(2)
    # 直接查 hidden input 的 value,不查 iframe
    js = "document.querySelector('[name=cf-turnstile-response]')?.value || ''"
    result = cdp_send(ws, 10, "Runtime.evaluate", {
        "expression": js, "returnByValue": True,
    })
    token = result["result"]["result"]["value"]
    elapsed = int(time.time() - start)
    print(f"  [{elapsed}s] tokenLen={len(token)}", file=sys.stderr)
    if len(token) > 10:
        break

ws.close()

if len(token) <= 10:
    print("FAIL: Turnstile token 未生成", file=sys.stderr)
    proc.terminate()
    sys.exit(1)

print(f"Turnstile token 获取成功 (len={len(token)})", file=sys.stderr)

# ─── 4. 调用登录 API ────────────────────────────────────

body = json.dumps({
    "email": email,
    "password": password,
    "turnstile_token": token,
}).encode("utf-8")

req = urllib.request.Request(API_URL, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0")

try:
    with urllib.request.urlopen(req, data=body, timeout=20) as r:
        resp = json.loads(r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
    resp = json.loads(e.read().decode("utf-8", "replace"))
    print(f"FAIL: {e.code} {json.dumps(resp, ensure_ascii=False)[:300]}", file=sys.stderr)
    proc.terminate()
    sys.exit(1)

data = resp.get("data") or {}
access_token = data.get("access_token", "")
refresh_token = data.get("refresh_token", "")

if not access_token:
    print(f"FAIL: {json.dumps(resp, ensure_ascii=False)[:300]}", file=sys.stderr)
    proc.terminate()
    sys.exit(1)

print(f"LOGIN: {email}")
print(f"ACCESS_TOKEN: {access_token}")
print(f"REFRESH_TOKEN: {refresh_token}")

proc.terminate()

流程图

启动 Chrome (CDP, 窗口移到屏幕外)
      │
      ▼
GET http://127.0.0.1:9230/json → 获取 webSocketDebuggerUrl
      │
      ▼
WebSocket 连接 → Page.navigate 到登录页
      │
      ▼
每 2 秒:Runtime.evaluate 读取 hidden input 的 value
      │                    ↑
      │          页面 JS 自动加载 Turnstile 脚本
      │          Turnstile invisible 模式后台验证
      │
      ▼  (~10 秒后)
token 长度 > 10 → 拿到 Turnstile token
      │
      ▼
POST /auth/login { email, password, turnstile_token }
      │
      ▼
拿到 access_token + refresh_token → 保存 → 继续签到

关键经验

1. Turnstile invisible 模式不创建 iframe

这是最关键的认知偏差。传统 Turnstile widget 会创建一个 iframe,invisible 模式则不创建 iframe,直接在后台完成验证并把 token 写入隐藏 input。

// ❌ 错误:先查 iframe,不存在就认为 Turnstile 没工作
if (document.querySelector("iframe[src*=challenges]")) { ... }

// ✅ 正确:直接查隐藏 input 的 value
var token = document.querySelector('[name=cf-turnstile-response]').value;
if (token.length > 10) { /* 拿到了 */ }

2. 轮询间隔很重要

Turnstile token 的生成时刻不确定(约 8-12 秒),轮询间隔太大会错过"刚刚生成"的窗口。2 秒间隔是经验值——足够精细,又不会给 CDP 造成负担。

3. Chrome 非 headless + CDP 不阻止 Turnstile

Cloudflare 官方文档说 Turnstile 会检测自动化环境。但实际测试发现,Chrome 非 headless 模式 + CDP,Turnstile invisible 模式依然能正常生成 token。

Headless 模式下 Turnstile 确实不工作,但只要有一个真实的窗口(即使移到屏幕外),token 就能正常生成。不需要 navigator.webdriver 反检测补丁。

4. SPA 的配置可能在服务端

SPA bundle 里的默认值 turnstile_enabled: false 只是 fallback。实际配置在 HTML 内联的 window.__APP_CONFIG__ 中,由服务端动态返回。永远不要只看客户端 bundle 的默认值。

5. CDP 通信要处理事件消息

CDP WebSocket 连接后会持续收到事件推送(Page.frameNavigated、Runtime.executionContextCreated 等),这些消息没有 id 字段或 id 不匹配。必须跳过它们,只返回匹配 id 的响应:

def cdp_send(ws, msg_id, method, params=None):
    ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
    while True:
        msg = json.loads(ws.recv())
        if msg.get("id") == msg_id:   # 只返回匹配的
            return msg
        # 跳过事件消息

6. 最简单的方案往往最有效

最后成功的方法极其简单:
1. 开 Chrome + CDP
2. 导航到登录页
3. 每 2 秒查 hidden input
4. 等 10 秒拿到 token
5. 调 API

不需要反检测补丁,不需要 Chrome 扩展,不需要 LevelDB 解析,不需要操作 Cookie 数据库。


反思

这个问题的本质是两个认知偏差叠加:

  1. "Turnstile 一定需要 iframe" —— 错。invisible 模式不需要。
  2. "CDP 被 Turnstile 检测了" —— 错。非 headless 模式下 CDP 不阻止 token 生成。

第一个偏差导致检查逻辑写错(查 iframe 而不是查 input),第二个偏差导致过早放弃了 CDP 方案,浪费了大量时间在 Chrome 扩展、反检测补丁等错误方向上。

如果一开始就只查 hidden input 的 value、用 2 秒间隔轮询,这个问题在第二次尝试(headless 失败后)就能解决。


本文仅记录技术探索过程,不构成对任何服务的破解指南。Turnstile 的行为可能随版本更新而变化。

标签: 暂无
最后更新:2026-09-24

蒋程

这个人很懒,什么都没留下

点赞
< 上一篇
下一篇 >

文章评论

您需要 登录 之后才可以评论
Table of ContentsToggle Table of ContentToggle
  • 名词解释
  • 背景
  • 环境前提
  • 踩坑过程
    • 第一次尝试:直接调 API(天真版)
    • 第二次尝试:Chrome Headless + CDP
    • 第三次尝试:非 Headless Chrome + CDP
    • 第四次尝试:反检测补丁
    • 第五次尝试:Chrome 扩展(Content Script)
    • 第六次尝试:仔细查看 SPA 源码
    • 第七次尝试:CDP + 高频轮询
      • 为什么第三次失败、第七次成功?
  • 最终方案
    • CDP 通信基础
    • 完整登录脚本
    • 流程图
  • 关键经验
    • 1. Turnstile invisible 模式不创建 iframe
    • 2. 轮询间隔很重要
    • 3. Chrome 非 headless + CDP 不阻止 Turnstile
    • 4. SPA 的配置可能在服务端
    • 5. CDP 通信要处理事件消息
    • 6. 最简单的方案往往最有效
  • 反思

COPYRIGHT © 2026 白泽图. ALL RIGHTS RESERVED.

京ICP备2026064017号-1

登录
注册|忘记密码?