Newshot
Account

JA3 指纹墙实战:一个 md5 拦住了 curl,却被四行参数绕过

Summary

JA3 指纹墙实战:一个 md5 拦住了 curl,却被四行参数绕过 做数据采集的人大概都遇到过这种事:代理池换了一轮,UA 也换了,Cookie 池也是新的,账号还是秒封。查日志看不到任何异常请求——因为对方根本没走到应用层, 在 TLS 握手阶段就把你掐了 。 这就是 TLS 指纹(JA3)最常见的使用场景。这

Original text(via FreeBuf)

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}
      

三个指纹,两两不同。差异在哪:

客户端密码套件数扩展数指纹前缀
curl9012adc2bd4bb2698725
python(urllib/OpenSSL)9110da2e319a5accae0c
openssl s_client91118230ec99306445c5

三者底层都是 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 为什么过期了

三个原因,按影响从大到小:

  1. TLS 1.3 加密了大部分握手

TLS 1.3 里,ServerHello 之后的握手内容全是加密的,而且 ClientHello 里的很多字段被 EncryptedExtensions 取代。基于明文握手的指纹信息量在减少。

  1. Chrome 的扩展顺序是随机的

Chrome 从很早的版本开始就在 ClientHello 里随机化扩展顺序(为了防指纹追踪)。这意味着 Chrome 的 JA3 本身就不稳定——同一个用户连续刷新,md5 会变。想用 JA3 识别 Chrome,先得处理这个噪声。

  1. 指纹墙的维护成本高于收益

维护一份"合法客户端指纹白名单"需要持续跟进浏览器的每次发版。而绕过它只需要改一个参数。攻防成本严重不对等。

这就是 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。

最后一句:指纹是"他是谁"的线索,不是"他能不能进"的凭证。任何把线索当凭证的系统,都会在对手改一行参数之后失效。

The text above was extracted automatically from the source page and may miss images, tables or code blocks. The original is authoritative.

Published by FreeBuf
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗