TLS 指纹:JA3、JA4 与 HTTP/2 帧
ClientHello 如何生成 JA3 或 JA4,为什么一个值在多次运行间会变,SETTINGS 帧声明了什么,以及如何检查自己的客户端。
BotBrowser Team
连接层指纹由任何文档出现之前就已存在的字节计算得出。BotBrowser 提供声明式配置,可将页面可读的各表面保持在彼此一致的状态,但它不负责 TLS ClientHello 或 HTTP/2 设置帧,因为两者都不是页面表面,页面设置也不会更改它们。下面的分析基于一个抓取的 ClientHello 和一个解码后的设置帧,说明每种指纹如何拼装,并解释同一浏览器的两次运行为何在一种构造下得到稳定值,在另一种构造下得到不同值。
摘要
- JA3 按出现顺序拼接 ClientHello 的五个字段并对结果做哈希,因此扩展顺序本身就是值的一部分。
- 一个抓取到的 Chrome 157 ClientHello 生成了
t13d1517h2作为 JA4 前缀:TLS 1.3、存在域名、15 个加密套件、17 个扩展,以及取自列表第一个协议名的h2。 - 同一浏览器的两次运行产生了不同的 JA3 值和相同的 JA4 值,因为 Chrome 会随机打乱扩展顺序,而 JA4 在哈希前先排序。
- 两次运行之间扩展顺序完全改变,而排序后的集合保持不变,所以未经排序的哈希测的是一个客户端并不固定的顺序。
- TLS 1.3 客户端报告
legacy_version0x0303,并把真实版本放在支持版本扩展中,因此只看旧字段会把 TLS 1.3 握手读成 TLS 1.2。 - HTTP/2 SETTINGS 帧由六字节条目组成,每个条目含一个两字节标识符和一个四字节值;客户端和服务器发送的条目集合不同。
- 两次握手只在提供的协议列表上不同,却以相同加密套件到达相同 TLS 版本并协商出不同协议;页面能读到协商出的名称,读不到其后的提供列表。
- 页面脚本读不到其中任何一个值,页面侧的任何设置也不能改变它们。 下面各节依次是:ClientHello、JA3 顺序、JA4 前缀、SETTINGS 帧、运行间变化,以及如何检查自己的客户端。
目录
- ClientHello 及其字段,以及 JA3 的顺序问题
- JA4、前缀含义与 TLS 1.3 隐藏的内容
- SETTINGS 帧、运行间变化与代理终结
- 检查自己的客户端,以及为什么指纹不是结论
- 实践结论
ClientHello 及其字段,以及 JA3 的顺序问题
TLS 连接的第一条消息是 ClientHello。它声明客户端支持的版本、接受的加密套件,以及一组带编号的扩展。从本地监听器而不是从库对象抓取,才让后面的分析可核查,因为这些字节是对端真正读取的输入。
record_bytes=1949 handshake_bytes=1944
legacy_version=0x0303
offered_versions=0x0304,0x0303
sni=localhost
alpn=h2,http/1.1
cipher_count=15 extension_count=17
groups=11ec,001d,0017,0018
point_formats=0
输出里有两行集中了这门主题的大部分误解。第一行是 legacy_version=0x0303,这是 TLS 1.2 的值,尽管这里是一个 TLS 1.3 客户端;真正的答案在支持版本扩展的 0x0304。把旧字段当作版本报告的工具读字段没错,回答的却是与读者所问不同的问题。
第二行是扩展列表里的 grease 值。客户端插入一个 grease 值来检查服务器能否正确处理未知编号,这里描述的每种指纹构造都会过滤 grease。保留这些值的解析器会得到每次连接都变化、与任何记录都不匹配的指纹。
JA3 取出 ClientHello 的五个字段,按固定顺序写入,用逗号和连字符连接,再对结果做 MD5。五个字段分别是版本、加密套件列表、扩展列表、椭圆曲线列表和椭圆曲线点格式列表。构造简单到可以完整说明,而正是这种完整性让它传播得如此广泛。
ja3_string=771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,17613-35-51-13-11-43-65281-5-0-65037-10-45-16-23-51764-27-18,4588-29-23-24,0
ja3=33bb5fa9c4be545f39008976c0f67d67
读这个字符串的第二个字段,值为何脆弱就显而易见了。加密套件按客户端提供的顺序列出,而顺序进入哈希。同一组十五个加密套件换一个次序写入,就会得到不同的 MD5,而客户端支持的内容没有任何变化。
扩展字段把同样的弱点表现得更强。扩展是一个列表,打乱列表的客户端仍然具备相同能力。JA3 对打乱后的顺序做哈希。这不是定义本身的缺陷,因为定义写的就是这样;这是值能表达什么的边界,把变化的 JA3 当成客户端变化的证据,就是把顺序读成了身份。
JA4、前缀含义与 TLS 1.3 隐藏的内容
JA4 保留原思路并改了两点:哈希前先对每个列表排序,并在前面加一个可读前缀,让值无需查表就能检查。前缀是固定的字符序列,每个字符陈述一个具体事实。
ja4_prefix=t13d1517h2
ja4=t13d1517h2_8daaf6152771_cb7bf5808d99
t 表示 TCP 连接。13 是协商出的 TLS 版本,取自支持版本扩展而非旧字段。d 表示存在域名,客户端连接的是地址且未发送域名时则为 i。其后的两位 15 是加密套件数量,第二对 17 是扩展数量。末尾的 h2 取自提供列表中第一个协议名的首尾字母数字字符,所以 h2 得到 h2,而以 http/1.1 开头的列表得到 h1。
第一个下划线后的值对加密套件做哈希,第二个下划线后的值对扩展和签名算法一起做哈希。由于两个列表都先排序,重新排列不会改变任一哈希。正是这一处改动,把描述客户端的值与描述某条具体消息的值区分开来。
前缀里的计数与哈希的计数不是同一个数字。前缀统计每个非 grease 扩展,包括域名扩展和协议协商扩展,所以一份带十七个非 GREASE 扩展的抓包,前缀里写 17,实际参与哈希的是剩下的十五个。哈希部分去掉这两个,因为前缀已经说明它们,再把剩余列表与签名算法一起哈希。手工重算两者的人必须把两个计数分开,前缀算出比预期低两位,通常是把哈希的排除规则用到了前缀上。
HTTP/2 连接以设置帧开始,这个帧是一组六字节条目:两字节标识符加四字节值。已定义的标识符有六个,某个客户端只发送其中一部分。
settings:chrome_like bytes=24 HEADER_TABLE_SIZE=65536 ENABLE_PUSH=0 INITIAL_WINDOW_SIZE=6291456 MAX_HEADER_LIST_SIZE=262144
settings:node_like bytes=30 HEADER_TABLE_SIZE=4096 ENABLE_PUSH=0 INITIAL_WINDOW_SIZE=65535 MAX_FRAME_SIZE=16384 MAX_CONCURRENT_STREAMS=4294967295
settings_distinct=true
因此两个都说 HTTP/2 的客户端会发送明显不同的帧。第一个声明窗口 6291456 和头部列表上限 262144;第二个声明窗口 65535 和帧大小 16384。帧长度本身也不同,24 字节对 30 字节,因为第一个发送四个条目,第二个发送五个。
方向和内容同样重要。客户端发送一套条目,从服务器读到另一套:
server_sees_client_settings -> headerTableSize=4096 enablePush=true initialWindowSize=65535 maxFrameSize=16384 maxConcurrentStreams=100
这段输出是服务器从对端观察到的内容,而不是任一方打算声明的内容。拿抓到的帧与文档里的默认值比较,等于拿消息与默认值比较;只要客户端省略了某个标识符,两者就会不一致,因为省略的条目保持默认值。
转向 TLS 1.3 改变了指纹分析的对象,这一点容易陈述,也容易被误读。ClientHello 之后的握手几乎全部加密,包括服务器发送的证书。因此路径上的被动观察者先看到明文的 ClientHello,随后只看到密文。观察者能学到的东西确实减少了,并带来一个具体后果:ClientHello 成为连接中信息量最大的消息,因为它是唯一仍然可读的消息。
对指纹的影响比听起来要窄。JA3 只读 ClientHello,所以 TLS 1.3 没有移除它的输入,只是让这些输入在总量中占比更大。JA4 在设计时考虑到了这一点,读取支持版本扩展,使 TLS 1.3 客户端标记为 13,而不是旧字段暗示的 12。两个值描述的都是开场消息,都不描述其后的加密交换。
还有第二个不太明显的影响。TLS 1.3 中客户端在扩展里发送密钥共享,猜中服务器偏好组的客户端可以提前一个往返发送数据。因此客户端提供的组集合及其顺序,部分属于性能决策而不只是兼容性决策;改变默认组的构建可能改变握手中靠近加密算法的部分,却不改变页面能读到的任何内容。比较跨版本升级的两个指纹时,应当预期组列表会变化,而不要把这种变化读成配置漂移。
经代理的连接会引出一个问题:指纹描述的是哪个客户端,答案取决于 TLS 在哪里终止。代理只隧道转发原始字节时,客户端自己的 ClientHello 原样到达目标,指纹属于客户端。代理终止 TLS 并另开一条自己的连接时,目标看到的是代理的 ClientHello,指纹属于代理。
tunnel_mode -> target reads the client's ClientHello; value describes the client
terminate_mode -> target reads the proxy's ClientHello; value describes the proxy
这个区别解释了一种看似不可能的记录:两台装了不同浏览器的机器给出相同指纹,或者一台机器在交替请求中给出两个不同指纹。两者都不是测量错误。前者是终止型代理用固定协议栈挡在两个浏览器前面,后者是成员使用不同 TLS 库构建的代理池。
实际检验就是前面描述的抓取,在两端各做一次。在客户端抓一次,再抓目标实际收到的内容;两者不同,说明路径上有环节重建了握手,此后关于客户端的所有结论都建立在错误的消息上。这也是跨两个网络比较指纹时必须说明路由而不只是浏览器的原因,因为改变的变量可能就是路由。
SETTINGS 帧、运行间变化与代理终结
这组测量里最有用的一个,往往最出人意料。同一浏览器、同一版本、同一配置文件设置,对同一监听器启动两次,产生两个不同的 JA3 值和一个相同的 JA4 值。
run1: bytes=1949 ja3=b488a1aa72f7622264c0b7ecca9dcbf0 ja4=t13d1517h2_8daaf6152771_cb7bf5808d99
run2: bytes=1917 ja3=c88062ec9c84b56a124df18d005b1d9b ja4=t13d1517h2_8daaf6152771_cb7bf5808d99
same_ja3=false
same_ja4=true
后来的一对运行产生 1917 字节和 1949 字节,结果仍然是两个不同的 JA3 和一个共享的 JA4。字节数也会移动,因为扩展列表里的 grease 值宽度不同,所以长度相等只是一对运行的巧合,不是客户端的属性。
两次运行的字节数不同,这是 grease 值发生移动的第一条线索:Chrome 每次连接都会为加密套件列表、扩展列表和签名算法列表各抽一个新的 grease 编号,这些占位符的宽度会变化。过滤掉 grease 后,底层集合一致,而原始顺序仍不一致,所以排序比较成立,不排序比较不成立。
run1 ext order: 65037-11-35-0-23-13-5-65281-10-18-16-43-45-51764-17613-27-51
run2 ext order: 10-27-45-5-43-18-51-0-23-35-65037-16-11-17613-51764-65281-13
same_raw_ext_order=false
same_sorted_set=true
same_ja4=true
由此可得三条实用读法。同一浏览器两次运行之间 JA3 变化是正常的,不能作为配置错误的证据,这也是排查任何其他问题前应先排除的一项。多次运行中保持不变的 JA4 描述的是客户端而不是某条消息。只报告 JA3 而不说明来自哪条消息的工具,记录的是一个连接的事实,不是浏览器的事实。
其他变化确实会同时移动两个值,值得与重排序分开。TLS 库升级可能增加或删除加密套件,构建可能增加扩展,终止 TLS 的代理会整体替换握手,值随之属于代理而不是它后面的浏览器。这些变化都会改变排序后的集合,所以 JA4 变化与 JA3 变化是两类不同的事件。
检查自己的客户端,以及为什么指纹不是结论
了解自己客户端发送什么,安全做法是读取自己拥有的握手。一个接受连接并打印第一条记录的本地监听器就够用,也不需要证书,因为握手根本不会完成。
import socket
import struct
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8443))
server.listen(1)
# Point the client you want to measure at https://localhost:8443 and accept its hello.
connection, _ = server.accept()
record = connection.recv(8192)
connection.close()
server.close()
length = struct.unpack(">H", record[3:5])[0]
print("record_type", record[0], "record_length", length, "captured", len(record))
print("first_bytes", record[5:9].hex())
预期记录类型为 22,即握手记录,长度接近抓到的字节数。记录头之后的四个字节是握手类型和三字节长度。抓取返回空,通常意味着客户端尝试了该地址、失败后继续前进,这发生在域名解析到的地址并不是你监听的地址时。
两条规则让抓取可用。把客户端指向自己的监听器,而不是你不运营的站点,因为抓取别人的端点测量的是对方的连接。并且抓取不止一次,因为单条消息无法说明值是否稳定;前面的多次运行比较,才是把一个数字变成论断的关键步骤。
这里描述的值是描述性的,而描述客户端与评判客户端之间的差距,正是大多数误读发生的地方。JA4 与已知浏览器匹配,只说明握手与该浏览器的协议栈一致。这不代表会话合法,值不匹配也不代表会话是自动化的。同样的源码换一个 TLS 库编译、改写握手的公司中间设备、运行在终止型代理后面的浏览器,都会在没有自动化参与的情况下产生不匹配。
因此连接层的值通常要与其他证据一起读,而不是单独读。单个值只承载一个事实:这些握手参数在这条连接上被提供过。把它变成关于会话的论断,需要来自另一层的第二个观察,论断的强度取决于两者的一致程度。页面报告一种环境而握手报告另一种,这是值得关注的案例,恰恰因为两者之一可以被测量为错误。
对运行浏览器集群的人来说,有用的习惯是把值与路由、时间戳一起记录,而不是孤立保存。没有路由的指纹无法回答后来读者会问的问题:是客户端变了,还是路径变了。前面多次运行一节里的那对测量,就是这种记录的最小版本:两次运行、一条路由、一个值变化、一个值不变。再记下前缀 t13d1517h2 和抓包长度,另一位读者无需重测就能核对这一行。
实践结论
把 JA3 读成关于一条消息的事实,把 JA4 读成关于一个客户端的事实,令人困惑的报告就不再令人困惑。JA3 哈希的扩展顺序由客户端在连接之间打乱,所以 JA3 变化而 JA4 不变是预期结果,不是故障。TLS 1.3 握手故意报告 legacy_version 0x0303,所以只看该字段会低估协议版本。设置帧描述的是一个客户端向某个对端声明的内容,未出现的标识符保持默认值,这也是两个 HTTP/2 客户端看起来不同却都正确的原因。哪些检测规则需要线路值说明这些事实在哪里进入决策,网络信息指纹则涵盖页面自己能触及的相邻事实。
这些都不能让页面侧配置文件为握手负责。声明的配置文件可以让可读表面彼此一致 不依赖浏览器、直接使用这套构造的客户端,在curl-impersonate 与 curl_cffi 的 TLS 一致性里有实测。,而握手由它们下方的 TLS 协议栈产生,所以一致的文档和稳定的握手是两个不同位置得到的两个独立结果。把前者当作后者的证据,就是把两个独立结果合并成一个错误结论。
来源
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界