解决新版requests库连接旧的站点报错ssl握手失败的问题

56 阅读3分钟

在用requests获取一些省份的公共资源交易中心的站点源代码的时候遇到如下报错:requests.exceptions.SSLError: HTTPSConnectionPool(host='ggzyfw.beijing.gov.cn', port=443): Max retries exceeded with url: /jyxxzbhxrgs/index.html (Caused by SSLError(SSLError(1, '[SSL: BAD_ECPOINT] bad ecpoint (_ssl.c:1147)')))。查询相关资料,发现这是一个ssl握手失败的报错。网上很多的解决方法是在访问的时候禁用证书检查(verify=False),尝试之后问题还在。注意到详细的报错:bad ecpoint。查询openssl的相关文档 后知道了问题的根本原因:目标服务器(ggzyfw.beijing.gov.cn)在处理一种叫做 “椭圆曲线加密” (Elliptic Curve Cryptography, ECC) 的现代加密技术时,存在缺陷或配置错误。简单点来说就是目标站点的代码太老了,和新版本的requests的ssl握手的加密算法不兼容。

requests脚本尝试与服务器建立HTTPS连接时:

  1. 客户端(脚本)会告诉服务器:“我支持很多种加密方法,包括先进的椭圆曲线加密(ECDHE)。”
  2. 服务器响应说:“好的,那我们就用椭圆曲线加密吧!这是我的公钥参数。”
  3. 您的SSL库在收到服务器的参数后进行检查,发现这个参数是无效的(一个“坏点”),不符合密码学规范。为了安全起见,它立即中止了连接,并抛出了这个错误。

最终解决方案: 强制放弃椭圆曲线加密和强制锁定TLS协议版本为v1.2。既然我们知道服务器在椭圆曲线加密上“撒谎”或“犯错”,那我们干脆就不用这个了,放弃椭圆曲线加密算法。 创建一个自定义的加密套件列表 CIPHERS,并明确地从这个列表中移除了所有基于椭圆曲线的密钥交换算法(即 ECDHEECDH 开头的那些)。

    # 强制移除所有基于椭圆曲线的算法
    CIPHERS = (
        'DHE+AESGCM:DHE+CHACHA20:DH+AESGCM:DH+AES:RSA+AESGCM:RSA+AES:'
        '!aNULL:!eNULL:!MD5:!DSS'
    )
    ctx = create_urllib3_context(
        ciphers=CIPHERS,
    )

通过强制使用这个不包含椭圆曲线算法的列表,客户端在与服务器协商时,就不会再提议使用ECDHE了。服务器只能从我们给出的列表中选择一个它能正确处理的、更传统的密钥交换算法(如 DHERSA)。这就完美地绕过了它不能处理椭圆曲线算法的缺陷。

然而在调试过程中,通过ssl连接测试网站(www.ssllabs.com/):还发现服务器不支持最新的TLS 1.3协议。于是在创建SSL上下文 ctx 之后,我们通过设置其 minimum_versionmaximum_version 属性,将TLS协议版本严格限制在TLSv1_2

    ctx.minimum_version = ssl.TLSVersion.TLSv1_2
    ctx.maximum_version = ssl.TLSVersion.TLSv1_2

这确保了整个通信过程都使用我们指定的、最稳定的TLS 1.2协议,排除了因协议版本协商不一致而可能引发的任何潜在问题。 最终代码:

#创建一个自定义的HTTPAdapter
class CustomHttpAdapter (HTTPAdapter):
    def __init__(self, ssl_context=None, **kwargs):
        self.ssl_context = ssl_context
        super().__init__(**kwargs)

    def init_poolmanager(self, connections, maxsize, block=False):
        self.poolmanager = urllib3.PoolManager(
            num_pools=connections, maxsize=maxsize,
            block=block, ssl_context=self.ssl_context)

class BJ_中标:
    def __init__(self):
        self.pages=52
        self.headers={
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        }
        self.CIPHERS = (
            'DHE+AESGCM:DHE+CHACHA20:DH+AESGCM:DH+AES:RSA+AESGCM:RSA+AES:'
            '!aNULL:!eNULL:!MD5:!DSS'
        )
        self.ctx = create_urllib3_context(
            ciphers=self.CIPHERS,
        )
        self.ctx.minimum_version = ssl.TLSVersion.TLSv1_2
        self.ctx.maximum_version = ssl.TLSVersion.TLSv1_2
        self.session = requests.session()
        self.session.mount('https://', CustomHttpAdapter(self.ctx))

    def get_data(self):
        with open('BJ_中标.txt','a',encoding='utf-8') as f:
            try:
                url=f'https://ggzyfw.beijing.gov.cn/jyxxzbgg/index.html'
                response = self.session.get(url, headers=self.headers)
                response.encoding='utf-8'
                soup=BeautifulSoup(response.text,'html.parser')
                for i in soup.find('div',id='cmsContent').find('ul',class_='article-listjy2').find_all('li'):
                    href=i.find('a')['href']
                    title=i.find('a')['title']
                    f.write(f'https://ggzyfw.beijing.gov.cn{href},{title}\n')
                for page in range(2,self.pages+1):
                    url=f'https://ggzyfw.beijing.gov.cn/jyxxzbgg/index_{page}.html'
                    # 使用带有自定义SSL上下文的session
                    response = self.session.get(url, headers=self.headers)
                    response.encoding='utf-8'
                    soup=BeautifulSoup(response.text,'html.parser')
                    for i in soup.find('div',id='cmsContent').find('ul',class_='article-listjy2').find_all('li'):
                        href=i.find('a')['href']
                        title=i.find('a')['title']
                        f.write(f'https://ggzyfw.beijing.gov.cn{href},{title}\n')
            except Exception as e:
                traceback.print_exc()

if __name__ == '__main__':
    bj_中标=BJ_中标()
    bj_中标.get_data()