一次因SNI引起的HTTPS证书不可信的问题排查

avatar
@雪球财经

我们依赖的一个第三方服务的域名最近做了变更,旧域名与新域名均通过HTTPS访问。

在后端服务正常变更部署多个容器后出现了如下问题:

  • 部分容器对其新域名访问失败(在整个服务运行中一直对该域名访问失败)。

  • 另一部分容器对该域名访问正常。

  • 同一物理机上的不同容器也存在部分容器访问该域名成功,部分容器访问该域名失败的现象。

异常信息如下:图片

PKIX path building failed:其中PKI(Public Key Infrastructure) 是一种建立在公私钥基础上实现可靠传递消息和身份确认的公钥基础设施,而PKIX 是基于X.509 (一种数字证书的格式标准) 的PKI系统。HTTPS中的SSL/TLS证书正是遵循了这种标准。

unable to find valid certification path to request target:没有找到有效的证书,证书不可信。

可见这是一个SSL/TLS证书验证过程中的异常,要弄清楚个问题我们需要知道以下内容:

  1. HTTPS的请求是怎么样的,为什么需要证书验证?

  2. SSL/TLS证书验证发生在哪个环节,是如何进行验证的?

先从HTTPS说起,我们知道HTTPS相较于HTTP是更安全的,主要是基于以下原因:

  • HTTP传输的数据是明文的。

  • HTTPS是将数据进行了加密传输:

  • 通信双方持有同一个会话秘钥,以对称加密的形式对数据进行加解密。

  • 为了保证这个会话秘钥不能被第三方窃取,篡改通信数据,引入了安全传输层SSL/TLS (TLS是SSL3.0后的一种标准)。位于HTTP层与TCP层之间,起到确保通信安全的作用。

简单概括就是HTTPS = HTTP + SSL/TLS。在TCP连接建立后,先走TLS握手过程,传输会话秘钥(不是直接发送秘钥,而是通过握手过程中3个随机数来生成会话秘钥),然后才能通过会话秘钥加密应用数据传输。

至于会话秘钥的生成要从TLS四次握手说起,而本次异常就发生在这个TLS握手过程中的证书验证环节。

TLS四次握手

简单回顾下TLS握手过程:

图片

TLS握手过程

整个握手过程实际上是一个交换会话秘钥的过程,其间总共生成了3个随机数:

  1. ClientHello中客户端生成的随机数

  2. ServerHello中服务器生成的随机数

  3. ClientKeyExchange中客户端生成的随机数PreMaster Key,注意此随机数是通过服务器的公钥加密后传输的.

经过握手,客户端与服务器均持有这3个随机数,按照事先商定的加密方法,各自生成本次会话使用的对称加密秘钥即上面提到的会话秘钥,接下来就可以使用这个会话秘钥加密内容进行加密通信了。

这里需要注意,整个握手阶段的所有通信都是明文的。

那么在明文通信的情况下要如何保证与客户端交换会话秘钥的就是它所请求的服务器,而不是其他第三方冒充呢?这就要从证书验证说起。

经过ServerHello,客户端拿到了服务器的证书后,会对该证书的真实性做校验CertificateVerify,以确认响应通信的是其所请求的服务器。文章开头的那个异常也正是发生在这个环节,导致握手失败。

证书验证

一个遵循X.509 V3标准的数字证书包含以下基本内容:

图片

X.509 V3

证书的内容我们重点关注下证书主体公钥和签名。

HTTPS在数字证书验证过程中采用的是一种非对称加密的算法:

  • 这种算法包含两个秘钥: 私钥和公钥。

  • 用私钥加密的密文只有对应的公钥才能解密,反之,用公钥加密的密文只有对应的私钥才能解密。

  • 私钥由证书持有者保留并保密,而公钥则公开使用。

上面握手阶段我们提到第三个随机数PreMaster Key会用服务器的公钥加密,那个公钥指的正是这里的公开的公钥。

签名:是为了确认证书信息没被篡改而存在的,在证书发布时,颁发机构会将证书的指纹和指纹算法通过自己的私钥加密得到签名。

  • 指纹:是在证书发布前,颁发机构对证书的原始内容用指纹算法SHA1或SHA256计算得到的一个hash值,hash值不可逆.指纹的目的是为了保证证书内容是完整的。

客户端在拿到服务器的证书时:

  1. 先通过证书的公钥去解密证书的签名,得到指纹和指纹算法。

  2. 再用对应的指纹算法对证书的原始内容进行计算得到一个hash值和证书的指纹做比较。

对于指纹的验证可以保证证书内容的完整性,没有被篡改,但不能保证请求的连接被某个中间人截取(这个中间人也有相应的CA机构颁发的证书.他的证书也是颁发机构私钥进行加密的,也能通过证书完整性的校验)。

这种情况下通过对比客户端请求的url与证书url是否相同,来判断当前证书是不是客户端请求的服务器的证书(如果中间人修改了自己证书上的url,则其不能通过证书未被篡改的验证)。

总计认证了3项:

  1. 证书是否为受信任的权威机构颁发的。

  2. 证书是否被篡改。

  3. 证书是否为我们请求的服务器发过来的,而不是第三方发的。

而在实际的证书认证过程中证书是以证书链的形式存在的,证书链通常有三级:

  1. Root Certificate:是由证书权威机构CA(Certificate Authority)签发的。

  2. Intermediate Certificate:用来帮助CA向证书持有者颁发证书,这一层可以有多级。

  3. End-entity Certificate:证书持有者证书,是HTTPS中使用的证书。

图片

certificate-verify

证书链中每一层对下一层证书的有效性做担保,链式向上验证(验证签名),直到root。(操作系统和浏览器会预装根证书)。只有当整个证书链上的证书都可信时,才会认定当前证书可信。

看完了证书的验证过程,回到文章开头的异常,什么情况下会导致部分容器握手成功,部分容器握手失败呢?理论上我们请求到的证书应该是一样的话要么全部握手成功,要么全部握手失败才对。

有没有可能是不容容器上的内置证书列表不一致,或者说读取的证书库不一样,失败的容器恰好没有这个第三方域名证书链的根证书呢?这要从我们的服务说起。

我们的后端服务语言是Java,在JDK中有个扩展JSSE(Java安全套接字扩展):是SSL/TLS的Java实现标准,提供了支持SSL/TLS的API和实现.其中与证书相关的是TrustManager:信任管理器接口,主要负责决定是否信任对方的安全证书,按以下优先级读取:

  1. 属性javax.net.ssl.trustStore指定TrustStore(该文件表示信任证书存储库)

  2. ${java.home}/lib/security/jssecacerts

  3. ${java.home}/lib/security/cacerts (随jre发布,不同版本内置证书列表有所不同)

图片

TrustStoreManager

由此可知java虚拟机并不直接使用操作系统的内置证书,而是在自己的security manager中默认预置了一些根证书,不同版本的jdk内置证书列表有所不同。keytool -list -v -keystore cacerts可以查看证书内容。

经过排查,我们容器使用的jdk版本一致的,内置证书列表也是一样的,也没有特殊指定TrustStore.所以关于内置证书不同的怀疑也就不成立了。

再回到TLS握手流程,我们似乎遗漏了一种现今服务器部署服务绑定域名的情况:

服务器上并不是只会提供一个服务,特别是有nginx的情况下,我们一个服务器上通常会绑定多个域名对应多个服务,多个域名也就意味着存在多个证书。

那么在这种情况下,TLS握手过程中,服务器如何知道客户端到底请求的是哪个服务证书呢?

TLS提供了一个扩展SNI(Server Name Indication),就是用来解决一个服务器拥有多个域名的情况。它会在第一次握手ClientHello中客户端会带上请求的域名信息,这样服务器就可以得知该在ServerHello中响应给客户端哪个证书了。

用OpenSSL测试第三方服务新域名的连接,在不带SNI的情况(openssl s_client -showcerts -connect xxx.com:443)请求到的是阿里云盾证书(这是个自签名证书,没有一个可信的证书链),不是客户端所预期的可信证书,带上SNI后(openssl s_client -showcerts -connect xxx.com:443 -servername xxx.com)才能请求到正确的证书。

图片

openssl

看到这里,对于文章开头的异常情况,有个基本解释了:该三方新域名所在服务器上存在多个证书,我们访问失败的容器或许是因为某种原因导致SNI扩展失效,握手信息中没有带上域名信息,请求到了这个云盾自签名证书,导致证书验证失败。

上面提到我们的后端服务语言是Java,对该三方域名的访问又是基于HttpClient进行访问的,接下来继续从这两者对SNI扩展的支持上来看看代码上在什么情况下会造成了SNI扩展失效。

JDK与HttpClient对SNI扩展的支持

JDK

首先jdk是从1.7开始支持SNI,但是在jdk1.8的初期版本中(1.8u14之前)存在一个Bug:JDK-8144566 (bugs.openjdk.java.net/browse/JDK-… 会导致SNI扩展失效.其他需要注意的是:

  • 属性jsse.enableSNIExtension默认为true,开启SNI扩展详见JSSE文档 (docs.oracle.com/javase/8/do…

  • 属性javax.net.debug=ssl,handshake可以查看握手相关信息。

  • javax.net.ssl.SSLSocketFactory提供如下几种构造Socket的方法。

图片

jdk-sni

HttpClient

HttpClient是从4.3.2开始支持SNI详见 HTTPCLIENT-1119 (issues.apache.org/jira/browse…

  • 在老版本的HttpClient下,默认是使用jdk的SSLSocketFactory#createSocket()来创建socket的,所以握手不会带着SNI

  • 在新版本下,使用的是SSLConnectionSocketFactory#createLayeredSocket(Socket socket,String host,int port,HttpContext context),内部实现在普通的Scoket上,调用上面第12行的构造出SSLSocket,之后握手流程就支持SNI了。

总结

最后,基于对SNI扩展失效的怀疑,排查了我们代码使用,终于找到了文章开头问题的原因:代码使用不当,特殊情况下会动态设置了服务的jsse.enableSNIExtension=false,导致部分容器请求该第三方域名时,SNI扩展失效,拿到了错误的证书(云盾),握手失败。

如果你也遇到了类似的证书验证问题,且又确定证书没问题的情况下,可以看下是否是SNI的问题。