4. JA3与JA3S指纹原理与实现

JA3 与 JA3S 是 TLS 指纹识别中最经典、工程落地最广的一对方法。它们并不解密流量,也不依赖证书内容,而是把 TLS 握手阶段客户端与服务端各自“主动暴露”的协商参数,按固定规则拼接成字符串,再做一次 MD5 哈希,得到一个 32 位十六进制指纹。理解 JA3/JA3S 的关键,不是记住哈希值,而是理解“哪些字段被采集、按什么顺序拼接、为什么客户端和服务端要分开算”。

一、JA3 的字段拼接规则

JA3 针对的是 ClientHello 报文,采集五个字段,按固定顺序用逗号分隔,字段内部再用短横线连接:

  1. TLS Version:ClientHello 中客户端声明的最高支持版本,取十进制值,例如 TLS 1.2 记为 771,TLS 1.3 记为 772。
  2. Cipher Suites:客户端支持的密码套件列表,按报文中的原始顺序,取每个套件的十进制值,用“-”连接。GREASE 值需要剔除。
  3. Extensions:扩展类型列表,同样按原始顺序取十进制值,用“-”连接,剔除 GREASE。
  4. Elliptic Curves:supported_groups 扩展中的椭圆曲线列表,按顺序取十进制值。
  5. EC Point Formats:ec_point_formats 扩展中的点格式列表,按顺序取十进制值。

拼接格式为:Version,Ciphers,Extensions,Curves,PointFormats。例如一个简化的示例字符串可能是:

771,4865-4866-4867-49195-49199,0-11-10-35-22-23-13-43-45-51,29-23-24,0

对这个字符串做 MD5,得到的就是 JA3 指纹。需要注意:字段顺序不能变,列表内部顺序也不能重排,因为顺序本身携带了客户端实现特征。不同浏览器、不同 TLS 库、甚至同一库的不同版本,其扩展顺序和套件顺序都可能不同,这正是 JA3 能区分客户端栈的原因。

二、JA3S 的字段拼接规则

JA3S 针对的是 ServerHello 报文,字段更少,只有三个:

  1. TLS Version:服务端最终选定的版本,取十进制值。
  2. Cipher Suite:服务端选定的单个密码套件,取十进制值。
  3. Extensions:ServerHello 中出现的扩展类型列表,按顺序取十进制值,用“-”连接。

拼接格式为:Version,Cipher,Extensions。例如:

771,49195,65281-0-11-35-23

同样做 MD5 得到 JA3S 指纹。JA3S 的字段少,区分度天然低于 JA3,但它反映的是服务端的“选择行为”,在识别服务端软件、CDN、负载均衡或中间盒时非常有用。

JA3 / JA3S 指纹生成链路 ClientHello → JA3 TLS Version → 771 Cipher Suites → 4865-4866-4867-49195-49199 Extensions → 0-11-10-35-22-23-13-43-45-51 Curves / PointFormats → 29-23-24 / 0 ServerHello → JA3S TLS Version → 771 Cipher Suite → 49195 Extensions → 65281-0-11-35-23 字段少、区分度低,反映服务端选择行为 拼接字符串(逗号分隔,短横线连接) 771,4865-4866-4867-49195-49199,0-11-10-35-22-23-13-43-45-51,29-23-24,0 拼接字符串(逗号分隔,短横线连接) 771,49195,65281-0-11-35-23 MD5 → JA3 指纹 MD5 → JA3S 指纹 注意:GREASE 值(0x?a?a)必须剔除后再拼接

三、客户端与服务端指纹的差异

JA3 和 JA3S 虽然算法同源,但语义完全不同。JA3 描述的是“客户端想谈什么”,字段多、组合空间大,因此区分度高,适合做客户端栈识别、恶意工具识别和资产画像。JA3S 描述的是“服务端最终选了什么”,字段少、变化空间小,区分度低,但它能反映服务端的配置策略和实现特征。

工程上常见的用法是把 JA3 与 JA3S 组合成二元组。同一个 JA3 客户端与不同 JA3S 服务端交互,会形成不同的组合;反过来,同一个 JA3S 也可能被大量不同 JA3 客户端命中。组合使用可以显著降低误报,例如某些扫描器 JA3 很特殊,但 JA3S 与正常浏览器一致,就需要结合两者判断。

四、代码实现与验证方法

下面用 Python 演示 JA3 的核心拼接与哈希逻辑。实际解析 ClientHello 时,需要从 TLS 记录层逐层解析到握手层,再按字段读取。这里用结构化数据模拟已解析出的字段,重点展示拼接规则:

import hashlib

def ja3_fingerprint(version, ciphers, extensions, curves, point_formats):
    # 剔除 GREASE 值(0x?a?a 形式,十进制如 2570, 6682 等)
    def clean(lst):
        return [x for x in lst if not (x & 0x0f0f == 0x0a0a and (x >> 8) == (x & 0xff))]

    parts = [
        str(version),
        "-".join(map(str, clean(ciphers))),
        "-".join(map(str, clean(extensions))),
        "-".join(map(str, clean(curves))),
        "-".join(map(str, clean(point_formats))),
    ]
    raw = ",".join(parts)
    return hashlib.md5(raw.encode()).hexdigest(), raw

# 示例参数(模拟解析结果,非实测)
fp, raw = ja3_fingerprint(
    771,
    [4865, 4866, 4867, 49195, 49199],
    [0, 11, 10, 35, 22, 23, 13, 43, 45, 51],
    [29, 23, 24],
    [0]
)
print(raw)
print(fp)

运行后会输出拼接串和 32 位 MD5 值。验证时建议分三步:第一步,用 Wireshark 抓一次真实 TLS 握手,导出 ClientHello 的字段,手工按规则拼一遍,与代码输出比对;第二步,用同一客户端访问不同服务端,观察 JA3 是否稳定、JA3S 是否随服务端变化;第三步,用不同客户端(如 Chrome、curl、Python requests)访问同一服务端,观察 JA3 是否出现明显差异。

验证时优先确认 GREASE 是否被正确剔除。很多“指纹对不上”的问题,根源都是把 GREASE 值当成了普通套件或扩展参与拼接。另一个常见问题是扩展顺序被重排,务必保持报文原始顺序。
JA3/JA3S 是明文握手特征,TLS 1.3 的 EncryptedExtensions 和证书加密不影响 JA3/JA3S 的采集,因为 ClientHello 与 ServerHello 本身仍是明文。但若流量经过 TLS 代理或中间盒,指纹可能被改写,此时应结合证书、SNI、时序等特征综合判断。

掌握 JA3/JA3S 后,下一步就是把它接入检测流水线:从流量中提取握手字段、计算指纹、建立基线、对异常指纹告警。本节的重点是先把“字段怎么取、字符串怎么拼、哈希怎么算、结果怎么验”这条链路走通,后续的检测策略才有可靠的数据基础。