这次调研从一段看起来很合理的判断开始,大意是:
两个客户端虽然都声明模拟 Chrome 150,但实际网络指纹可能不同。Go 的静态 profile 通常不如 curl_cffi 接近真实 Chrome,因此可能更容易被识别。上线前,最好比较一下 JA3 和 Akamai 指纹。
这段话包含一个值得重视的风险,也夹带了一个尚未证明的结论。
版本名称相同,不保证网络行为相同。 但“Go 通常更差”是一个比较性判断,需要具体版本、配置和实测数据支撑。仅凭“Python”“Go”或“静态 profile”这几个标签,还不能得出结论。
于是,调研从查官方资料走向了实际测量:先比较 Chrome 150,再把真实浏览器升级到153,最后不再满足于指纹摘要,直接记录客户端发出的 ClientHello 字节。
结果比最初的判断更有意思。某些差异只需要调整配置就能消失;某些来自 profile 没跟上浏览器版本;还有一些,只有越过哈希摘要,检查字节行为才能看见。
本文记录的是 2026年9月27日、指定环境和依赖版本下的实验。它不是语言排行榜,也没有测量任何具体网站的反自动化通过率。
先弄清楚,我们究竟在比较什么
“浏览器指纹”这个词覆盖的范围很大。这次实验主要关注 TLS 和 HTTP/2,并没有试图用一个 HTTP 客户端模拟整个浏览器。
JA3 从 TLS ClientHello 中提取版本、密码套件、扩展列表、支持组等字段,再计算摘要。它只描述握手的一部分,不是完整 ClientHello,也不是 TCP 指纹。JA3 原项目说明
这里说的 Akamai 指纹,是常见的 HTTP/2 指纹格式,主要包含初始 SETTINGS、WINDOW_UPDATE、PRIORITY 和伪头顺序。它不等于 Akamai 完整的风控规则。curl_cffi 指纹格式文档
JA4 则提供另一种 TLS 指纹表达,但同样不是浏览器版本号。后面的实验会看到:一个名为 Chrome 152 的 profile,也可能产生与真实 Chrome 153 相同的 JA4。
还有一个容易误判的细节:Chrome 会随机排列 TLS 扩展顺序,而原始 JA3 对顺序敏感。因此,同一个 Chrome build 的不同连接,也可能产生不同 JA3。Chromium 的扩展随机排序说明
这次同时比较了 JA3N:将扩展编号排序后计算的 JA3 变体。它能帮助区分“只是扩展排列变了”和“扩展集合本身变了”,但它依然只是选定字段的摘要。
curl_cffi 官方 FAQ 也明确提醒,JA3 和 Akamai 指纹并不全面,建议用实际网络包做更深入的对照。curl_cffi 官方 FAQ
这些资料支持“需要验证”的建议,却不能替代 Python 与 Go 的对照实验。
第一轮:同样模拟 Chrome 150,Go 真的更差吗?
第一轮先把版本对齐:
| 组别 | 实际使用的版本或配置 |
|---|---|
| 真实浏览器 | Chrome for Testing 150.0.7871.124 |
| Python | curl_cffi 0.16.3,chrome150 |
| Go | tls-client v1.16.0,Chrome_150 |
| Go 对照组 | 同一个 profile,分别关闭、开启扩展随机排序 |
真实浏览器使用可见窗口,通过 Playwright 驱动。这里“真实”指运行实际 Chrome 二进制,并不意味着它与手工启动的日常浏览器在所有方面完全相同。
每组建立10次新连接。Chrome 和 Go 每次启动新进程,Python 每次创建新 Session,避免反复观察同一条复用连接。各组访问同一检测端点,使用同一个本机代理入口,并交错执行。
这一轮还统一了普通请求头,让比较更集中在网络实现上。
结果如下:
| 组别 | 10次连接产生的 JA3 种类 | JA3N、JA4、Akamai |
|---|---|---|
| 真实 Chrome 150 | 10 | 基准 |
| Python curl_cffi | 10 | 全部与基准一致 |
| Go,固定扩展排序 | 1 | 全部与基准一致 |
| Go,开启扩展随机排序 | 10 | 全部与基准一致 |
最明显的差异,不是密码套件或 HTTP/2 SETTINGS,而是 Go 是否开启了扩展随机排序。
在本次使用的 Chrome_150 profile 中,不开启该选项时,原始 JA3 始终不变;加入 WithRandomTLSExtensionOrder() 后,它也表现为每次连接产生不同 JA3。
这不能证明双方在所有字节上等价,但已经足以修正最初的推断:固定 profile 不代表无法产生 Chrome 式的随机变化。具体配置,比实现语言更直接地影响这个结果。
假如服务端确实观察多次连接的扩展排序,固定排序就提供了一个潜在的区分依据。但实验没有证据说明某个具体网站一定使用它,更没有测出拦截率。
第二轮:把真实 Chrome 换成153,差异出现了
接下来只升级真实浏览器基准,换成本机的 Chrome 153.0.8010.53。Python 和 Go 暂时保留原来的150配置,每组仍采样10次。
这是一轮刻意进行的跨版本对照,不应被描述成“双方都在模拟153”。
结果很清楚:
| 对比项 | Python 的150配置 | Go 的150配置 |
|---|---|---|
| JA3N | 与 Chrome 153 不同 | 与 Chrome 153 不同 |
| JA4 | 与 Chrome 153 不同 | 与 Chrome 153 不同 |
| Akamai HTTP/2 指纹 | 仍然一致 | 仍然一致 |
真实 Chrome 153 的扩展集合中,多出了编号 51764 的扩展,后来结合库中的 profile 定义确认它对应 trust_anchors。它的签名算法列表首位还包含 GREASE,而两个150配置都没有这个行为。
这里的 GREASE 是有意插入的保留值,用来避免协议实现对固定取值形成依赖,不是实际要协商使用的签名算法。
这一轮说明了两件事。
首先,改成153的 User-Agent,不会让底层150 profile 自动升级。 当时为了控制头部变量,库请求沿用了153基准的普通头模板;TLS 结构却依然停留在150。这种做法适合演示“改头不改握手”的区别,不适合用来声称身份已经一致。
其次,HTTP/2 指纹没变,不代表 TLS 也没变。只检查 Akamai 摘要,就会漏掉本轮已经出现的结构差异。
不过,这仍然不能说明 Go 比 Python 差。它们用的都是150配置,而且以相同方式落后于新的浏览器基准。
第三轮:先查库的能力,再检查实际发出的字节
真正有价值的比较,需要先回答:库到底支持什么?
这次没有只看文档里的默认别名,而是读取已安装 curl_cffi 的目标枚举,以及已编译 Go 客户端中的 profile 映射。检查结果是:
- curl_cffi 0.16.3 提供的最高桌面 Chrome 配置是150。
- tls-client v1.16.0 提供到152。
- 这两个实际安装版本都没有命名为153的 preset。
因此,第三轮使用 Python 150、Go 152,与真实 Chrome 153 比较。这是对各库在本次环境中可用配置的比较,不是同版本、只替换编程语言的实验。
请求头也改成与 profile 绑定:Python 声明150,Go 声明152。不能独立传一个153的 UA,让报告看起来像三个153客户端。真实导航没有发送的高熵 Client Hint,也不编造完整版本号补进去。
随后,实验增加了一个本机 HTTP CONNECT 观察器。它不终止 TLS,不安装证书,不解密应用数据,只转发字节并记录客户端最初发出的 ClientHello。
这一步获得了两类不同的证据:
- 指纹站返回的服务端观察结果。
- 本机实际捕获的 ClientHello 和 TLS record 原始字节。
对 Peet 的样本,两边的 client_random 和 JA3 必须相符,才接受为同一次握手的证据。这也避免误把浏览器额外建立的预连接当成测量请求。
真实 Chrome 的基准分别保存于 tls.peet.ws 和 BrowserLeaks TLS。主实验在同一端点上每组采集20份有效样本;另一端点对各库组补做一次独立交叉核对,没有把不同端点的数据混成一个分布。
正式采样共尝试82次,获得80份有效样本。Python 和 Go 乱序组各发生一次网络中断,错误保留在记录中,随后分别明确补采一次。网络错误没有被改写成“指纹检测失败”。
Go 152 的摘要和已检查结构,竟然能匹配 Chrome 153
第三轮的结果,与“Go 通常更不像 Chrome”的直觉并不一致。
| 项目 | 真实 Chrome 153 | Python 150 | Go 152+乱序 | Go 152+固定排序 |
|---|---|---|---|---|
| 有效样本数 | 20 | 20 | 20 | 20 |
| 原始 JA3 种类 | 20 | 20 | 20 | 1 |
| JA4、JA3N | 基准 | 不匹配 | 匹配 | 匹配 |
| Akamai HTTP/2 | 基准 | 匹配 | 匹配 | 匹配 |
| trust_anchors | 存在 | 未发送 | 存在,ID集合匹配 | 存在,ID集合匹配 |
| 签名算法 GREASE 行为 | 存在且变化 | 未出现 | 存在且变化 | 存在且变化 |
| 本次检查的结构与乱序 | 基准 | 未通过 | 通过 | 乱序项未通过 |
这里的“结构”包含扩展集合、去除 GREASE 后的密码套件和签名算法、支持组、密钥共享组及长度、ALPN、ALPS、证书压缩,以及 trust anchor ID 集合等已检查字段,不代表整个 TLS 状态机经过了完整验证。
一个重要的反例出现了:Go 的配置虽然名为152,实际 JA4 和 JA3N 却与这台153浏览器一致。
所以,不能预设“153必须有一个与152不同的 JA4 尾段”。版本名称要如实记录,协议行为要实际测量,两者不能互相替代。
Python 这一组则存在明确的结构缺口:它的150配置没有发送基准中的 trust_anchors,也没有相同的签名算法 GREASE 行为。注意,这不等于它没有 ML-DSA;本轮不匹配的是上述扩展和 GREASE 行为,不能把不同问题混在一起。
但如果实验到这里就结束,仍然会错过后面的发现。
ClientHello 的192字节差距,能解释清楚
记录原始字节后,就可以直接比较长度,而不是根据 JSON 内容猜测。
下面的 CH 长度指 4字节 handshake 头加 ClientHello body,不包含外层 TLS record 头。
| 组别 | 本轮出现的 CH 长度,字节 | ECH 扩展长度,字节 |
|---|---|---|
| 真实 Chrome 153 | 1914、1946、1978、2010 | 186、218、250、282 |
| Python 150 | 1722、1754、1786、1818 | 186、218、250、282 |
| Go 152+乱序 | 1914、1946、1978、2010 | 186、218、250、282 |
| Go 152+固定排序 | 1914、1946、1978、2010 | 186、218、250、282 |
首先,真实 Chrome 自己就出现了多个长度,而且相邻取值相差32字节。“同一个 build 的 CH 应当几乎固定”“出现网格分布就不自然”,都不能直接当作验收规则。 在本轮数据中,CH 的长度变化与 ECH 长度变化对应。
其次,把 ECH 长度扣除后,差距变得非常清晰:
- Chrome 153 和 Go 152:CH 减 ECH 恒为1728字节。
- Python 150:CH 减 ECH 恒为1536字节。
两者相差192字节,恰好可以由这次观察到的结构差异解释:
trust_anchors 扩展头4字节+payload 186字节+签名算法 GREASE 2字节=192字节。
这比“两个哈希不同”提供了更具体的信息:差异在哪里、占多少字节,以及为什么产生差异,都有对应的原始数据。
Go 与 Chrome 的长度取值相同,但频数并不完全相同。例如,20次采样中,Chrome 的1914字节出现6次,Go乱序组出现3次。
脚本对此给出的是小样本下的探索性告警,没有把它当成统计显著性结论,更没有据此推导网站一定能够识别。
同一个扩展集合,仍然可以有不同的字节行为
更有意思的差异藏在 trust_anchors 内部。
Go 与真实 Chrome 发送的 anchor ID 集合相同,payload 长度也都是186字节。但在各20份样本中:
| 组别 | trust_anchors 中观察到的 ID 排列种类 |
|---|---|
| 真实 Chrome 153 | 2 |
| Go 152+乱序 | 20 |
| Go 152+固定扩展排序 | 20 |
这不是前面讨论的“TLS 扩展列表顺序”,而是 一个扩展内部的 payload 排列。因此,即使关闭 TLS 扩展乱序,Go 的 trust_anchors 内部排列仍可能变化。
它解释了为什么检查扩展集合、JA3N 或 JA4 还不够:外层扩展存在,内容集合也一致,内部排列行为仍可能不同。
这个现象值得继续研究,但现阶段只能准确地说:本轮观察到了排列多样性的差异。
不能把 Chrome 仅出现2种排列写成该版本永远只有2种,也不能把 Go 出现20种直接写成“必然更容易被识别”。是否构成稳定、可利用的区分特征,还需要更大样本和更多环境验证。
为什么报告曾经把 Go 和 Python 都写成“完整153未通过”?
这是实验报告本身需要修正的一点。
最初的总判定混合了两类问题:
一类是实际协议差异,例如缺少扩展、摘要不匹配、没有出现基准中的随机行为。
另一类是版本声明门槛:Go 使用152 profile,头也诚实声明152,因此没有满足“声明目标版本153”的要求。
如果把它们压缩成同一个“失败”,读者很容易误以为两个库都在协议质量上不合格。
实际上,本轮应该这样读:
- Go 152+乱序: 摘要和已检查结构通过,字节行为有告警;没有足够证据声称它与153完整等价。
- Python 150: 与153基准存在直接可见的结构缺口,摘要和结构项未通过。
- Go 152+固定排序: 摘要可以匹配,但扩展乱序行为不匹配,另有字节行为告警。
Go 的 profile 和头都声明152,并没有形成“头说153、配置说152”的内部矛盾。若接受经过验证的等价 preset,就不能仅凭名字里写着152,判定它质量不合格。
反过来,几个摘要相同,也不能作为把它直接重新命名为153的充分依据。
这次实验真正改变了什么
回头看,最初那个“Python还是Go更靠谱”的问题,范围太大。
在 Chrome 150 的同版本实验中,两个库的主要摘要和已检查字段都能对齐。显著差异来自 Go 的扩展乱序选项。
当真实浏览器换成153、两个库仍停在150时,它们都出现结构差异。
当 Go 使用本次依赖中更高的152配置后,它在已测网络特征上又比 Python 的150配置更接近153。但这反映的是 具体版本和 profile 的能力,不能上升为编程语言的优势。
对后续选型,更有帮助的问题是:
这个版本实际支持哪个 profile?它发出的 ClientHello 包含什么?请求头与声明是否一致?新连接、复用连接、会话恢复分别是什么行为?观察到的是稳定结构差异,还是正常随机变化?
本次仍有明确边界:测试运行在 macOS 和指定 Chrome build 上,经过同一个代理入口,但不能保证所有连接使用同一个出口;没有测 TCP SYN 或 JA4T,也没有覆盖 TLS 会话恢复、HTTP/3、JavaScript 指纹和真实业务站点。
保存下来的文件是 ClientHello 和 TLS records 二进制,并非 Wireshark pcap。测试没有访问 Ticketmaster 验证检测规则,所以不能从这些结果反推出它会拦截哪个客户端。
现在能支持的判断是:Go 默认排序可以成为差异,旧 profile 确实可能缺少新结构,摘要一致之后也仍有值得检查的字节行为。
至于“Go通常比curl_cffi更容易被识别”,这次实验没有证明它。实验反而说明,值得比较的是实际发出的数据,而不是代码使用的语言。
实验实现参考:curl_cffi、tls-client、uTLS、Playwright。
文中数字来自三轮本地实验的保存记录,可查看脱敏后的实验汇总数据。第三轮分析器包含14项针对性测试,覆盖分段握手解析、长度计算、GREASE、未知扩展、版本绑定与缺失证据处理。本文没有据此宣称已经验证完整浏览器等价或真实站点通过率。