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 字符!
为什么第三次失败、第七次成功?
两件事同时变了:
-
轮询间隔从 10s 改成 2s。 第三次在 10s/20s/30s 三个时间点检查,但 Turnstile token 是在页面加载后约 8-10 秒生成的——如果刚好在 token 生成之前检查(比如第 8 秒),看到的就是空值。而 2 秒间隔能把生成瞬间捕捉到。更关键的是,第三次的检查代码先查 iframe 再查 input,iframe 始终不存在(invisible 模式不创建 iframe),导致检查逻辑提前返回了"无 token"。
-
检查逻辑从"先查 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 通信。完整流程是:
- 启动 Chrome 时加
--remote-debugging-port=9230 GET http://127.0.0.1:9230/json拿到所有标签页的webSocketDebuggerUrl- 用 WebSocket 连接这个 URL
- 发送
{"id": 1, "method": "Runtime.evaluate", "params": {...}}格式的命令 - 接收
{"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 数据库。
反思
这个问题的本质是两个认知偏差叠加:
- "Turnstile 一定需要 iframe" —— 错。invisible 模式不需要。
- "CDP 被 Turnstile 检测了" —— 错。非 headless 模式下 CDP 不阻止 token 生成。
第一个偏差导致检查逻辑写错(查 iframe 而不是查 input),第二个偏差导致过早放弃了 CDP 方案,浪费了大量时间在 Chrome 扩展、反检测补丁等错误方向上。
如果一开始就只查 hidden input 的 value、用 2 秒间隔轮询,这个问题在第二次尝试(headless 失败后)就能解决。
本文仅记录技术探索过程,不构成对任何服务的破解指南。Turnstile 的行为可能随版本更新而变化。
文章评论