HTTPS 拨测需求实现

HTTPS 现如今已经是非常常见的了,但是在拨测环境下依旧有这不小的限制。

现在我们讲下 HTTPS 拨测需求实现的案例,以期有更加详细的了解。

Python 网络请求库的选型

要求:

  • 兼容 Python 2.7
  • 不能太大,要尽可能精简
  • 开发效率高

选择网络请求所使用的库,基本很少有直接拿 python 原生库,比如 Python 2 的 urllib 和 urlib2 (Python3 已统一为 urllib)做开发的,因为操作繁琐,开发效率过低,所以开发人员基本上都是用经过包装的第三方库。

一般常用的库有 requests,aiohttp。aiphttp 是基于 async 的异步请求库,性能很高。requests 是另一个第三方库 urllib3 的上层包装。考虑到 拨测设备 大部分是 Python 2.7 的版本,不具备 async 功能,所以 aiohttp 无法使用。而 requests 库简单易用,开发效率较高,所以基本上并无选择。

需要注意的是不能使用依赖 openssl 的库,比如 pyOpenSSL。

另外 requests 库同时依赖 urllib3、idna、dns、chardet、certifi 这些库,经过修剪后,其目录大小为 2.65MB,经过压缩后大小在 752KB,大小可接受。

https 请求分析

https 验证包括以下几个方面:

  • tcp 握手
  • SSL/TLS 握手
  • 加密通信

其中,SSL/TLS 握手又分为以下几个环节

  • 加密协议协商
  • 证书验证
  • 秘钥交换

其他几个部分我们不用考虑,但是证书验证环节需要特别注意。

证书验证

证书验证环节,需要对获取到的服务端证书进行合法性验证,如何验证呢?通过 CA 根证书进行验证。这就要讲到一般 ssl 的签发流程:

  • 证书,其实就是一个凭证,根证书,是 CA 厂商经过证书颁发机构评审之后认可的数字证书,是一个凭证,有个根证书,CA 厂商才叫 CA 厂商。有了这个凭证,代表这在现行操作系统,浏览器等环境下的认可,一个根证书的产生,条件是非常苛刻的。这个根证书,是不断更新的,且一般都内置在系统和浏览器中。一旦根证书失效,后果往往是灾难性的。
  • 理论上有了根证书之后,就可以开展商业活动,通过这个根证书作为担保,签发其他的证书,这些证书证书理论上就可以作为产品进行售卖了。
  • 但实际上,由于根证书很难获取,且为了避免误签发或者各种原因导致本来没有资格的个人或组织,获得了由根证书签发的证书,那么这个根证书的合法性就收到了质疑,如果审计不通过,这个根证书就要被吊销。
  • 所以 CA 厂商为了避免这种情况,一般会由根证书签发的下一级证书不会作为商品(一般情况下),然后由这些下一级证书开始给正常的组织或个人签发。

所有这些层级的证书,形成了证书链。

以上只是最简略的说法:理论上证书可以无限签发下一级的,除了最终到组织或个人手上的证书。从根证书签发的下一级,到组织或个人证书的上一级,都叫做中间证书。

有时中间证书也会互相签发。称为交叉证书,为何要交叉?是因为中间证书有兼容性问题。为什么有兼容性问题?是因为操作系统和浏览器需要定期更新。旧的操作系统和浏览器,对于新签发的根证书和中间证书可能不会再更新。

如果想要有更细致的了解,可以看 let’s encrypt 的文档  。

拨测为什么要验证 CA 证书?

可能要问,为什么一定要验证 CA 证书呢?

因为需求。

拨测的需求很多:

  • 有时候需要要探测是否被劫持,比如状态码劫持、http 劫持、js 劫持,https 内容劫持和证书劫持就需要考虑。
  • 合作的 CDN 厂商,上线前拨测就需要探测证书是否正确等等问题。

所以,拨测必须有一个可信且及时的 CA 证书。

CA 证书文件来源

正是因为 CA 证书需要不断更新,所及基本上没有一劳永逸的办法,https 要验证证书,就需要一个可信的 CA 证书来源。

操作系统一般都会维护 CA 证书,例如 centos 7 有专门的包,ca-certificates,证书更新命令为:update-ca-trust。

windows 是随补丁更新更新。

正常来讲,拨测设备 所在设备也是一个操作系统,基本可以理解为是 openwrt 的改版,所以操作系统本身也维护 CA 证书列表,但是一来 拨测设备 所在设备本身属于不可信环境,二来也不能保证其证书是最新的,甚至可能根本就不会更新。更有甚者,可能本身就没有安装 ca 证书包,openssl 也是在 22.03 才默认带有 CA 证书不需要额外安装。

具体分析

首先分析 requests 请求 https 请求流程:

  1.  requests 会检查有没有环境内有没有 certifi 库,certifi 库是一个第三方的维护 ca 证书的库。
  2. 没有的话,会转而使用系统内的 ca 证书。
  3. 系统没有话,报出异常无法进行证书验证。

再分析在 拨测设备上拨测时:

  1. 首先不会保证会有 requests 库,即便有,不保证代码没有被修改。
  2. 不保证会有 certifi 库,即便有,不保证代码没有被修改。
  3. 不保证系统有 ca 证书,即便有,不保证 CA 证书时效性。

综上,所以目前的最优实现方案拨测自带 requests 库,至于 certifi 库,需要不断更新。而拨测脚本一般没有问题不会更新。

于是考虑了以下几种办法:

  1. 一个方法是定期发布拨测新版本,附带 certifi 库且 ca 证书文件是最新的(至于为什么是这种说法,请看下面注 1),这种方式不太好,一来不够优雅,二来 拨测设备也没有提供自动上传拨测脚本的接口,都是通过网页端手动操作。
  2. 第二个方法是 拨测设备自己维护一套 ca 证书,通过定期下发最新的 ca 证书文件,拨测时只要引用该文件即可。目前只能说已与 拨测设备人员沟通。
  3. 第三种方法,每次下发拨测数据时附带传入最新的 ca 证书文件。不可行,因为 拨测设备支持的传递的数据最大大小时 200kB,而单单 ca 证书文件就有 216KB。
  4. 拨测脚本每次运行时下载 CA 证书文件,然后再做验证。为保证成功性,下载时从多个地址进行下载,加入全部下载失败时,使用拨测脚本自带的 certifi 库。

目前的做法是使用第 4 种方法。

注 1:Python 2 已停止支持,certifi 库对 Python 2 已不再更新,即便安装也是停留在 2021.10.8 的版本,但实际上 certifi 库只是附带 ca 证书文件,所以安装好 certifi 库后,还要手动替换该文件。

好了,CA 证书的 问题流程上解决了,接下来就是具体实现了。

拆分 https 拨测需求

对于 https 拨测需求,分析下来是 5 个小需求

  1. 下载 CA 证书
  2. 证书合法性验证
  3. 证书域名验证
  4. 证书过期时间检查
  5. 指定 IP 请求

为什么第 5 个也是一个需求?

是因为 requests 的 https 请求原生不支持指定 IP 地址。

实际上,证书的合法性验证就包含了证书域名验证和证书过期时间检查,只不过我们要求证书距过期时间在 30 天以内的也认为证书验证不通过,所以最终下来是三个实现:

  1. 下载 CA 证书。
  2. 指定 IP 请求与证书合法性验证,一次网络 IO 内完成。
  3. 证书过期时间检查,目前需要单独一次网络 IO。

下载 ca 证书文件

目前的逻辑是:

  1. 判断拨测是否是 https 请求,如果是 https 进行 ca 证书下载。
  2. 多线程同时下载多个 url 地址的 ca 证书文件,通过队列获取最快返回。
  3. requests 指定 ca 证书进行验证。

requests 的 verify 参数支持 bool 类型和路径文件字符串类型。

Ture 代表启用证书合法性验证,一般情况下,ca 证书文件先是尝试使用 certifi 库,没有的话使用系统内 ca 证书文件。如果是路径文件字符串,则通过文件 io 读取文件进行验证。

下载 CA 证书这块比较容易实现,直接贴代码:

下载 ca 证书文件代码

import sys
from threading import Thread, Event

import requests
from requests import RequestException

if sys.version_info.major == int(2):
    from Queue import Queue
else:
    from queue import Queue


# 下载证书,下载完后将CA证书数据写入队列,并通过thread_event实现优雅退出。
def download_file(url, download_file_queue, thread_event):
    try:
        with requests.get(url, stream=True, timeout=5) as origin_task_info:
            if origin_task_info.status_code == 200:
                all_data = bytes()
                for chunk in origin_task_info.iter_content(chunk_size=4096):
                    if thread_event.is_set():
                        return
                    else:
                        all_data += chunk
                download_file_queue.put(all_data)
            else:
                print("Error downloading, status_code error: {}".format(origin_task_info.status_code))
    except RequestException as e:
        print("Error downloading {}: {}".format(url, e))


# 开启多线程。生成一个队列,download_data_queue,用于存储CA证书数据。生成一个event,用于通知线程退出。
def run_download():
    file_urls = [
        'https://mkcert.org/generate/',
        'https://curl.se/ca/cacert.pem',
        'https://gitee.com/mirrors/python-certifi/raw/master/certifi/cacert.pem'
    ]

    download_data_queue = Queue()
    threads = list()
    thread_event = Event()
    for url in file_urls:
        thread = threading.Thread(target=download_file, args=(url, download_data_queue, thread_event,))
        thread.start()
        threads.append(thread)

    return download_data_queue, thread_event, len(file_urls)


# 堵塞方式读取队列第一个数据,读取后通过event关闭其他线程。
def get_ca_cert(ca_cert_data_queue, thread_event):
    ca_byte_data = ca_cert_data_queue.get()

    thread_event.set()

    return ca_byte_data


# 调用
data_queue, event, url_num = run_download()

# 中间可做其他事情

ca_cert = get_ca_cert(data_queue, event)

下载完证书,requests 就可以使用了,requests 的使用自定义 ca 证书的方式:

# 先将证书写入文件,然后调用
with  open("/path/cacert.pem", "w") as cacert_file
        cacert_file.write(ca_cert.decode("utf-8"))


requests.get(url,verity="/path/cacert.pem")

优化 – 节省一次文件 IO

可以看到在 ca 文件下载完成后,还要写入磁盘文件才能使用。

考虑到 拨测设备设备,尽量不在其磁盘上留下临时文件,其一是存储性能很差,且避免存储设备出现问题,二来考虑平白消耗 IO,216KB 的大小,能直接放到内存中的话最为合适。

但是 requests 只支持 bool 类型和路径文件字符串类型,并不支持直接传递数据。

如果能传递文件对象,那么可以通过 StringIO 或者 BytesIO 实现。

结果发现更方便。

让我们分析下 urllib3 的源码。

我们来看这里的调用关系:

通过阅读 requests 源码,发现 requests 请求的的处理都是使用 HTTPAdapter 实现的。

verity 参数的处理在 HTTPAdapter.cert_verify 方法中:

其中 conn 是 urllib3 的 HTTPSConnection  对象,而后者在 HTTPConnection.connect 方法中

(附:从上图中也可以看出,当没有各种形式的 ca 证书传入时,会加载系统内的 ca 证书。)

我们再看 ssl_wrap_socket 函数:

load_verify_locations 方法,此时形参名为 cadata,该方法属于 SSLContext 类,请看下图:

注意上面的 from ssl import SSLContext,使用的是 Python 原生 ssl 库。

cadata 接受三种类型:Text,bytes,None。

综上,我们得知,可以将下载的证书可以直接传递,都不用 StringIO 和 BytesIO。

所以我们只需将 HTTPAdapter.cert_verify 方法重写即可,到这里还没有完,我们先看看官方新版有没有实现。

没有,代码与旧版一致,于是我们最终的代码实现是,继承 HTTPAdapter 并重写 cert_verify 方法:

import os
import sys

import requests
from requests.adapters import HTTPAdapter
from requests.compat import basestring
from requests.utils import DEFAULT_CA_BUNDLE_PATH, extract_zipped_paths


class HostHeaderCACertDataSSLAdapter(HTTPAdapter):
    def cert_verify(self, conn, url, verify, cert):
        """Verify a SSL certificate. This method should not be called from user
        code, and is only exposed for use when subclassing the
        :class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.

        :param conn: The urllib3 connection object associated with the cert.
        :param url: The requested URL.
        :param verify: Either a boolean, in which case it controls whether we verify
            the server's TLS certificate, or a string, in which case it must be a path
            to a CA bundle to use, otherwise a StringIO object, in which case it is CA data.
        :param cert: The SSL certificate to verify.
        """
        if url.lower().startswith('https') and verify:

            # Allow self-specified cert location.
            if isinstance(verify, bytes):
                conn.ca_cert_data = verify
            else:
                cert_loc = None

                if verify is not True:
                    cert_loc = verify

                if not cert_loc:
                    cert_loc = extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)

                if not cert_loc or not os.path.exists(cert_loc):
                    raise IOError("Could not find a suitable TLS CA certificate bundle, "
                                  "invalid path: {}".format(cert_loc))

                conn.cert_reqs = 'CERT_REQUIRED'

                if not os.path.isdir(cert_loc):
                    conn.ca_certs = cert_loc
                else:
                    conn.ca_cert_dir = cert_loc
        else:
            conn.cert_reqs = 'CERT_NONE'
            conn.ca_certs = None
            conn.ca_cert_dir = None

        if cert:
            if not isinstance(cert, basestring):
                conn.cert_file = cert[0]
                conn.key_file = cert[1]
            else:
                conn.cert_file = cert
                conn.key_file = None
            if conn.cert_file and not os.path.exists(conn.cert_file):
                raise IOError("Could not find the TLS certificate file, "
                              "invalid path: {}".format(conn.cert_file))
            if conn.key_file and not os.path.exists(conn.key_file):
                raise IOError("Could not find the TLS key file, "
                              "invalid path: {}".format(conn.key_file))

使用方式是

import requests
from requests.adapters import HTTPAdapter

s.mount('https://', HostHeaderCACertDataSSLAdapter(max_retries=max_failed - 1))
s.request(method, url)

requests 的 https 请求指定 IP 地址 与 证书合法性验证

这两个放在一块来说,是出于具体实现上。

先说前提,https 的需求是在已有拨测的基础上增加,原本使用的就是 requests 实现。实现了状态码、响应 Header、MD5 验证。

requests http 请求可以通过 IP+ Host Header 的方式请求特定的服务器,以下为示例:

import requests
with requests.get(url="https://1.2.3.4/abc?a=a", headers={'Host': 'www.abc.com'})

但是 requests 请求 https 时,这种方法不再可用,这个问题早在 2017 年就[有人向官方提出过]( https://github.com/psf/requests/issues/4287

网络上有人实现了指定 IP 的方法,但是这种方法却不能做证书验证。

urllib3 本身是支持 HTTPS 指定 IP 的,其方法是在连接池中指定 server_hostname 。

pool = urllib3.PoolManager(server_hostname=domain)
r = pool.request('GET', 'https://' + ip + '/', headers=headers)

所以我们可以通过修改下 requests 的代码来实现。

和上面 verify 直接传递证书数据的修改一样,也是在 HTTPAdapter 里修改就可以了,只不过我们这次修改的是 HTTPAdapter 的 send 方法。

以下是原版代码:

另外,要实现证书验证,urllib3.PoolManager 还需要指定 assert_hostname。重写的代码如下:

def send(self, request, **kwargs):
    # HTTP headers are case-insensitive (RFC 7230)
    host_header = None
    for header in request.headers:
        if header.lower() == "host":
            host_header = request.headers[header]
            break

    connection_pool_kwargs = self.poolmanager.connection_pool_kw

    if host_header:
        connection_pool_kwargs["assert_hostname"] = host_header
        connection_pool_kwargs["server_hostname"] = host_header
    elif "assert_hostname" in connection_pool_kwargs:
        connection_pool_kwargs.pop("assert_hostname", None)
        connection_pool_kwargs.pop("server_hostname", None)

    return super(HostHeaderCACertDataSSLAdapter, self).send(request, **kwargs)

证书过期时间检查

request 获取不到服务端证书,urllib3 也在完成证书合法性验证之后就关闭了,不再保留服务端证书。

所以此处暂时没有找到更好的办法。

python 内置库 ssl 原生支持获取服务端证书,通过 SSLSocket.getpeercert()方法获取

此处代码如下:

# 获取证书
def get_server_cert_info(domain, port, ip):
    port = int(port)
    context = ssl.create_default_context()

    cert = dict()
    try:
        sock = socket.create_connection((ip, port), timeout=5)
    except socket.timeout as e:
        print("socket create_connection timeout: {}".format(str(e)))
        return cert
    except OSError as e:
        print("socket create_connection error: {}".format(str(e)))
        return cert

    try:
        ssl_sock = context.wrap_socket(sock, server_hostname=domain)
        cert = ssl_sock.getpeercert()
    except ssl.SSLError as e:
        print("ssl wrap socket error: {}".format(str(e)))
    finally:
        sock.close()

    return cert

# 证书有效期验证
if task_info["target"]["protocol"] == "https" and cert_valid_verify:
    server_cert = get_server_cert_info(task_info["target"]["domain"], task_info["target"]["port"], task_info["target"]["ip"])

        valid_days = (datetime.strptime(server_cert['notAfter'], '%b %d %H:%M:%S %Y %Z') - datetime.now()).days
        if valid_days < 30:
                cert_valid_time_verify = False

至此我们当初的需求已经实现。

我们最终来总结下。

总结

由于 拨测设备设备环境不可信,仅提供 Python2.7 的运行,于是:

  • 我们自带拨测需要的第三方库
  • ca 证书从多个地方下载,使用最快返回,节省时间
  • 重写 HTTPAdapter 的 cert_verify 和 send 方法,分别实现:
    • ca 证书从内存中读取,省却 io 读写,加快速度
    • 使用 ip+header 方式,支持 https 对域名指定 IP 进行请求

目前仍有不足,因为证书的域名验证和有效期检查仍多出一次请求,因为 拨测设备设备所在网络环境复杂,尽量减少多余的网络 IO 消耗。

后续

拨测设备的终端节点是不可信的,我们目前还没有实现拨测脚本的混淆,但是最低要求是不能明文,于是生成 pyc 字节码文件后发布应用。

一方面也加快了些许速度,代价是文件变大。

纯 .py 文件压缩打包后是 752KB 大小,而转为字节码后大小是 2.25MB。

扩展阅读

证书验证扩展阅读

此部分不感兴趣可以略过。

吊销证书列表

吊销证书列表(CRL)是已签发的证书因为到期或者出错等跟其他原因吊销的证书,这个列表目前已达到 300MB,所以,最为严格的验证是同样要充分考虑吊销证书列表的,但因为体积过大暂时不考虑。

mozilla 提出了一个 CRLite 项目。

OCSP

实际上由于 CA 证书和 CRL 需要不断更新的问题,造成了 CRL 的实时性问题。

于是,就出现了一个并不完美的解决方案:OCSP

在线证书状态协议 (OCSP), 是一种用于获取 X.509 数字证书吊销状态的 Internet 协议。具体来讲是提供一个在线证书的查询接口,它建立一个可实时响应的机制,让浏览器发送查询证书请求到 CA 服务器,然后 CA 服务器实时响应验证证书是否合法有效,这样可以实时查询每一张证书的有效性,解决了 CRL 的实时性问题。

而事实上,OCSP 接口的连通性又成了新的问题。

urllib3 不支持返回服务端证书的原因及修改实现

urllib3 源码初分析

urllib3 原生不支持证书返回,因为 https 验证完后就关闭了 ssl socket,且并没有预留任何返回的可能。

我们来看下源码:

因为本是想为响应结果增加服务端证书返回,所以我们倒着看源码:

urllib3 的响应是 response.py 的 HTTPResponse 类。

其被自身的一个方法调用(注意 @classmethod 修饰符 )。

而 HTTPResponse 类什么时候被实例化呢? 或者说一个请求完成后,在哪里开始生成返回结果的呢?

在 connectionpool.py 中,connectionpool.py 主要看两个类 HTTPConnectionPool 和 HTTPSConnectionPool ,其中 HTTPSConnectionPool 继承 HTTPConnectionPool。

HTTPResponse 只在 HTTPConnectionPool 中实例化,HTTPSConnectionPool 只是继承 HTTPConnectionPool。

一个正常的 http 请求,它的响应如何得到的呢?

是通过 getresponse 获取的。

而 getresponse ,是 Python 原生库 httplib.py 中的 HTTPConnection 类 中的方法

这里只是 http 的响应结果,而不是 https 的。 这也是符合逻辑的,因为 https 就是 http in tls,urllib3 对 https 进行分层处理。

那么 tls 是在哪里处理的呢?

我们返回去看 HTTPConnectionPool 和 HTTPSConnectionPool:

请注意下 HTTPConnectionPool 中的

ConnectionCls = HTTPConnection

和 HTTPSConnectionPool 中的

ConnectionCls = HTTPSConnection

然后我们再分别看下 HTTPConnection 和 HTTPSConnection,(在 connection.py 中),HTTPSConnection 又是继承 HTTPConnection。

然后我们看到 HTTPSConnection 这里:

从前文 证书过期时间检查 中看过来即可知道,这正是是获取服务端证书的函数。

然而,HTTPConnection 和 HTTPSConnection 均不保存任何结果,只是在这里获取到一个 http 连接而已,https 会额外做服务端证书的校验,然后关闭。

所以到这里我们可以知道 ,urllib3 对 https 的处理是分层的,先是处理 tls/ssl 层,这个过程中不保存任何返回。然后再处理 http 这时返回 http 的响应。就是我们平时看到的响应。

说到此处,情况基本明了了,urllib3 对 tls 处理,在设计上就没有考虑返回,我们想要改的话,就必须:

  1. 先修改 HTTPSConnection
  2. 再修改 HTTPSConnectionPool
  3. 再修改  HTTPResponse

因为我们不是直接使用 urllib3 而是用的 requests ,requests 再调用 urllib3,所以 requests 的调用也要跟着修改

如此一来,不能再像前面的方式一样,不修改源码,仅通过继承+重写方法的方式就可以完成的了。

当然,我们可以直接修改 urllib3 代码来实现,只是只修改依赖库的代码不算优雅的方式。

下面只是给出代码修改的方式。

尝试修改 urllib3 代码以支持获取服务端证书

1.HTTPSConnection 的修改

connection.py 文件中 HTTPSConnection 增加类变量 server_cert

server_cert 赋值,在 urllib3 对证书做验证时保留服务端证书信息。

2.HTTPSConnection 的修改

connectionpool.py 文件中 HTTPConnectionPool 增加类变量 server_cert。

之所以选择在 HTTPConnectionPool,是因为:

  1. 一是为了保持返回结果一致性,否则在用户获取到响应之后,仅在 https 的请求中有 server_cert 变量,在 http 的请求中读取则会报异常,可能会造成困扰, 目前这种方式在 http 中会返回 server_cert = None。
  2. 二是因为返回结果的代码只在 HTTPConnectionPool 中,HTTPSConnectionPool 只是继承 HTTPConnectionPool ,要实现的话,需要在 HTTPSConnectionPool 中重写相关方法,同时也会有第一个问题。

 HTTPConnectionPool 返回响应结果的代码是 urlopen 方法。

 赋值:在 HTTPSConnectionPool 中 获取 前面 HTTPSConnection 中增加的 server_cert 变量。

在 HTTPSConnection 中的 urlopen 方法中传递 server_cert, 注意使用的是关键字参数 response_kw 来传递。这里的 from_httplib 就是将响应结果返回到 HTTPResponse 中。

3.修改 response.py 文件中的 HTTPResponse 类。

__init__中加入 server_cert 参数。

然后赋值给类变量

最后修改 from_httplib 方法,传递 server_cert。

测试

测试代码:

执行结果:

因为直接改 urllib3 源码并不优雅,所以目前拨测还只是直接使用 Python 的 ssl 来额外处理一次,只用几行代码解决,但多了一次网络 IO 的消耗。

发表评论

您的电子邮箱地址不会被公开。 必填项已用 * 标注