JA3 指纹墙实战:一个 md5 拦住了 curl,却被四行参数绕过
做数据采集的人大概都遇到过这种事:代理池换了一轮,UA 也换了,Cookie 池也是新的,账号还是秒封。查日志看不到任何异常请求——因为对方根本没走到应用层,在 TLS 握手阶段就把你掐了。
这就是 TLS 指纹(JA3)最常见的使用场景。这篇文章不讨论理论,我在本机搭了一个真的按指纹拦截的 TLS 服务,把 curl、Python、openssl 三种客户端打进去,看它怎么分辨、怎么拦、以及为什么这道墙其实很容易绕。
所有输出都是本机实跑,实验代码在文末。
一、JA3 到底在算什么
TLS 握手的第一个包是 ClientHello。它里面有一堆字段,其中五个在同一个客户端版本上是固定有序的:
- 支持的密码套件列表(顺序敏感)
JA3 的做法很朴素:把这五个字段按固定格式拼成一个字符串,取 md5。
TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats
771,4866-4867-4865-49196-...,65281-11-10-16-22-...,4588-29-23-30-24-25,0
拼接前要剔掉 GREASE 值(0x0a0a、0x1a1a这种形如0x?a?a的填充套件),否则同一客户端每次握手指纹都不一样。
注意一个关键点:这个字符串里没有 IP、没有 UA、没有任何应用层信息。它完全由客户端的 TLS 库和编译时的默认配置决定。
二、先写一个能算指纹的服务
要算 JA3,不需要完成握手——读到 ClientHello 就够了。但这里有个坑:如果直接recv(),字节就被消费掉了,后面想继续正常握手就没数据了。
正确做法是MSG_PEEK:
peek = c.recv(8192, socket.MSG_PEEK)
t = parse(peek)
md5 = hashlib.md5(ja3str(t).encode()).hexdigest()
if md5 in BLOCK:
c.close()
return
# 放行:字节还在缓冲区里,可以把同一个 socket 交给 ssl
s = CTX.wrap_socket(c, server_side=True)
MSG_PEEK只读不消费,所以放行分支能无缝接着走完整握手。这是写这类中间件的第一个要点,不用它就只能自己重放缓冲区,麻烦得多。
解析部分按 ClientHello 的结构硬解:
p = 9 # record(5) + handshake(4)
ver = int.from_bytes(d[p:p+2], "big")
p += 2 + 32 # 跳 random
p += 1 + d[p] # 跳 session_id
n = int.from_bytes(d[p:p+2], "big") # cipher suites 长度
p += 2
cs = [int.from_bytes(d[p+i:p+i+2], "big") for i in range(0, n, 2)]
p += n
p += 1 + d[p] # 跳 compression
el = int.from_bytes(d[p:p+2], "big") # extensions 总长
之后遍历扩展,取 type 10(supported_groups)和 type 11(ec_point_formats)。
三、三个客户端,三个指纹
服务起来,先只记录不拦。三种客户端打同一个端口:
$ curl -sk https://127.0.0.1:9600/
hello from JA3 wall
$ python3 -c "...urllib.request.urlopen('https://127.0.0.1:9600/')..."
hello from JA3 wall
{"verdict": "PASS", "md5": "adc2bd4bb2698725", "ciphers": 90, "exts": 12}
{"verdict": "PASS", "md5": "da2e319a5accae0c", "ciphers": 91, "exts": 10}
{"verdict": "PASS", "md5": "8230ec99306445c5", "ciphers": 91, "exts": 11}
三个指纹,两两不同。差异在哪:
| 客户端 | 密码套件数 | 扩展数 | 指纹前缀 |
|---|---|---|---|
| curl | 90 | 12 | adc2bd4bb2698725 |
| python(urllib/OpenSSL) | 91 | 10 | da2e319a5accae0c |
| openssl s_client | 91 | 11 | 8230ec99306445c5 |
三者底层都是 OpenSSL 系,差异只有一两个扩展——但在指纹墙眼里,这已经是三个完全不同的"物种"。而浏览器是第四个物种,Chrome 的 TLS 1.3 套件顺序、ALPN 列表、扩展顺序都跟这三个都不一样。
这就解释了为什么"我明明用了代理、换了 UA,为什么还是被封":你的请求在 TLS 层面就已经招供了。
四、开拦截:只拦 curl
把 curl 的完整 md5 丢进黑名单,重启服务:
$ curl -sk https://127.0.0.1:9601/ ; echo rc=$?
rc=35
35是 curl 的SSL connect error。客户端看到的是"握手失败",没有任何 HTTP 状态码,也没有响应体——从应用层看,这个请求根本没发生过。
同一个时刻,Python 打同一个端口:
$ python3 -c "...urlopen('https://127.0.0.1:9601/')..."
hello from JA3 wall
{"verdict": "BLOCK", "md5": "adc2bd4bb2698725", "ciphers": 90, "exts": 12}
{"verdict": "PASS", "md5": "da2e319a5accae0c", "ciphers": 91, "exts": 10}
{"served": "GET / HTTP/1.1", "ja3": "da2e319a5accae0c"}
精确命中,精确放行。这就是指纹墙的全部魔法——它不看你说了什么,只看你长什么样。
五、然后它就破了
下面四个操作都只改命令行参数,不改一行代码。
改 TLS 1.2 密码套件顺序:
$ curl -sk --ciphers 'AES128-GCM-SHA256:AES256-GCM-SHA384' https://127.0.0.1:9601/
hello from JA3 wall
{"verdict": "PASS", "md5": "a89bd0721ccae87c", "ciphers": 5, "exts": 12}
密码套件从 90 个变成 5 个,指纹直接换了一张脸。
只留一个 TLS 1.3 套件:
$ curl -sk --tls13-ciphers 'TLS_AES_128_GCM_SHA256' https://127.0.0.1:9601/
hello from JA3 wall
{"verdict": "PASS", "md5": "53bbda3304f732d8", "ciphers": 88, "exts": 12}
降级到 TLS 1.2:
$ curl -sk --tlsv1.2 --tls-max 1.2 https://127.0.0.1:9601/
hello from JA3 wall
{"verdict": "PASS", "md5": "ac234bdf6db16ab0", "ciphers": 87, "exts": 8}
扩展数从 12 掉到 8——因为 TLS 1.3 相关的几个扩展不发了。
openssl 换曲线:
$ echo | openssl s_client -connect 127.0.0.1:9602 -servername a.com -curves X25519
{"verdict": "PASS", "md5": "fcf1a548e666c124", "ciphers": 91, "exts": 11}
再看一眼黑名单里的那条adc2bd4bb2698725——一次都没再命中。
这说明什么
JA3 是一个指纹,不是一个身份。它标识的是"某个 TLS 库的某套默认配置",而不是"某个客户端软件"。同一个 curl,改个参数就是另一个指纹;反过来,攻击者想伪装成 Chrome,只要把参数调成 Chrome 的样子(社区里的curl-impersonate就是干这个的,把 Chrome 的 ClientHello 硬编进 curl)。
更麻烦的是,如果你的指纹墙是"白名单"模式(不在列表里就拦),那正常用户升级了 curl 版本、换了 OpenSSL 版本,也会被误杀。这是生产环境最常见的事故。
六、JA3 为什么过期了
三个原因,按影响从大到小:
- TLS 1.3 加密了大部分握手
TLS 1.3 里,ServerHello 之后的握手内容全是加密的,而且 ClientHello 里的很多字段被 EncryptedExtensions 取代。基于明文握手的指纹信息量在减少。
- Chrome 的扩展顺序是随机的
Chrome 从很早的版本开始就在 ClientHello 里随机化扩展顺序(为了防指纹追踪)。这意味着 Chrome 的 JA3 本身就不稳定——同一个用户连续刷新,md5 会变。想用 JA3 识别 Chrome,先得处理这个噪声。
- 指纹墙的维护成本高于收益
维护一份"合法客户端指纹白名单"需要持续跟进浏览器的每次发版。而绕过它只需要改一个参数。攻防成本严重不对等。
这就是 JA4 出现的原因。JA4 的思路是把 JA3 里不稳定的部分规范化:
- 密码套件和扩展先排序再 hash,消除顺序噪声
- 用明文前缀编码协议(TCP/QUIC)、版本、有无 SNI、ALPN 首值、数量等信息
- 格式形如t13d1516h2_55b375c5d22e_eb985b0a4d1d,三段分别是元信息、密码套件 hash、扩展 hash
排序这一步很关键:它让"同一套配置、不同顺序"归为同一个指纹,把 Chrome 随机化的噪声消掉了。但代价是,改顺序不再能造新指纹,改集合仍然可以——前面实验里的第 1、2、4 种绕过照样有效,只是第 3 种(降级 TLS 1.2)会在版本位上直接暴露。
换句话说,JA4 比 JA3 好用,但仍然不是"身份认证"。
七、防守方该怎么用
如果你在写 WAF 或者反爬策略,别把指纹当唯一判据。我的建议是按"信号强度"分层:
强信号(可以单独触发拦截)
- 指纹与 UA 声明明显不符:声明 Chrome,实际是 OpenSSL 命令行的指纹。这是最容易抓的一类,误伤也最低。
- 已知的扫描器指纹(nikto、sqlmap、masscan 等固定客户端)
- 同一指纹在极短时间内来自大量不同 IP——这是代理池的特征,比单个 IP 高频更可靠
中信号(用于加权,需要配合其他维度)
- 指纹出现频率异常低(长尾指纹)
- HTTP/2 的 SETTINGS 帧参数与指纹不一致
- TLS 扩展里有服务端不需要但客户端强发的字段
弱信号(只记录,别用来拦)
- 单独的 JA3/JA4 命中黑名单——本文已经证明它一改就破
- 单独的 UA、单独的 IP 段
真正的工程做法是组合评分 + 分级处置:低分只记日志,中分加验证码,高分才断连。指纹在这个体系里的价值是"提高对手的伪造成本",而不是"识别对手"。
还有一条常被忽略:指纹墙会误伤你自己的监控和压测工具。上线前先跑一遍内部工具,把它们加白,不然第一个报警的会是你自己。
八、自查清单
- [ ] 有没有把 JA3 黑名单当成绝对判据
- [ ] 指纹与 UA 是否做了一致性校验(这条性价比最高)
- [ ] 白名单模式下,客户端升级导致的误杀有没有兜底
- [ ] 指纹是否只做记录、不做单独拦截
- [ ] 有没有把指纹和请求频率、IP 分布做联合判断
- [ ] 内部监控/压测工具的指纹是否已加白
- [ ] 目标站点是否在 TLS 层就断连(无 HTTP 响应即命中)
- [ ] 用的客户端指纹和目标站点的正常用户是否同一类
- [ ] 是否需要复刻真实浏览器的完整握手栈(不只是 UA)
- [ ] 指纹是否稳定(同一个出口 IP 连续请求,md5 是否漂移)
九、实验代码
products/lab/ja3/
ja3wall.py # MSG_PEEK 读 ClientHello,按 md5 黑白名单决定放行或断连
ja3waf.py # 只记录不拦的观察版
ja3full.py # 单纯算指纹,输出 JSON
capture.py # 最早的只读 ClientHello 脚本
# 生成自签证书
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /tmp/ja3lab/key.pem -out /tmp/ja3lab/cert.pem \
-days 2 -subj "/CN=ja3lab"
# 观察模式
python3 ja3wall.py 9600
# 拦截模式:把 curl 的指纹丢进去
python3 ja3wall.py 9601 adc2bd4bb269872516eeaa7ade78fe75
整份代码没有任何外部依赖,标准库就能跑。想验证自己的 WAF 规则,把BLOCK集合改掉就行;想看自己的客户端指纹,ja3waf.py起在任意端口,把客户端指过去,服务端日志会打出 md5。
最后一句:指纹是"他是谁"的线索,不是"他能不能进"的凭证。任何把线索当凭证的系统,都会在对手改一行参数之后失效。