返回知识中心
指纹

curl-impersonate 与 curl_cffi:TLS 一致性的边界

三个客户端连接同一个监听器:模拟 TLS 的 HTTP 客户端能匹配什么,为什么仍会差一个扩展,以及它永远到不了的页面层。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

HTTP 客户端可以被配置成发送一个非常接近浏览器的握手,而这种相似性会在第一个需要文档的表面上终止。BotBrowser 提供一个声明式配置文件,让页面能读到的各个表面彼此保持一致,包括 JavaScript 可见的那些,但它不会把 HTTP 客户端变成浏览器。这组测量把三个客户端放到同一个本地监听器前:curl 二进制、一个模拟 Chrome 的 Python 客户端,以及 Chrome 本身。真正有用的是后两者之间的差别,因为它小到容易被忽略。

TL;DR

  • 三个客户端向同一个监听器发送了三种握手:curl t13i4906h2、Node t13i521000、Chrome t13d1517h2。
  • 被要求模拟 Chrome 的客户端产生了 t13d1516h2,它在密码套件数量上与 Chrome 一致,但差一个扩展。
  • 扩展上的差别才是关键:少一个扩展的近似匹配仍然是一个可区分的取值。
  • 这些前缀里的 i 与 d 是域名标志位;按地址连接和按名称连接是两种不同的指纹。
  • 这台机器上的 curl 链接的是 SecureTransport 而不是 OpenSSL,所以它的密码套件和扩展集合来自与多数教程假设不同的 TLS 库。
  • HTTP 客户端没有 DOM、没有 canvas、没有 WebGL,因此来自这些表面的信号是缺失,而不是不匹配。
  • 设置 user agent 请求头会改变服务器读到的东西,不会改变握手所说的话。
  • 对齐连接层和对齐页面层是两项独立的工作,各有一套工具。

目录

三个客户端连接同一个监听器,以及冒充匹配了什么

三个客户端连接同一个监听器:curl、Node 和 Chrome 发出不同的密码套件与扩展计数,模拟客户端落在 Chrome 下方一个扩展的位置,第四块面板列出 HTTP 客户端没有运行时可以产生的页面层取值。

测量被刻意做得很小。回环接口上的 TCP 监听器接受连接、记录第一条记录然后关闭;握手从不完成,所以不涉及服务器证书,也不会有信任判断干扰结果。每个客户端依次指向这个地址。

curl:   bytes=301  alpn=h2,http/1.1 ciphers=49 exts=6  versions=0x0304,0x0303,0x0302,0x0301
        ja4=t13i4906h2_0d8feac7bc37_7395dae3b2f3
node:   bytes=1566 alpn=(none)     ciphers=52 exts=10 versions=0x0304,0x0303
        ja4=t13i521000_b262b3658495_8e6e362c5eac
chrome: bytes=1917 alpn=h2,http/1.1 ciphers=15 exts=17 versions=0x0304,0x0303
        ja4=t13d1517h2_8daaf6152771_cb7bf5808d99

把三个值并排读,问题的形状立刻出现。它们在三个独立位置上不同:密码套件数量、扩展数量和协议标签。只修正前两项的客户端仍会在第三项暴露自己,而一项都不修正的客户端可以轻易区分。

Node 那一行的计数写作 521000,最后四个字符是 1000 而不是协议标签。这是因为普通的 Node TLS 连接根本不提供任何协议,标签因此退化为零,而计数继续有效。一个从不协商 HTTP/2 的 HTTP 客户端因此拥有一个自我声明的指纹,不需要任何检测逻辑参与。

前两个值里的 i 与 Chrome 的 d 是域名标志位。这次运行中 curl 和 Node 指向裸地址,没有发送 server name;Chrome 指向名称。这个字符不是对意图的微妙判断,而是关于客户端往消息里放了什么的陈述。把 curl 与模拟客户端指向主机名而非地址后,它们的标志位变为 d,扩展计数各增一,所以标志位和计数是对同一个决定的两种读数。Node 并未跟随:它始终停在 t13i521000、十个扩展,直到显式传入 servername 才变为 t13d521100、十一个。库决定名字是否随请求发出,而调用方决定是否告诉库。

curl-impersonate 和 curl_cffi 都通过替换或重新配置 TLS 与 HTTP/2 层,让字节看起来像选定的浏览器。这个说法范围很窄,值得测试而不是假定,因此同一个监听器也用于一个被要求模拟 Chrome 的 Python 客户端:

default: bytes=517  ciphers=28 exts=12 alpn=h2,http/1.1
         ja4=t13d2812h2_257f3020b3a2_cbb9361b6bf9
chrome:  bytes=1815 ciphers=15 exts=16 alpn=h2,http/1.1
         ja4=t13d1516h2_8daaf6152771_d8a2da3f94cd

第二行才是重要的结果。被要求模拟 Chrome 后,客户端产生了十五个密码套件和十六个扩展,其哈希后的密码套件集合 8daaf6152771 与真实浏览器完全相同。在密码套件这一轴上,冒充是精确的。

扩展数量不是。Chrome 发了十七个,冒充客户端发了十六个,所以前缀分别是 t13d1516h2 和 t13d1517h2。只差一位数字,而这一位正是取值被存储下来要捕捉的差异。

这不是对两个项目的批评。浏览器会随附一些客户端库没有理由发送的扩展,完整复现那份列表意味着追逐一个不断移动的目标,且没有功能收益。要点更具体:近似匹配不是匹配,只检查哈希第一段的比较会在前缀已经分歧的地方报告成功。

底层库决定取值

开头那些 curl 数字看起来不寻常,原因与冒充无关。本地构建报告的 TLS 后端是:

curl 8.7.1 (x86_64-apple-darwin24.0) libcurl/8.7.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.12 nghttp2/1.64.0
curl_has_http2=true

是 SecureTransport,不是 OpenSSL。链接 SecureTransport 的 curl 从该库取得密码套件列表和扩展集合,因此它的指纹是构建的属性,而不是 curl 这个程序的属性。两台机器运行同一版本 curl 但后端不同,会产生不同取值,两者都不算错。

这对跨机群比较指纹的人有实际影响。只记录“curl”作为客户端并不够,因为它没有指明 TLS 库,而取值描述的正是那个库。同样的推理适用于冒充客户端:底层库的版本比包装器的版本更重要。

第二组帧在 HTTP/2 层展示同样的分离。连接以 settings 帧开始,该帧中的条目是任何请求出现之前对端就能看到的内容:

chrome_like: bytes=24 HEADER_TABLE_SIZE=65536 ENABLE_PUSH=0 INITIAL_WINDOW_SIZE=6291456 MAX_HEADER_LIST_SIZE=262144
node_like:   bytes=30 HEADER_TABLE_SIZE=4096 ENABLE_PUSH=0 INITIAL_WINDOW_SIZE=65535 MAX_FRAME_SIZE=16384 MAX_CONCURRENT_STREAMS=4294967295
curl_like:   bytes=36 HEADER_TABLE_SIZE=4096 ENABLE_PUSH=0 MAX_CONCURRENT_STREAMS=100 INITIAL_WINDOW_SIZE=65535 MAX_FRAME_SIZE=16384 MAX_HEADER_LIST_SIZE=16384

三个客户端,三种帧长度,差别不是装饰。浏览器声明了较大的初始窗口和较大的头部列表;Node 客户端完全不声明头部列表上限,窗口保持默认值;形似 curl 的客户端声明了另外两者省略的并发流上限。读取该帧的对端在解析请求行之前,就知道自己在与三种架构中的哪一种对话。

这组帧也说明为什么“匹配浏览器”比匹配密码套件列表更难。密码套件顺序是库构建的固定属性;settings 帧则是客户端为自己的工作负载所做的一组调优选择。让两者都像浏览器,意味着也要采用浏览器的调优,包括那些为服务渲染引擎而存在、而该客户端并不拥有的部分。

冒充库确实处理了这一点,上面的测量显示它们已经很接近。它们无法改变的是调优本身的存在:只负责取字节的客户端,其自然设置与负责渲染页面的客户端不同,即使代码已经调整,帧仍会记录差别。

复用连接的客户端会让这一点在第二个地方显现。会话恢复和连接复用都有利于吞吐,两者都意味着第二个请求不会重复握手。统计握手的对端因此看到十个请求对应一个指纹而不是十个,而每个请求都开新连接的客户端会显示十个。两种行为都不是欺骗,它们是两种正常设计,计数不同是因为设计不同。

HTTP 客户端没有的那一层

以上全部关于连接。页面层是另一组取值,HTTP 客户端一个都没有,就像文本编辑器没有屏幕分辨率。

page_layer: ua=header-only  languages=en-US,en  platform=MacIntel
            hardwareConcurrency=16  deviceMemory=null
            canvas=true  webgl=true  timezone=America/Toronto
            screen=1280x720  dpr=1  webdriver=true

这些值来自在浏览器中执行文档。解析字节的客户端无法产生其中任何一个,因为每一项都由客户端并不运行的 JavaScript 引擎或渲染系统计算。这里没有部分得分,也没有巧妙绕行:头部可以设成任意字符串,而字符串不是取值。

最后一项值得与其余分开。webdriver=true 由浏览器报告,因为它是在自动化下启动的,而 HTTP 客户端根本没有等价字段可报。把“字段缺失”当作“字段干净”的比较把缺失读反了,因此指纹报告必须说明评估了哪些表面,而不只是找到了哪些值。

把两层放在一起,近似匹配的代价就具体了。到达 t13d1516h2 的客户端已经在连接层投入了实际工作,却仍与它模仿的浏览器差一个扩展。它在页面层也没有任何收获,那里它仍然是一个没有文档的客户端。

这种组合在一个具体意义上同时占了两边的最差位置:它邀请了一场赢不了的比较。完全不做相似性尝试的普通客户端显然不是浏览器,工具也会这样对待它。在连接层酷似浏览器的客户端会设定一个期望,而页面层随后无法满足。

plain_client      -> clearly not a browser on both layers
impersonated      -> close on connection, absent on page
browser           -> both layers present and consistent

因此有用的问题不是“握手能靠多近”,而是“这项工作实际需要哪一层”。简单检查之后的 JSON 请求需要第一行。登录之后的渲染列表请求需要第三行。选择第二行,等于为一种只到达一半的相似性付费。

两层在实践中保持分离还有第三个原因,它涉及比较究竟能以什么形式陈述。连接层论断的形式是“这个握手提供了这些参数”,一条记录下来的消息就能支持它。页面层论断的形式是“这个环境报告了这些值”,它需要在文档内部执行,并记录这次执行能触达什么。两种论断的证据要求不同,所以把它们合并成一个分数的报告,必须丢掉说明自己在做哪一种的那个字段。

这在出现不匹配时最重要。如果客户端在连接层匹配而页面层缺失,报告应该说“页面层未评估”,而不是“页面层干净”。前一句为真且可执行;后一句是关于一次没人做过的测量的断言。同样的纪律反过来也成立:一个握手从未被捕获的浏览器会话,无论它的页面侧报告多详尽,都无法回答关于连接的问题。

代价也遵循同样的划分。连接层匹配在建置时支付一次,方式是选定一个库和一个版本并固定住。页面层匹配则持续付费,因为文档报告的取值取决于渲染器、字体、屏幕和所用配置文件,其中任何一项都可能在两次运行之间漂移。团队常常为前者做预算,却被后者惊到,这也是为什么固定 TLS 库看起来已经完成,而一致的页面环境永远差一点。

最后一条运维说明应放在这里而不是教程里,因为它是会静默失效的部分。客户端库升级时,密码套件列表和扩展集合随之移动,记录下来的指纹会因与配置无关的原因失配。在固定的自动化设置之下升级浏览器时,页面层取值也会以同样方式移动。两者都是版本事件,如果取值与版本存在一起,两者都能在 diff 中看到;如果取值单独存放,两者都不可见。

近似匹配的代价与实践结论

先测量再假定一致性:捕获自己客户端的 ClientHello,比较扩展数量,而不只是一个哈希。这里测量的冒充客户端与 Chrome 的密码套件集合完全一致,并差一个扩展,前缀已经直白写出这个差别。记录 TLS 库而不只是程序,因为同一 curl 版本的 SecureTransport 与 OpenSSL 构建会产生不同取值。任何比较中都把两层分开:没有 DOM 的客户端可以近似连接层,而任何客户端都无法近似页面层。连接层取值如何构造,见 JA3、JA4 与 HTTP/2 settings 帧;哪些检测规则需要网络取值,见 页面脚本无法评估的边界规则。渲染页面需要浏览器,用于抓取的浏览器配置 覆盖了那条路径。

边界值得不加修饰地说明。声明式配置文件可以让页面暴露的取值彼此一致;它不会让握手匹配另一个客户端,也不会给 HTTP 客户端一个可渲染的文档。匹配连接和匹配页面是两套工具得到的两项独立结果,而一项工作通常只需要其中之一,而不是两者。

关于这些测量没有说明什么,需要一小段,因为读者有理由追问。它们没有说近似匹配毫无价值:对于简单评分之后的 JSON 端点,连接层就是被检查的绝大部分,密码套件正确的客户端与不正确的客户端处境不同。它们也没有说每个渲染页面都需要浏览器,因为把数据放在初始状态对象里交付的页面,可以被完全没有渲染器的客户端读取。

它们真正说明的是,两层不是一个渐变。没有任何设置能把客户端从“没有 DOM”移动到“渲染页面”的中间点,也没有任何头部能把浏览器的握手变成另一个取值。需要两层的工作需要浏览器;只需要连接层的工作常常可以由从不加载文档的客户端完成。判断眼前是这两项工作中的哪一项,并把它写进记录,就是全部纪律。

测量装置本身值得一段,因为想复核这些数字的读者需要相同条件。监听器必须接受连接并在不完成握手的情况下关闭;如果它出示证书,客户端可能发送不同的第二条消息,比较就会移动。抓包必须发生在任何代理的客户端一侧,否则记录的字节属于终止 TLS 的那一端,而不是被研究的客户端。解析必须在计数前丢弃 grease 取值,因为每次连接都抽取新占位符的客户端,在两次运行之间看起来会增长和缩小它的密码套件列表。这些步骤都不难,而每一步一旦跳过,产生的数字都会因与客户端无关的原因而变化。

来源

#TLS#Curl#Curl_cffi#JA4#HTTP2

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

在 BotBrowser 里跑一遍同样的检查

加载 profile,让刚读到的这些信号在浏览器内部保持一致,并在正式上线前先在自己的机器上确认结果。