ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Chromium内核TLS指纹随机化改造:从原理到实践

Chromium内核TLS指纹随机化改造:从原理到实践 前阵子做浏览器自动化兼容性测试我发现一个特别有意思的现象同一台机器、同一个网络环境用 Selenium 驱动 Chromium 去访问一个对反爬比较敏感的网站验证码出现的频率远高于我手动点开浏览器操作。User-Agent 改了、浏览器版本也伪装了、甚至还挂上了各种反检测插件结果还是会被识别。后来抓到 TLS 握手报文一看原因很清楚——ClientHello 的构造方式像身份证一样稳定网站只看 TLS 指纹就能确定“这不是一个正常用户”。TLS 指纹随机化就是解决这个问题的核心手段。本文我会从 Fingerprint 的生成原理讲起手把手带你在 Chromium 源码里做修改获取源码、本地构建、定位 BoringSSL 握手逻辑、设计可控的随机化策略最后再用抓包和在线检测服务验证效果。适合爬虫工程、反爬对抗测试、安全研究、浏览器内核开发这几类读者参考。提前说清楚边界这篇文章不是教你怎么去攻击哪家网站。指纹随机化在隐私浏览器、合规数据采集、自家系统安全测试、客户端兼容性研究里都有实用价值但只能用在你有权测试的场景里。1. 为什么网站能用TLS指纹识别你1.1 TLS握手里的“指纹”到底是什么先澄清一个容易混淆的概念TLS 指纹不是证书指纹也不是 CA 指纹而是 TLS 握手中客户端向服务器发送的 ClientHello 消息所携带的一整套参数特征。TLS 1.2 和 TLS 1.3 握手时客户端会先发 ClientHello里面包含支持的 TLS 版本号、客户端随机数、session id、密码套件列表、压缩算法列表以及一堆扩展字段。每个字段的取值、顺序、内容长短合在一起就能唯一标识一类客户端。你可以把 ClientHello 想象成一个人进餐厅点单时说的话我喜欢吃什么、不吃什么、先报哪个菜、后报哪个菜、用什么样的语气说。每个人的习惯都有差异。不同浏览器和网络库实现 TLS 的方式也有差异。比如 Chromium 的 BoringSSL 和 Firefox 的 NSS 生成的 ClientHello密码套件列表与扩展顺序完全是两套风格。把抓到的 ClientHello 按特定规则拼接后算一个哈希值就得到 JA3 指纹之后业内又演进出了 JA4逻辑类似只是把字段拆分得更细。关键是这个指纹很“诚实”。服务器不需要执行 JavaScript不需要种 Cookie只要在 TLS 握手阶段读取客户端声明的能力参数就能把 Firefox、Chrome、Python requests、Go 的 http.Client、Java 的 HttpClient 区分开。整个过程在 TLS 层完成普通上层脚本基本感知不到更别说修改。1.2 风控系统为什么偏爱TLS指纹网站的风控体系通常不是一个单一模块而是多维度交叉验证IP 信誉、设备指纹、Cookie、JS 行为轨迹、鼠标键盘事件、协议栈指纹等。TLS 指纹在其中扮演的是“协议栈特征”角色。它有几个天然优势不可见、难伪造、跨设备稳定。普通用户根本不知道浏览器在 TLS 层每次都会暴露固定特征即使知道了也无法通过装个插件去改。对风控来说这是性价比极高的识别维度。举个例子一个固定 IP 段下如果突然出现大量相同的 JA3 指纹而这些指纹又恰好对应 Python requests 或某个开源爬虫框架即使每个请求的 User-Agent 都伪装成了 Chrome风控系统依然可以打一个很高的风险分。因为正常用户不可能几百个人都用同一个 TLS 指纹除非你们整个机房都在跑同一个自动化客户端。这也就是为什么很多团队发现光靠 puppeteer-extra 和 stealth 插件并不能解决网站识别问题——上层能伪装的东西太容易被检测了底层 TLS 这个“出气口”反而一下就暴露了。1.3 这套改造会影响哪些场景TLS 指纹随机化并不是一个只在“对抗网站”的场景里才用得到的小技巧。我自己接触到的需求大概有这么几类第一类是隐私浏览器和反检测浏览器。这类产品希望让每个实例的浏览器指纹尽量多元避免“同一指纹反复出现”成为关联依据。TLS 指纹随机化正好能让 ClientHello 在合理范围内变化提升环境的“自然感”。第二类是合规的数据采集。很多公司会用自动化浏览器去抓取公开数据或者做价格监测、舆情分析。网站的反爬策略如果过度激进会把正常自动化也一并拦掉。此时让自动化浏览器的 TLS 指纹更接近真实浏览器分布能降低被误伤的概率。注意这里说的是“接近正常分布”不是去伪造某个特定目标更不是恶意绕过授权系统。第三类是安全研究。做红队评估、风控策略有效性测试、WAF 规则绕过验证时需要验证“仅通过 TLS 特征”能否识别出不同客户端。这时一个可自由控制指纹的浏览器非常有价值。2. 修改前的准备工作获取源码与本地构建2.1 获取源码版本选择与仓库同步要修改 Chromium第一步是拿到源码。官方推荐使用 depot_tools 工具链它负责 fetch、gclient sync、gn 和 ninja 编译的协调工作。以 Linux 为例基础流程大致如下mkdir ~/chromium cd ~/chromium git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git export PATH$PATH:$HOME/chromium/depot_tools fetch --nohooks chromium gclient syncfetch 会拉取主干仓库初始体积非常大国内网络环境下建议直接在 gclient 配置里换成你熟悉的开源镜像源或者提前准备好足够大的磁盘和稳定的带宽。磁盘空间至少预留 120GB 以上代码和产物加起来远超 100GB。内存方面如果要跑 release 官方构建16GB 是底线32GB 会更从容。版本选择上我不建议直接编译最新的主干因为主干代码变化快你辛苦改完的补丁可能下个月就冲突满天飞。更稳妥的做法是固定一个长期维护的稳定分支比如某个版本的 chrome/m118 分支或者直接根据你实际要集成的产品版本选一个 tag。固定版本之后在整个改造和测试过程中不要随意 gclient sync 升级否则 BoringSSL 头文件结构变了手写的补丁全部要重来。2.2 本地编译参数怎么定才合适Chromium 的构建系统是 GN Ninja。用 gn gen 创建构建目录并写入编译参数cd ~/chromium/src gn gen out/fp --argsis_debugfalse is_official_buildtrue symbol_level0 autoninja -C out/fp chrome这三个参数值得解释一下。is_debugfalse 就是 release 模式。is_official_buildtrue 会走官方构建优化生成的产物更接近正式版 Chrome但编译时间会变长如果你只是做学习和验证也可以改成 false能省不少时间。symbol_level0 表示不生成调试符号链接速度会明显提升产物体积也小很多缺点是崩了之后没法用 gdb 调试。建议前期先带上符号把功能跑通之后再关掉重新编一版。编译时间取决于机器性能。我在一台 16 核 32 线程、32GB 内存的机器上release 构建大约需要一个半小时如果 CPU 只有 8 核那基本要做好过夜的准备。编译过程中会大量消耗内存建议不要同时开太多浏览器标签页或大型应用不然 OOM 把编译进程杀了只能从头继续。2.3 先抓一次基线包留好对照样本源码编译完成之后不要急着改代码。先把“原始状态”下这个自编译 Chromium 的 TLS 指纹抓下来作为后续验证的对照样本。这一步特别重要没有基线后面根本说不清是随机化生效了还是编译参数本身就改了指纹。启动产物时建议用独立的用户目录./out/fp/chrome --no-sandbox --user-data-dir/tmp/fp-test打开 chrome://version 确认一下可执行文件路径确实是 out/fp 下的产物而不是系统里自带的 Chrome。然后用 tcpdump 抓包tcpdump -i lo -w baseline.pcap tcp port 443浏览器里随便访问一个 HTTPS 网站关掉抓包用 Wireshark 打开 pcap定位到 ClientHello 记录把 cipher suites 顺序、扩展列表顺序、supported_groups 这些字段记录下来。这个就是“默认指纹基线”。正常来说它和官方 Chrome 的指纹基本一致如果发现差异很大先排查是不是编译参数或某些 feature 开关导致的不要带着异常基线进入下一步。3. 核心改造在BoringSSL层实现TLS指纹随机化3.1 先定位“组装指纹”的代码Chromium 使用的 TLS 库是 BoringSSL源码在third_party/boringssl/src目录下。真正决定 ClientHello 长什么样的代码并不在 Chromium 的 net 层而在 BoringSSL 的握手状态机里。我通常先到这几个文件里找关键逻辑third_party/boringssl/src/ssl/ssl_lib.ccSSL 对象创建、参数配置入口。third_party/boringssl/src/ssl/handshake_client.cc客户端握手主逻辑ClientHello 组装的大本营。third_party/boringssl/src/ssl/tls13_client.ccTLS 1.3 客户端的专用处理路径。third_party/boringssl/src/ssl/ssl_client_hello.cc扩展相关的序列化和解析逻辑。不同版本的代码结构会有些出入如果你当前的版本没有某个文件就用代码搜索来找入口。搜索ClientHello、client_hello、SSL_CTX_set_cipher_list这类关键字很快就能定位到组装流程。定位之后不要一口气看完所有代码先画出从SSL_connect到发送 ClientHello 的调用链确定往哪个函数插入“指纹选择逻辑”最合适。3.2 ClientHello里哪些字段适合随机化不是所有字段都可以动。随机化必须建立在“能正常完成 TLS 握手”的前提下否则网站打不开做出来的东西也没有意义。我按可动性分了三类必须保持稳定SNI 扩展、ALPN 扩展里的协议顺序、支持的 TLS 版本范围。这些字段直接决定服务器如何选择协议和证书乱改会让很多网站直接拒绝连接。可以随机化密码套件列表的顺序和子集、supported_groups 的顺序和子集、signature_algorithms 的顺序、扩展字段的先后顺序、GREASE 值的位置和内容。这些属于“声明我支持什么”的清单顺序变化一般不会破坏协商只要保证必需套件仍然在列表里。不建议随机化session_id、PSK 扩展、ticket 相关字段、证书验证相关的扩展。这些与会话恢复和身份校验强相关改坏了会导致频繁重新握手、性能下降或者证书校验异常。我在实际操作中会把“可以随机化”的字段做成一份配置表每个字段有候选值列表。随机化就是从这个配置表里选一组合法组合而不是每次把所有字段都打乱那等于在赌博。3.3 指纹池方案从全随机到可控随机最初我做测试时想到的是“每次生成完全随机的 ClientHello”。跑了几个小时后发现完全随机会导致两个问题一是部分旧服务器兼容性很差扩展顺序稍微一变就握手失败二是随机出来的组合五花八门反而会形成一个“看似不同但总是很怪异”的群体特征风控只要捕捉到这种怪异分布照样能识别。后来我改成了“指纹池”方案。提前定义一组经过验证的合法指纹配置文件每个配置里包含固定的 cipher suites 顺序、扩展顺序、supported_groups 顺序等参数相当于一个“TLS 性格”。每次握手前用一个密码学安全的随机数生成器从指纹池里选一条然后把这条配置应用到当前连接。核心逻辑用伪代码表达是这样// 伪代码仅演示思路实际代码需要结合当前源码版本调整 struct FingerprintProfile { std::vectoruint16_t cipher_suites; std::vectoruint16_t supported_groups; std::vectorint extension_order; }; // 内部维护一组合法配置 extern const std::vectorFingerprintProfile g_fingerprint_profiles; bool SelectProfileForSSL(SSL* ssl) { const FingerprintProfile profile g_fingerprint_profiles[RandGenerate(0, g_fingerprint_profiles.size() - 1)]; ApplyCipherSuites(ssl, profile.cipher_suites); ApplySupportedGroups(ssl, profile.supported_groups); ApplyExtensionOrder(ssl, profile.extension_order); return true; }必须用密码学安全随机数不能用简单的rand()。原因有两个一是随机数质量差会影响 TLS 握手的随机性要求带来潜在安全问题二是质量差的随机数在并发场景下会产生大量重复 profile随机化效果大打折扣。指纹池的生成方式也经历过迭代。最省事的方式是抓取不同平台、不同版本 Chrome 的 ClientHello 记录把他们的参数排序整理成 profile更精细的方式是自己写脚本枚举合法组合然后在本地测试环境里逐个验证握手成功率。实际操作中几十个 profile 就够了。池子太大维护成本高太小则容易被枚举出规律。3.4 轮换策略别有“每次不同”的新特征TLS 指纹随机化最容易踩的坑是把“每个连接都换一个指纹”当成目标。真实用户的行为不是这样的。正常情况下同一个浏览器的 TLS 指纹在很长一段时间内是稳定的只有浏览器升级或者换了设备才会变化。如果风控看到同一个 IP 或同一个会话里每一个新连接都是不同的 JA3这种“过于随机”本身就是一个极强的异常信号。我的建议是默认以浏览器实例为粒度做轮换同一个 Chromium 进程启动后从指纹池里随机选一个 profile后续所有的连接都使用这个 profile直到进程退出、实例重启或通过外部命令主动切换。这样看起来就像普通用户换了一台设备而不是一个服务器在批量发请求。如果做的是自动化采集系统可以进一步让“实例 出口 IP 段”绑定同一个 profile进一步降低关联风险。3.5 测试入口命令行、文件配置与扩展点改完 BoringSSL 之后一定要预留方便测试的入口。最直接的方式是命令行开关。启动参数里增加一个--tls-fingerprintrandom表示启用随机化--tls-fingerprintfixed:12表示固定使用第 12 号 profile。代码里解析命令行参数后把配置传给 BoringSSL 的环境变量或全局配置对象即可。指纹池也不要硬编码在 C 数组里建议设计成可外部加载的 JSON 文件。这样调试时改 profile 不用重新编译整个 Chromium只需要改 JSON 再重启浏览器效率完全是两个量级。文件加载逻辑可以放在 Chromium 启动早期在 SSL 初始化之前读入内存。再往后做工程化时还可以通过 DevTools Protocol 暴露一个自定义命令让自动化脚本在运行时动态切换指纹。但这条路径需要自己实现协议扩展工作量不小前期没必要。我自己实践下来命令行 JSON 配置文件已经能覆盖 90% 的测试需求。4. 验证与效果怎么证明你的指纹真的在变化4.1 抓包看ClientHello最朴素的验证方式代码改完之后第一个验证动作就是抓包看 ClientHello 到底变没变。抓包命令和前面基线抓包基本一致tcpdump -i lo -w randomize.pcap tcp port 443需要注意一个细节Chromium 默认会复用连接。同一个页面里的多个请求可能都在同一条 TCP 连接上ClientHello 只会在建立新连接时出现一次。验证的时候要么每访问一个新域名要么在两次访问之间清掉 SocketPool或者直接重启页面让每条连接都是新连接。我在测试时会打开多个不同域名的标签页确保每访问一个站点就产生一次完整的 TLS 握手。抓完包之后用 Wireshark 或者 tshark 看 ClientHello。重点看三处cipher suites 列表是否变化、扩展字段顺序是否变化、supported_groups 等子字段的顺序是否变化。如果这些字段每次都一样说明随机化逻辑根本没有被调用先查代码插入位置是否正确。更进一步可以对每个流计算 JA3。Wireshark 的某些版本内置了解析也可以写脚本提取字段后按 JA3 算法做哈希。只要同一台机器、同一个 UE 环境下的多次握手算出的 JA3 不同就说明指纹随机化在底层生效了。4.2 在线检测服务与第三方识别结果抓包能验证“变了”但变完之后呈现给服务器的是什么还是得用第三视角确认。网上有一些公开的 TLS 指纹检测服务访问后会返回服务器视角看到的 JA3、JA4 以及猜测的客户端类型。这类服务适合做横向对比。使用在线服务时要注意两点第一不要在这些检测页面上传任何敏感数据它本质上是把 ClientHello 暴露给第三方服务第二有些服务方有自己的缓存或限流策略结果不一定实时多刷新几次取趋势即可。我更推荐把在线检测当成辅助主要结论还是以本地抓包计算为准。打开检测页面后可以多做几次“完全新建连接”的刷新操作。如果启用了轮换策略看到的结果应该是多次访问返回不同的 JA3或至少是不同的客户端类型。如果在同一实例内指纹始终不变不一定代表失败因为默认轮换粒度是“每个实例一个 profile”这是符合设计预期的。4.3 和常见自动化客户端的对比为了更直观地感受改造效果我建了一个小对照表。在同一台服务器上分别发起访问记录各自的 JA3 特征客户端JA3/特征表现与随机化版差异官方 Chromium指纹固定但属于“真实浏览器”特征随机化版会变化Python requests固定且极具辨识度的 OpenSSL 指纹一眼可识别Undetected-Chromedriver与默认 Chromium 一致不修改时等于 Chrome本文随机化 Chromium每次实例或连接从池中抽取无固定 JA3这个表的重点不是“随机化版比谁高明”而是说明默认情况下不管上层怎么伪装TLS 指纹总是暴露真实的客户端库身份。随机化改造解决了“固定特征”这个问题让自动化浏览器在协议栈维度上更接近真实用户分布。4.4 兼容性回归别让指纹变化拆了网站指纹随机化最怕的副作用是客户端“觉得”自己在随机化“服务器”却因此无法完成握手。所以每一项改动跑通后都必须做一轮兼容性回归。我的测试清单通常包含四类站点主流大站验证 TLS 1.3 HTTP/2老旧的 TLS 1.2 服务验证兼容性只支持 HTTP/1.1 的服务验证 ALPN 顺序没被改出问题以及带双向证书的测试端点验证证书相关扩展未被破坏。测试时关注四个指标是否出现 SSL_ERROR 页面、证书是否报错、页面加载时间是否有异常增加、是否被重定向到安全验证页。如果某个站点握手失败先判断失败是随机化本身导致的还是该站点的特定实现过于严格。通常做法是把容易触雷的 profile 从池里剔除或者在连接失败时自动回退到默认 profile。回退逻辑点可以在握手失败事件里做标记下次该域名强制走默认配置。5. 常见问题与排查技巧实录5.1 编译期踩坑编译阶段我踩过几个比较典型的坑。磁盘空间不够。Chromium 源码加构建产物很容易突破 100GB如果 out 目录和 src 目录不在同一分区很容易出现磁盘写满。解决办法是把 out 目录放到剩余空间大的盘或者用symbol_level0减小体积。我最多的时候一个月内积累了三个不同分支的构建目录一个就吃掉七八十 GB。编译过程中 OOM。尤其是开is_official_buildtrue时链接阶段内存占用非常激进。如果机器内存只有 16GB建议把并发数降下来autoninja -C out/fp chrome -j4或者临时增加 swap 空间。不要用极端的-j64去挑战机器极限折腾半天可能只是把一次编译拖成了三次。依赖版本不匹配。depot_tools 和 Chromium 源码通常需要保持版本一致混用新 depot_tools 和旧分支会产生各种诡异报错。遇到这种情况不要急着改代码先检查 depot_tools 是不是同步到了和源码匹配的版本。5.2 运行期异常改完代码第一天最容易遇到的问题就是“TLS 指纹根本没变”。排查顺序是先确认启动的可执行文件路径确实是 out/fp/chrome再看启动参数里有没有意外禁用了补丁逻辑最后抓包确认访问目标确实产生了新连接。很多时候是复用连接池导致抓不到 ClientHello并不是补丁没生效。另一个常见问题是“随机之后某个网站打不开”。这通常是因为扩展顺序或密码套件列表里少了服务器必需项。解决办法不是在代码里打补丁而是把该域名加入白名单强制使用默认 profile。如果需要严格随机就把 profile 列表里容易出问题的组合删掉重新生成 JSON 配置。有些时候检测结果会显示“TLS 指纹没变化但 JA3 变了”。这不是矛盾。JA3 对某些字段做了规范化处理比如排序和不区分大小写导致部分随机化细节被抹平。说明你在 GREEASE 值或扩展内部子字段上的改动对 JA3 这种粗粒度指纹无效。这时候要改用 JA4 或者直接看原始 ClientHello才能发现更细的差异。5.3 容易忽略的其他指纹维度TLS 指纹只是协议栈指纹里的一环。在你把精力花在 ClientHello 上时有几个很容易被忽略的维度也在暴露客户端身份。第一个是 HTTP/2 指纹。Chromium 在 HTTP/2 的 SETTINGS 帧、WINDOW_UPDATE 、优先级树构造方式上也有固定特征。有些风控系统会从 HTTP/2 帧层面提取另一套哈希值。TLS 指纹随机化管不到这一层必须另外处理。第二个是 QUIC 指纹。如果开了 QUIC客户端使用 UDP 的情况下也会携带 HTTP/3 和 TLS 1.3 特征此时抓包方向和修改的位置都要调整。第三个是会话恢复机制。如果启用了 TLS 会话复用后续连接不会重新发送完整 ClientHello指纹自然也不会变。在工程化测试时我建议把 QUIC 先关掉减少变量等 TLS 指纹逻辑验证通过后再逐步开启其他传输层特性。怎么关在命令行加--disable-quic就行。这样抓包和指纹计算都会清晰很多。5.4 合规红线与使用边界最后必须强调红线。TLS 指纹随机化这项技术它的合理用途是安全研究、合法数据采集、隐私保护产品和客户端兼容性测试。它不应该被用来绕过金融机构、政府服务、在线支付等系统的安全策略也不应该用来抓取未授权数据更不应该用来实施任何形式的攻击。在动手之前先确认你有权对目标系统进行测试。如果你是开发自家产品的反爬策略或者测试自己部署的服务那没问题如果目标系统不是你的先拿到书面授权再说。技术本身是中性的但使用不当的后果很现实这个边界各位在动手前一定想清楚。我自己做这个改造最大的体会是TLS 指纹随机化不是银弹它只是把“协议栈特征”这个维度做模糊化想完全融入真实用户环境还要同步处理 HTTP/2 指纹、行为轨迹、IP 信誉这些维度。但在隐私浏览器、单点登录兼容测试、自家系统安全验证这些场景里它确实能把“容易被识别”这个问题压到一个很合理的水平。最后分享一个提升调试效率的技巧先在本地用 nginx 搭一个支持 TLS 1.2 / TLS 1.3 的测试端点配合 tcpdump 做对比比直接拿线上站点压测要高效得多。调试随机化逻辑时把指纹池设计成可外部加载的 JSON 文件会省掉大量重新编译的时间。
返回列表