摘要

TLS 握手的第一步——ClientHello 消息——在明文传输中承载了客户端支持的加密套件、TLS 版本和数十个扩展字段,其精确组合构成了每个 TLS 实现的独特指纹。JA3 及其继任者 JA4 将这些字段哈希化,为网络防御者提供了一柄识别恶意软件与异常流量的利器。然而,指纹的稳定性本身就是一把双刃剑:Go 的 crypto/tls 与浏览器的 BoringSSL 在扩展排列、GREASE 扩展生成和 ALPN 排序上的细微差异,形成了不可伪造的指纹裂隙。攻击者利用 utls 等库精心伪造 ClientHello,却仍在 JA4 指纹空间中留下暴露真实客户端的二阶痕迹。

ClientHello 指纹的构建原理

从 JA3 到 JA4

JA3 指纹由 Salesforce 在 2017 年提出,通过对 ClientHello 中的 TLS 版本、密码套件、扩展列表和椭圆曲线参数进行 MD5 哈希,生成一个 32 字符的指纹字符串。其核心假设是:同一 TLS 实现的 ClientHello 在这些字段上保持稳定,从而可作为识别特定客户端软件(如 Chrome、Python requests、Go 程序)的强标识。

然而,JA3 存在两个根本性局限:

  • 对 QUIC 无效:JA3 基于 TLS over TCP,对 HTTP/3 使用的 QUIC 协议无法直接应用。
  • ALPN 排序被忽略:ALPN(应用层协议协商)是 TLS 扩展之一,但 JA3 将 ALPN 列表视为无序集合,丢失了协议优先级的排序信息——而不同的客户端实现在 ALPN 排序上存在差异。

JA4 在 JA3 的基础上引入 QUIC 支持,并将 ALPN 排序纳入指纹计算。JA4 指纹由四部分组成:

JA4 = JA4_a + JA4_b + JA4_c + JA4_d
  • JA4_a:TLS 版本、SNI、密码套件数量和扩展数量的哈希。
  • JA4_b:密码套件列表(按顺序排列,包括 GREASE 值)的哈希。
  • JA4_c:扩展列表(按顺序排列,包括 GREASE 值)的哈希。
  • JA4_d:ALPN 列表(按原始顺序排列)的哈希。

这种四段式结构使得 JA4 对 ClientHello 的微小变化更加敏感,理论上比 JA3 更难被精确伪造。

ClientHello 扩展排列

ClientHello 消息的 extensions 字段是一个 TLV(类型-长度-值)序列,每个扩展由一个 2 字节的类型码和一个长度可变的值组成。TLS 1.3 标准并未规定扩展的排列顺序——任何实现都可以按任意顺序排列扩展字段。然而,在实践中,主流 TLS 库形成了“约定俗成”的排列顺序:

TLS 库
典型扩展排列(简化)
BoringSSL (Chrome)
supported_versions
key_sharepsk_key_exchange_modessignature_algorithmssigned_certificate_timestampapplication_layer_protocol_negotiation, …
Go crypto/tls
supported_versions
key_sharesupported_groupssignature_algorithmsapplication_layer_protocol_negotiationserver_name, …
OpenSSL 3.x
supported_versions
key_sharesignature_algorithmssupported_groupspsk_key_exchange_modesapplication_layer_protocol_negotiation, …
Mozilla NSS (Firefox)
supported_versions
key_sharepsk_key_exchange_modessignature_algorithmssupported_groupsapplication_layer_protocol_negotiation, …

这种排列差异看似微小,却构成了 TLS 指纹最顽固的部分:攻击者必须精确复现目标客户端的扩展排列,否则 JA4_c 哈希将不匹配。然而,不同的 TLS 库在内部实现中对扩展的编码顺序是由代码结构决定的——Go 的 crypto/tls 在 writeExtensions() 函数中按硬编码的顺序写入扩展,BoringSSL 则在 add_extensions() 中按不同的顺序组织。攻击者要完全模仿,就需要不仅理解这些排列,还要准确模拟每个扩展的内部字段值。

随机值中的特征指纹

GREASE 扩展的设计与实现差异

GREASE 是 Google 为 TLS 生态系统引入的一种“润滑剂”,其原理是:在 ClientHello 中随机插入一些由 IANA 预留但尚未分配给任何实际扩展的类型码,以检测服务器是否正确忽略未知扩展。如果服务器因未知扩展而中断握手,说明该服务器的实现存在兼容性问题。

不同 TLS 库在 GREASE 值的生成上存在显著差异,而这些差异恰恰成为指纹识别的新维度:

  • BoringSSL(Chrome):在每次 ClientHello 中,随机选择 1-3 个 GREASE 类型码,分别插入到密码套件列表、扩展列表和 ALPN 列表中。随机种子来源于进程的 PRNG,每个 Chrome 进程的实例在重启前保持相同的 GREASE 值。
  • Gocrypto/tls:仅在密码套件列表中插入一个 GREASE 值,扩展列表不插入 GREASE 扩展(截至 Go 1.21)。ALPN 列表中同样不插入 GREASE。
  • OpenSSL:默认不插入 GREASE 值,除非编译时启用 enable-ssl3 或显式调用相关 API(极少使用)。
  •  Firefox(NSS):与 BoringSSL 类似,在密码套件和扩展列表中都插入 GREASE 值,但随机值由 NSS 的 PRNG 独立生成,种子策略与 BoringSSL 不同。

这些差异意味着:即使攻击者精确复制了扩展排列和密码套件列表,GREASE 值的存在与否、插入位置、随机值的分布特征仍然可能暴露真实客户端。

随机值的分布与可检测性

更进一步,不同 TLS 库的 PRNG 在 GREASE 值的选择上表现出不同的统计特征。BoringSSL 使用 CRYPTO_sysrand 作为随机源,在 Linux 上直接读取 /dev/urandom,其 GREASE 值分布在 16 个预留类型码上均匀分布。Go 的 crypto/rand 在 Linux 上同样读取 /dev/urandom,但 Go 的 crypto/tls 在生成 GREASE 值的代码路径中额外调用了 math/rand(用于选择插入位置),这导致插入位置的分布与 BoringSSL 有所不同——Go 的 GREASE 插入位置偏向密码套件列表的前 1/3 区域。

这些统计特征可以通过对大量 ClientHello 样本的分析来提取。安全厂商(如 Google 的 SSL 搜索引擎)已经积累了数十亿计的 ClientHello 样本,能够以统计学精度区分 BoringSSL 生成的 GREASE 与 Go 生成的 GREASE——即使攻击者使用 utls 伪造了 GREASE 的存在,其插入逻辑仍然可能留下可识别的统计痕迹。

key_share扩展密码学

ECDHE 密钥生成

key_share 扩展是 TLS 1.3 中最关键的扩展之一,包含了客户端的 ECDHE 公钥。不同 TLS 库在生成 ECDHE 密钥时的实现细节,在最终的 ClientHello 中留下了无法伪造的密码学指纹:

  • BoringSSL:使用 P-256(secp256r1)作为首选曲线,生成的公钥点为未压缩格式(04 || X || Y)。关键的是,BoringSSL 在生成 ECDHE 密钥时使用了确定性随机数生成器(基于 HMAC-DRBG),其公钥的 X 和 Y 坐标的统计分布与从 /dev/urandom 直接采样的分布存在细微差异。
  • Go crypto/ecdh:同样使用 P-256,但生成的是压缩格式公钥(02 或 03 || X)。这直接导致了 key_share 扩展值的长度和内容与 BoringSSL 不同——BoringSSL 的 P-256 公钥长度为 65 字节,Go 的 P-256 公钥长度为 33 字节。
  • Firefox (NSS):使用 P-256,公钥为未压缩格式,但其椭圆曲线实现基于 libsecp256k1 的修改版,生成的公钥在特定的数论性质上(如二次剩余性)与 OpenSSL/BoringSSL 存在可检测的差异。

公钥格式差异

JA4 并不直接对 key_share 扩展的内容进行哈希——它只哈希扩展的类型码和出现顺序。因此,从 JA4 的视角看,如果攻击者正确排列了扩展,key_share 的公钥格式差异不会影响 JA4 哈希值。

但二阶指纹(JA4+)通过分析 key_share 扩展的实际内容来补充 JA4 的不足。一个使用 utls 伪造 Chrome ClientHello 的攻击者,即使精确复制了扩展排列和密码套件,如果其使用的 Go TLS 库生成了压缩格式的 ECDHE 公钥(而非 BoringSSL 的未压缩格式),key_share 扩展的长度和内容就会与真正的 Chrome 不一致。服务器端或流量分析系统可以通过检查 key_share 公钥长度来识别这种不一致——Chrome 的 P-256 公钥总是 65 字节,而 Go 伪造的总是 33 字节。

这就是“指纹悬崖”的核心含义:攻击者在 JA4 层的高度伪造越接近目标,越容易在二阶特征上暴露差异——因为要完全模仿目标实现的每一个密码学细节、每一个扩展的内部格式、每一个随机值生成的统计特征,已经几乎等同于重新实现整个 TLS 库。

HTTP/2 SETTINGS 帧

SETTINGS 帧参数序列

TLS 握手完成后,HTTP/2 连接立即进入 SETTINGS 帧交换。SETTINGS 帧包含客户端和服务端的参数配置,如 SETTINGS_MAX_CONCURRENT_STREAMSSETTINGS_INITIAL_WINDOW_SIZESETTINGS_HEADER_TABLE_SIZE 等。不同客户端发送的 SETTINGS 参数集合、排列顺序和具体值同样具有高度区分性:

客户端
典型 SETTINGS 参数及顺序
Chrome 120+
HEADER_TABLE_SIZE=65536
MAX_CONCURRENT_STREAMS=1000INITIAL_WINDOW_SIZE=6291456
Firefox 120+
HEADER_TABLE_SIZE=65536
MAX_CONCURRENT_STREAMS=128INITIAL_WINDOW_SIZE=12517376
Python httpx
MAX_CONCURRENT_STREAMS=100
INITIAL_WINDOW_SIZE=65535
Go net/http
HEADER_TABLE_SIZE=4096
MAX_CONCURRENT_STREAMS=250INITIAL_WINDOW_SIZE=65535

SETTINGS 帧指纹的独特优势在于:它不受 TLS 指纹伪造的任何影响。攻击者通过 utls 或类似库伪造 ClientHello 后,TLS 层的指纹可能已接近目标浏览器,但 HTTP/2 连接建立后的 SETTINGS 帧是由上层 HTTP 库生成的。Python 的 httpx 即使在底层使用 utls 伪造了 Chrome 的 ClientHello,其上层的 HTTP/2 实现仍然会发送 Python 风格的 SETTINGS 帧,从而在 TLS 指纹之外暴露真实客户端。

多协议指纹联合检测

网络检测系统可以将 JA4(TLS 指纹)与 H2Settings(HTTP/2 指纹)进行组合,形成更鲁棒的设备标识:

JA4_H2_Fingerprint = JA4 + ":" + HASH(SETTINGS参数序列)

即使 JA4 层被精确伪造,只要 H2Settings 指纹存在差异,整体指纹就会暴露异常。在实际部署中,Google 的 SSL 搜索引擎和 Cloudflare 的 Bot Management 已经将 TLS 指纹与 HTTP/2 指纹结合使用,作为识别自动化流量和恶意软件的重要维度。

JA4 伪造与二阶指纹暴露

使用 utls 伪造 Chrome ClientHello 并对比差异

// ja4_compare.go — 比较真实 Chrome 与 utls 伪造的 ClientHello 差异
// 依赖: go get github.com/refraction-networking/utls

package main

import (
"crypto/tls"
"fmt"
"net"

    utls "github.com/refraction-networking/utls"
)

funccaptureClientHello(conn net.Conn) *utls.ClientHelloMsg {
// 通过 utls 库的钩子捕获 ClientHello 的完整字节
// 简化示例:使用 utls 内置的 ClientHello 抓取功能
returnnil// 实际需通过 utls.FingerprintClientHello 等函数
}

funcmain() {
// 1. 使用 Go 标准 crypto/tls 发起连接(默认行为)
    stdConfig := &tls.Config{InsecureSkipVerify: true}
    stdConn, _ := tls.Dial("tcp", "google.com:443", stdConfig)
defer stdConn.Close()

// 2. 使用 utls 伪造 Chrome 120 的 ClientHello
    chromeSpec, _ := utls.UTLSIdToSpec(utls.HelloChrome_120)
    utlsConfig := &utls.Config{InsecureSkipVerify: true}
    utlsConn, _ := utls.Dial("tcp", "google.com:443", utlsConfig, chromeSpec)
defer utlsConn.Close()

    fmt.Println("[*] 已分别通过 Go crypto/tls 和 utls (Chrome 120) 建立连接")
    fmt.Println("[*] 使用 Wireshark 抓包比较两个 ClientHello 的差异:")
    fmt.Println("  1. GREASE 扩展: Go 不插入 GREASE 扩展, Chrome 插入 1-3 个")
    fmt.Println("  2. key_share 公钥格式: Go 使用压缩格式(33B), Chrome 使用未压缩(65B)")
    fmt.Println("  3. ALPN 排列: Go 默认 ['h2', 'http/1.1'], Chrome ['h2', 'http/1.1']  (相同)")
    fmt.Println("  4. 扩展排列: Go 与 Chrome 在 signature_algorithms 和 supported_groups 顺序上存在差异")
}

说明:运行此程序后,通过 Wireshark 抓取两个连接的全量数据包,可直观比较真实 Chrome 与 utls 伪造的 ClientHello 在扩展排列、GREASE 插入和公钥格式上的精确差异。

解析 ClientHello 并计算 JA4

#!/usr/bin/env python3
"""
ja4_extractor.py — 从 Wireshark 导出的 ClientHello 中提取 JA4 指纹
依赖: pip install dpkt
"""

import hashlib
import struct
import sys
from collections import OrderedDict

# GREASE 类型码列表(IANA 预留)
GREASE_VALUES = {
0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a,
0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa
}

defis_grease(val):
return val in GREASE_VALUES

defparse_clienthello_extensions(data, offset, length):
"""解析 ClientHello 扩展列表"""
    extensions = []
    end = offset + length
while offset < end:
if offset + 4 > len(data):
break
        ext_type = struct.unpack('!H', data[offset:offset+2])[0]
        ext_len = struct.unpack('!H', data[offset+2:offset+4])[0]
        extensions.append((ext_type, ext_len))
        offset += 4 + ext_len
return extensions

defcompute_ja4(tls_version, ciphers, extensions, alpn_list):
"""简化版 JA4 计算(仅演示核心逻辑)"""
# JA4_a: 版本 + SNI + 密码套件数量 + 扩展数量
    a_str = f"{tls_version:04x}_{len(ciphers)}_{len(extensions)}"
    ja4_a = hashlib.sha256(a_str.encode()).hexdigest()[:12]

# JA4_b: 密码套件列表(按顺序,GREASE 保留)
    b_str = ",".join(f"{c:04x}"for c in ciphers ifnot is_grease(c))
    ja4_b = hashlib.sha256(b_str.encode()).hexdigest()[:12]

# JA4_c: 扩展列表(按顺序,GREASE 保留)
    c_str = ",".join(f"{e[0]:04x}"for e in extensions)
    ja4_c = hashlib.sha256(c_str.encode()).hexdigest()[:12]

# JA4_d: ALPN 列表(按原始顺序)
    d_str = ",".join(alpn_list)
    ja4_d = hashlib.sha256(d_str.encode()).hexdigest()[:12]

returnf"{ja4_a}_{ja4_b}_{ja4_c}_{ja4_d}"

# 示例:Chrome 120 的 JA4 指纹计算
if __name__ == "__main__":
# 模拟 Chrome 120 的 ClientHello 参数
    tls_version = 0x0304# TLS 1.3
    ciphers = [0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f]  # 含 GREASE
    extensions = [
        (0x002b, 5),  # supported_versions (GREASE 插入在列表中)
        (0x000a, 2),  # supported_groups
        (0x0033, 74), # key_share
        (0x002d, 2),  # psk_key_exchange_modes
        (0x000d, 28), # signature_algorithms
        (0x0010, 0),  # application_layer_protocol_negotiation
    ]
    alpn_list = ["h2", "http/1.1"]

    ja4 = compute_ja4(tls_version, ciphers, extensions, alpn_list)
    print(f"JA4: {ja4}")

说明:该脚本演示了 JA4 指纹计算的核心逻辑。实际 JA4 规范还包括 QUIC 支持和更详细的哈希参数,完整实现见 FoxIO 的官方 GitHub 仓库。

防御对抗

基于二阶指纹的异常检测

  • 组合 JA4 + H2Settings + TCP/IP 指纹:单一维度的 TLS 指纹容易被伪造,但组合多个维度的指纹可以大幅提高伪造难度。例如,JA4 显示为 Chrome 120,但 HTTP/2 SETTINGS 帧的参数序列与 Python httpx 一致,则强烈提示底层库的不匹配。
  • 利用 GREASE 统计特征:对来自同一 IP 或同一 JA4 指纹的大量 ClientHello 进行分析,统计 GREASE 扩展的插入频率和位置。Go crypto/tls 的 GREASE 插入频率(100% 插入一个 GREASE 值在密码套件列表中,但扩展列表无 GREASE)与 Chrome(80% 插入 GREASE 扩展)存在显著差异。
  • 监控 key_share 公钥长度:Chrome 的 P-256 公钥长度为 65 字节(未压缩),Go 为 33 字节(压缩)。服务器端可以被动记录 ClientHello 中 key_share 扩展的长度分布,标识不符合已知客户端模型的异常连接。

尽可能逼近目标指纹的“全栈”模拟

  • 不仅伪造 ClientHello,还伪造 HTTP/2 SETTINGS 帧:使用 net/http 的自定义 Transport 修改 SETTINGS 帧的参数值,使其与目标浏览器一致。这需要对 Go 的 HTTP/2 实现进行 monkey-patch 或使用更低级别的 HTTP 库(如 fasthttp)。
  • 使用与目标一致的公钥格式:若目标使用未压缩格式,则修改 Go 的 ECDHE 公钥输出或直接使用 BoringSSL 绑定(如通过 CGo 调用 BoringSSL 的 EC_KEY 生成函数)。但这涉及底层密码学库的替换,技术门槛极高。
  • 动态调整 GREASE 插入策略:通过修改 utls 的 GREASE 生成器,使其模拟目标库的插入频率、位置分布和随机值选择策略。这需要预先分析目标的 GREASE 统计模型。

结语

TLS 指纹的攻防是一场在毫厘之间的信息博弈。JA4 比 JA3 更精细,但指纹的本质决定了它必然存在可伪造与不可伪造之间的“悬崖”——攻击者可以复现扩展排列,却难以复现 BoringSSL 的 ECDHE 公钥格式;可以伪造密码套件列表,却难以模拟 Chrome 的 GREASE 统计特征;可以欺骗 TLS 层,却逃不过 HTTP/2 的二次认证。真正的威胁不是那些完全无法伪造的指纹,而是防御方是否具备将多个维度的指纹联合分析的能力。在指纹悬崖的边缘,一个微小的疏忽就足以让精心伪装的恶意流量现出原形。而对于攻击者而言,从“伪造 ClientHello”到“模拟完整浏览器网络栈”的技术跨越,正成为下一代对抗性恶意软件的关键门槛。