浏览器指纹与反爬虫:TLS JA3、HTTP/2指纹原理及绕过方法
2026-6-3 12:1:32 Author: blog.axiaoxin.com(查看原文) 阅读量:18 收藏

微信公众号二维码

本文已同步发布到微信公众号「人言兑

👈 扫描二维码关注,第一时间获取更新!

最近不仅在折腾反爬虫,也在搞一个数据抓取的需求,知己知彼,也能更有效的进行防御和进攻。

在抓取数据时,发现同样的请求在浏览器里能正常打开,用代码跑就返回 403 或者验证码页面。

折腾了挺久,最后靠模拟浏览器指纹解决了问题。

这篇文章记录一下学到的内容,主要是 TLS 指纹、HTTP/2 指纹这些之前没太关注过的东西。

问题现象

一开始我的思路很常规:补全 Headers、加 Cookie、换 User-Agent、调请求间隔。这些手段以前对付大多数网站都够用,但这次完全不行。即使我把代码里的请求头弄得跟浏览器开发者工具里复制出来的一模一样,服务器照样拦截。

后来用 curl 测试,发现 curl 也被拦。但浏览器(包括隐身模式)完全正常。这说明问题不在 IP、不在 Cookie、也不在 Headers 内容本身。

什么是浏览器指纹

我们平时说的「指纹」不是指 Cookie 或者登录状态那种主动标识,而是软件在实现网络协议时自然暴露的行为特征。服务器不需要你主动告诉它「我是谁」,只要观察你「怎么做」,就能判断出你大概是什么类型的客户端。

主要分三个层面:

TLS 指纹(JA3)

HTTPS 连接建立前要先进行 TLS 握手。这个握手过程里,客户端会发送一个叫 Client Hello 的数据包,里面包含:

  • 支持的 TLS 版本
  • 支持的加密算法列表(CipherSuites)
  • 支持的扩展功能列表(Extensions)
  • 支持的椭圆曲线
  • 扩展的发送顺序

不同软件实现 TLS 协议时,这些参数的选择和排列组合是不一样的。比如 Chrome 128 支持 TLS_AES_128_GCM_SHA256 这个算法,并且把它放在 CipherSuites 列表的第一个位置;而 Go 的标准库 net/http 虽然也能连 HTTPS,但它的算法列表、扩展数量、顺序都跟 Chrome 不一样。

JA3 是一种把这些参数标准化成字符串的算法。具体做法是按固定顺序提取 TLSVersion、CipherSuites、Extensions、EllipticCurves、EllipticCurvePointFormats 这五个字段,用逗号和连字符拼接成一个字符串。这个字符串就是 JA3 指纹。

举个例子,下面这两个 JA3 字符串,一眼就能看出不是同一个软件产生的:

Chrome 128:
769,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

Go 1.22:
769,49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-5-10-11-13-23-65281,29-23-24,0

差异很明显:Chrome 的 CipherSuites 里有 4865、4866、4867 这几个 TLS 1.3 专属算法,Go 没有;Chrome 的 Extensions 有 18 个,Go 只有 6 个;Chrome 支持 X25519 椭圆曲线,Go 默认不支持。

服务器端维护一个已知指纹的数据库,收到连接后提取 JA3 一比对,就能判断「这大概是 Chrome」还是「这大概是某个脚本」。

HTTP/2 指纹

如果协商使用 HTTP/2,连接建立后双方会先交换 SETTINGS 帧。这个帧里包含各种配置参数,不同客户端的默认值差异很大:

参数Chrome 128Go net/http
SETTINGS_HEADER_TABLE_SIZE655364096
SETTINGS_MAX_CONCURRENT_STREAMS1000250
SETTINGS_INITIAL_WINDOW_SIZE6291456 (6MB)1048576 (1MB)
SETTINGS_MAX_FRAME_SIZE16384未设置

除此之外,首次 WINDOW_UPDATE 的值、流优先级的设置方式、HPACK 动态表的使用策略,这些细节组合起来也构成指纹。Go 的 HTTP/2 实现为了简洁和通用,很多参数都设得比较保守,跟浏览器的激进优化策略形成鲜明对比。

HTTP/2 的请求头以 HEADERS 帧发送,包含一组伪头部(:method、:authority、:scheme、:path)和普通头部(accept、user-agent 等)。

Chrome 发送这些头部时有固定的先后顺序,这是由 Chromium 源码里的硬编码逻辑决定的。比如它总是先发送 :method,然后是 :authority、:scheme、:path,接着是 accept、accept-encoding、accept-language,然后是 cookie,最后才是 user-agent 和各种 sec-* 头部。

但 Go 的 http.Header 底层是 map[string][]string,而 Go 的 map 遍历顺序是随机的。这意味着每次请求,头部的发送顺序都不一样。这次可能是 cookie 在最前面,下次可能是 user-agent 在最前面。风控系统很容易检测出这种「没有固定说话顺序」的异常行为。

反爬虫系统的工作逻辑

结合以上三个层面的指纹,一个典型的风控判断流程大概是:

  1. 收到新连接,提取 JA3 指纹
  2. 查指纹库,如果是已知浏览器的指纹,进入下一步细粒度检测;如果是已知爬虫/工具指纹,直接加分
  3. 分析 HTTP/2 行为,SETTINGS 参数、WINDOW_UPDATE 值是否匹配该浏览器版本的一贯表现
  4. 检查 Header 发送顺序,是否跟指纹对应的浏览器一致
  5. 综合评分,超过阈值就返回 403 或触发验证码

这个过程中,User-Agent 字符串其实只起到辅助验证的作用。如果 JA3 指纹显示你是 Go 程序,但 UA 说自己是 Chrome,这种矛盾本身就会提高可疑分数。

绕过方法

使用指纹模拟库

最直接的方法是用能模拟真实浏览器指纹的 HTTP 客户端库。以 Go 语言为例,bogdanfinn/tls-client 这个库内置了 Chrome、Firefox、Safari 等多个版本的完整指纹定义,包括 TLS 参数、HTTP/2 参数、Header 顺序等。

安装:

go get github.com/bogdanfinn/tls-client
go get github.com/bogdanfinn/fhttp

注意这里需要同时安装 fhttp,它是 tls-client 配套的 HTTP 库,支持 Header 顺序控制。

使用示例:

package main

import (
    "fmt"
    "io"

    http "github.com/bogdanfinn/fhttp"
    tls_client "github.com/bogdanfinn/tls-client"
    "github.com/bogdanfinn/tls-client/profiles"
)

func main() {
    // 创建模拟 Chrome 120 的客户端
    options := []tls_client.HttpClientOption{
        tls_client.WithTimeoutSeconds(10),
        tls_client.WithClientProfile(profiles.Chrome_120),
        tls_client.WithNotFollowRedirects(),
    }

    client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...)
    if err != nil {
        panic(err)
    }

    req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
    if err != nil {
        panic(err)
    }

    // 使用 fhttp.Header 格式,值必须是字符串切片
    req.Header = http.Header{
        "User-Agent":      {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36..."},
        "Accept":          {"text/html,application/xhtml+xml..."},
        "Accept-Language": {"zh-CN,zh;q=0.9"},
        // HeaderOrderKey 控制发送顺序,必须跟 Chrome 一致
        http.HeaderOrderKey: {
            "accept",
            "accept-encoding",
            "accept-language",
            "user-agent",
        },
    }

    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    fmt.Println(string(body))
}

关键点:

  • profiles.Chrome_120 指定要模拟的浏览器版本
  • fhttp 替代标准库的 net/http,因为后者不支持 Header 顺序控制
  • HeaderOrderKey 是一个特殊键,它的值数组定义了头部发送的先后顺序
  • 不需要手动处理 gzip/br 解码,tls-client 内部会自动处理

其他语言的类似方案

Python:

curl_cffi 是一个模拟浏览器指纹的库,底层基于 curl-impersonate:

from curl_cffi import requests

# impersonate 参数指定要模拟的浏览器版本
r = requests.get("https://example.com", impersonate="chrome120")
print(r.text)

Node.js:

curl-impersonate 有 Node.js 绑定:

npm install curl-impersonate
const { curl } = require("curl-impersonate");

(async () => {
  const response = await curl({
    url: "https://example.com",
    impersonate: "chrome120",
  });
  console.log(response.body);
})();

使用真实浏览器自动化

如果指纹模拟仍然被拦截,最后的手段是用真实的浏览器。Go 语言可以用 Rod 或 chromedp 控制 Chrome:

go get github.com/go-rod/rod
package main

import (
    "fmt"
    "github.com/go-rod/rod"
    "github.com/go-rod/rod/lib/launcher"
)

func main() {
    // 启动无头 Chrome
    l := launcher.New().Headless(true).NoSandbox(true)
    defer l.Cleanup()

    browser := rod.New().ControlURL(l.MustLaunch()).MustConnect()
    defer browser.Close()

    page := browser.MustPage("https://example.com")
    fmt.Println(page.MustHTML())
}

Rod 会启动一个真正的 Chrome 进程,所有网络行为都是真实的浏览器行为,指纹层面无法区分。缺点是资源开销大,不适合高并发场景。

macOS 上如果提示找不到 Chrome,可以手动指定路径:

l := launcher.New().
    Bin("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome").
    Headless(true)

Linux 上通常需要安装 Chromium:

# Ubuntu/Debian
sudo apt-get install chromium-browser

# CentOS/RHEL
sudo yum install chromium

Windows 上如果 Chrome 安装在默认位置,Rod 通常能自动找到。如果不行,同样用 Bin() 指定路径:

l := launcher.New().
    Bin(`C:\Program Files\Google\Chrome\Application\chrome.exe`).
    Headless(true)

一些不推荐的方案

  • 单纯补全 Headers:没用,指纹在更底层
  • 轮换代理 IP:如果指纹本身被识别,换 IP 只是换个被拦截的 IP
  • 降低请求频率:对基于频率的风控有效,但对指纹层面的拦截无效

如何在自己的服务中做基础防护

如果你自己跑服务,想做一些简单的爬虫识别,可以从这些方面入手:

检查 UA 与行为的一致性

func checkConsistency(ua string, headers http.Header) bool {
    // 如果 UA 说是 Chrome,但缺少 sec-ch-ua 头,矛盾
    if strings.Contains(ua, "Chrome") && headers.Get("Sec-Ch-Ua") == "" {
        return false
    }
    // 如果 UA 说是现代浏览器,但 Accept 头很简陋,矛盾
    if strings.Contains(ua, "Chrome") && !strings.Contains(headers.Get("Accept"), "text/html") {
        return false
    }
    return true
}

请求频率限制

import "golang.org/x/time/rate"

var limiter = rate.NewLimiter(rate.Every(time.Second), 10) // 每秒最多 10 个请求

func handler(w http.ResponseWriter, r *http.Request) {
    if !limiter.Allow() {
        http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
        return
    }
    // 正常处理
}

关于深度指纹检测

真正做 JA3 指纹提取、HTTP/2 帧级分析,在 Go 标准库里很难实现。TLS 握手细节被封装在 crypto/tls 包里不暴露,HTTP/2 帧处理被封装在 x/net/http2 里。要做这个级别,要么用第三方库(如 utls),要么直接用 Cloudflare、阿里云 WAF 这类现成服务。

我个人项目目前只做到 UA 一致性检查 + 频率限制 + 简单的行为分析(比如检查是否按正常流程访问页面,还是直接 POST 接口),对于独立开发者来说够用了。更复杂的交给专业服务商。

一些参考资源

  • JA3 原始论文和算法说明:https://github.com/salesforce/ja3
  • tls-client 库文档:https://github.com/bogdanfinn/tls-client
  • curl-impersonate 项目:https://github.com/lwthiker/curl-impersonate
  • Go 的 HTTP/2 实现源码:golang.org/x/net/http2
  • Rod 浏览器自动化文档:https://go-rod.github.io

最后

现代反爬虫已经不只是「检查你有没有加 User-Agent」这种水平了。协议实现层面的指纹检测,对于只用过标准库 HTTP 客户端的开发者来说,是一个很容易忽视的盲区。

tls-client 这类库的出现,本质上是把「用真实浏览器」和「用标准 HTTP 库」之间的鸿沟填上了——既保留了代码层面的可控性,又在协议行为上做到了以假乱真。

当然,技术手段永远是对抗性的。指纹库在更新,检测方法也在更新。这篇文章记录的是当前这个时间点(2026 年 6 月)的有效方案,半年后可能又有变化。保持关注底层协议细节,比死记某个库的 API 更重要。


文章来源: https://blog.axiaoxin.com/post/browser-fingerprint-anti-crawler-bypass/
如有侵权请联系:admin#unsafe.sh