Iruko

優しい世界に 誰だって行きたいわ

Iruko

从 JA3/JA4 到 Akamai HTTP/2 指纹,以及对底层特征的接管

目录

在现代网络架构中,由于各类 WAF 和风控系统的升级,仅靠伪造 HTTP Header(如 User-Agent、Cookie)早已无法绕过身份识别,服务端的检测特征已逐渐下沉至传输层(TLS)与应用层协议(HTTP/2)

本文将从技术研究的角度,剖析目前最主流的三种底层指纹:JA3、JA4 以及 Akamai HTTP/2,探讨它们的生成原理,并结合 Go 语言的底层协议栈,演示如何实现对这些特征的完全接管


1. TLS 指纹:JA3 与 JA4

当客户端与服务器建立 HTTPS 连接时,第一步是进行 TLS 握手,在握手的 ClientHello 阶段,客户端会明文发送自己支持的加密算法、扩展等信息,由于不同浏览器和不同编程语言底层的 TLS 实现不同,这个明文的 ClientHello 就成了极佳的“指纹”

1.1 JA3 指纹是如何产生的?

JA3 是一种经典 TLS 指纹算法,它的生成规则非常简单粗暴,即将 ClientHello 中的五个关键字段提取出来,用英文逗号拼接,然后计算 MD5

拼接格式: TLS版本,支持的加密套件(用-连接),支持的扩展(用-连接),支持的椭圆曲线(用-连接),椭圆曲线点格式(用-连接)

例如,一个典型的 JA3 原始字符串: 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0 对这个字符串求 MD5,得到的就是熟悉的 32 位 JA3 Hash:340b5b91cdefd018d1ab75fb30fbdd43

1.2 现代演进:JA4 指纹

JA3 算法虽然经典,但它只是对整个明文字符串做了一次简单粗暴的 MD5,这导致它存在一个致命缺陷:极度脆弱

这主要归咎于现代 Chrome(BoringSSL)引入的两大防僵化机制:

  1. GREASE 机制(RFC 8701):浏览器会在每次握手时,随机向 ClientHello 中插入一些无效的特征值(如加密套件、扩展等)
  2. 扩展乱序:现代浏览器会在每次握手时,动态打乱绝大部分 Extension 的发送顺序

由于 JA3 只是对原始明文直接求 MD5,它把随机变化的 GREASE 和每次都在变动顺序的扩展列表全算进去了,这导致同一个 Chrome 浏览器在每次发起请求时,产生的 JA3 Hash 都在剧烈变动,防守方根本无法建立稳定的特征库

为了应对这种动态变化,JA4 指纹应运而生,它的核心改进恰好针对了上述两点:一是主动剔除干扰项,二是强制排序,在计算哈希前,JA4 会明确过滤掉所有 GREASE 相关的占位符,然后再将剩余的加密套件和扩展列表进行排序,这种机制不仅抹平了 GREASE 带来的随机影响,也直接废掉了浏览器的扩展乱序机制

JA4 的格式类似于 t13d1516h2_8daaf6152771_b120ffed55aa,主要分为三段:

  1. 协议特征 (a):t13 (TCP+TLS 1.3),d (SNI为域名),1516 (加密套件数量),h2 (ALPN 为 h2)
  2. 加密套件哈希 (b):剔除 GREASE 并排序后,对 Cipher Suites 列表计算出的截断 SHA256 哈希
  3. 扩展特征哈希 (c):剔除 GREASE 并排序后,对 Extensions 列表计算出的截断 SHA256 哈希

2. 应用层:Akamai HTTP/2 指纹

即使 TLS 层面看起来毫无破绽,当进入 HTTP/2 层通信时,依然存在另一层更隐蔽的校验,Akamai 提出了一种专门针对 HTTP/2 的指纹生成算法

HTTP/2 指纹由四个以 | 分割的部分组成: SETTINGS参数 | WINDOW_UPDATE增量 | 优先级信息 | 伪头顺序

例如一个典型的 Chrome 指纹: 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p

它的含义是:

  1. SETTINGS 帧:
    • 1:65536 -> HEADER_TABLE_SIZE 为 65536
    • 2:0 -> ENABLE_PUSH 为 0 (禁用)
    • 4:6291456 -> INITIAL_WINDOW_SIZE 为 6291456
    • 6:262144 -> MAX_HEADER_LIST_SIZE 为 262144
  2. WINDOW_UPDATE:首个 Window Update 帧的增量为 15663105
  3. PRIORITY:优先级树信息,这里 Chrome 没有发送特殊的优先级权重,计为 0
  4. Pseudo-Headers (伪头):m,a,s,p 代表发送 HTTP 请求时的头部顺序为 :method, :authority, :scheme, :path

3. 从理论到实战:特征的接管

原生 Go 语言的 crypto/tls 标准库并不允许自定义 ClientHello 的字段顺序,而 net/http 同样把 HTTP/2 的成帧逻辑写死了,既然上层库走不通,唯一的办法就是直接向下接管底层协议栈

为了把这套接管流程沉淀下来,我将其封装成了开源项目 illutls,借着它的内部实现,我们可以直观地看看这些底层特征是如何被重写的

先看 TLS 层,我们的 ClientHelloSpec 模板是从真实浏览器的抓包数据中硬编码提取的,在拿到 TCP 连接后,这个模板会被交给 refraction-networking/utls 进行组装

但直接重放抓包数据会存在一个逻辑漏洞:真实浏览器的 ClientHello 是动态变化的,而抓包得到的只是某一时刻的静态快照,如果每次请求都原封不动地发送这个静态快照,会导致以下两个问题:

  1. 固化的扩展顺序:现代 Chrome(BoringSSL)引入了扩展乱序(Extension Permutation)机制,每次握手时 Extension 的顺序都会随机打乱,我们当初抓包拿到的,其实只是它在某次握手时碰巧生成的一个特定乱序排列,如果我们的程序以后每次都发这个固定的顺序,导致 JA3 Hash 永远不变,反而不符合 Chrome 应该一直处于乱序状态的真实特征
  2. 固定的 GREASE 值:抓包数据里的 GREASE 值是写死的,如果 WAF 发现一个客户端每次发起连接时,GREASE 特征位都不发生变化,就能判定这是一个重放行为

为了解决这个问题,我们在每次发包前都需要对静态模板做一次深拷贝,重新打乱扩展顺序,并动态注入新的 GREASE:

// 每次请求前,深拷贝真实的浏览器 Spec 模板
spec := CloneClientHelloSpec(t.profile.TLSSpec)

// 1. 动态生成并替换 GREASE(完全复刻 BoringSSL 的 Seed-Index 机制)
greaseSeed := uint16(rand.Intn(0xFFFF))
getGreaseValue := func(index uint16) uint16 {
    // BoringSSL 逻辑:结合特征 Index 与 Seed 进行运算,取低 4 位作为 ω
    w := (greaseSeed ^ index) & 0x0F 
    // 构造出 0xωAωA 格式的合法 GREASE 值
    return (w << 12) | 0x0A00 | (w << 4) | 0x000A
}

// 针对不同字段使用不同的 Index 避免特征值碰撞
for i, cs := range spec.CipherSuites {
    if isGREASE(cs) {
        spec.CipherSuites[i] = getGreaseValue(0) // CipherSuites 对应 Index 0
    }
}
// 扩展列表同样使用 getGreaseValue(1)、getGreaseValue(2) 来保证数值不同...

// 2. 模拟现代 Chrome 的扩展乱序 (Extension Permutation) 机制
// 注意:不能简单地 rand.Shuffle 打乱所有扩展!
// 必须保证首位是 GREASE,且 Padding 和 PSK 必须被压在最后面
var shufflable, pre, padding []utls.TLSExtension
for _, ext := range spec.Extensions {
    switch ext.(type) {
    case *utls.UtlsPaddingExtension, *utls.UtlsPreSharedKeyExtension:
        padding = append(padding, ext)
    default:
        if len(pre) == 0 && isGREASEExt(ext) {
            pre = append(pre, ext) // 保留首位 GREASE
        } else {
            shufflable = append(shufflable, ext)
        }
    }
}
// 仅对中间的常规扩展进行乱序
rand.Shuffle(len(shufflable), func(i, j int) {
    shufflable[i], shufflable[j] = shufflable[j], shufflable[i]
})
// 重新拼装 ClientHello 扩展队列
spec.Extensions = append(append(pre, shufflable...), padding...)

uConn := utls.UClient(rawConn, tlsCfg, utls.HelloCustom)
uConn.ApplyPreset(spec)

很多人对 GREASE(RFC 8701)的理解仅仅是“在加密套件和扩展列表里随机塞几个 0x?A?A”,但如果去翻阅 Chromium 底层 BoringSSL 的源码(如 ssl/handshake.cc),就会发现它的 GREASE 并不是随便 rand() 出来的

在一次握手中,BoringSSL 会维护一个统一的 grease_seed,然后根据当前需要注入的 GREASE 类型(如 CipherSuite、Extension、ALPN 等)结合特定的 Index 进行运算,生成对应的 0x1A1A0x2A2A 等值,此外,它还会进行碰撞检测,确保同一个 ClientHello 中不同的 GREASE 字段不会出现相同的值,如果仅仅是简单粗暴地全随机替换,很容易被高级的指纹检测系统察觉出规律异常,因此,RandomizeGREASE 函数实际上是完全复刻了 BoringSSL 的这种 seed 生成逻辑

搞定 TLS 层并完成 ALPN 协商后,就要处理 HTTP/2 的成帧特征了,这里我引入了定制版的 bogdanfinn/fhttp/http2,在 ApplyH2Settings 函数里直接强行覆写了底层的各种状态

具体而言,代码不仅重置了 Settings 映射字典,还通过 SettingsOrder 切片严格控制了参数的发送顺序,确保 HEADER_TABLE_SIZE 等字段能精准贴合目标浏览器的特征,处理完 Settings 帧后,对于 WINDOW_UPDATE 的伪造,我通过覆写 ConnectionFlow 强制发出了特定大小的窗口更新增量,顺带将优先级树的权重与流依赖关系也一并注入

至于请求头和伪头(Pseudo-Headers)的顺序,这在原生 net/http 中是完全黑盒的,但在 fhttp 中,它通过特殊的 Map Key(如 http.PHeaderOrderKey)暴露了底层 HPACK 编码器的排序逻辑,在代码中,我们可以直接把包含 m,a,s,p(即 :method, :authority, :scheme, :path)的切片塞进去,强制编码器按照该顺序输出字节流:

// 节选自 transport.go 和 header.go
// 1. 设置 HTTP/2 Header 和 Pseudo-Header 排序
if len(profile.HeaderOrder) > 0 {
    h[http.HeaderOrderKey] = profile.HeaderOrder
}
if len(profile.PHeaderOrder) > 0 {
    h[http.PHeaderOrderKey] = profile.PHeaderOrder
}

// 2. 深入 http2.Transport,强行注入帧层面的指纹
func ApplyH2Settings(t *http2.Transport, s H2Settings, windowUpdate uint32, priority H2Priority) {
    // 严格控制 SETTINGS 帧的内容及发送顺序
    t.Settings = make(map[http2.SettingID]uint32)
    t.Settings[http2.SettingInitialWindowSize] = s.InitialWindowSize
    t.SettingsOrder = []http2.SettingID{
        http2.SettingHeaderTableSize,
        http2.SettingEnablePush,
        http2.SettingInitialWindowSize,
        http2.SettingMaxHeaderListSize,
    }
    
    // 劫持首次 WINDOW_UPDATE 帧的增量
    if windowUpdate > 0 {
        t.ConnectionFlow = windowUpdate
    }
    
    // 注入 PRIORITY 帧的权重与流依赖关系 (RFC 7540)
    if priority.Weight > 0 {
        t.HeaderPriority = &http2.PriorityParam{
            Weight:    priority.Weight,
            StreamDep: priority.DependsOn,
            Exclusive: priority.Exclusive,
        }
    }
}

不仅是 Header 顺序,在上面这段核心代码中,我们直接操控了 http2.Transport 的底层结构体成员:我们显式指定了 SettingsOrder(决定 SETTINGS 帧中各参数的串行排列顺序)、通过 ConnectionFlow 强制定义了首次发送的窗口更新增量,还利用 HeaderPriority 写入了目标浏览器独有的并发流依赖优先级权重

通过这一套操作,底层的字节流和帧结构已经被完全接管,从外部抓包来看,这段 Go 程序发出的流量特征已经和一台真实的浏览器别无二致了

写在最后

对抗的战线,已经从早期的 HTTP Header 伪装,下沉到了模拟 JA3/JA4 的密码学特征,再细化到了 HTTP/2 帧排列的逐字节博弈,但这绝不是终点,未来这种身份识别的对抗极有可能会进一步下沉到更底层的网络层与传输层,即对 TCP/IP 指纹(如 p0f 操作系统特征)的精准识别

到那时,服务端不仅会看你的 TLS 和 HTTP/2,还会校验你建立 TCP 连接时的初始窗口大小(Window Size)、TCP 选项的排列顺序(MSS, SACK, Window Scale, Timestamps 等),甚至 IP 数据包的 TTL 和 DF(Don’t Fragment)标志位,因为这些特征通常是由操作系统内核的网络栈(如 Windows 的 tcpip.sys 或 Linux 内核)硬编码决定的,处于应用层的 Go 程序将极难跨越特权边界去进行直接干预

至于未来要如何在应用层去伪造这些内核级别的特征,那就是另一个维度的麻烦了


免责声明: 本文所涉及的技术分析与代码片段仅供学术研究、网络安全防御测试及合规的技术交流使用,请勿将本文内容或关联项目用于任何未经授权的测试、非法爬虫、规避安全防护系统或其他违反使用者所在地及相关管辖区法律法规的活动,因滥用本文涉及的技术或工具而造成的任何直接或间接法律责任及后果,均由使用者自行承担,原作者概不负责