Home
avatar

nax

深入理解 Cloudflare 过盾原理:无需 AI,从零实现 CF Turnstile 绕过

本文深入分析 CF-Gateway-Pro 的核心实现原理,从 Cloudflare Turnstile 的检测机制讲起,逐步拆解”反检测→点击验证→Cookie 复用→智能降级”的完整技术链路。读完你可以自己从零写一个。

如果你对 AI 驱动的通用验证码求解方案感兴趣,推荐阅读姊妹篇:《用 AI 从零实现通用验证码求解服务:OhMyCaptcha 原理剖析

深入理解 Cloudflare 过盾原理:无需 AI,从零实现 CF Turnstile 绕过


Cloudflare Turnstile 到底在检测什么?

在写代码之前,我们必须先搞清楚对手。Turnstile 和传统验证码(reCAPTCHA 的”请选择红绿灯”)完全不同,它几乎不依赖视觉挑战,而是靠检测你的浏览器环境来判断你是不是机器人。

Turnstile 的检测维度主要包括:

1. 自动化工具特征

// Cloudflare 会检测这些属性
navigator.webdriver          // Selenium/Playwright 会设为 true
window.cdc_adoQpoasnfa76pfcZLmcfl_Array  // ChromeDriver 特有属性
window.__playwright          // Playwright 特有属性
navigator.plugins.length     // 自动化浏览器通常为 0

普通 Chrome 打开一个页面,navigator.webdriverundefined。但如果你用 Selenium 或 Playwright 控制浏览器,这个值会自动变成 true——这就是最简单的检测点。

2. TLS 指纹

每个 HTTP 客户端在建立 TLS 连接时,都会发送一个 Client Hello 消息。这个消息里包含了支持的密码套件、扩展列表、椭圆曲线等信息。不同的客户端(Chrome、Firefox、Python requests、curl)产生的 TLS 指纹完全不同。

真实 Chrome 136 的 JA3 指纹: 771,4865-4866-4867-49195-49199-49196-49200-...
Python requests 的 JA3 指纹: 771,49196-49200-159-52393-52392-52394-49195-...

Cloudflare 会将你的 TLS 指纹和请求头中的 User-Agent 进行交叉验证——如果你 UA 声称是 Chrome,但 TLS 指纹是 Python,直接拒绝。

3. Canvas / WebGL 指纹

浏览器渲染同一个图形,不同硬件/驱动/系统环境产生的像素级差异是固定的。Cloudflare 用这个来:

  • 区分真实浏览器和 headless 浏览器
  • 跨会话追踪同一个用户

4. 行为特征

  • 页面加载后多久出现交互
  • 鼠标移动轨迹
  • 网络请求时序

理解了这些,我们就知道要从哪些方面”伪装”。


核心原理一:反检测脚本注入

这是过盾的第一步,也是最关键的一步。你需要在页面 JavaScript 执行之前,先注入一段脚本来”修补”浏览器的自动化特征。

隐藏 navigator.webdriver

// 必须在页面加载之前注入
Object.defineProperty(navigator, 'webdriver', {
    get: () => undefined,
    configurable: true
});

// 还要清理原型链上的痕迹
delete navigator.__proto__.webdriver;

这是最重要的一行。没有这个,基本上所有 CF 盾都过不了。

伪造 navigator.plugins

自动化浏览器的 navigator.plugins 通常是空的,但真实 Chrome 至少有 3 个内置插件:

const pluginData = [
    { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
    { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
    { name: 'Native Client', filename: 'internal-nacl-plugin' }
];

// 构造一个完整的 PluginArray 对象
const pluginArray = {
    length: pluginData.length,
    item: function(index) { return this[index] || null; },
    namedItem: function(name) { /* ... */ },
    refresh: function() {}
};

pluginData.forEach((p, i) => { pluginArray[i] = { ...p }; });

Object.defineProperty(navigator, 'plugins', {
    get: () => pluginArray,
    configurable: true
});

修复 window.chrome

// 真实 Chrome 有 window.chrome.runtime 对象
// 自动化浏览器可能没有或不完整
if (!window.chrome) {
    window.chrome = {};
}
if (!window.chrome.runtime) {
    window.chrome.runtime = {
        connect: function() {},
        sendMessage: function() {},
        onMessage: { addListener: function() {} },
        PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', ... },
        // ... 完整的属性模拟
    };
}

清除 ChromeDriver 特征

// ChromeDriver 会在 window 上挂载 cdc_ 开头的属性
const cdcProps = Object.keys(window).filter(
    k => k.startsWith('cdc_') || k.startsWith('$cdc_')
);
cdcProps.forEach(prop => delete window[prop]);

防止 toString 检测

这是一个很狡猾的检测手段——检测方会调用你重写的函数的 toString(),如果返回的不是 [native code],就知道被篡改了:

const nativeToString = Function.prototype.toString;
const customFunctions = new WeakSet();

Function.prototype.toString = function() {
    if (customFunctions.has(this)) {
        // 返回伪造的 native code 格式
        return 'function ' + this.name + '() { [native code] }';
    }
    return nativeToString.call(this);
};

customFunctions.add(Function.prototype.toString);

伪造网络连接信息

Headless 浏览器可能没有 navigator.connection

if (!navigator.connection) {
    Object.defineProperty(navigator, 'connection', {
        get: () => ({
            effectiveType: '4g',
            rtt: 50,
            downlink: 10,
            saveData: false
        }),
        configurable: true
    });
}

核心原理二:Canvas / WebGL 指纹随机化

直接修改 Canvas/WebGL 的全部输出会破坏页面渲染(包括 Turnstile 自己的动画)。关键技巧是只在指纹采集时注入噪声,正常渲染不干扰

智能判断是否是指纹采集

function isFingerprinting(canvas) {
    // 指纹检测通常使用小尺寸 Canvas(<= 300x300)
    if (canvas.width <= 300 && canvas.height <= 300) {
        return true;
    }
    // 检查是否有非空像素(说明有渲染内容,可能在做指纹)
    const ctx = canvas.getContext('2d');
    const imageData = ctx.getImageData(0, 0, 16, 16);
    for (let i = 0; i < imageData.data.length; i += 4) {
        if (imageData.data[i] !== 0) return true;
    }
    return false;
}

只在 toDataURL 时加噪声

const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;

HTMLCanvasElement.prototype.toDataURL = function(type, quality) {
    if (isFingerprinting(this)) {
        const ctx = this.getContext('2d');
        const imageData = ctx.getImageData(0, 0, this.width, this.height);
        // 每个像素加微小噪声(±1 以内)
        for (let i = 0; i < imageData.data.length; i += 4) {
            const noise = seededRandom(noiseSeed + i) * 2 - 1;
            imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + noise));
        }
        ctx.putImageData(imageData, 0, 0);
    }
    return originalToDataURL.call(this, type, quality);
};

WebGL 渲染器伪造

// WebGL 参数 37446 = UNMASKED_RENDERER_WEBGL
// 检测方用这个区分真机和虚拟机/headless
const renderers = [
    'ANGLE (NVIDIA, NVIDIA GeForce GTX 1080 Direct3D11 vs_5_0 ps_5_0)',
    'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0)',
    'ANGLE (AMD, AMD Radeon RX 580 Direct3D11 vs_5_0 ps_5_0)',
];

WebGLRenderingContext.prototype.getParameter = new Proxy(originalGetParameter, {
    apply: function(target, thisArg, args) {
        if (args[0] === 37446) {
            return renderers[noiseSeed % renderers.length];
        }
        return Reflect.apply(target, thisArg, args);
    }
});

WebRTC 泄露防护

使用代理时,WebRTC 可能泄露真实 IP:

if (typeof RTCPeerConnection !== 'undefined') {
    RTCPeerConnection.prototype.createDataChannel = function() { return null; };
    RTCPeerConnection.prototype.createOffer = function() {
        return Promise.reject('WebRTC disabled');
    };
}

核心原理三:Turnstile 验证码点击

反检测做好后,下一步是和 Turnstile 交互。这里选择 DrissionPage 而非 Playwright / Selenium 是有原因的——DrissionPage 底层基于 CDP (Chrome DevTools Protocol) 直接操作真实 Chrome,而不是像 Playwright 那样用修改过的 Chromium,TLS 指纹天然真实。

Turnstile 的 DOM 结构

Turnstile 的 checkbox 藏在多层 Shadow DOM 里:

<div>  ← 外层容器
  └── <input name="cf-turnstile-response">  ← 隐藏的 response 字段
  └── #shadow-root
       └── <iframe>  ← Cloudflare 的 iframe
            └── <body>
                 └── #shadow-root
                      └── <input>  ← 真正的 checkbox!

普通的 document.querySelector 无法穿透 Shadow DOM。DrissionPage 的 .shadow_root API 可以做到:

# 1. 找到 turnstile 响应字段
box = page.ele("@name=cf-turnstile-response", timeout=0.5)

# 2. 向上找到父容器,进入 shadow root
wrapper = box.parent()
iframe = wrapper.shadow_root.ele("tag:iframe")

# 3. 进入 iframe 的 shadow root,找到真正的 checkbox
cb = iframe.ele("tag:body").shadow_root.ele("tag:input")

# 4. 点击!
cb.click()

完整的过盾循环

def solve_turnstile(url, proxy=None):
    instance = browser_pool.acquire(timeout=60, proxy=proxy)
    page = instance.page

    page.get(url)

    start_time = time.time()
    click_count = 0
    last_click_time = 0

    while time.time() - start_time < 30:  # 30秒超时
        title = page.title.lower()

        # 尝试找到并点击验证码
        try:
            box = page.ele("@name=cf-turnstile-response", timeout=0.5)
            if box:
                wrapper = box.parent()
                iframe = wrapper.shadow_root.ele("tag:iframe")
                cb = iframe.ele("tag:body").shadow_root.ele("tag:input")
                # 避免频繁点击,至少间隔 1.5 秒
                if cb and (time.time() - last_click_time) > 1.5:
                    click_count += 1
                    cb.click()
                    last_click_time = time.time()
        except Exception:
            pass

        # 判断是否过盾成功:标题变了 + 验证码消失了
        if "just a moment" not in title and "cloudflare" not in title:
            # 双重确认:确保验证码元素也消失了
            try:
                still_has = page.ele("@name=cf-turnstile-response", timeout=0.3)
                if still_has:
                    continue  # 标题变了但验证码还在,继续等
            except Exception:
                pass  # 找不到验证码元素 = 真的过盾了

            time.sleep(1)  # 等 cf_clearance Cookie 写入
            break

    # 提取凭证
    cookies = parse_cookies(page.cookies())
    ua = page.user_agent

    return {"cookies": cookies, "ua": ua}

关键细节:

  1. 多次点击支持:某些站点需要多次点击才能通过
  2. 点击间隔控制:至少 1.5 秒,避免被判定为机器人
  3. 双重成功判断:标题变化 + 验证码元素消失,防止误判
  4. 等待 Cookie:过盾成功后 cf_clearance Cookie 不是立即写入的,需要等 1 秒

核心原理四:Cookie 复用(性能关键)

每次都启动浏览器过盾太慢了(5-15 秒),而且资源消耗大。核心优化是——过一次盾,凭证复用 30 分钟

过盾后返回两样东西:

  • cf_clearance Cookie:Cloudflare 的通行证
  • User-Agent:必须和过盾时一致
# 凭证缓存结构
{
    "cookies": {"cf_clearance": "xxx", "__cf_bm": "yyy", ...},
    "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..."
}

为什么必须用 curl_cffi 而不是 requests

这就回到 TLS 指纹的问题了。过盾时浏览器是 Chrome 136,Cloudflare 记录了对应的 TLS 指纹。如果你复用 Cookie 时用 Python requests 发请求,TLS 指纹变成了 Python 的,Cloudflare 发现指纹不匹配就会拒绝。

curl_cffi 可以模拟特定浏览器版本的 TLS 指纹:

from curl_cffi import requests as curl_requests

# impersonate="chrome136" 让 TLS 握手的行为完全模拟 Chrome 136
resp = curl_requests.get(
    url,
    cookies=cached_cookies,
    headers={"User-Agent": cached_ua},
    impersonate="chrome136"
)

这样 Cloudflare 看到的 TLS 指纹和 Cookie 对应的浏览器一致 → 放行。

缓存的存储

# SQLite 存储(本地模式)
CREATE TABLE credentials (
    domain TEXT PRIMARY KEY,      -- 域名作为 key
    cookies TEXT NOT NULL,        -- JSON 序列化的 cookie dict
    ua TEXT NOT NULL,             -- User-Agent 字符串
    expire_at REAL NOT NULL,      -- 过期时间戳
    created_at REAL NOT NULL
);

# 缓存查找逻辑
def get_credentials(url, force_refresh=False):
    domain = urlparse(url).netloc

    if not force_refresh:
        row = db.query("SELECT * FROM credentials WHERE domain = ? AND expire_at > ?",
                       domain, time.time())
        if row:
            return {"cookies": json.loads(row.cookies), "ua": row.ua}

    # 缓存未命中 → 启动浏览器过盾
    creds = solve_turnstile(url)
    db.upsert(domain, json.dumps(creds["cookies"]), creds["ua"],
              time.time() + 1800)  # 30分钟过期
    return creds

凭证自动刷新

后台看门狗每 5 分钟扫描一次,将即将过期(5 分钟内)的凭证提前刷新,避免请求时才发现 Cookie 失效:

async def watchdog_task():
    while True:
        await asyncio.sleep(300)  # 每5分钟

        # 找到即将过期的凭证
        expiring = cache.get_expiring_domains(threshold_seconds=300)
        for domain in expiring[:3]:  # 每次最多刷新3个
            cache.refresh_credential(domain)

核心原理五:智能降级策略

不是所有站点 Cookie 复用都能成功。有些站点会做额外检测(比如检查请求时序、验证 Cookie 对应的 session 是否有完整的浏览记录)。CF-Gateway-Pro 实现了一套自动降级机制。

两种请求模式

CookieFetcher (默认)          BrowserFetcher (备选)
    │                              │
    ├── 复用 Cookie + curl_cffi    ├── 浏览器直接加载页面
    ├── 速度快 (0.5-2s)           ├── 速度慢 (5-15s)
    ├── 资源消耗低                 ├── 资源消耗高
    └── 可能被拦截                 └── 几乎不会被拦截

拦截检测

def _is_response_blocked(resp):
    # 1. 检查状态码
    if resp.status_code in [403, 503, 429]:
        return True

    # 2. 检查 CF 专用响应头
    if resp.headers.get("cf-mitigated") == "challenge":
        return True

    # 3. 检查页面内容特征(即使状态码是 200!)
    check_text = resp.text[:10000]
    blocked_patterns = [
        "cf-turnstile",                       # Turnstile 组件
        "challenge-platform",                  # CF 挑战平台
        "_cf_chl_opt",                        # CF 挑战选项
        "challenges.cloudflare.com/turnstile", # Turnstile 脚本 URL
    ]
    for pattern in blocked_patterns:
        if pattern in check_text:
            return True

    return False

注意:即使状态码是 200,页面内容也可能是 CF 的验证页面,所以必须检查页面内容

自动降级流程

def proxy_request(url, method="GET", headers=None, auto_fallback=True):
    # 默认用 CookieFetcher
    response = cookie_fetcher.fetch(url, method, headers)

    # 检查是否被拦截
    if auto_fallback and _is_response_blocked(response):
        # 降级到 BrowserFetcher
        response = browser_fetcher.fetch(url, method, headers)

    return response

域名智能学习

系统会记录每个域名在两种模式下的成功率,自动推荐最佳模式:

class DomainStats:
    domain: str
    cookie_success: int = 0
    cookie_failure: int = 0
    browser_success: int = 0
    browser_failure: int = 0

    def update_recommendation(self):
        # Cookie 模式至少 5 次请求,且失败率 > 50%
        if self.cookie_total >= 5 and self.cookie_failure_rate > 0.5:
            self.recommended_mode = "browser"

        # 如果 Cookie 失败率降到 25% 以下,恢复 Cookie 模式
        elif self.recommended_mode == "browser":
            if self.cookie_failure_rate <= 0.25:
                self.recommended_mode = "cookie"

统计数据 24 小时过期,定期重新评估。这意味着如果某个站点临时加强了检测,系统会暂时切换到 Browser 模式;恢复正常后又自动切回高效的 Cookie 模式。


核心原理六:浏览器池化

每次过盾都启动/关闭 Chrome 太昂贵了。CF-Gateway-Pro 实现了一个线程安全的浏览器对象池:

class BrowserPool:
    def __init__(self, min_size=1, max_size=3, idle_timeout=300):
        self._pool = Queue()           # 可用实例队列
        self._all_instances = []       # 所有实例列表
        self._lock = threading.Lock()  # 线程安全

    def acquire(self, timeout=30, proxy=None):
        """借出一个浏览器实例"""
        # 如果指定了代理,必须创建新实例(代理是启动参数)
        if proxy:
            return self._create_browser(proxy=proxy)

        # 无代理:从池中获取
        try:
            instance = self._pool.get(timeout=timeout)
            # 检查浏览器是否还活着
            if not instance.page.process_id:
                instance = self._create_browser()
            instance.mark_used()
            return instance
        except Empty:
            # 池空了,看能不能创建新的
            if len(self._all_instances) < self.max_size:
                return self._create_browser()
            return None  # 池满了

    def release(self, instance):
        """归还浏览器实例"""
        instance.mark_free()
        self._pool.put(instance)

    def destroy(self, instance):
        """销毁损坏的实例并补充新实例"""
        instance.page.quit()
        self._all_instances.remove(instance)
        # 低于最小数量时自动补充
        if len(self._all_instances) < self.min_size:
            new = self._create_browser()
            self._pool.put(new)

关键设计:

  • 代理实例不复用:因为代理是 Chrome 启动参数,不同代理需要不同实例
  • 崩溃检测:通过 process_id 判断浏览器进程是否还活着
  • 损坏标记:过盾异常时标记实例为 _is_broken,归还时销毁而不是放回池
  • 内存监控:看门狗定期检查每个浏览器进程的内存,超限自动重启

从零实现:最小可运行版本

理解了以上原理,你可以用不到 100 行代码写一个最简版本:

"""最小化 CF Turnstile 过盾 + Cookie 复用示例"""

import time
import json
from DrissionPage import ChromiumOptions, ChromiumPage
from curl_cffi import requests as curl_requests

# ========== 1. 反检测配置 ==========
STEALTH_JS = """
(function() {
    Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
    delete navigator.__proto__.webdriver;

    Object.defineProperty(navigator, 'plugins', {
        get: () => [{name:'Chrome PDF Plugin'},{name:'Chrome PDF Viewer'},{name:'Native Client'}]
    });

    if (!window.chrome) window.chrome = {};
    if (!window.chrome.runtime) window.chrome.runtime = {};
})();
"""

# ========== 2. 过盾核心 ==========
def solve(url):
    co = ChromiumOptions()
    co.set_argument("--disable-blink-features=AutomationControlled")
    co.set_argument("--no-sandbox")
    co.set_argument("--window-size=1920,1080")
    co.headless(False)

    page = ChromiumPage(co)
    page.add_init_js(STEALTH_JS)
    page.get(url)

    start = time.time()
    while time.time() - start < 30:
        try:
            box = page.ele("@name=cf-turnstile-response", timeout=0.5)
            if box:
                iframe = box.parent().shadow_root.ele("tag:iframe")
                cb = iframe.ele("tag:body").shadow_root.ele("tag:input")
                if cb:
                    cb.click()
                    time.sleep(2)
        except Exception:
            pass

        if "just a moment" not in page.title.lower():
            time.sleep(1)
            break

    cookies = {}
    for c in page.cookies():
        if isinstance(c, dict):
            cookies[c["name"]] = c["value"]

    ua = page.user_agent
    page.quit()
    return cookies, ua

# ========== 3. Cookie 复用 ==========
def fetch_with_cookies(url, cookies, ua):
    return curl_requests.get(
        url,
        cookies=cookies,
        headers={"User-Agent": ua},
        impersonate="chrome136"
    )

# ========== 使用 ==========
if __name__ == "__main__":
    target = "https://某个CF保护的网站.com"

    # 首次:浏览器过盾
    cookies, ua = solve(target)
    print(f"过盾成功!cf_clearance: {cookies.get('cf_clearance', 'N/A')}")

    # 后续:直接用 Cookie
    resp = fetch_with_cookies(target, cookies, ua)
    print(f"状态码: {resp.status_code}, 内容长度: {len(resp.text)}")

安装依赖:

pip install DrissionPage curl_cffi
# 还需要系统安装 Google Chrome

整体架构总结

              ┌────────────────────────────────────┐
              │           客户端请求                 │
              └──────────────┬─────────────────────┘


              ┌────────────────────────────────────┐
              │         ProxyService (调度层)        │
              │   检查域名智能学习 → 选择模式         │
              └──────┬───────────────┬─────────────┘
                     │               │
            默认模式  │      降级/推荐  │
                     ▼               ▼
         ┌──────────────────┐  ┌──────────────────┐
         │  CookieFetcher   │  │  BrowserFetcher  │
         │                  │  │                  │
         │ 1.查缓存凭证     │  │ 1.从池获取浏览器  │
         │ 2.curl_cffi发请求 │  │ 2.浏览器直接加载  │
         │ 3.检测是否被拦截  │  │ 3.自动过盾       │
         └────────┬─────────┘  │ 4.提取HTML       │
                  │            └──────────────────┘

         ┌──────────────────┐
         │  CredentialCache │
         │  (SQLite/Redis)  │
         │                  │
         │ 未命中? → Solver │
         └────────┬─────────┘


         ┌──────────────────┐
         │  solve_turnstile │
         │                  │
         │ BrowserPool取实例 │
         │ 注入反检测脚本    │
         │ 点击Turnstile    │
         │ 提取Cookie+UA    │
         │ 归还实例到池      │
         └──────────────────┘

关键技术选型理由

选择原因
DrissionPage 而非 Playwright操作真实 Chrome,TLS 指纹天然真实
curl_cffi 而非 requests支持 TLS 指纹模拟,Cookie 复用不会被识别
SQLite 作为默认缓存零外部依赖,单机足够
同步 API 而非异步DrissionPage 是同步库,但通过线程池 + FastAPI 实现并发
headless=FalseDrissionPage 在 False 模式下过盾率更高(配合 xvfb 在 Linux 上使用)

⚠️ 免责声明:本文仅供安全研究和技术学习用途。请勿将相关技术用于未经授权的网站访问,遵守目标网站的服务条款和当地法律法规。

Cloudflare Turnstile 浏览器 安全分析 技术拆解