NiceLeeのBlog 用爱发电 bilibili~

Python 关于检查域名证书是否过期遇到的问题

2021-09-14
nIceLee

阅读:


现在要做这么一件事情,批量监控域名是否快要到期,并作出提醒。

卡卡西

  • 可以确定的是,获取一个网站的域名证书,这是一个加密网络通信必须要实现的最基础的功能,HTTPS请求肯定要用到这个。 平常我们日常使用到的基本上是已经封装好的更高层的东西,比如requests包。现在我们并不需要一个完整的HTTPS请求,只需要最开始建立连接用到的证书。

  • 我坚信python本身底层应该提供相关API,但是给我文档我也不太好怎么下手。
    https://docs.python.org/3.8/library/😳
    我更倾向于看看有没有现成的例子可以参考。

  • 打开gayhub,关键词https cert
    找到一个项目tls-cert-discovery
    这个项目功能是批量扫描ip的443端口, 探测绑定的证书域名。
    抠下来获取cert,并从cert中提取信息的实现代码如下:

    import socket,ssl
    import OpenSSL
    cert = ssl.get_server_certificate((host, 443))
    
    x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
    for i in range(0, x509.get_extension_count()):
    ext = x509.get_extension(i)
    if "subjectAltName" in str(ext.get_short_name()):
        san = []
        content = ext.__str__()
        for d in content.split(","):
            san.append(d.strip()[4:])
    

出错log

  • 我发现这样子对有些域名可以,但是有些是会报错的
    import ssl
    cert = ssl.get_server_certificate(('baidu.com', 443))  ok
    cert = ssl.get_server_certificate(('nicelee.top', 443))  报错
    
  • 报错是这样的:

      ...
      File "D:\Python\Python38\lib\ssl.py", line 1484, in get_server_certificate
        with context.wrap_socket(sock) as sslsock:
      File "D:\Python\Python38\lib\ssl.py", line 500, in wrap_socket
        return self.sslsocket_class._create(
      File "D:\Python\Python38\lib\ssl.py", line 1040, in _create
        self.do_handshake()
      File "D:\Python\Python38\lib\ssl.py", line 1309, in do_handshake
        self._sslobj.do_handshake()
    ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:1108)
    

瞎折腾

百度一下、谷歌一下、stackoverflow一下,完全不管用。

  • 这些解决方案不外乎:
    • 它报了一个SSLV3的错,那我就想着禁用这个,直接指定协议使用TLS
    • 将支持的cipher套件设为ALL(一个服务器解决方案)
  • 无脑试了一下,卒。
  • 去看了一下官方文档,毫无头绪。

出错原因

  • 先看看get_server_certificate干了什么
    # python ssl.py 源码
    def get_server_certificate(addr, ssl_version=PROTOCOL_TLS, ca_certs=None):
        """Retrieve the certificate from the server at the specified address,
        and return it as a PEM-encoded string.
        If 'ca_certs' is specified, validate the server cert against it.
        If 'ssl_version' is specified, use it in the connection attempt."""
      
        host, port = addr
        if ca_certs is not None:
            cert_reqs = CERT_REQUIRED
        else:
            cert_reqs = CERT_NONE
        # 上面的都是配置不用管,下面实现    
        # 创建一个上下文
        context = _create_stdlib_context(ssl_version,
                                         cert_reqs=cert_reqs,
                                         cafile=ca_certs)
        # 创建一个socket连接
        with  create_connection(addr) as sock:
            # 将该socket升级为sslSocket
            with context.wrap_socket(sock) as sslsock:
                # 获取der证书
                dercert = sslsock.getpeercert(True)
        # 获取pem证书
        return DER_cert_to_PEM_cert(dercert)
    
  • 再看看报错的地方。
    这里有个注释,在ctx._wrap_socket()调用前需要处理server_hostname。
    好家伙,我们调的时候直接传的是None。
    # python ssl.py 源码
    def wrap_socket(self, sock, server_side=False,
                  do_handshake_on_connect=True,
                  suppress_ragged_eofs=True,
                  server_hostname=None, session=None):
      # SSLSocket class handles server_hostname encoding before it calls
      # ctx._wrap_socket()
      return self.sslsocket_class._create(
          sock=sock,
          server_side=server_side,
          do_handshake_on_connect=do_handshake_on_connect,
          suppress_ragged_eofs=suppress_ragged_eofs,
          server_hostname=server_hostname,
          context=self,
          session=session
      )
    
  • 破案了,我们在往域名服务器请求加密连接时,在握手环节并没有指定需要证书的域名(即SNI),导致了出错。 那么为什么有的域名可以成功,有的则会失败呢?
    • 当我们请求的域名对应的服务器仅仅绑定了一个域名,或者设置了默认访问的域名,那么没有SNI可以成功。
    • 当我们请求的域名对应的服务器仅仅绑定了多个域名,并且没有设置默认访问的域名,那么没有SNI,则会报错。因为服务器也不知道你要访问哪个。
      这种情况在有CDN的情况下比较常见。

解决方案

  • 更新最新版本
    • 这应该算是一个python的bug吧,于是看了一下,发现已经有人提交并merge了,也就是说新版本就不会有这个问题。
    • python/cpython/pull#16820
  • 自定义实现
    import socket,ssl
    import OpenSSL
      
    def get_server_certificate(domain, port):
        # 创建一个上下文
        context = ssl.SSLContext()
        # 不进行证书合法性校验,检验出错会报一个SSLV3的错误,这会损失真正的错误信息
        context.verify_mode = ssl.CERT_NONE
        context.check_hostname = False
        # 创建一个TCP socket
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # 将该socket升级为sslSocket,并指定SNI
        with context.wrap_socket(s, server_hostname=domain) as sslSocket:
            # 开始连接服务器,注意这里如果要绕过CDN的话,可以传真实的IP
            sslSocket.connect((domain, port))
            # 获取der证书
            dercert = sslSocket.getpeercert(True)
            # 获取pem证书
            return ssl.DER_cert_to_PEM_cert(dercert)
    

一个简单的实现的例子

import socket,ssl
import OpenSSL
from dateutil import parser
import time,datetime

def get_server_certificate(domain, port):
    context = ssl.SSLContext()
    context.verify_mode = ssl.CERT_NONE
    context.check_hostname = False
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    with context.wrap_socket(s, server_hostname=domain) as sslSocket:
        sslSocket.connect((domain, port))
        dercert = sslSocket.getpeercert(True)
        return ssl.DER_cert_to_PEM_cert(dercert)



def check(domain:str, validDays:int = 7, port:int = 443) -> (bool, str):
    try:
        cert = get_server_certificate(domain, port)
        # cert = ssl.get_server_certificate((domain, port))
        
        x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
        dt_notAfter = parser.parse(x509.get_notAfter().decode("UTF-8"))
        t_notAfter = time.mktime(dt_notAfter.timetuple())
        t_now = time.time()
        time_remain = (t_notAfter - t_now) / 3600 / 24
        tips = ("距离证书到期还剩约 %.1f 天"%time_remain)
        if time_remain < validDays:
            return False, tips
        for i in range(0, x509.get_extension_count()):
            ext = x509.get_extension(i)
            if b'subjectAltName' == ext.get_short_name():
                content = ext.__str__()
                for d in content.split(","):
                    subjectAltName = d.strip()[4:]
                    if isValidDomain(domain, subjectAltName):
                        return True, tips
        
        return False, "域名与证书不匹配 %s, %s"%(domain, content)
    except Exception as e:
        # raise
        return False, str(e)

def isValidDomain(domain, dn_pattern):
    '''
    仅仅是简单匹配,更全面的参考ssl._dnsname_match(dn, hostname)
    '''
    if domain == dn_pattern:
        return True
    if dn_pattern.startswith('*.') and domain.endswith(dn_pattern[1:]):
        return True
    return False

if __name__ == "__main__":
    result, tips =  check("nicelee.top")
    # result, tips =  check("baidu.com")
    print(result, tips)

内容
隐藏