python调用go_cycletls过tls检测

1. 什么是tls指纹?

1.1 什么是tls指纹?

TLS 用于加密常见应用程序的通信(以确保数据安全)和恶意软件(使其可以隐藏在噪音中)。

要启动 SSL 会话,客户端将在 TCP 3 次握手后发送 SSL 客户端 Hello 数据包。

该数据包及其生成方式取决于构建客户端应用程序时使用的包和方法。服务器如果接受 SSL 连接,将使用 SSL Server Hello 数据包进行响应,该数据包是根据服务器端库和配置以及 Client Hello 中的详细信息制定的。

由于 SSL 协商是以明文形式传输的,因此可以使用 SSL 客户端 Hello 数据包中的详细信息来指纹和识别客户端应用程序。

1.2 什么是ja3?

github: https://github.com/salesforce/ja3
JA3 是一种 TLS 指纹识别方法,JA3 收集 Client Hello 数据包中以下字段的字节十进制值;SSL 版本、密码套件、扩展列表、椭圆曲线和椭圆曲线格式。然后,它按顺序将这些值连接在一起,使用“,”分隔每个字段,使用“-”分隔每个字段中的每个值。

SSLVersion,Cipher,SSLExtension,EllipticCurve,EllipticCurvePointFormat

769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0

de350869b8c85de67a350c8d186f11e6

wireshark抓包工具可以看到ja3指纹

对于 Google 的 GREASE(生成随机扩展和维持可扩展性),是一种机制用来来防止 TLS 生态系统中的可扩展性失败。JA3 完全忽略这些值,以确保仍然可以使用单个 JA3 哈希来识别使用 GREASE 的程序。
因此,JA3 是一种比 IP更有效的检测 SSL 恶意活动的方法。

1.3 已经发现有tls检测的网站

  1. www.kroger.com的特定api
  2. www.amazon.com的高并发场景
  3. https://ascii2d.net

2. TlS解决方案

大概分为3类:

  1. 修改一部分试题答案
  2. 修改所有试题答案
  3. 修改答卷最终得分

2.1 python半突破

2.1.1原理

修改cipher里的加密算法即可,也就是TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats里的【Ciphers】

2.1.2 使用方法

import urllib3
urllib3.util.ssl_.DEFAULT_CIPHERS = 'EECDH+AESGCMEDH+AESGCM

只需两行代码即可搞定。

2.1.3 缺陷

python目前只能改Ciphers里面的算法套件,来生成非默认的ja3指纹,然后可以骗过检测不是太高的反爬机制。
但是其他的Extensions,EllipticCurves,EllipticCurvePointFormats是没法改的,原因是:
python跟openssl没有很直接的联系,python发https请求最后还是借助openssl库暴露出来的方法,也就是的ssl_.py里的方法create_urllib3_context,因为openssl库对外提供的方法或者接口是没办法这么高度自定义的,Ciphers部分也最多能改改算法,都不能给个自己定义的算法进去的,而浏览器有自己的ssl,例如火狐使用 nss 而不是 OpenSSL 编译curl。对于 Chrome 版本,使用 BoringSSL 进行编译。
所以,不管用requests,httpx,还是aiohttp都不行,因为这三个库底层都借助了openssl库发请求。

2.1.4 实测

测试网址:https://ascii2d.net

并未通过。

2.2 使用python的curl_cffi库

算是目前为止市场上主流的解决方案
github:https://github.com/yifeikong/curl_cffi 基于curl-impersonate构建的python请求库
github:https://github.com/lwthiker/curl-impersonate 魔改的curl

2.2.1 原理

它是一种特殊的curl构建,可以模拟四种主要浏览器:Chrome、Edge、Safari和Firefox。基于curl-impersonate 能够执行与真实浏览器相同的 TLS 和 HTTP 握手。

  1. 对于火狐使用 nss(Firefox 使用的 TLS 库)而不是 OpenSSL 编译curl。对于 Chrome 版本,使用 Google 的 TLS 库 BoringSSL 进行编译。
  2. 修改curl配置各种TLS扩展和SSL选项的方式。
  3. 添加对新 TLS 扩展的支持。
  4. 更改curl 用于其HTTP/2 连接的设置。
    支持模拟的浏览器:

2.2.2 使用方法

from curl_cffi import requests

headers = {
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
}

response = requests.get('https://ascii2d.net/', headers=headers,impersonate='chrome110') #impersonate选择使用哪一种浏览器
print(response.text)
print(response.status_code)

2.2.3 实测

测试通过。

2.2.4 优缺点

优点:简单易用
缺点:

  1. 受限于curl-impersonate,只能模拟11个浏览器,做高并发可能还是会被风控住
  2. 在某些爬取场景下可能有意外的报错,例如stream流未关闭等等

2.3 使用golang的cycletls

2.3.1 原理

hook hello包,然后把原来的ja3指纹修改成了自传递的ja3字段发出client hello(能够自己构造并发出传输层的数据包),是避开了直接改5个JA3参数,而是在5个JA3参数创建好之后进行拦截替换。

go语言的优势:之前提到python底层用的openssl除了密码套件暴露了接口,其余的都没有暴露,对于tls数据包的传输过程也无法介入;而Go 的net/http库有一个名为Transport的传输结构负责编写如何将数据包发送到目标服务器。由于 JA3 的签名是基于 ClientHello 数据包的,我们可以进行 TLS 握手,三次握手之后,到实际要发起client hello包之前,transport把数据包拦截了。

2.3.2 使用方法

package main //表名改脚本属于main包

import (
    "log" //日志模块
    "github.com/Danny-Dasilva/CycleTLS/cycletls" //cycletls模块
)

func main() {

    client := cycletls.Init() //返回一个cycletls类型的结构体

    response, err := client.Do("https://ascii2d.net/", cycletls.Options{
        Body : "", 
        Ja3: "771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-51-57-47-53-10,0-23-65281-10-11-35-16-5-51-43-13-45-28-21,29-23-24-25-256-257,0",
        UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0",
      }, "GET");
    if err != nil {
        log.Print("Request Failed: " + err.Error())
    }
    log.Println(response.Status)
    log.Println(response.Body)
}

2.3.3 实测

测试通过。

2.3.4 优缺点

优点:简单易用,并且支持ja3指纹传参数,意味着只要积累的指纹的足够的,就能实现足够高的指纹随机。
缺点:

  1. 走的是hook的思路,相当于把卷面分改成了100,但做题过程没动,如果遇到了强校验,不一定能够,但目前还没有遇到过这样的网站。
  2. 和python存在语言类型不通,需要借助c实现联动。

3 go程序的安装环境

  1. go官网安装网址:https://golang.google.cn/ golang ^v1.16+
  2. go环境配置:https://blog.csdn.net/m0_59139260/article/details/131252452
  3. 环境配置完成后:go get github.com/Danny-Dasilva/CycleTLS/cycletls
  4. 编写完go程序后,运行:go run xx.go

注意点:go程序的开发以文件夹为单位,一个文件夹只能有一个main脚本

4 python调用go

4.1 原理

想要用python调用go完成爬虫任务,需要借助c,python通过ctypes可以加载和调用C动态库的细节,而go可以编译成c动态库。
注意点:
由于整个过程是通过C进行中转的,因此对于python需要事先约定参数转到C的方式,返回值转到python的方式,然后正式调用时传入bytes
对于go,参数需要选择C类型,然后在转成go类型,最后输出时为C类型,否则会因类型问题报编码错误。

4.2 go代码修改

package main

/*
struct Res {
long long status;
char* text;
char* err;
};
struct Req {
char* url;
char* method;
char* body;
char* ua;
char* ja3;
char* proxy;
char* headers;
};
*/
import "C" //引入C是为了操作C类型的数据,函数的结果与返回值都要是C类型的
import (
    "encoding/json" //用于json的解码和编码
    "fmt" //打印
    "github.com/Danny-Dasilva/CycleTLS/cycletls" //爬虫库
)

//export cycleTls
func cycleTls(req C.struct_Req) C.struct_Res {
    var url_str string = C.GoString(req.url) //C结构体中的字符串需要转成go类型,用C.GoString
    var ja3_str string = C.GoString(req.ja3) 
    //没有传入ja3参数,则使用默认的
    if len(ja3_str) == 0 {
        ja3_str = "771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-51-57-47-53-10,0-23-65281-10-11-35-16-5-51-43-13-45-28-21,29-23-24-25-256-257,0"
    }
    var method_str string = C.GoString(req.method)
    var headers_str string=C.GoString(req.headers)

    //headers反序列化成map[string]string类型
    var headers map[string]string
    json.Unmarshal([]byte(headers_str), &headers)  //go中json需要事先定义变量用来接收

    var body_str string = C.GoString(req.body)
    var proxy_str string = C.GoString(req.proxy)
    var ua_str string = C.GoString(req.ua)
    //没有传ua,则使用默认的
    if len(ua_str)==0{
        ua_str="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0"
    }
    client := cycletls.Init()
    response, err := client.Do(url_str, cycletls.Options{
        Body:  body_str, //post请求的载荷
        Ja3:   ja3_str, //ja3指纹
        Proxy: proxy_str, //代理,格式为"http://qq839552471:end3sp8ttd@23.239.184.114:60000"
        UserAgent: ua_str, 
        Headers: headers,
    }, method_str) 
    //如果客户端发生问题,需要传递出报错信息,这里用C结构体来接收,go的基本类型int用C.longlong转变,string用C.CString转变
    if err != nil {
        return C.struct_Res{
            status:C.longlong(0),
            text:C.CString(""),
            err:C.CString(err.Error()),
        }
    }
    return C.struct_Res{
        status:C.longlong(response.Status), 
        text:C.CString(response.Body),
        err:C.CString(""),
    }
}

// 作为代编译动态库,main可以为空,但不能不写
func main() {
    //headers用的是匿名结构体的写法,结构体就相当于python中的字典
    req_headers:=struct{
        UA string `json:"user-agent"`
    }{
        UA : "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0",
    }
    headersBytes, _ := json.Marshal(&req_headers) //&表示取出变量指针,这里是把headers变成json的字节数组
    req := C.struct_Req{
        url:    C.CString("https://ascii2d.net"), //go字符串都需要用C.CString变成C类型的字符串
        method: C.CString("GET"),
        body:   C.CString(""),
        ja3:    C.CString("771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-51-57-47-53-10,0-23-65281-10-11-35-16-5-51-43-13-45-28-21,29-23-24-25-256-257,0"),
        proxy:  C.CString(""),
        ua: C.CString("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0"),
        headers: C.CString(string(headersBytes)), //字节数组需要用string强转成字符串

    }
    result := cycleTls(req)
    fmt.Println(result.status) //int类型,c和go是互通的,因为属于基本类型,不会参杂各自语言的本身特性
    fmt.Println(C.GoString(result.text)) //Cstring需要用C.GoString转成go类型
    fmt.Println(C.GoString(result.err))
}

编译命令: go build -buildmode=c-shared -o library.so main.go

4.3 python代码修改

# 引入C结构体,uft-8字符串,int,和动态链接库方法
from ctypes import Structure, c_char_p, c_int, cdll 
import json 
#url, method, body,ua, ja3, proxy,headers
"""
下面是建立Ctypes结构体,需要注意字段顺序与Cgo结构体一致
"""
class Req(Structure):
    _fields_ = [
        ("url", c_char_p),
        ("method", c_char_p),
        ("body",c_char_p),
        ("ua",c_char_p),
        ("ja3",c_char_p),
        ("proxy",c_char_p),
        ("headers",c_char_p),
    ]
class Res(Structure):
    _fields_ = [
        ("status", c_int),
        ("text", c_char_p), 
        ("err",c_char_p),
    ]

__library = cdll.LoadLibrary('library.so') # 引入同路径下的.so文件
cycleTls = __library.cycleTls # 取出其中的爬虫函数
cycleTls.argtypes = [Req] # 约定py-c之前的参数类型都用Req结构体
cycleTls.restype = Res # 约定py-c之前的返回类型都用Res结构体


headers = {}
result=cycleTls(
    Req(
        url="https://ascii2d.net".encode('utf-8'), # string都需要编码成utf-8字节
        method="GET".encode('utf-8'),
        body="".encode('utf-8'),
        ja3="771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-51-57-47-53-10,0-23-65281-10-11-35-16-5-51-43-13-45-28-21,29-23-24-25-256-257,0".encode('utf-8'),
        proxy="".encode('utf-8'),
        ua="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0".encode('utf-8'),
        headers=json.dumps(headers).encode('utf-8'), # headers需要先转json字符串
))

print(result.status)
print(result.text.decode('utf-8')) #结构也需要从字节解码成utf-8字符串
print(result.err.decode('utf-8'))

4.4 实测结果

测试通过。

5. 未来与展望

5.1 c内存泄露

python调用go需要借助c,而c语言的内存管理需要手动回收,目前笔者还未证实长期服务是否会产生内存泄露。

如果出现内存泄露:

  1. 直接用go重够爬虫全部流程,因为go是自动回收内存的,方案是标记清除,会隔断时间就遍历所有的根变量,如果没有引用标记则回收变量。
  2. go语言可以主动回收内存,因此将返回体变成c变量的内存地址,在python通过地址访问c变量,并在使用完变量后,通过调用go的free方法传入内存地址,主动回收掉c的内存。笔者采用这种方式通过循环爬虫测试(50次)发现,是否回收内存最终产生的内存开销相近。
    最终这种方案是否会照成内存泄露,仍需进一步实际测试。
  3. 用go搭建web服务,走http请求。

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达,如有问题请邮件至2454612285@qq.com。
跃迁主页