bigywater.tech
爬虫工程 · 开篇

requests 的 TLS 指纹:JA3 是怎么把 Python 爬虫和 curl 区分开的

很多人写爬虫第一反应是把 User-Agent 换成 Chrome 的字符串,觉得这样就"伪装成浏览器"了。但 HTTP 请求发出去之前,先要完成一次 TLS 握手——这一层完全在 requests/urllib3/OpenSSL 内部完成,应用代码一行都碰不到,却把"你到底是谁写的客户端"暴露得干干净净。本机真实抓了一次握手,现算了一遍 JA3,看看这东西到底有多诚实。

2 个不同哈希
本机同一个端口,requests 和系统 curl 各连一次,真实算出两个完全不同的 JA3
0 处用到 Header
JA3 只看 ClientHello 原始字节,User-Agent、Cookie 一个字节都没参与运算

思路:JA3 到底在指纹什么

它指纹的不是"你说你是谁",是"你的 TLS 库真实发出来的握手长什么样"。

HTTPS 连接建立时,客户端要先发一条 ClientHello,里面明明白白写着:愿意用哪个 TLS 版本、支持哪些加密套件(而且是按什么顺序列出来的)、带了哪些扩展(同样有顺序)、支持哪些椭圆曲线、支持哪些点压缩格式。这些内容不是应用代码决定的,是客户端用的 TLS 库(OpenSSL / LibreSSL / BoringSSL / SecureTransport ……)和它的默认配置决定的。JA3(Salesforce 2017 年提出,最早是给安全团队识别恶意软件 C2 流量用的)干的事很简单:把这五项按固定顺序拼成一个字符串,再取一次 MD5:

JA3 的拼接规则 JA3 = MD5( TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats ) 每一项内部本身是个列表,用 "-" 连起来; 列表之间用 "," 分隔; 关键的一点:列表顺序原样保留,不做排序 —— 顺序本身就是指纹的一部分。

换句话说,不管请求头里 User-Agent 怎么写,只要底层还是用 requests 默认的连接方式,握手阶段发出去的字节顺序就是 requests/urllib3/OpenSSL 那一套,跟"自称"是谁毫无关系。

真实验证:自己起一个只收不回的假 TLS 服务器

不用连任何外部站点——自己在本机监听一个端口,对方一发 ClientHello 就截下来,根本不用真的握手成功。

用标准库 socket 起一个 TCP 服务器,不加载任何证书、不回任何 TLS 字节,客户端发来的第一批数据就是完整的 ClientHello 记录(TLS record header 5 字节 + Handshake header 4 字节 + 正文),按 TLS 规范里 record 的长度字段读够字节就收工:

core.py —— 抓包 + 解析 ClientHello + 拼 JA3(节选) def parse_client_hello(buf): pos = 5 # 跳过 TLS record header pos += 4 # 跳过 Handshake header(类型 + 3 字节长度) client_version = struct.unpack('>H', buf[pos:pos+2])[0]; pos += 2 pos += 32 # random pos += 1 + buf[pos] # session_id cs_len = struct.unpack('>H', buf[pos:pos+2])[0]; pos += 2 ciphers = [struct.unpack('>H', buf[pos+i:pos+i+2])[0] for i in range(0, cs_len, 2)] ciphers = [c for c in ciphers if c not in GREASE] # 见下文"GREASE" pos += cs_len pos += 1 + buf[pos] # compression_methods ext_len = struct.unpack('>H', buf[pos:pos+2])[0]; pos += 2 # ... 遍历扩展,分别取出扩展类型列表 / supported_groups / ec_point_formats def ja3_string(f): return '{},{},{},{},{}'.format( f['version'], '-'.join(map(str, f['ciphers'])), '-'.join(map(str, f['extensions'])), '-'.join(map(str, f['curves'])), '-'.join(map(str, f['point_formats'])))

分别用 requests.get() 和系统自带的 curl 去连这个假服务器,两次都必然会因为握手不完整而报错(SSLError / 超时),但这不重要——需要的字节在报错之前就已经被服务器截下来了:

真实实测
=== python requests (urllib3 + OpenSSL) ===
raw bytes: 1511
JA3 string: 771,4866-4867-4865-49196-49200-49195-49199-52393-52392-49188-49192-49187-49191-159-158-107-103,65281-11-10-16-22-23-49-13-43-45-51,4588-29-23-30-24-25-256-257,0
JA3 hash  : c03c05ae0046e4d0ba623919f6bbe326

=== system curl (SecureTransport) ===
raw bytes: 301
JA3 string: 771,4867-4866-4865-52393-52392-52394-49200-49196-49192-49188-49172-49162-159-107-57-...(省略中段)...-10-255,43-51-11-10-13-16,29-23-24-25,0
JA3 hash  : 4f2655722e37c542ebeaf1eed48cbbbb
requests 的握手包了 1511 字节,curl 只有 301 字节 —— 光是包大小就差了 5 倍 两边 JA3 hash 完全不同,而且是在同一台机器、同一个端口、同一次实验里测出来的

同一台机器,同一个目标,唯一的变量是"用哪个库发起连接",JA3 就已经完全不一样了。这两个进程既没商量过 User-Agent,请求本身也从没到达任何应用层代码——区别在 TLS 握手那一层就已经定死了。

拆解 JA3 字符串:每一段具体是什么

逐字段对照着看,能看出两边的 TLS 库在"怎么发起一次握手"这件事上,策略完全不是一套。

字段requests(节选)curl / SecureTransport(节选)
version771(0x0303,TLS1.2 legacy 值,实际靠扩展 43 宣布支持 TLS1.3)771,同样靠扩展 43
ciphers 数量17 个,过滤掉 GREASE 后49 个,老式套件列表明显更长
extensions 顺序65281-11-10-16-22-23-49-13-43-45-5143-51-11-10-13-16(只有 6 个)
curves4588-29-23-30-24-25-256-25729-23-24-25
point_formats00

扩展类型的数字不是随便编的,是 IANA 登记过的固定编号,挑几个对得上号的:10=supported_groups、11=ec_point_formats、13=signature_algorithms、16=ALPN、43=supported_versions、51=key_share。requests 这边多带了 65281(renegotiation_info)、22(encrypt_then_mac)、23(extended_master_secret)、49(post_handshake_auth)、45(psk_key_exchange_modes)这些 curl 那边压根没出现的扩展——不是谁对谁错,纯粹是 OpenSSL 和 SecureTransport/LibreSSL 两套库各自的默认策略不一样。

curves 里那个 4588 是什么?0x11EC 换算成十进制正好是 4588——这是 OpenSSL 3.6(本机装的版本)新支持的后量子混合密钥交换组 X25519MLKEM768 的编号。连本机 OpenSSL 的具体版本,都会顺带烙进这个指纹里,不只是 urllib3 的选择。

还有一个容易漏掉的细节:两边的 cipher/extension/curve 列表在真实抓到的原始字节里,其实各自还夹了几个形如 0x0A0A、0x2A2A、0xBABA 这种"高低字节相同"的怪值,叫 GREASE(RFC 8701)——TLS 库故意在列表里插几个不存在的值,逼下游的服务器/中间设备别把"目前见过的固定列表"当成永远不变的协议,防止生态僵化。这些值每次握手都会重新随机挑一个,如果不过滤掉,同一个客户端每次算出来的 JA3 都会不一样,指纹就失去意义了——上面的解析代码里 GREASE 集合就是干这个的,JA3 官方规范也明确要求剔除它们。

这对写爬虫意味着什么

JA3 揭示的是一个更根本的点:反爬识别不是只看 Header 的。

如果一套反爬系统只核对 Header 里的 User-Agent,那把它改成 Chrome 的字符串确实能过关。但只要它在 TLS 握手层多看一眼 JA3(现在很多 CDN/WAF/反爬服务都会看),requests 发出来的握手包跟真实 Chrome 浏览器的握手包在 cipher 顺序、扩展列表、曲线列表上都对不上——这时候改 Header 完全没用,因为 Header 那时候还没发出去,TLS 层早就已经把身份亮出来了。

反过来,这也不是说 JA3 就能百分百认出"这是不是浏览器":同一个 TLS 库(同一个 OpenSSL 版本、同一套 urllib3 默认配置)发出来的握手必然长得一样,JA3 认的是"这是哪一套客户端软件栈",不是"这个人是谁"。社区里也有像 curl_cffi、tls-client 这类项目,做法是在更底层换掉 TLS 握手的实现(基于 BoringSSL 等),让握手包在字节层面贴近真实 Chrome/Firefox,这类工具本身也说明了一件事:要在 JA3 这个维度上跟浏览器一致,只能从 TLS 库这一层入手,应用层的 Header 再怎么改都够不着。而现在更完整的识别方案(比如 JA4、HTTP/2 帧顺序指纹、再加上行为特征)会把 JA3 当成众多信号里的一个,单独伪装一个信号并不代表能绕开整套检测。

参考与说明

  • 本文 JA3 均在本机真实抓包、真实解析原始字节、真实计算得出,没有连接任何外部站点——服务端只是一个不完成握手的裸 socket 监听器。
  • JA3 规范原文:Salesforce Engineering, "TLS Fingerprinting with JA3 and JA3S"(2017);GREASE 机制定义见 RFC 8701。
  • 没有涉及:JA4 指纹算法的具体字段、HTTP/2 帧序指纹、浏览器自动化(Selenium/Playwright/CDP)层面的检测特征——这些留给系列后面的文章。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号