如何修复 Python Requests 中的 TLS 指纹阻断

tls fingerprinting - How to Fix TLS Fingerprinting Blocks in Python Requests

如果你的请求头已经完美无缺,网站却仍然拦截你,那么拦截其实发生在你的第一个请求头到达之前。TLS 指纹识别通过 TLS 握手的形态来识别你的 HTTP 客户端,而 Python 的 requests 库产生的握手,是地球上任何浏览器都不会产生的。复制一个 user agent 字符串解决不了这个问题,真正的解法是让握手本身看起来像浏览器。这篇文章会讲解如何读取你自己的指纹、如何改变它,以及如何把这个问题和另外两个容易混淆的问题区分开。

你需要什么

  • Python 3.10 或更新版本。示例先使用 requests 库来展示问题,再使用 curl_cffi 来解决它。
  • 一个终端和大约十分钟时间。诊断步骤只需要两个请求,不需要修改任何代码。
  • CapSkip 处于运行状态,仅最后一节需要用到,可以是在回环地址上以 Local 模式运行,也可以是在你的 worker 能访问到的机器上以 Server 模式运行。这两种模式都记录在 连接设置中,请在开始之前选定一种。

TLS 指纹识别到底读取了什么

每一个 TLS 连接都以一条 ClientHello 消息开始。这条消息列出了你支持的 TLS 版本、你提供的加密套件、你发送的扩展、你接受的椭圆曲线,以及所有这些内容的排列顺序。这些都不是秘密,也都不能按请求单独配置,而是由你的 HTTP 客户端所依赖构建的那个 TLS 库决定的。

对这份清单做哈希,就能得到客户端软件的一个稳定标识符。JA3 是这种哈希最早被广泛使用的版本。JA4 是当前的版本,它会在哈希之前先对 ClientHello 的扩展进行排序,这样同一个浏览器就不会在每次连接时产生不同的指纹。Cloudflare 把这两者都描述为一种 通过连接发起方式来识别 TLS 客户端的方法,并将该值开放给防火墙规则、分析和 Workers 使用。

这就是为什么它比你设置的任何请求头都更有分量。Python 自带的 OpenSSL、Chrome 自带的 BoringSSL、Firefox 自带的 NSS,三者发送的 ClientHello 消息各不相同。因此,一个自称是 Chrome、握手方式却像 Python 的请求,并不是什么需要巧妙检测才能发现的隐晦破绽,而是两个互相矛盾的字段,一条反爬规则用一行代码就能对比出来。

第一步:在改动任何东西之前,先读出你自己的指纹

不要靠猜测来判断 TLS 指纹识别是不是你的问题,因为它是可以测量的。有一个公开的端点会把它在你连接上看到的 JA3 哈希原样返回给你,用它对比两个请求,用不了一分钟。

# pip install requests
import requests

# The endpoint echoes back the handshake it received from you.
r = requests.get("https://tls.browserleaks.com/json", timeout=30)
seen = r.json()

print(seen["ja3_hash"])       # stable per TLS library, not per user agent
print(seen["ja3_text"])       # the raw cipher and extension list

现在在请求头里设置一个浏览器的 user agent,再运行一次同样的调用,然后再读一次哈希。它不会有任何变化。这一个实验就是全部的道理:user agent 存在于请求头里,指纹存在于握手里,改变前者对后者毫无影响。如果你一直在轮换 user agent 想借此绕过拦截,这就是为什么它从没起效过。

第二步:用 curl_cffi 让握手像一个真正的浏览器

你没办法让标准的 Python TLS 协议栈产生 Chrome 的 ClientHello,因为扩展集合及其顺序是固化在库的构建里的,而不是作为设置暴露出来的。你能做的是换一个库。curl_cffi 这个包绑定了一个能复现特定浏览器握手的 curl 构建版本,并且它提供了一个类似 requests 的 API,所以你代码的其余部分几乎不用改动。

# One dependency, no browser and no driver involved.
pip install curl_cffi --upgrade

真正起作用的参数是 impersonate。它决定握手要模仿哪个浏览器版本,当某个网站对当前版本变得挑剔时,你还可以固定到某个具体版本。

# pip install curl_cffi
import curl_cffi

# Same call as before, but the handshake now matches Chrome.
r = curl_cffi.get("https://tls.browserleaks.com/json", impersonate="chrome")
print(r.json()["ja3_hash"])   # a different hash from the requests run above

# Pin a version when a site rejects the current default.
r = curl_cffi.get("https://example.com/", impersonate="chrome124")
print(r.status_code)

把两个哈希放在一起对比。如果它们不同,说明伪装已经生效,这也是唯一值得关心的确认方式。该项目 在文档中列出了支持的目标,其中除 Chrome 外还包括 Safari 和 iOS Safari 版本。手写的 JA3 字符串也可以使用,不过在几乎所有情况下,有人维护的预设都比它更好,因为预设会随着浏览器一起更新。

确认可行之后就切换到 session。你需要连接复用和 cookie jar,原因和你在 requests 里需要它们一样。

# pip install curl_cffi
import curl_cffi

# One session, one handshake profile, cookies carried across calls.
s = curl_cffi.Session(impersonate="chrome")

s.get("https://example.com/login")
r = s.get("https://example.com/dashboard")

print(r.status_code, len(s.cookies))

第三步:让客户端的其余部分保持一致

一个浏览器握手挂在一个明显是脚本发出的请求上,本身就是另一种不匹配,而只修好一个信号、却让其他信号继续大声报警,是这件事最常出错的方式。有三样东西必须和你选择的画像保持一致。

你的 user agent 必须和伪装目标是同一个浏览器家族,版本也要大致相符。自称 Firefox 却用 Chrome 的方式握手,比什么都不声明还要糟糕,因为它把一个微弱的信号变成了一个自信满满的信号。

你的请求头顺序很重要,HTTP/2 的行为同样重要。浏览器会发送稳定的请求头顺序、稳定的一组 HTTP/2 设置帧,以及稳定的伪首部顺序,这些和 TLS 层一样,能被清清楚楚地识别出指纹。按照你的字典恰好迭代出来的顺序发送请求头,会把你刚做的工作全部抵消掉。这也是选择一个有人维护的伪装预设的另一个理由,它会替你处理好 HTTP/2 这一层,而不是把它交给客户端的默认行为。

你的 IP 也必须和你发送的流量相匹配。一个以机器般速度发出浏览器形态请求的数据中心地址是另一个独立信号,任何握手都无法修复它。关于这些选项,完整介绍见 验证码代理轮换,其中讲了轮换应该放在 worker 池的哪个位置。

给这些信号排出优先级,让你按正确的顺序去修复

信号在哪里被读取能否更改
TLS ClientHello,以 JA3 或 JA4 形式哈希在发送任何 HTTP 数据之前能,通过更换 TLS 库
HTTP/2 设置和伪首部顺序连接的最初几帧能,一个好的预设会替你完成
请求头的名称、值和顺序请求本身能,而且这是最容易出错的一项
IP 信誉和地址类型连接来源只能通过改变你的连接来源地址
浏览器运行时信号,例如 canvas 和 WebGL在真实页面内,JavaScript 运行之后当你不运行浏览器时不适用
请求速率和访问模式跨多个请求能,通过放慢速度并改变访问的路径

按这张表从上往下逐项处理,而不是横向跳着来。TLS 指纹识别发生在连接中最早的阶段,因此对防御方来说这是做决策成本最低的地方,也是你的修复必须首先落地的地方。

这些都修不了的部分

正确的握手不是一张入场券。它只是消除了一个不信任你的理由,这意味着网站现在会去评估你流量的其余部分,而不是直接把你挡在门外。很多网站在你修好指纹之后仍然会给你一个挑战,这是预期中的结果,而不是修复失败的迹象。

这是一条实实在在的边界,值得说清楚。CapSkip 不会改变你的 TLS 指纹,也不是什么隐身层。它做的是识别你被出示的验证码,这是这个问题里唯一最终会给出一个 token 的部分。如果挡在你和响应内容之间的是一个 Turnstile 小组件或一个挑战页面,识别器会生成 token,再由你现有的客户端把它提交上去。

# pip install capskip
from capskip import CapSkip

solver = CapSkip(host="127.0.0.1", port=8080)

# The challenge you were served, solved on your own machine.
result = solver.turnstile(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/protected",
)

token = result["code"]
agent = result["userAgent"]   # send this exact user agent with the token

识别器返回的那个 user agent 不是摆设。Turnstile 会把 token 和生成它的浏览器身份绑定在一起,因此用一个不同的 user agent 提交一个完全有效的 token,几乎肯定会被拒绝。把两者一起发送,提交才能成立。 Cloudflare Turnstile 识别 页面分别讲解了小组件场景和挑战页面场景,因为它们需要不同的输入。

改成把求解器跑在服务器上

指纹相关的工作通常出现在 worker 池里,而不是单个脚本里,而一个池不会共享同一个回环地址。connection settings 覆盖了这两种情况:

模式监听地址适用场景
本地127.0.0.1,仅限该设备你的爬虫和求解器跑在同一台机器上
服务器你的内网地址或公网 IPWorker、VPS 或托管平台通过 API 调用

把 SDK 的 host 指向识别器所在的机器,代码的其他部分不需要任何改动,这样整支队伍就能共享同一个实例。如果调用方位于你自己的网络之外,建议使用静态公网 IP。详情记录在 连接设置中。Server 模式用的仍然是你自己的硬件,仍然不按次计费:它改变的只是识别器运行的位置,而不是它归谁所有。

常见问题

我能改变 requests 库本身的 TLS 指纹吗?

改是能改,但没什么实际用处。你可以通过底层的 SSL context 重新排列加密套件,从而改变哈希值,但这种方式无法复现浏览器的 ClientHello,因为扩展集合及其顺序来自库的构建,而不是来自配置。最终得到的是一个和任何东西都对不上的指纹,这比匹配 Python 的处境还糟糕。换一个库才是正解。

驱动一个真实浏览器能让这个问题消失吗?

在 TLS 层面上能,因为真实的 Chrome 会发送真实的 Chrome 握手。但它也会让每个 worker 多花掉数百 MB 内存,请求也慢得多,对于一个范围很窄的问题来说,这是个很重的解法。当你真的需要页面执行 JavaScript 时才用浏览器,如果你只需要响应内容,用一个伪装的 HTTP 客户端就够了。

我怎么知道拦截是因为指纹识别,而不是速率限制?

把速度大幅放慢,用同一个客户端再试一次。速率限制会在你等待之后放松,通常还会告诉你需要等多久,之后就能正常返回。TLS 指纹拦截完全不在乎时间,所以当天的第一个请求会和第一百个请求一样失败。如果一个全新的客户端在第一次请求时就失败,那等待并不是你的解法。

CapSkip 会改变我的 JA3 或 JA4 指纹吗?

不会,而且它本来也不应该改变。CapSkip 是一个识别器:你把挑战交给它,它把 token 还给你,运行在你自己拥有的硬件上,不按次收费。你的指纹属于发出请求的那个客户端,所以这部分工作仍然是你自己的事。这两者在实践中配合得很好,因为一个具有浏览器形态的客户端会得到一个可以被识别的挑战,而不是一个直接的拒绝。

最短的版本

先读出你的指纹,因为这只需要一个请求,就能把问题说清楚。如果你在轮换 user agent 的同时哈希一直不动,问题就出在握手上,任何请求头都修不好它。换成一个伪装客户端,让 user agent、请求头顺序和 HTTP/2 特征都和你伪装的目标保持一致,然后再看你的 IP。之后如果出现了挑战,那是进展而不是失败,而一个 本地验证码识别工具 正是把它变成 token 的那个环节。关于如何把它接入 worker 池的说明记录在 网络爬虫验证码识别工具。真正写代码时, Python 验证码识别器页面 包含了你接下来会用到的 SDK 细节。