
本文已同步发布到微信公众号「人言兑」
👈 扫描二维码关注,第一时间获取更新!
最近不仅在折腾反爬虫,也在搞一个数据抓取的需求,知己知彼,也能更有效的进行防御和进攻。
在抓取数据时,发现同样的请求在浏览器里能正常打开,用代码跑就返回 403 或者验证码页面。
折腾了挺久,最后靠模拟浏览器指纹解决了问题。
这篇文章记录一下学到的内容,主要是 TLS 指纹、HTTP/2 指纹这些之前没太关注过的东西。
一开始我的思路很常规:补全 Headers、加 Cookie、换 User-Agent、调请求间隔。这些手段以前对付大多数网站都够用,但这次完全不行。即使我把代码里的请求头弄得跟浏览器开发者工具里复制出来的一模一样,服务器照样拦截。
后来用 curl 测试,发现 curl 也被拦。但浏览器(包括隐身模式)完全正常。这说明问题不在 IP、不在 Cookie、也不在 Headers 内容本身。
我们平时说的「指纹」不是指 Cookie 或者登录状态那种主动标识,而是软件在实现网络协议时自然暴露的行为特征。服务器不需要你主动告诉它「我是谁」,只要观察你「怎么做」,就能判断出你大概是什么类型的客户端。
主要分三个层面:
HTTPS 连接建立前要先进行 TLS 握手。这个握手过程里,客户端会发送一个叫 Client Hello 的数据包,里面包含:
不同软件实现 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,连接建立后双方会先交换 SETTINGS 帧。这个帧里包含各种配置参数,不同客户端的默认值差异很大:
| 参数 | Chrome 128 | Go net/http |
|---|---|---|
| SETTINGS_HEADER_TABLE_SIZE | 65536 | 4096 |
| SETTINGS_MAX_CONCURRENT_STREAMS | 1000 | 250 |
| SETTINGS_INITIAL_WINDOW_SIZE | 6291456 (6MB) | 1048576 (1MB) |
| SETTINGS_MAX_FRAME_SIZE | 16384 | 未设置 |
除此之外,首次 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 在最前面。风控系统很容易检测出这种「没有固定说话顺序」的异常行为。
结合以上三个层面的指纹,一个典型的风控判断流程大概是:
这个过程中,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 是一个特殊键,它的值数组定义了头部发送的先后顺序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)
如果你自己跑服务,想做一些简单的爬虫识别,可以从这些方面入手:
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 接口),对于独立开发者来说够用了。更复杂的交给专业服务商。
现代反爬虫已经不只是「检查你有没有加 User-Agent」这种水平了。协议实现层面的指纹检测,对于只用过标准库 HTTP 客户端的开发者来说,是一个很容易忽视的盲区。
tls-client 这类库的出现,本质上是把「用真实浏览器」和「用标准 HTTP 库」之间的鸿沟填上了——既保留了代码层面的可控性,又在协议行为上做到了以假乱真。
当然,技术手段永远是对抗性的。指纹库在更新,检测方法也在更新。这篇文章记录的是当前这个时间点(2026 年 6 月)的有效方案,半年后可能又有变化。保持关注底层协议细节,比死记某个库的 API 更重要。