最近接了个跨境数据采集的活,跑到第三步就卡住了:requests 头部伪装得严严实实,cookie 也仔细核对过,UA 换成了最新版 Chrome,可对面那个海外电商平台就像长了眼睛一样,每次请求都甩给我一个 403。我一度以为是 IP 被拉黑,换了代理还是不行,最后抓包对比才发现,问题根本不在 HTTP 层,而在 TLS 握手阶段——我的客户端指纹在服务端眼里,等于在大街上举着“我是脚本”的牌子。
这个案例不是个例。现在稍微正规一点的防护系统,早就不只看 User-Agent 和 header 了。它们会通过 JA3/JA4 指纹识别你的 TLS 客户端特征,判断你是真浏览器还是代码在跑。这篇文章我就把这套东西彻底拆开讲清楚:JA3/JA4 到底是什么、为什么 Python 默认请求库容易中招、以及几种经过实战验证的 TLS 指纹伪装方案,附完整可复现代码。适合爬虫开发、接口联调、网络分析爱好者参考。
1. 从 403 说起:反爬到底在拦什么
1.1 你的 requests 到底哪里“一眼假”
很多人有个误解:只要把 Header 里的 User-Agent、Accept、Referer 全模仿一遍,服务端就认不出你是脚本了。这在五年前还行,现在早就不灵了。真正暴露身份的不是这些明面上的字段,而是 TCP 连接建立之后、HTTP 请求发出之前的 TLS 握手过程。
你可以把 TLS 握手理解成两个人见面时的“寒暄”。浏览器和服务器会先互相确认:你支持什么加密方式、你认识哪些扩展协议、你偏好哪种椭圆曲线、你最高能用到哪个 TLS 版本。这些信息按固定顺序拼在一起,就会形成一个独特的“指纹”。就像人的声纹一样,Chrome 发出来的是一套组合,Firefox 是另一套组合,而 Python requests 基于 OpenSSL/urllib3 发出来的又是完全不同的组合。
服务端只需要在收到 Client Hello 时,把这个指纹算出来,跟已知的浏览器指纹库对比一下。匹配不上,直接就当成异常流量处理。这时候你 Header 伪装得再完美都没用,因为指纹在更底层的 TLS 层就已经暴露了。
我见过很多人排查 403 时,第一反应是换代理、换 IP、加 cookie,折腾一整天也没解决。实际上只要抓个包看一眼 Client Hello,对比一下已知的浏览器 JA3 指纹,问题立刻就能定位。
1.2 JA3 指纹到底是怎么生成的
JA3 是 2017 年由 Salesforce 的研究人员提出的一种 TLS 客户端指纹识别方法。它的原理并不复杂:把 TLS 握手第一阶段 Client Hello 里的几个关键字段提取出来,按固定顺序拼接,再做一次 MD5 哈希,得到一串 32 位的十六进制字符串。
具体拼接顺序是这样:
- TLS 版本号(如 771 代表 TLS 1.2)
- 客户端支持的密码套件列表(按 Client Hello 中的顺序)
- 客户端支持的扩展类型列表
- 支持的椭圆曲线列表
- 椭圆曲线点格式列表
把这些字段用逗号连接,最后对整串做 MD5,结果就是 JA3 指纹。
给你看一个实际计算的 Python 例子,方便你理解整个流程:
import hashlib import struct def extract_ja3(tls_client_hello_hex): # 此处是从十六进制报文里解析 ClientHello 的简化示例 # 真实场景建议用 pyshark / tshark 直接解析 pcap data = bytes.fromhex(tls_client_hello_hex) # 跳过 Record Layer 头(5字节) + Handshake 头(4字节) # 剩下的就是 ClientHello 内容,这里省略复杂解析细节 # 实际解析需要处理:版本(2字节)、Random(32字节)、 # SessionID、Cipher Suites、Compression Methods、Extensions # 伪代码流程: # 1. 读取 TLS version # 2. 读取 cipher_suites:先取长度2字节,再按2字节拆分成ID列表 # 3. 读取 extensions:先取长度2字节,再逐个解析type # 4. 如果是 supported_groups 扩展,解析其中的曲线ID # 5. 如果是 ec_point_formats 扩展,解析点格式列表 # 拼接字符串示例(真实解析后得到): ja3_str = "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-21,29-23-24-25-256-257-258,0" return hashlib.md5(ja3_str.encode()).hexdigest() # Chrome 122 在常见环境下的 JA3 指纹形如: # cd08e31494f9531f560d64c695473da9 # 不同系统/版本会有差异,建议自己抓真浏览器包计算一次作为基准注意,同一个浏览器在不同操作系统、不同版本下,密码套件列表和扩展列表都会有细微差异,所以指纹也不是完全固定的。比如 Chrome 120 和 Chrome 124 的 JA3 就不一样,Windows 和 macOS 下的 Chrome 也不完全一样。这也是为什么防护系统需要维护一个庞大的指纹库,而不是写死几个哈希值。
1.3 JA4 指纹的演进逻辑
JA3 虽然好用,但存在明显缺陷:它的拼接格式是公开的,攻击者完全可以手工构造一个 Client Hello,让 JA3 哈希精确等于某个浏览器的值。说白了,JA3 只能证明“你的 TLS 参数组合接近某个浏览器”,但无法防止有人照着模板伪造。
2023 年左右,业内提出了 JA4 指纹方案。它不是简单对字段做 MD5,而是用一种更结构化、更“可读”的编码方式,把 TLS 握手的特征分为多个维度,比如 TLS 版本、加密策略、扩展特征、密码套件特征、ALPN 协议等。JA4 的格式看起来像这样:
t13d1516h2_8daaf6152771_02713d6af862三段式结构:第一段表示 TLS 版本和加密策略,第二段是扩展列表的哈希摘要,第三段是密码套件特征的哈希摘要。相比 JA3 的“一把梭”式哈希,JA4 给服务端提供了更细的比对维度。比如两份 Client Hello 的 JA3 恰好碰撞了,但 JA4 的第二段扩展差异很大,照样能区分出来。
对爬虫开发者来说,JA4 意味着单纯“模拟某个版本的 Chrome”可能不够了,还得考虑 TLS 扩展的顺序、数量、以及字段的具体编码方式。这也就是为什么我不推荐自己手工造轮子去拼 Client Hello,直接用成熟封装方案会更稳。
2. 伪装 TLS 指纹的方案选型
2.1 手工修改 ssl context 为什么不够
先给一个最直观的尝试方案:Python 的 ssl 模块允许你手动设置密码套件,比如这样:
import ssl import socket context = ssl.create_default_context() context.set_ciphersuites( "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:" "TLS_CHACHA20_POLY1305_SHA256" ) # 再尝试启用 ALPN 等 context.set_alpn_protocols(["http/1.1", "h2"]) with socket.create_connection(("example.com", 443)) as raw: with context.wrap_socket(raw, server_hostname="example.com") as ssock: print(ssock.version())这么做确实能改变一部分特征,但问题是,浏览器的 Client Hello 远不止“密码套件”这一个维度。扩展的顺序、扩展里携带的具体参数、supported_groups 的椭圆曲线列表、key_share 扩展里临时公钥的长度,甚至 Client Hello 里那个 GREASE 随机值,全部会参与指纹计算。靠 ssl 模块手工微调,你只能改到五六成相似,很难完全复刻 Chrome 的完整骨架。
我个人的判断标准是:如果目标站点只是普通防护,手工调 ssl 可能就够用了;但如果遇到上了 JA4 或者严格指纹库的站点,手工方案就是给自己挖坑。与其花两天时间研究 TLS 协议细节,不如直接用现成的模拟方案。
2.2 常见的替代方案横向对比
目前社区里用得比较多的方案有这么几类:
| 方案 | 原理 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| curl_cffi | 封装 libcurl,支持 impersonate 参数 | API 接近 requests,几乎无缝替换;同时模拟 TLS 和 HTTP2 指纹 | 需要系统有对应动态库,但 pip 安装一般能自动解决 | Python 爬虫首选 |
| tls-client | 基于 Go 实现的 TLS 客户端,内置大量浏览器 Profile | 配置灵活,可自定义 JA3 参数;部署简单 | 部分平台需要额外依赖,API 设计比较独特 | 需要精细控制指纹的场景 |
| pyhttpx | 专门针对 Chrome 指纹的 Python 库 | 使用简单,支持指定浏览器版本 | 更新节奏不一定跟得上新浏览器版本 | 快速验证用 |
| curl-impersonate | 独立命令行工具,支持 Chrome/Safari/Firefox impersonate | 不依赖 Python,适合 shell 脚本和调试 | 不能在 Python 进程里直接调用 | 非 Python 技术栈 |
这里我要多说一句:curl_cffi 是我目前最常用的方案,因为它不只是改了 User-Agent,而是通过底层绑定 curl 的实现,把 Client Hello 的密码套件顺序、扩展顺序、ALPN、HTTP2 指纹全部按浏览器模板重放了。你在使用层面根本感知不到底层发生了什么,但服务端看到的就不再是“Python 脚本”,而是一个“标准 Chrome”。
2.3 为什么这些库能模拟“真实浏览器指纹”
核心原因是,这些库不是“猜测”浏览器指纹,而是把真实浏览器的 Client Hello 完整抓下来,然后按模板复刻。你可以理解成:有人拿了一个真实 Chrome 的 TLS 握手全过程,把它变成了一个可复用的“动作模板”。
具体到实现层级,curl_cffi 底层通过 curl-impersonate 的能力,绑定了 BoringSSL 或者修改过的 OpenSSL,让密码套件的排列顺序、扩展字段的生成逻辑都和 Chrome 保持一致。tls-client 则是直接用 Go 重写了一套 TLS 栈,通过 Profile 机制预置了不同浏览器的参数。
这比“改几个 header”要靠谱得多。因为 TLS 握手是二进制协议层面的东西,只要有一个字节的顺序和真实浏览器不一致,指纹就会变。而这些库帮你把整条链路的细节都处理好了,你在应用层只需要指定一个impersonate="chrome124",剩下的事它全包了。
3. 核心实操:三种方式实现 TLS 指纹伪装
3.1 准备环境
建议使用 Python 3.9 以上版本。我测试时用的是 3.10,curl_cffi、tls-client、pyhttpx 这三个库都能正常安装。
pip install curl_cffi tls-client pyhttpx如果有网络环境限制导致 pip 安装失败,可以先装依赖库再重试。另外提醒一句:curl_cffi 的版本迭代比较快,隔几个月就会把新版 Chrome 指纹放进去,所以尽量保持最新版本,否则你用impersonate="chrome124"时可能提示该版本不可用。
3.2 最快上手的 curl_cffi 方案
curl_cffi 的 API 设计得非常接近 requests,如果你有 requests 使用经验,几乎零成本迁移。下面是一个最简单的例子:
from curl_cffi import requests resp = requests.get( "https://example.com", impersonate="chrome124", timeout=15 ) print(resp.status_code) print(resp.text[:200])第一次跑通这个例子之后,你会明显感觉到,原本 requests 直连直接 403 的站点,curl_cffi 用impersonate参数请求就能正常返回 200。这个体验上的差异,就是 TLS 指纹伪装的效果。
如果是要保持登录态并且多接口请求,建议用 Session 模式:
from curl_cffi import requests session = requests.Session() # 首次请求建立 cookie resp = session.get("https://example.com/login", impersonate="chrome124") # 后续请求自动携带 cookie resp2 = session.get( "https://example.com/api/data", impersonate="chrome124", headers={ "Referer": "https://example.com/login" } ) print(resp2.status_code)session 会帮你自动管理 cookie,和 requests.Session 的用法几乎一样。需要走代理时也很简单,直接给 proxies 参数:
proxies = { "http": "http://127.0.0.1:7890", "https": "http://127.0.0.1:7890", } resp = session.get( "https://example.com", impersonate="chrome124", proxies=proxies )3.3 更细粒度的 tls-client 方案
tls-client 的 API 和 requests 差别比较大,但它支持自定义 Profile,适合需要精细控制指纹的场景。
最简单的用法是这样:
import tls_client session = tls_client.Session( client_profile="chrome_110", random_tls_extension_order=True ) resp = session.get("https://example.com") print(resp.status_code)client_profile参数指定了伪装目标,random_tls_extension_order可以在保持指纹簇的前提下随机调整扩展顺序,这会让每次请求的 JA3 在允许范围内略有变化,减少被关联的风险。
如果你连底层 JA3 参数都要手动控制,tls-client 也支持传自定义 JA3 字符串:
ja3 = "769,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-21,29-23-24-25-256-257-258,0" session = tls_client.Session( ja3_string=ja3, h2_settings={ "HEADER_TABLE_SIZE": 65536, "MAX_CONCURRENT_STREAMS": 1000, "INITIAL_WINDOW_SIZE": 6291456, "MAX_HEADER_LIST_SIZE": 262144 }, h2_settings_order=[ "HEADER_TABLE_SIZE", "MAX_CONCURRENT_STREAMS", "INITIAL_WINDOW_SIZE", "MAX_HEADER_LIST_SIZE" ], supported_versions=["GREASE", "1.3", "1.2"], key_share_curves=[23, 24, 25], global_extension_order=[0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 21] )这种玩法适合你手里已经有一份抓包得到的真实 Chrome JA3 数据,想完全照着复刻的情况。我建议初学者先去理解第 3.4 节的验证方法,等你能看懂抓包数据了,再考虑手动定制。
3.4 验证指纹是否真的伪装成功了
代码写完不能光看“能返回 200”就以为万事大吉。严格的做法是抓包对比 JA3/JA4。
本地验证时,最简单的方法是用 tcpdump 抓 TLS 包,然后用 tshark 解析出 Client Hello 字段。先抓包:
sudo tcpdump -i lo0 -w client_hello.pcap port 443在另一个终端发起请求,然后用 tshark 提取 JA3 所需字段:
tshark -r client_hello.pcap -Y "tls.handshake.type == 1" -T fields \ -e tls.handshake.version \ -e tls.handshake.ciphersuite \ -e tls.handshake.extensions.type \ -e tls.handshake.extensions.supported_group \ -e tls.handshake.extensions.supported_point_format这里的输出就是计算 JA3 需要的原始数据。你可以写个小脚本拼接并计算 MD5,也可以搜网上的 JA3 计算器。把这串哈希和你真实浏览器抓包计算出的哈希对比一下,如果一致,说明伪装生效。
我机器上跑出来的典型对比是这样:
| 请求方式 | JA3 特征表现 |
|---|---|
| requests | 套件列表短,扩展顺序固定,和浏览器差异明显 |
| curl_cffi impersonate=chrome124 | 套件列表、扩展顺序、曲线列表都和 Chrome 一致 |
| tls-client chrome_110 | 指纹整体接近 Chrome,但需验证 HTTP2 指纹 |
建议你把“抓包-计算-对比”这三个步骤固化成一个本地测试流程。不要等线上 403 了再去翻日志,而是在本地先把指纹验证跑通,再上线使用。这一步真的能省掉大量排查时间。
4. 常见问题与避坑技巧
4.1 TLS 伪装成功后还是返回 403
这是我被问得最多的问题。很多读者照着教程改完代码,发现目标站点依然 403。先说结论:TLS 指纹只是第一道门槛,后面还有 HTTP2 指纹、IP 信誉库、行为检测、JS 验证等多道关卡。
最典型的坑是只处理了 TLS,没有处理 HTTP2 指纹。Chrome 在建立 HTTPS 连接之后,会通过 HTTP2 的 SETTINGS 帧、HEADERS 帧顺序、伪头部字段大小写等细节继续暴露自己的身份。如果这些细节和真实浏览器对不上,服务端一样能判断你不是 Chrome。解决方法是优先使用 curl_cffi 这类同时模拟 TLS 和 HTTP2 指纹的库,而不要自己拼装 HTTP2 请求。
另外,如果请求频率过高、目标站点对某个 IP 段的风控特别严,即使指纹伪装成功,也可能因为 IP 信誉度低而被拦截。这时候要优先排查是否是频率问题,可以在连续请求之间加随机延时,并确保代理 IP 的稳定性。
还有一个容易忽略的点:某些站点会在首页下发一段 JS 挑战脚本,等你的客户端执行完拿到合法 cookie 之后,才会放行后续请求。这类防护跟 TLS 指纹无关,纯靠 requests 直接请求是过不去的,需要配合一个能执行 JS 的环境来做处理。
4.2 握手报错或版本不匹配
如果你看到这样的报错:
SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version number大概率是因为目标站点根本不在 443 端口,或者服务端强制要求 TLS 1.3 而你当前用的 Profile 只支持到 1.2。排查思路分两步:
第一步,确认目标站点完整支持 HTTPS,且端口真的对外开放。用浏览器直接访问一次,如果浏览器能开而脚本报错,说明是脚本的问题;如果浏览器也开不了,说明是目标站点的问题。
第二步,切换 Profile 试试。curl_cffi 里可以换不同的 Chrome 版本,或者直接试 Firefox、Safari 的 impersonate。tls-client 里则可以调整supported_versions参数,把"1.3"和"1.2"都加进去。
另外,如果你本机的 OpenSSL 版本过旧,可能导致部分新密码套件无法使用。这种情况建议升级 Python 到新版本或者装一个带较新 OpenSSL 的发行版。实测下来,Python 3.10+ 的 OpenSSL 版本基本够用。
4.3 请求能通但容易被识别
有些时候你的请求能返回数据,但只要一深入访问,比如执行搜索、加购物车等操作,马上触发验证。这类问题通常不是 TLS 指纹没伪装好,而是行为特征太明显。
常见的反面例子有:单 IP 每秒发几十个请求、请求间隔完全均匀、访问路径固定不变、从不加载静态资源。真实浏览器用户绝不会这么“准时”。我在做采集任务时,会在请求之间加入随机抖动延迟,并且模拟先访问首页、再访问列表页、最后进详情页的顺序。频率控制在目标站点可接受范围内,比单纯伪装指纹重要得多。
建议你在开发阶段就养成习惯:所有请求统一走一个底层函数,里面预留随机延迟、重试机制、请求头轮换的接口。这样后面被风控时,调整成本会低很多。
4.4 合规边界
这个技术本身是中立的能力,就像一把工具刀,可以用于正当的接口调试和自动化测试,也可能被用来做违规的数据采集。我在任何案例里都建议:只在你拥有授权或有明确合法依据的站点上使用。开发调试阶段可以拿自己的测试服务器做实验,真正做采集项目之前,先仔细看一下目标平台的条款和政策。
遵守目标站点的 robots 规则和服务条款、控制合理的请求频率、不以绕过对方明确禁止的技术措施为目的——这不仅是合规要求,也是维持这个技术生态能长期良性发展的基础。
5. 进阶探索:从 JA3 到 HTTP2 指纹再到整体对抗
5.1 HTTP2 指纹是藏在 TLS 后面的下一道线
很多站点在 TLS 指纹一致后,依然能看出访问者不是浏览器,秘密就在 HTTP2 层。HTTP2 协议里,客户端和服务器通过 SETTINGS 帧进行参数协商,而 Chrome、Firefox、Edge 的 SETTINGS 默认值都不一样。更关键的是,请求头在 HTTP2 帧里的发送顺序、首字母大小写、伪头字段排列,每款浏览器都有自己固定的习惯。
这里有一个真实的例子:同样是一组请求头,Chrome 可能发送的顺序是:method、:authority、:scheme、:path、user-agent、accept,而某脚本库发送的是:method、:path、:authority、:scheme、accept、user-agent。后者就算 TLS 指纹伪造成功,在 HTTP2 指纹这一步也会被打回原形。
像 curl_cffi 这种库之所以推荐,正是因为它把 HTTP2 指纹也一起处理了。你可以通过指定impersonate="chrome124",让库在内部同时重放 Chrome 的 HTTP2 参数和行为。用 tls-client 时则需要在 h2_settings、h2_settings_order 里手动配置,比较繁琐,但控制力更强。
5.2 指纹伪装与请求行为结合
只靠某一项技术打天下的时代已经过去了。我把这种对抗理解成一个“木桶效应”:TLS 指纹、HTTP2 指纹、UA 一致性、行为频率、cookie 生命周期、代理 IP 质量,每个维度都决定你的请求是不是会被识别。
实际操作中,我比较推荐的做法是“组合拳”:
import time import random from curl_cffi import requests session = requests.Session() user_agents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", ] def fetch(url): headers = { "User-Agent": random.choice(user_agents), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://example.com/", } resp = session.get( url, headers=headers, impersonate="chrome124", timeout=15 ) time.sleep(random.uniform(2, 5)) # 随机延迟,避免机械感 return resp这套组合里没有哪一项是“银弹”,但合在一起可以让你的请求在指纹和表面行为上都更贴近真实用户。
5.3 未来指纹技术会怎么变
JA3 刚出来时大家觉得已经很厉害了,后来出现了 JA4,再往后也许会有更复杂的多维指纹。现在已经有团队在研究通过 TLS 证书状态、OCSP 请求、QUIC 指纹等方式做进一步识别。对爬虫开发者来说,这意味着不能只盯着某一个库的某个版本去更新,而应该持续关注指纹方案的演进,及时调整自己的请求链路。
好消息是,主流开源库的迭代速度也很快。curl_cffi 基本每个大版本都会跟随最新的浏览器指纹发布更新。tls-client 也维护了多种 Profile。只要你养成了“定期更新依赖库”的习惯,大部分新指纹特征都能被覆盖到。
最后分享一个小技巧
我在实际项目中,养成了一个习惯:每接触一个新的目标站点,第一步不是直接写请求代码,而是先用真实浏览器打开一次,同时在后台抓包,保存一份“基准指纹”。然后写一个十几行的小脚本,计算这个站点的 TLS 指纹归属、HTTP2 特征、cookie 生成逻辑。等心里有了底数,再去写爬虫代码。
这个方法帮我避免了很多无谓的返工。有一次遇到一个防护很强的站点,我对比基准指纹后,发现光模拟 Chrome 还不够,它还会检测浏览器渲染环境。我直接在代码里接了一个无头浏览器方案,让 TLS 指纹和浏览器环境都保持一致,问题就迎刃而解了。
记住一点:403 并不可怕,可怕的是连“它为什么拒绝你”都不知道。把 TLS 指纹这东西搞明白之后,你会发现反爬对抗突然变得清晰很多——至少你知道下一刀该往哪儿切了。