你好,我是胜辉。

在前面三节课里,我带你排查了HTTP协议相关的问题。不知你有没有注意到,这三个案例里的HTTP都没有做加密,这就使得我们的排查工作省去了不少的麻烦,在抓包文件里直接就可以看清楚应用层的信息了。但在现实场景下,越来越多的站点已经做了HTTPS加密,所以像前面的三讲那样,在Wireshark里直接看到应用层信息的情况,已经越来越少了。

根据w3techs.com的调查数据,目前Internet上78%以上的站点,都默认使用了HTTPS。显而易见,要对Internet上的问题做应用层方面的分析,TLS是一道绕不开的坎。

那你可能会问了:“我主要处理内网的问题,应该不用关心太多HTTPS的事了吧?”

这句话也许目前还勉强算对,但是随着各大企业不断推进零信任(Zero Trust)安全策略,越来越多的内网流量也终将运行在HTTPS上,内网和公网将没有区别。

所以说,掌握HTTPS/TLS的相关知识和排查技巧,对于我们开展网络排查来说,是一项必备的技能了。

那么接下来的两节课,我们会集中到HTTPS/TLS这个主题上,来全面学习一下它的工作原理、常见问题和排查思路。这样以后面临HTTPS/TLS的问题时,你就可以运用这两讲里学到的知识和方法,展开排查工作了。

什么是HTTPS?

首先我们要认识一下HTTPS。它其实不是某个独立的协议,而是HTTP over TLS,也就是把HTTP消息用TLS进行加密传输。两者相互协同又各自独立,依然遵循了网络分层模型的思想:

补充:这也就是我们在第1讲学习网络分层模型时候的图。

为了更好地理解HTTPS,我们也来简单学习一下加密技术,因为它是HTTPS的核心。

加密技术基础

加密技术其实也是一个古老的话题。早在公元前400年,斯巴达人就创造了密码棒加密法。就是把纸条缠绕在一根木棒上,然后在纸上写字,这张纸条离开这根木棒后,就无法正确读取了。要“破解”它,就得找到同样粗细的木棒,然后把纸条绕上去后,才能解读。

那么在这里,纸条就相当于密文,而木棒,就相当于密钥了。而因为加密和解密用的木棒是相同的,所以它属于对称加密算法

时间推进到现代,密码专家们已经开发出了众多优秀的对称加密算法,比如AES、DES。与木棒加密法类似,Alice和Bob都知道同一把密钥,Alice用这个密钥做加密,Bob收到密文后,也是用这把密钥做解密,就得到了明文。如下图所示:

另外一类算法就是非对称算法,这也是PKI(公开密钥架构)的基础。在非对称算法中,加密和解密用了不同的密钥,这两个密钥形成了密钥对。比如Bob和Alice都各自生成了密钥对,然后互相交换了公钥。Alice用Bob的公钥对明文做了加密,变成密文传给Bob。Bob收到后,用自己的私钥解密,就还原出了明文。如下图所示:

TLS基础

那么TLS跟加密技术的关系具体是怎样的呢?实际上,TLS同时使用了对称算法和非对称算法。TLS的整个过程大致可以分为两个主要阶段:

TLS综合利用了对称算法和非对称算法的优点,因为对称算法的效率高,而非对称算法的安全性高,所以两者结合,就兼顾到了效率和安全性。不得不说,TLS确实是一个很精妙的设计。

那么同样地,我们对TLS相关问题的排查,也就面临着两类问题:一类是TLS握手阶段的问题,一类是TLS通信过程中的问题。

在TLS握手阶段,真正的加密还没开始,所以依托于明文形式的握手信息,我们还有可能找到握手失败的原因。在这一阶段,我们需要掌握TLS握手的原理和技术细节,这样才能指导我们展开排查工作。

而在TLS数据交互阶段,加密已经开始,所有的数据已经是密文了。假如应用层发生了什么,而我们又看不到,那如何做排查呢?这个时候,我们需要把密文解密,才能找到根因。不过你可能会问:“TLS要是能随便解密,是不是说明这个协议还有漏洞啊?”

放心,TLS是很安全的。我说的解密,当然是有前提条件的,跟数据安全性并不冲突。具体的细节,我到下节课会给你详细展开。

下面呢,我们就来看看案例,一起来学习下TLS握手失败的问题排查思路。

案例1:TLS握手失败

TLS握手失败,估计你也遇到过。引起这个问题的原因还是比较多的,比如域名不匹配、证书过期等等。不过,这些问题一般都可以通过“忽略验证”这个简单的操作来跳过。比如,在浏览器的警告弹窗里点击“忽略”,就可以让整个TLS的过程继续下去。

而还有一些问题,就无法跳过了。

我们曾经遇到的一个例子就是这样。当时,我们有一个应用需要访问Kubernetes集群的API server。因为我们有很多个集群,所以相应的API server也有很多个。这个问题是,从同一台客户端去访问API server 1是可以的,但访问API server 2就不行。进而发现,失败原因就是TLS握手失败。

在客户端的应用日志里,报告的是这段错误:

javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure

这段日志有没有告诉我们有价值的信息呢?好像并不多,只是告诉我们握手失败了。这也是我反复提及的,网络排查中两大鸿沟之一的应用现象跟网络现象之间的鸿沟:你可能看得懂应用层的日志,但是不知道网络上具体发生了什么。

补充:我在第4讲里有介绍这两大鸿沟,我们要在网络排查方面取得实质性的进步,关键在于突破这两个鸿沟。

同样的,这里的日志也无法告诉我们:到底TLS握手哪里出了问题。所以我们需要做点别的事情。

排除服务端问题

首先,我们用另外一个趁手的小工具 curl,从这台客户端发起对API server 2(也就是握手失败的那个)的TLS握手,发现其实是可以成功的。这说明,API server 2至少在某些条件下是可以正常工作的。我们来看一下当时的输出:

curl -vk https://api.server.777.abcd.io
* Rebuilt URL to: https://api.server.777.abcd.io/
* Trying 10.100.20.200...
* Connected to api.server.777.abcd.io (10.100.20.200) port 443 (#0)
* found 153 certificates in /etc/ssl/certs/ca-certificates.crt
* found 617 certificates in /etc/ssl/certs
* ALPN, offering http/1.1
* SSL connection using TLS1.2 / ECDHE_RSA_AES_128_GCM_SHA256
* server certificate verification SKIPPED
* server certificate status verification SKIPPED
* common name: server (does not match 'api.server.777.abcd.io')
* server certificate expiration date OK
* server certificate activation date OK
* certificate public key: RSA
* certificate version: #3
* subject: CN=server
* start date: Thu, 24 Sep 2020 21:42:00 GMT
* expire date: Tue, 23 Sep 2025 21:42:00 GMT
* issuer: C=US,ST=San Francisco,L=CA,O=My Company Name,OU=Org Unit 2,CN=kubernetes-certs
* compression: NULL

补充:在第8行可以看到协商出的密码套件 * SSL connection using TLS1.2 / ECDHE_RSA_AES_128_GCM_SHA256

既然curl是可以TLS握手成功的,那是不是客户端程序本身有点问题呢?我们就进行了“问题复现”。在上节课里我们讨论了偶发性问题的“复现+抓包”的策略,而这里的问题是必现的,所以只要发起一次请求,同时做好抓包就可以了。

我们来看一下抓包文件:

图片

还真是“话不投机半句多”,客户端也就发了一个Client Hello报文,服务端就回复TLS Alert报文,结束了这次对话。那为啥聊不起来呢?我们看一下这个Alert报文:

图片

这个TLS Alert报文显示,它的编号是40,指代的是Handshake Failure这个错误类型。到这一步,我们需要去了解这个错误类型的具体定义。正确的做法是:去RFC里寻找答案,而不是随意地去网络上搜索,因为很可能你会被一些似是而非的信息误导。

因为这次握手用的是TLS1.2协议,我们就来看它的RFC5246。在这个RFC里,找到Alert Protocol部分,我们看看它是怎么说的:

   handshake_failure
      Reception of a handshake_failure alert message indicates that the
      sender was unable to negotiate an acceptable set of security
      parameters given the options available.  This is a fatal error.

结合这里的实际场景,这段话的意思就是:“基于已经收到的Client Hello报文中的选项,TLS服务端无法协商出一个可以接受的安全参数集”。而这个所谓的安全参数集,在这里具体指的就是加密算法套件 Cipher Suite。我们来认识一下它。

补充:这里的suite读音是sweet而不是suit,我也错读过很多年。另外,suite还有旅馆套房的意思。

Cipher Suite

前面提到过,在TLS中,真正的数据传输用的加密方式是对称加密;而对称密钥的交换,才是使用了非对称加密。实际上,TLS的握手阶段需要在下面四个环节里实现不同类型的安全性,它们可以说是TLS的“四大护法”。

补充:如果是双向验证(mTLS),服务端会验证客户端的证书。

每一个类型都有很多不同的具体算法实现,它们的组合,就是密码套件Cipher Suite。你可能以前也见过它,这次咱们来拆解认识一下它的组成结构。

先看一个典型的密码套件:

TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA(0xc013)

另外,在不同的客户端和服务端软件上,这些密码套件也各不相同。所以,TLS握手的重要任务之一,就是找到双方共同支持的那个密码套件,也就是找到大家的“共同语言”,否则握手就必定会失败。

所以这个案例排查的下一步,就是要搞清楚,客户端和服务端到底都支持了哪些Cipher Suite。

那么客户端的密码套件有哪些呢?你可能很快想到了前面curl命令里的输出。确实,那里就明确显示,双方协商出来的是 ECDHE_RSA_AES_128_GCM_SHA256。但是,这里有两个问题:

所谓出问题的客户端,就是实际的业务代码去连接API server时候用的客户端,它是一个Java库,而不是curl,这一点一定要分清。

那么,我们怎么获得这个Java库能支持的密码套件列表呢?其实最直接的办法,还是用抓包分析。我们回到前面那个抓包文件,检查一下Client Hello报文。在那里,就有Java库支持的密码套件列表。

图片

补充:这个列表往下还有,因为屏幕小,我没有全部展示。

找到了客户端的密码套件列表,接下来是不是就去找服务端的密码套件的列表呢?不过,这个抓包里,服务端直接回复了Alert消息,并没有提供它支持的密码套件列表。那我们的排查如何继续推进呢?

其实,可以换个思路:看看服务端在TLS握手成功后用了哪个密码套件,而不是去拿到它的全部列表。前面curl已经成功了,我们来看下curl那次协商出来的套件是哪个,看它是否被Java库支持,就可以判定了

我们要导出这次Client Hello里面的密码套件列表,可以这样做:选中Cipher Suite,右单击,选中Copy,在次级菜单中选中All Visible Selected Tree Items:

图片

这样,我们就得到了下面这个列表:

Cipher Suites (28 suites)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 (0xc023)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 (0xc027)
    Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA256 (0x003c)
    Cipher Suite: TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256 (0xc025)
    Cipher Suite: TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256 (0xc029)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 (0x0067)
    Cipher Suite: TLS_DHE_DSS_WITH_AES_128_CBC_SHA256 (0x0040)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA (0xc009)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA (0xc013)
    Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA (0x002f)
    Cipher Suite: TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA (0xc004)
    Cipher Suite: TLS_ECDH_RSA_WITH_AES_128_CBC_SHA (0xc00e)
    Cipher Suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA (0x0033)
    Cipher Suite: TLS_DHE_DSS_WITH_AES_128_CBC_SHA (0x0032)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA (0xc008)
    Cipher Suite: TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA (0xc012)
    Cipher Suite: TLS_RSA_WITH_3DES_EDE_CBC_SHA (0x000a)
    Cipher Suite: TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA (0xc003)
    Cipher Suite: TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA (0xc00d)
    Cipher Suite: TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA (0x0016)
    Cipher Suite: TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA (0x0013)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_RC4_128_SHA (0xc007)
    Cipher Suite: TLS_ECDHE_RSA_WITH_RC4_128_SHA (0xc011)
    Cipher Suite: TLS_RSA_WITH_RC4_128_SHA (0x0005)
    Cipher Suite: TLS_ECDH_ECDSA_WITH_RC4_128_SHA (0xc002)
    Cipher Suite: TLS_ECDH_RSA_WITH_RC4_128_SHA (0xc00c)
    Cipher Suite: TLS_RSA_WITH_RC4_128_MD5 (0x0004)
    Cipher Suite: TLS_EMPTY_RENEGOTIATION_INFO_SCSV (0x00ff)

可见,里面确实没有 ECDHE_RSA_AES_128_GCM_SHA256 这个套件。所以到这里,我们可以确认问题根因了:因为这个Java库和API server 2之间,没有找到共同的密码套件,所以TLS握手失败。

根因找到了,下一步就是升级Java库,让双方能够协商成功。

补充:API server 1能兼容这个相对旧的Java库,所以没有问题。

你觉得这个问题难吗?其实还好,对吧?这是因为我们一旦对协议本身有准确的理解,那么很多问题就容易被“看穿”。这也说明了理论知识的重要性。

好,我们再来看一个复杂一点的案例。

案例2:有效期内的证书为什么报告无效?

有一次,一个产品开发团队向我们运维团队报告了一个问题:他们的应用在做了代码发布后,就无法正常访问一个内部的HTTPS站点了,报错信息是:certificate has expired。

这就很奇怪了,我们日常对证书都做了自动更新处理,不会有“漏网之鱼”。然后我们也手工检查了这个HTTPS站点的证书,确定是在有效期内的,这就使得这个报错显得尤其古怪。

既然是代码发布后新出现的问题,那我们自然认为问题是跟发布有关。我们了解到:这次确实有个变更,会在客户端打开服务端证书校验的特性,而这个特性在以前是不打开的。但这还是无法解释,为什么客户端居然会认为,一个明明在有效期内的证书是过期的。

真是“秀才遇到兵”,感觉“讲理”是行不通了,于是我们换了个思路,不纠结在有效期的问题上。跟前一个案例类似,我们用交叉验证的方式来推进排查。具体做法是:在这台客户端和另一台客户端上,用OpenSSL向这个HTTPS站点发起TLS握手。

结果我们发现了更有意思的情况:从另外一台客户端的OpenSSL去连接这个HTTPS站点,也报告certificate has expired。

这给了我们很大的信心:既然OpenSSL可以复现这个问题,那我们就可以做进一步的检查了!因为OpenSSL属于OS上的命令,虽然我们不了解如何在Node.js上做debug,但是我们对如何在OS上做排查是很有经验的。

于是,我们在OpenSSL命令前面加上 strace,以便于追踪OpenSSL在执行过程中,特别是在报告certificate has expired之前,具体发生了什么。执行这个命令:

strace openssl s_client -tlsextdebug -showcerts -connect abc.ebay.com:443 

输出的关键部分如下:

stat("/usr/lib/ssl/certs/a1b2c3d4.1", {st_mode=S_IFREG|0644, st_size=2816, ...}) = 0
openat(AT_FDCWD, "/usr/lib/ssl/certs/a1b2c3d4.1", O_RDONLY) = 6
......
write(2, "verify return:1\n", 16verify return:1
)       = 16
.......
write(2, "verify error:num=10:certificate "..., 44verify error:num=10:certificate has expired
) = 44
write(2, "notAfter=", 9notAfter=)                = 9
write(2, "Oct 14 18:45:33 2020 GMT", 24Oct 14 18:45:33 2020 GMT) = 24

这里的关键信息是:

这又是一个明显的进展:很可能就是这个文件导致了错误。这是个什么文件,为什么会导致错误呢?

其实,它就是TLS客户端本地的Trust store里,存放的中间证书文件。Trust store一般用来存放根证书和中间证书文件,你可能对这几个名词还不太熟悉,我给你介绍一下TLS证书校验的原理。

补充:一般来说,证书先存入文件系统,然后通过命令或者代码,导入到应用的Trust store。

TLS证书链

TLS证书验证是“链式”的机制。比如,客户端存有根证书和它签发的中间证书,那么由中间证书签发的叶子证书,就可以被客户端信任了,也就是这样一条信任链:

信任根证书 -> 信任中间证书 -> 信任叶子证书

我画了三种不同情况下的信任链的示意图,供你参考:

场景1和3中,信任链是完整的,证书验证就可以通过。场景2中,由于中间证书既不在客户端的Trust store里,也不在服务端回复的证书链中,这就导致信任链断裂,验证就会失败。

而我们发现在这个案例里,服务端发送的证书链中包含了正确的中间证书,那为什么还会失败呢?其实这是因为,从前面strace openssl的输出里已经发现,客户端本地也有一张中间证书,而且是过期的,示意图如下:

这两张中间证书,签发机构是同一个CA,证书名称也相同,这就导致了OpenSSL在做信任链校验时,优先用了本地的中间证书,进而因为这张本地的中间证书确实已经过期,导致OpenSSL抛出了certificate has expired的错误!

这个结论你看明白了吗?你也许觉得还是有哪里不对,比如你可能会问:“照理说叶子证书是新的中间证书签发的,那用老的中间证书去验证叶子证书的签名的时候,应该会失败啊?”

你说得没错。这里最烧脑的地方在于:这两张中间证书,不仅签发机构一样,名称一样,而且私钥也一样

如果你对TLS不熟悉,学到这里可能已经觉得有点“爆炸”了。先别急,下面有详细的解释。

这里的核心秘密在于:每次证书在更新的时候,它对应的私钥不是必须要更新的,而是可以保持不变的

我们把本地的已经过期的中间证书,称为old_cert,新的中间证书称为new_cert。整个故事就是这样:

如果你还没有完全看明白,说明你真的在思考了,因为我确实还没有讲完。接下来介绍核心知识点:TLS证书签名。

TLS证书签名

你应该也知道,TLS证书都有签名部分,这个签名就是用签发者的私钥加密的。客户端为什么会相信叶子证书真的是这个CA签发的呢?就是因为,客户端的Trust store里就有这个CA的公钥(在CA证书里),它用这个公钥去尝试解开签名,能成功的话,就说明这张叶子证书确实是这个CA签发的。

这里最关键的部分在于,新老中间证书用的私钥是同一把,所以这张叶子证书的签名部分,用老的中间证书的公钥也能解开,这就使得下图中的橙色的验证链条得以“打通”,不过,谁也没料到打通的是一条“死胡同”。

补充:PKI里有交叉签名的技术,就是新老根证书对同一个新的中间证书进行签名,但并不适用于这个案例。

OpenSSL报错的原因找到了,根据这个发现,我们也确认了Node.js的Trust store也存在同样的问题。我们把它的Trust store里的过期证书全部删除后,问题就被解决了。

另外在排查过程中,我们偶然发现Stack Overflow上也有人报告了类似的问题。于是我在Stack Overflow上也做了回复,期望可以对遇到类似问题的人提供帮助。

补充:我的留言在三楼,署名VictorYang。

小结

这节课,我们通过两个典型案例,学习了TLS相关的知识,你可以重点关注和掌握以下知识点。

对称加密算法:加密和解密用同一个密钥,典型算法有AES、DES。

非对称加密算法:加密和解密用不同的密钥,典型的非对称加密算法有RSA、ECDSA。

TLS是先完成握手,然后进行加密通信。非对称算法用于交换随机数等信息,以便生成对称密钥;对称算法用于信息的加解密。

在握手阶段,TLS需要四类算法的参与,分别是:密钥交换算法、身份验证和签名算法、对称加密算法、消息完整性校验算法。这四类算法的组合,就形成了密码套件,英文叫Cipher Suite。这是TLS握手中的重要内容,我们的案例1就是因为无法协商出公用的密码套件,所以TLS握手失败了。

TLS的信任是通过对证书链的验证:

信任根证书 -> 信任中间证书 -> 信任叶子证书

本地证书加上收到的证书,就形成了证书链,如果其中有问题,那么证书校验将会失败。我们的案例2,就是因为一些极端情况交织在一起,造成了信任链过期的问题,导致证书验证失败了。

它是客户端使用的本地CA证书存储,其中的文件过期的话可能导致一些问题,在排查时可以重点关注。

在排查技巧方面,你要知道使用 curl命令,检查HTTPS交互过程的方法:

curl -vk https://站点名

以及使用 OpenSSL命令来检查证书的方法,也就是:

openssl s_client -tlsextdebug -showcerts -connect 站点名:443 

另外在需要分析OpenSSL为什么报错的时候,你可以在前面加上 strace,这对于排查根因有不少的帮助。

然后,我也带你学习了如何在Wireshark里导出Cipher Suite的方法,就是在TLS详情中选中Cipher Suite,右单击,选中Copy,在次级菜单中选中All Visible Selected Tree Items。这时,列表就被复制出来了。

除此之外,我们还在排查TLS Alert 40这个信息时,通过查阅RFC5246得到了答案。所以,在遇到一些协议类型、定义相关的问题时,最好查阅权威的RFC文档,这样可以获得最准确的信息

思考题

最后还是给你留两道思考题:

欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。