原文首发于我的博客:https://yifei.me/note/2719
越来越多的网站开始使用 TLS 指纹反爬虫,而 Python 中竟然没有任何方法解决这个问题。前一阵
看到由国外大神写了一个 curl-impersonate 命令行工具,可以完美模拟主流浏览器的指纹,遂用
cffi 封装成了 Python 库 curl_cffi,这样就可以
继续愉快地写爬虫啦!
TLS 指纹
首先来回顾一下什么是 TLS 指纹。如果已经了解,可以直接跳到后边的 curl_cffi 部分。
现在绝大多数的网站都已经使用了 HTTPS,要建立 HTTPS 链接,服务器和客户端之间首先要进行
TLS 握手,在握手过程中交换双方支持的 TLS 版本,加密算法等信息。不同的客户端之间的差异
很大,而且一般这些信息还都是稳定的,所以服务端就可以根据 TLS 的握手信息来作为特征,识别
一个请求是普通的用户浏览器访问,还是来自 Python 脚本等的自动化访问。
JA3 是生成 TLS 指纹的一个常用算法。它的工作原理也很简单,大概就是把以上特征拼接并求 md5。
有证据表明,阿里云、华为云、Akamai 和 Cloudflare 都在使用 TLS 指纹技术来识别机器访问流量。
Akamai 更是直接在宣传稿中说明了在通过 TLS 指纹技术检测非法请求。
在真正发现 Cipher Stunting 之前,Akamai 观察到的 TLS 指纹大概有数万个。在初步发现后不久,
TLS 指纹数量激增至数百万,最近跃升至数十亿。
https://www.akamai.com/blog/security/bots-tampering-with-tls-to-avoid-detection
查看 tls 指纹的网站有:
不同网站的生成的指纹可能有差异,但是多次访问同一个网站生成的指纹是稳定的,而且能区分开
不同客户端。下文以第一个网站为例。
浏览器的指纹:53ff64ddf993ca882b70e1c82af5da49
httpx 的指纹:44423a0e34badcd72364f09ff481fcc9
Python 3.10.9 (main, Jan 11 2023, 15:21:40) [GCC 11.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import httpx
>>> r = httpx.get("https://tls.browserleaks.com/json")
>>> r.json()
{'ja3_hash': '44423a0e34badcd72364f09ff481fcc9', 'ja3_text': '772,4866
curl 的指纹:0ef95c8302480557fbc3cd8a7c87973c
$ curl --version
curl 7.81.0 (x86_64-pc-linux-gnu) libcurl/7.81.0 OpenSSL/3.0.2 zlib/1.2.11 brotli/1.0.9 zstd/1.4.8 libidn2/2.3.2 libpsl/0.21.0 (+libidn2/2.3.2) libssh/0.9.6/openssl/zlib nghttp2/1.43.0 librtmp/2.3 OpenLDAP/2.5.11
Release-Date: 2022-01-05
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM NTLM_WB PSL SPNEGO SSL TLS-SRP UnixSockets zstd
$ curl https://tls.browserleaks.com/json
{"ja3_hash":"0ef95c8302480557fbc3cd8a7c87973c","ja3_text":"772,4866-4867-4865
可以看到,每个客户端的指纹都是不一致的,服务端也就可以据此防御异常流量。显然,防御等级分
两个层次。
非法指纹黑名单
这个思路很直接,把常用的爬虫工具的指纹收集起来,然后全都屏蔽了就好了。比如说:curl,
requests, golang 访问时,直接 403。当然,突破也很简单,别用默认的指纹,直接随便改一下
tls hello 包的值就行了。
比如,修改 httpx 的 TLS 协议。以 httpx 为例:
# 默认 cipher 在这里定义:https://github.com/encode/httpx/blob/master/httpx/_config.py
import ssl
import httpx
# create an ssl context
ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS)
CIPHERS = 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+CHACHA20:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+HIGH:DH+HIGH:RSA+AESGCM:RSA+AES:RSA+HIGH'
ssl_context.set_ciphers(CIPHERS)
r = httpx.get('https://tls.browserleaks.com/json', verify=ssl_context)
print(r.json())
# {'ja3_hash': 'cc8fc04d55d8c9c318409384eee468b6'
可以看到 JA3 指纹已经变了。
合法指纹白名单
既然指纹可以随便改,那就直接只认常用浏览器的指纹好了。这时候如果爬虫或者其他脚本再想要
突破防御,需要把每一个值都改成和浏览器都完全相同,难度还是挺大的。尤其是考虑到大多数
语言的标准库都是直接使用系统的 SSL 库,很多底层的东西直接没提供接口,所以这种防御还是非常
有效的。
例如,Python 使用了 OpenSSL,而 Chrome 则使用了 BoringSSL,这两者的细节差异很多。所以,
纯 Python 的库,比如 requests 和 httpx,再怎么改也不可能改成和 Chrome 一样的指纹,必须
使用第三方的 C 扩展库,才能够实现完美模拟浏览器指纹。
此外,还又一个小细节,可以由 TLS 指纹反推出客户端是从哪些操作系统或者软件来的,如果和
User-Agent 互相矛盾,那也说明有问题。不过实际中,我还没有遇到这种情况。
curl_cffi
为了完美模拟浏览器,国外有大佬给 curl 打了一些 patch,把相应组件全部都替换成了浏览器使用
库,连版本都保持一致,这样就得到了和浏览器完全一样的指纹,这个库是:curl-impersonate
Python 中早就有 curl 的 binding -- pycurl,但是非常难用,安装的时候总是出现编译错误;接口
也很低级,相比 requests,甚至 urllib,用起来都比较费劲。curl-impersonate 的作者提出使用
环境变量 + 替换 libcurl 来在不同语言中使用 curl-impersonate,但是似乎 pycurl 没法工作。
于是乎,我直接另起炉灶,写了一个 curl(-impersonate) 的 Python binding.
相比 pycurl,有以下优点:
- 原生支持 curl-impersonate
- pip install 直接是二进制包,无需编译,也就不会有编译错误
- 提供了一个简单的 requests-like 接口
废话少说,看代码吧!
pip install curl_cffi
使用起来也很简单
from curl_cffi import requests
# 注意这个 impersonate 参数,指定了模拟哪个浏览器
r = requests.get("https://tls.browserleaks.com/json", impersonate="chrome101")
print(r.json())
# output: {'ja3_hash': '53ff64ddf993ca882b70e1c82af5da49'
我们可以看到,输出的 JA3 指纹和浏览器中的指纹一模一样!
代理也支持:
>>> proxies={"http": "http://localhost:7777", "https": "http://localhost:7777"}
>>> r = requests.get("http://baidu.com",
proxies=proxies,
allow_redirects=False,
impersonate="chrome101"
)
>>> r.text
'<html>\r\n<head><title>302 Found</title></head>\r\n<body bgcolor="white">\r\n<center><h1>302 Found</h1></center>\r\n<hr><center>bfe/1.0.8.18</center>\r\n</body>\r\n</html>\r\n'
>>> r = requests.get("https://tls.browserleaks.com/json",
proxies=proxies,
impersonate="chrome101"
)
>>> r.json()
{'ja3_hash': '53ff64ddf993ca882b70e1c82af5da49'
同样的功能,也可以用底层一点的 Curl 对象:
from curl_cffi import Curl, CurlOpt
from io import BytesIO
buffer = BytesIO()
c = Curl()
c.setopt(CurlOpt.URL, b'https://tls.browserleaks.com/json')
c.setopt(CurlOpt.WRITEDATA, buffer)
c.impersonate("chrome101")
c.perform()
c.close()
body = buffer.getvalue()
print(body.decode())
仓库在这里:https://github.com/yifeikong/curl_cffi
其他指纹技术概览
- HTTP Header 指纹。通过浏览器发送的 header 的顺序和值的组合来判断是合法用户还是爬虫
- DNS 指纹。参考:http://dnscookie.com
- 浏览器指纹。通过 canvas,webgl 等计算得到一个唯一指纹,Cookie 禁用时监视用户的主流技术
- TCP 指纹。也是根据 TCP 的一些窗口、拥塞控制等参数嗅探、猜测用户的系统版本
总结一下,指纹技术就是通过不同的设备和客户端在参数上的微妙差异来识别用户。本来按照规范,
这些值都是应该任意选取的,但是,现实世界中,服务端反而对不同值采取了区别对待。指纹技术
可以说应用到了 OSI 网络模型中所有可能的层,基于 HTTP header 顺序的指纹工作在第七层应用层,
SSL/TLS 指纹工作在传输层和应用层之间,TCP 指纹在第四层传输层。而在 TCP 之下的 IP 层和物理
层,因为建立的不是端到端的链路,所以只能收集上一跳的指纹,没有任何意义。
对于爬虫来说,User-Agent 相当于自报门户。除了初学者以外,没有人会顶着 Python/3.9 requests
这样的 UA 去爬的,而指纹则是很难更改的内部特征。通过指纹技术可以防御一大批爬虫,而使用
能够模拟指纹的 http client 则轻松突破这道防线。