在过去,网络研究和流量分析往往只需关注 HTTP 协议层的 Header(例如 User-Agent、Accept-Language),但在现代网络架构中,由于各类中间件和防火墙的升级,流量的身份识别早已下沉到了传输层(TLS)和应用层协议(HTTP/2)
本文将从技术研究的角度,深入剖析目前最主流的三种网络指纹:JA3、JA4 以及 Akamai HTTP/2 指纹,探讨它们是如何被计算出来的,以及如何从底层协议去复现这些特征
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 指纹
随着 TLS 1.3 和 QUIC 的普及,JA3 的局限性逐渐显现,JA4 是一种更现代、高度模块化的指纹格式,它的格式类似于 t13d1516h2_8daaf6152771_b120ffed55aa,分为三部分:
- 协议特征 (a):t13 (TCP+TLS 1.3),d (SNI为域名),1516 (加密套件数量),h2 (ALPN 为 h2)
- 加密套件哈希 (b):提取 ClientHello 中 Cipher Suites 列表的截断 SHA256 哈希
- 扩展特征哈希 (c):提取 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
它的含义是:
- 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
- WINDOW_UPDATE:首个 Window Update 帧的增量为 15663105
- PRIORITY:优先级树信息,这里 Chrome 没有发送特殊的优先级权重,计为 0
- Pseudo-Headers (伪头):m,a,s,p 代表发送 HTTP 请求时的头部顺序为 :method, :authority, :scheme, :path
3. 从理论到实战:特征的接管
理解了原理,接下来就是如何在代码里实现,但 Go 语言原生的 crypto/tls 标准库并不允许自定义 ClientHello 顺序,net/http 也硬编码了 HTTP/2 的成帧逻辑,既然原生库走不通,唯一的办法就是直接向下接管底层协议栈
为了把这套复杂的接管流程沉淀下来,我顺手把它封装成了一个开源项目 illutls,借着它的内部实现,正好可以直观地看看这些底层特征到底是怎么被我们重写的
先说 TLS 这一层,在拿到建立好的 TCP 连接后,我没有走标准库,而是把它直接抛给了 refraction-networking/utls 进行包装,虽然代码里预先硬编码了目标浏览器的 ClientHelloSpec 参数用来对齐 JA3/JA4,但光这样其实不够,为了避免鹦鹉,我在发包前多做了一步深拷贝,加了个动态的 GREASE 随机化处理机制
// 每次请求前,深拷贝 Spec 并动态替换 GREASE
spec := CloneClientHelloSpec(t.profile.TLSSpec)
RandomizeGREASE(spec)
uConn := utls.UClient(rawConn, tlsCfg, utls.HelloCustom)
uConn.ApplyPreset(spec)
这个 RandomizeGREASE 函数其实就是在加密套件和扩展列表里挨个找 0x?a?a 这种特定规律的占位符,找到之后每次建立连接都用合法的随机值给它换掉,这样不仅骗过了结构性的特征校验,还保留了真实浏览器那种每次握手都不太一样的动态特征
等 ALPN 协商确认走 h2 协议之后,真正的硬核戏码才刚刚开始,这时候必须介入 HTTP/2 的成帧器,我引入了定制过的 bogdanfinn/fhttp/http2,在 ApplyH2Settings 函数里直接强行覆写了底层的各种状态
在具体的代码层面,我们需要直接干预 HTTP/2 的帧发送逻辑,代码中不仅重置了 Settings 的映射字典,还通过 SettingsOrder 切片严格接管了参数的发送顺序,以此确保 HEADER_TABLE_SIZE 等字段能精准贴合真实浏览器的特征,处理完 Settings 帧后,对于 WINDOW_UPDATE 的伪造,我通过覆写 ConnectionFlow 的方式强行发出了特定大小的窗口更新增量,并在需要时将优先级树的权重与流依赖关系一并注入底层
请求头顺序,通过配合 fhttp 自带的机制和项目里预设的 HeaderOrder 切片,HPACK 编码器只能乖乖地按照 m,a,s,p 这种伪头顺序,连同其他的常规 Header 一起打包发送出去
// 节选自 h2.go
if s.HeaderTableSize > 0 {
t.HeaderTableSize = s.HeaderTableSize
t.Settings[http2.SettingHeaderTableSize] = s.HeaderTableSize
t.SettingsOrder = append(t.SettingsOrder, http2.SettingHeaderTableSize)
}
// 强制设置 WINDOW_UPDATE
if windowUpdate > 0 {
t.ConnectionFlow = windowUpdate
}
通过一系列的操作,底层的字节流和帧结构已经被彻底重塑了,外部那些检测系统抓包看起来,这发出来的就是一台完美的真实浏览器发出的流量
写在最后
对抗的战线,已经从早期的 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 程序将极难跨越特权边界去进行直接干预
至于未来要如何在应用层去伪造这些内核级别的特征,那就是另一个维度的麻烦了
免责声明: 本文所涉及的技术分析与代码片段仅供学术研究、网络安全防御测试及合规的技术交流使用。请勿将本文内容或关联项目用于任何未经授权的测试、非法爬虫、规避安全防护系统或其他违反使用者所在地及相关管辖区法律法规的活动。因滥用本文涉及的技术或工具而造成的任何直接或间接法律责任及后果,均由使用者自行承担,原作者概不负责