# OpenSSL 支持 UPF HTTPS 头增强调研 openssl是一个功能丰富且自包含的开源安全工具箱。它提供的主要功能有:SSL协议实现(包括SSLv2、SSLv3和TLSv1)、大量软算法(对称/非对称/摘要)、大数运算、非对称算法密钥生成、ASN.1编解码库、证书请求(PKCS10)编解码、数字证书编解码、CRL编解码、OCSP协议、数字证书验证、PKCS7标准实现和PKCS12个人数字证书格式实现等功能。 那么在介绍 OpenSSL 使用前,有必要回顾一下 `HTTPS`的握手过程。 ## HTTPS篇之SSL握手过程详解 ### 什么是握手 像两个人沟通一样,握手是表示一个回话的开始。对于SSL/TLS来说,通过握手建立连接,交换客户端与服务器之间的信息从而生成会话秘钥(主秘钥),用来加密之后的消息。 在TLS中有两种主要的握手类型:一种基于[RSA](https://en.wikipedia.org/wiki/RSA_(cryptosystem)),一种基于[Diffie-Hellman](https://en.wikipedia.org/wiki/Diffie–Hellman_key_exchange)。 这两种握手类型的主要区别在于主秘钥交换和认证上。 | | 秘钥交换 | 身份验证 | | ------- | -------- | -------- | | RSA握手 | RSA | RSA | | DH握手 | DH | RSA/DSA | 用RSA握手还是DH握手取决于加密套件,后面我们也会带你简单了解加密套件。现在,我们用openssl查看套件时可看到 ``` ➜ openssl ciphers -v ECDHE-RSA-AES256-GCM-SHA384 TLSv1.2 Kx=ECDH Au=RSA Enc=AESGCM(256) Mac=AEAD ECDHE-ECDSA-AES256-GCM-SHA384 TLSv1.2 Kx=ECDH Au=ECDSA Enc=AESGCM(256) Mac=AEAD AES256-GCM-SHA384 TLSv1.2 Kx=RSA Au=RSA Enc=AESGCM(256) Mac=AEAD AES256-SHA256 TLSv1.2 Kx=RSA Au=RSA Enc=AES(256) Mac=SHA256 ... ``` 其中kx,au就对应了秘钥交换与身份验证。 ### RSA握手 说了这么多终于踏入正题。 基于RSA的TLS握手整个流程如下如所示。 ![openssl_for_https_enrichment1.png](../../../_static/openssl_for_https_enrichment1.png)] 通过Wireshark抓包,访问[https://www.razeen.me,我们可以看到](https://www.razeen.xn--me%2C-ys9d8az03bmrd604b7p3b/) ![openssl_for_https_enrichment2.png](../../../_static/openssl_for_https_enrichment2.png) 结合上面两个流程图,我们可以了解整个流程可以分解为: 1. 客户端向服务器发送Client Hello,告诉服务器,我支持的协议版本,加密套件等信息。 2. 服务器收到响应,选择双方都支持的协议,套件,向客户端发送Server Hello。同时服务器也将自己的证书发送到客户端(Certificate)。 3. 客户端自己生产预主密钥,通过公钥加密预主秘钥,将加密后的预主秘钥发送给服务器 (Client Exchange)。 4. 服务器用自己的私钥解密加密的预主密钥。 之后,客户端与服务器用相同的算法根据客户端随机数,服务器随机数,预主秘钥生产主密钥,之后的通信将都用主密钥加密解密。 下面分别带你一一了解其中细节。 #### Client Hello 点开Client Hello,我们可以看到客户端向服务器发送了哪些数据。 ![openssl_for_https_enrichment3.png](../../../_static/openssl_for_https_enrichment3.png) 在一次新的握手流程中,Client Hello 消息总数第一条消息。这条消息将客户端的功能和首选项告诉服务器。通过抓包数据,通过其字段名我们也很容易理解它的含义。 **Content Type** 消息的内容类型,告诉服务器,我要握手了。 **Version** 协议版本(protocol version) 告诉服务器 客户端支持的最佳协议版本。 **Random** 随机数,也就是流程图中的客户端随机数。包含32字节的数据,其中28字节是随机生成的(Random Bytes)。剩下的4字节包含额外的信息(GMT Unix Time),受客户端时钟影响(一般浏览器会给他们的时间添加时钟扭曲,或者简单的发送随机4字节)。在握手的时候这随机数都是独一无二的,他们在身份验证中起到举足轻重的作用(可以防止重复攻击,并确认初始数据交换的完整性)。 **Session ID** 在第一连接时,会话ID(Session ID)字段是空的,这表示客户端告诉服务器 我是新会话,没有其他会话需要恢复。在后续的连接中,这个字段可以保存会话的唯一标识。服务器可以借助会话ID在自己的缓存中找到对应的会话状态。 典型的会话ID包含32字节的随机生成的数据,这些数据本身并没有什么价值。 **Cipher Suites** 密码套件块是由客户端支持的所有密码套件组成的列表,该列表是按优先级顺序排列的。 密码套件(cipher suite)是一组选定的加密基元和其他参数,它可以精确定义如何实现安全。套件大致由以下这些属性定义。 - 身份验证方法 - 密钥交换方法 - 加密算法 - 加密密钥大小 - 密码模式(可应用时) - MAC算法(可应用时) - PRF(只有TLS1.2一定使用,其他版本取决于各自协议) - 用于Finished消息的散列函数(TLS1.2) - verify_data结构的长度(TLS1.2) 密码套件都倾向于使用较长的描述名称,并且相当一致:它们都是由密钥交换方法、身份验证方法、密码定义以及可选的MAC或PRF算法组合而成,如下图所示: ![openssl_for_https_enrichment4.png](../../../_static/openssl_for_https_enrichment4.png) **Compression** 客户端可以提交一个或多个支持的压缩方法。默认是null,代表没有压缩。 **Extensions** 扩展块由任意数量的扩展组成。这些扩展会携带额外的数据。扩展可以在不修改协议本身的条件下为TLS协议增加功能。如果你想了解更多可以参考[RFC 6066](https://tools.ietf.org/html/rfc6066),这里不多说。 #### Server Hello 当服务器收到客户端的hello消息的时候,服务器会将服务器选择的参数传送回客户端。这个消息的结构与Client Hello类似,只是每个字段只包含一个选项。 服务器不需要支持客户端支持的最佳版本。如果服务器不支持与客户端相同的版本,可以提供某个其他版本以期待客户端能够接受。 ![openssl_for_https_enrichment5.png](../../../_static/openssl_for_https_enrichment5.png) 如上图所示,服务端选择使用TLS 1.2 (version),套件使用TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256。 #### Certificate 从下图可以看出在服务器发送Server Hello的时候,会同时发送Certificate,Server Hello Done。 ![openssl_for_https_enrichment6.png](../../../_static/openssl_for_https_enrichment6.png) **Certificate** 典型的Certificate消息用于携带X.509证书链。证书链是以ASN.1 DER编码的一系列证书,一个接一个组合而成。叶子证书必须是第一个发送,中间证书按照正确的顺序跟在叶子证书之后。根证书可以并且应该省略掉,因为在这个场景中它没有用处。 服务器必须保证它发送的证书与选择的算法套件一致。比方说,公钥算法与套件中使用的必须匹配。除此以外,一些密钥交换算法依赖嵌入证书的特定数据,而且要求证书必须以客户端支持的算法签名。所有这些都表明服务器需要配置多个证书(每个证书可能会配备不同的证书链)。 **Server Hello Done** Sever Hello Done 消息表明服务器已经将所有预计的握手消息发送完毕。在此之后,服务器会等待客户端发送消息。 #### Client Key Exchange 之后客户端向服务器发送Client Key Exchange。最后客户端与服务器互发 Change Cipher Spec,Encrypted Handshake Message。 ![openssl_for_https_enrichment7.png](../../../_static/openssl_for_https_enrichment7.png) **Client Key Exchange** Client Key Exchange 消息携带客户端为密钥交换提供的所有信息。从抓包的信息中我们可以看到,秘钥交换的主要内容是RSA Encrypted PreMaster Secret, 也就是利用证书公钥加密后的预主密钥。其中预主密钥(PreMaster Secret)是由客户端生成的48字节的随机数,[RFC5426](https://tools.ietf.org/html/rfc5246#page-58)可看到其结构是这样的。 ``` struct { ProtocolVersion client_version; opaque random[46]; } PreMasterSecret; ``` **Change Cipher Spec** Change Cipher Spec 消息表明发送端已取得用以生成连接参数的足够信息,已生成加密密钥(主密钥),并且将切换到加密模式。客户端和服务器在条件成熟是会发送这个消息。 主密钥的是由预主密钥进一步计算而成,这个过程通过一个伪随机函数(pseudorandom function, PRF)来完成,这个函数可以生产任意数量的伪随机数据。其计算过程如下。 ``` master_secret = PRF(pre_master_secret,"master secret",ClientHello.random+ServerHello.random) ``` #### Finished(Encrypted Handshake Message) Encrypted Handshake Message 这是由客户端服务器之间协商的算法和密钥保护的第一个消息。它意味着握手已经完成。消息内容将加密,以便双发可以安全地交换验证整个握手完整性所需要的数据。 ![openssl_for_https_enrichment8.png](../../../_static/openssl_for_https_enrichment8.png) 这个消息包含verify_data字段,它的值是握手过程中所有消息的散列值。这些消息在连接两端都按照各自所见的顺序排列,并以协商新得到的主密钥计算散列。散列函数与PRF一致,除非协商的套件指定使用其他算法。 客户端与服务器的计算方法一致。 ``` verify_data = PRF(master_secret,finished_label,Hash(handshake_messages)) ``` ### DH握手 下图为TLS握手采用DH算法的流程图。与RSA最大的区别在于密钥交换与身份认证上。在RSA中是由客户端发送客户端密钥交换信息完成密钥交换,通过客户端公钥加密,服务端私钥解密完成身份认证。而在DH握手过程中则略有不同。 ![openssl_for_https_enrichment9.png](../../../_static/openssl_for_https_enrichment9.png) 下图是我访问[https://razeen.me](https://razeen.me/) 用Wireshake抓取的数据。 ![openssl_for_https_enrichment10.png](../../../_static/openssl_for_https_enrichment10.png) 我们从两个图中可以看出,在DH握手过程中,多了一步Server Key Exchange。Server Key Exchange消息的目的与Client Key Exchange目的相同,都是携带密钥交换的额外数据。而在这里它带的不再是加密的预主密钥了。 也就是说它的整个流程如下: 1. 客户端向服务器发送Client Hello,告诉服务器,我支持的协议版本,加密套件等信息。 2. a. 服务端收到响应,选择双方都支持的协议,套件,向客户端发送Server Hello。同时服务器也将自己的证书发送到客户端(Certificate)。 b. 服务器利用私钥将客户端随机数,服务器随机数,服务器DH参数签名,生成服务器签名。 3. 服务端向客户端发送服务器DH参数以及服务器签名(Server Key Exchange)。 4. 客户端向服务端发送客户端DH参数(Client Key Exchange)。 之后,客户端利用公钥验证服务器签名,客户端与服务器各自利用服务端DH参数、客户端DH参数生成预主密钥,再通过预主密钥、客户端随机数、服务端随机数生成主密钥(会话密钥)。最后握手完成,所有的消息都通过主密钥加密。 由于DH握手过程中大部分消息格式与RSA相同,这里不再一一说明。这里主要说明一下DH的密钥交换。 #### DH密钥交换 ![openssl_for_https_enrichment11.png](../../../_static/openssl_for_https_enrichment11.png) ![openssl_for_https_enrichment12.png](../../../_static/openssl_for_https_enrichment12.png) 在DH密钥交换过程中主要需要的参数有6个,其中两个(dh_p和dh_g)成为域参数,由服务器选取。协商过程中,客户端和服务器各自生成另外两个参数,相互发送其中一个参数(dh_Ys和dh_Yc)到对端,经过计算,获得预主共享密钥(PreMasterSecret),我们可以先看一下DH算法的数学基础。 ``` +--------------------------------------------------------------------+ | Global Pulic Elements | | | | dh_p prime number | | dh_g prime number, dh_g < dh_p | +--------------------------------------------------------------------+ +--------------------------------------------------------------------+ | User A Key Generation | | | | Select private dh_Ys_a sh_Ys_a < dh_p | | Calculate public dh_Yc_a dh_Yc_a = dh_g^dh_Ys_a mod dh_p | +--------------------------------------------------------------------+ +--------------------------------------------------------------------+ | User B Key Generation | | | | Select private dh_Ys_b sh_Ys_b < dh_p | | Calculate public dh_Yc_b dh_Yc_b = dh_g^dh_Ys_b mod dh_p | +--------------------------------------------------------------------+ +--------------------------------------------------------------------+ | Calculation of Secret Key by User A | | | | Secret Key premaster premaster = dh_Yc_b^dh_Yc_a mod p | +--------------------------------------------------------------------+ +--------------------------------------------------------------------+ | Calculation of Secret Key by User B | | | | Secret Key premaster premaster = dh_Yc_a^dh_Yc_b mod p | +--------------------------------------------------------------------+ ``` 上面一共出现了 dh_p, dh_g, dh_Ys_a, dh_Yc_a, dh_Ys_a, dh_Yc_a, premaster 共 7 个数,其中: - 公开的数:dh_p, dh_g, dh_Yc_a, dh_Yc_a - 非公开数:dh_Ys_a, dh_Ys_b, premaster 通常情况下,dg_g 一般为 2 或 5,而 dh_p, dh_Ys_a 和 dh_Ys_b 的取值也非常大,其复杂度至少为 `O(dh_p^0.5)`。对于攻击者来说,已知 dh_Yc_a,dh_Ys_a 的求解非常困难,同理 dh_Ys_b 的求解也很困难,所以攻击者难以求出 premaster,所以 DH 能够保证通信双方在透明的信道中安全的交换密钥。 在上面的介绍中你也知道,如果其中的域参数太弱,将很容易被攻破,正如2015年披露的Logjam攻击表明,512位的DH参数在使用合适的资源情况下可以被攻击者在很短的时间内成功利用。对于这些问题随后有了椭圆曲线的DH秘钥交换(ECDH),ECDH密钥交换发生在一条服务器定义的特殊椭圆曲线上,这条曲线代替了DH中的域参数,从而一定程度上提高了安全性。 ## 建立 OpenSSL 套接字 需要通过 SSL 通信的 app,需要是通过调用 `OpenSSL api` 创建 `SSL socket`实现出在 正常的 `IP/TCP`协议栈后插入`SSL流程`,创建流程如下图所示: ![openssl_for_https_enrichment13.png](../../../_static/openssl_for_https_enrichment13.png) ### OpenSSL状态机 `OpenSSL`是通过“握手“建立加密信道,在该信道双方的身份都是合法的,并且传输数据都是密文传输。`OpenSSL`握手通过客户端和服务端互相交换信息计算出`secret`。计算出密钥的方式有很多种。这中间可能需要几个`RTT`来回。状态机需要针对约定好的加密算法按照一定的步骤执行。所以需要状态机保存握手过程中的参数。 **状态机是什么** 简单地说,状态机保存Ssl握手需要一些消息处理函数,和算法函数来解析消息,执行加解密操作。要么是发送处理好的消息流,要么是接收对方的消息流。所以一个状态机是在读写函数不断切换。消息状态机如果不按正常的流程走,就形成了状态机的异常或者遭受到了安全攻击。 **消息流状态机** 消息流状态机由`MSG_FLOW_UNINITED`到读写多次来回切换到`MSG_FLOW_FINISHED`状态。为什么这里`MSG_FLOW_FINISHED`有可能会重新执行新的读写操作?当前的代码是没有实现`MSG_FLOW_FINISHED`入口。当然消息状态异常也有个状态`MSG_FLOW_ERROR`。遇到这种状态`SSL`握手失败,并且该`SSL`连接不会再进入握手流程。 ``` MSG_FLOW_UNINITED(1) MSG_FLOW_FINISHED(4) | | +-------------------------------+ v MSG_FLOW_WRITING(写状态机)(2) <---> MSG_FLOW_READING(读状态机)(3) | V MSG_FLOW_FINISHED(4) | V [SUCCESS](5) ``` **写状态机** 写的状态机是由消息流状态机调用,写状态机调用结束后有两种返回状态:`SUB_STATE_FINISHED`或者`SUB_STATE_END_HANDSHAKE`。`SUB_STATE_FINISHED`表明此次写状态机调用结束,写状态机完成必要的状态迁移或者发送操作,控制权转交给消息流状态机,由消息流状态机决定下个操作。`SUB_STATE_END_HANDSHAKE`则向消息流状态机表示握手已经完满成功。 `WRITE_STATE_TRANSITION`决定`SSL`握手的下一步状态。`WRITE_STATE_PRE_WORK`和`WRITE_STATE_POST_WORK`则会根据ssl握手的当前状态,进行相对应的操作。也就是一个`switch-case`操作。也可能对`BIO`进行必要的操作(比如清空buffer)。 这里的`BIO`是什么?`BIO`和`EVP`是`OpenSSL`两个重要系列的函数。`BIO`或者`EVP`只不过是一些底层的支撑接口,没有任何的现实意义,正是`SSL`使用了`BIO`和`EVP` 的机制提供了一个已经成型的安全套接字的实现策略。其实想象一下,安全套接字有两层含义,一层就是安全,这个由`EVP`接口实现了,另外一层含义就是套接 字,也就是说它必须是一个套接字,必须在操作的网络协议栈上进行`IO`,这一层含义是在`BIO`接口体现的,这个意义上,`SSL`正是通过组合`BIO`和`EVP`来 实现安全套接字的。 ``` +-> WRITE_STATE_TRANSITION ------> [SUB_STATE_FINISHED] | | | v | WRITE_STATE_PRE_WORK -----> [SUB_STATE_END_HANDSHAKE] | | | v | WRITE_STATE_SEND | | | v | WRITE_STATE_POST_WORK | | +-------------+ ``` **读状态机** ``` READ_STATE_HEADER <--+<-------------+ | | | | | | READ_STATE_BODY-----+-->READ_STATE_POST_PROCESS | | +---------------------------------+ v [SUB_STATE_FINISHED] ``` `READ_STATE_HEADER`:根据读到的消息头(type)去决定`ssl`握手的状态。并且决定之后怎么处理该消息。 `READ_STATE_BODY`:读取消息的剩余部分,接着处理 `READ_STATE_POST_PROCESS`:由于阻塞`block`的消息,有可能需要在当前SSL握手状态继续重试读取消息。 **Openssl握手状态** 这些消息流状态机、写状态机、读状态机共同完成了`TLS`握手过程。`WRITE_STATE_TRANSITION(READ_WRITE_TRANSITION)`完成了以下状态的迁移。 握手从服务器端收到 Client Hello开始处理 ![openssl_for_https_enrichment14.png](../../../_static/openssl_for_https_enrichment14.png) `v`:版本号 `kx`: 密钥协商算法 `rid`:会话id `rticket`:会话ticket `cask`:客户端鉴权证书请求 `coffer`:客户端鉴权 `ntick`:服务端是否发生session ticket ## 修改 `OpenSSL` 支持 `UPF HTTPS` 头增强,目前初步计划 * A:首先需要在 `OpenSSL` 中支持识别 `CMCC HTTPS` 特殊 `EXTENSION`(计划以 17516 为示例) * B:在计算 `MAC` 时,从报文中剥离掉 CMCC 特殊 `EXTENSION` 字段 * C:将剥离的 `EXTENSION` 字段放入 `ssl->extension` 中,供 app 使用 后续计划 先以 17516 字段为例,修改出 demo。 ## OpenSSL 计算 MAC 流程修改 OpenSSL 版本: **1.1.1-stable** Nginx 版本:**1.20.1** 原生函数流程如下所示,其中关键步骤,为蓝色框,会收集握手过程中所有报文: ![openssl_for_https_enrichment15.png](../../../_static/openssl_for_https_enrichment15.png) 加入对 `client hello extension` 解析以及剥除 `17516 extension` 字段流程,如下图绿色所示,修改流程如下: ![openssl_for_https_enrichment16.png](../../../_static/openssl_for_https_enrichment16.png) 目前代码已经完成,并完成了 **client hello 报文解析**,**extension 解析**,**剥除 17516 extension 后的报文重组** 测试OK。 ### 功能验证 测试环境拓扑 ![openssl_for_https_enrichment17.png](../../../_static/openssl_for_https_enrichment17.png) 测试流程:`UE` 发起 `HTTPS` 访问 `Nginx https://192.168.1.1`,`client hello` 报文在 `UPF` 进行 `HTTPS` 头增强插入 `17516 Extension` 字段 * 情况一:未修改 `OpenSSL` 源码时 `Nginx server` 会回复 `Bad Record MAC`,表明 `server` 检测到了 `tls 握手流程中`报文被篡改,握手失败 ![openssl_for_https_enrichment18.png](../../../_static/openssl_for_https_enrichment18.png) * 情况二:按照上文思路修改 `OpenSSL` 计算 `MAC` 流程后 `tls 握手成功`,`Nginx server` 正常返回数据到 `UE ` ![openssl_for_https_enrichment19.png](../../../_static/openssl_for_https_enrichment19.png) ## OpenSSL & Nginx 重新编译安装 ### OpenSSL 重新编译 ``` # 下载源码 https://www.openssl.org/source/ ``` ``` # openssl: ./config shared zlib-dynamic # 设置替换静态库 # 安装依赖库 yum install libtool perl-core zlib-devel -y # 编译 make & make install # 替换系统自带 OpenSSL mv /usr/bin/openssl /usr/bin/openssl.bak mv /usr/include/openssl /usr/include/openssl.bak ln -s /usr/local/bin/openssl /usr/bin/openssl ln -s /usr/local/include/openssl /usr/include/openssl echo "/usr/local/lib64/" >> /etc/ld.so.conf ldconfig -v # 查看当前 OpenSSL 版本,可比对编译时间确定是否为新编译版本 openssl version -a ``` ### Nginx 重新编译 ``` # 下载源码 http://nginx.org/en/download.html ## nginx: ./configure --prefix=/usr/local/nginx --with-http_stub_status_module --with-http_ssl_module --with-http_realip_module --with-openssl=/usr/local/ssl # --with-openssl=/usr/local/ssl # 配置 OpenSSL 源码目录 # 编译 make & make install # 启动 Nginx ./sbin/nginx -V # 查看 Nginx 版本信息 ./sbin/nginx # 启动 Nginx ``` **后续研究 OpenSSL 源码,会同步更新文档**