Go 绕过 Cloudflare 防护

书接上文,在用了简单的界面模拟后chatgpt用是能用了,但受限制条件太多,终究不是长久之法,还是比不上逆向接口来的高效实用。经过一番折腾最后成功实现接口的调用,这期就来先探究下为何网页操作可以,而在代码里模拟接口却被403拒绝的问题。

TLS Fingerprinting

上文说到逆向受阻主要是受到 Cloudflare 的反爬虫防护,经过查找发现 tls-client 这个库竟然可以绕过 Cloudflare,在一番上手使用后效果是有的,但有几点让我极其难以忍受

  • net/http 侵入性过强,丧失对标准库 net/http 使用
  • 构建出的程序体积上大了好几兆,触发了我的强迫症

于是继续探究这个库究竟用了什么魔法绕过 Cloudflare。在其 Readme.md 有这么一段内容引起了我的注意

Some people think it is enough to change the user-agent header of a request to let the server think that the client requesting a resource is a specific browser. Nowadays this is not enough, because the server might use a technique to detect the client browser which is called TLS Fingerprinting.

这段内容大概意思是:仅仅用 User-Agent 来伪装浏览器是不够的,服务器可能会通过 TLS 指纹识别 来判断请求。随后引用了 TLS 指纹识别如何工作这篇文章来介绍 TLS 指纹识别

根据文章的说法在 TLS 握手期间 Client Hello 会发送大量的客户端信息,这些的信息会被防护厂家做甄别过滤,防止恶意访问,信息的详细解读可以参考 tls13.xargs.org ,目前常用的客户端标记方法是 ja3,它通过计算下列信息的 MD5 来标识

1
SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat

知道大概的原理了,那么 tls-client 是如何解决的呢?查看 tls-client的依赖 会发现了一个关于 tls 的依赖 github.com/bogdanfinn/utls,去查看 bogdanfinn/utls 介绍里写着 Fork 的 https://gitlab.com/yawning/utls,再去看 yawning/utls 好家伙这介绍里写的 Fork 的 https://github.com/refraction-networking/utls,去到 refraction-networking/utls 这回对了,star 数和介绍都相当到位,这个才是真正处理 TLS 指纹识别

关于客户端请求的代码在官方示例有详细介绍,这里仿照写一段测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
func TestUtls(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "https://chat.openai.com/", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
config := tls.Config{ServerName: req.URL.Hostname()}
// 设置网络魔法
u, _ := url.Parse("socks5://127.0.0.1:1080")
d, _ := proxy.FromURL(u, proxy.Direct)
dialConn, err := d.Dial("tcp", "chat.openai.com:443")
if err != nil {
return
}
uTlsConn := tls.UClient(dialConn, &config, tls.HelloChrome_Auto)
defer uTlsConn.Close()

err = uTlsConn.Handshake()
if err != nil {
return
}
resp, err := httpGetOverConn(req, uTlsConn, uTlsConn.ConnectionState().NegotiatedProtocol)
if err != nil {
return
}
val, _ := httputil.DumpResponse(resp, true)
fmt.Println(string(val))
}
// 处理请求
func httpGetOverConn(req *http.Request, conn net.Conn, alpn string) (*http.Response, error) {
switch alpn {
case "h2":
req.Proto = "HTTP/2.0"
req.ProtoMajor = 2
req.ProtoMinor = 0

tr := http2.Transport{}
cConn, err := tr.NewClientConn(conn)
if err != nil {
return nil, err
}
return cConn.RoundTrip(req)
case "http/1.1", "":
req.Proto = "HTTP/1.1"
req.ProtoMajor = 1
req.ProtoMinor = 1

err := req.Write(conn)
if err != nil {
return nil, err
}
return http.ReadResponse(bufio.NewReader(conn), req)
default:
return nil, fmt.Errorf("unsupported ALPN: %v", alpn)
}
}

就在我以为十拿九稳的时候,请求之后得到结果却给我了一盆冷水 403

1
2
3
4
5
HTTP/2.0 403 Forbidden
Alt-Svc: h3=":443"; ma=86400
Cache-Control: private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Cf-Mitigated: challenge
Cf-Ray: 848c6ee97eddce6c-SJC

这让我意识到一定有什么东西在 tls-client 库里我没注意到,于是我经过一系列的查找终于让我找了关键信息 HTTP/2 Fingerprinting

HTTP/2 Fingerprinting

说起来找到这个关键信息在于我某次调整 http.Transport 意外强制使用了 HTTP/1.1 然后得到了 200 的响应码,让我意识到也许问题不在 TLS 指纹识别 而在于 HTTP/2 也存在某种类似的指纹机制,谷歌一下 http2 Fingerprinting 第一篇 http2-fingerprinting 就是我们想要的答案。

在这篇文章里介绍到 http2 Fingerprinting 它可以识别浏览器类型和版本,或者是否使用脚本。该方法依赖于 HTTP/2 协议的内部机制,与简单的前身 HTTP/1.1 相比,该协议不太为人所知。

先来介绍下 HTTP/2 协议,HTTP/2 是一种二进制协议,与文本协议 HTTP/1.1 不同。 HTTP/2 中的消息由帧组成,有十种类型的帧服务于不同的目的。在请求的交互中 通常会有几种帧可能会被用来做浏览器与脚本的区分

  • SETTINGS
  • WINDOW_UPDATE
  • HEADERS
  • PRIORITY

这里着重介绍其中最关键的 SETTINGS 帧,通过 SETTINGS 帧,客户端向服务器通知其 HTTP/2 配置项。客户端可以通过六种不同的设置来控制参数,例如并发流的最大数量、HTTP 标头的最大数量、默认窗口大小以及是否支持服务器推送功能。我们可以通过 https://browserleaks.com/http2 ,来观察所用的浏览器用的相关 HTTP/2 帧信息,我用的 Chrome SETTINGS 帧参数大概如下

1
2
3
4
SETTINGS_HEADER_TABLE_SIZE: 65536
SETTINGS_ENABLE_PUSH: 0
SETTINGS_INITIAL_WINDOW_SIZE: 6291456
SETTINGS_MAX_HEADER_LIST_SIZE: 262144

由于每个 HTTP/2 客户端都有自己独特的 SETTINGS 帧设置。且不受到实际的 HTTP 请求影响,最主要的是 SETTINGS 帧设置通常被认为是繁琐的,一般会被软件厂商封装,用户通常难以设置这些值,这便给安全厂商提供了一个绝佳的机制来验证请求。

很明显 Go 的 http.Client 也一定是有一套不同于浏览器的设置,那么该如何改变这些值伪装成浏览器呢?在上面的例子中 tr.NewClientConn(conn) 点进去会发现源码中有这么一段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, error) {
// 省略
initialSettings := []Setting{
{ID: SettingEnablePush, Val: 0},
{ID: SettingInitialWindowSize, Val: transportDefaultStreamFlow},
}
if max := t.maxFrameReadSize(); max != 0 {
initialSettings = append(initialSettings, Setting{ID: SettingMaxFrameSize, Val: max})
}
if max := t.maxHeaderListSize(); max != 0 {
initialSettings = append(initialSettings, Setting{ID: SettingMaxHeaderListSize, Val: max})
}
if maxHeaderTableSize != initialHeaderTableSize {
initialSettings = append(initialSettings, Setting{ID: SettingHeaderTableSize, Val: maxHeaderTableSize})
}
cc.bw.Write(clientPreface)
cc.fr.WriteSettings(initialSettings...)
cc.fr.WriteWindowUpdate(0, transportDefaultConnFlow)
cc.inflow.init(transportDefaultConnFlow + initialWindowSize)
cc.bw.Flush()
// 省略
return cc, nil
}

if 中的方法 t.maxFrameReadSize() t.maxHeaderListSize() 对应了 http2.Transport 中的配置,结果测试微调上面的代码 http2.Transport{} 改成如下内容

1
tr := http2.Transport{MaxHeaderListSize: 262144}

即可获取到非 403 的响应头,表明通过鉴权。

原生 net/http 适配

最难的一环通过检测是完成了,但是如何优雅的集成到原生的 net/http 库里呢?要知道折腾这么老半天可就是想要替换掉 tls-client 这个臃肿的三方库,为了集成只能接着啃源码。根据观察发现在 http 源码中有个 (t *Transport) onceSetNextProtoDefaults() 函数,这个函数调用了http2configureTransports 配置 http2.Transport,最关键的一点来了我直接贴上源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
t2, err := http2configureTransports(t)
if err != nil {
log.Printf("Error enabling Transport HTTP/2 support: %v", err)
return
}
t.h2transport = t2

// Auto-configure the http2.Transport's MaxHeaderListSize from
// the http.Transport's MaxResponseHeaderBytes. They don't
// exactly mean the same thing, but they're close.
//
// TODO: also add this to x/net/http2.Configure Transport, behind
// a +build go1.7 build tag:
if limit1 := t.MaxResponseHeaderBytes; limit1 != 0 && t2.MaxHeaderListSize == 0 {
const h2max = 1<<32 - 1
if limit1 >= h2max {
t2.MaxHeaderListSize = h2max
} else {
t2.MaxHeaderListSize = uint32(limit1)
}
}

看到了么,源码中也有设置 MaxHeaderListSize 这一流程,可以通过 MaxResponseHeaderBytes 来关联设置 MaxHeaderListSize,最后总结一下关于原生 绕过 Cloudflare 的设置

1
2
3
h1 := (http.DefaultTransport).(*http.Transport).Clone()
h1.MaxResponseHeaderBytes = 262144
client := &http.Client{Transport: h1}

可能有的人会问 TLS 指纹识别 呢,这里经过测试 Cloudflare 没有用这个验证,只用了 HTTP/2 验证,个人猜测是担心浏览器的更新升级之后变更了 TLS 指纹 影响访问。

写在最后

最初只是对第三方库侵入 net/http 而感到不爽,当抽丝剥茧 找到最后的答案时竟然让我感到了一丝滑稽,原来我们找一圈来绕过 Cloudflare 的方式竟然如此简单。当然非常感谢 tls-client 这个库为我带来这一次宝贵的学习经验,也希望这篇文章可以帮到你。