时光飞逝。距离Heartbleed漏洞被发现刚刚超过五年半。Heartbleed之所以家喻户晓,不仅因为它是第一个具有自己的网页和徽标的漏洞,还因为它揭示了整个互联网的脆弱性。由于Heartbleed的存在,密码库中的一个小小漏洞就可以暴露几乎所有在线网站用户的个人数据。
Heartbleed是一个未被充分重视的漏洞类型中的一例,这类漏洞是:远程内存泄露漏洞。除Heartbleed 以外,还有其他备受瞩目的例子,包括Cloudbleed和最近的NetSpectre。这些漏洞允许攻击者通过简单地向服务器发送特制的数据包来窃取秘密。Cloudflare最近完成了一个为期多年的项目,以使我们的平台对此类漏洞有更强的抵御能力。
在过去的五年中,业界一直在处理导致Heartbleed如此具有影响力的设计的后果。在此博客文章中,我们将深入研究内存安全性,以及我们如何重新设计Cloudflare的主要产品以保护私钥免受下一个Heartbleed的侵害。
内存泄漏
对于带有在线组件的企业来说,完美的安全性是不可能的。历史已经告诉我们,不管他们的安全程序有多强大,一个意想不到的漏洞都会让公司暴露在危险之中。最近发生的此类事件中,最著名的事件之一就是Heartbleed,这是一个被称为OpenSSL的常用密码库中的一个漏洞,它将数百万web服务器的内部细节暴露给任何能够连接到互联网的人。Heartbleed事件成为国际新闻,造成了数百万美元的损失,至今仍未完全得到解决。
典型的web服务只通过定义良好的面向公众的接口(称为API)返回数据。客户通常不会看到服务器内部发生了什么,这将是一个巨大的隐私和安全风险。Heartbleed打破了这种范例:它使互联网上的任何人都可以访问Web服务器使用的运行内存,从而发现通常不通过API公开的特权数据。Heartbleed可用于提取以前发送到服务器的数据(包括密码和信用卡)的结果。它还可以揭示服务器内部使用的内部工作原理和加密秘密,包括TLS 证书私钥。
Heartbleed让攻击者得以偷窥幕后,但不会太深。攻击者可以提取敏感数据,但并非服务器上的所有内容都面临风险。例如,Heartbleed不允许攻击者窃取服务器上的数据库内容。您可能会问:为什么有些数据有风险,而其他数据没有?原因与现代操作系统的构建方式有关。
隔离流程的简化视图
大多数现代操作系统被分成多个层。这些层类似于安全许可级别。所谓的用户空间应用程序(例如您的浏览器)通常位于名为用户空间的低安全性层中。他们只有在更低层、更可信的层允许的情况下才能访问计算资源(内存、CPU、网络)。
用户空间应用程序需要资源才能运行。例如,他们需要内存来存储代码,需要工作内存来进行计算。但是,让应用程序直接访问它们所运行的计算机的物理内存是有风险的。相反,原始计算元素被限制在一个被称为操作系统内核的较低层。内核仅运行专门设计的应用程序,这些应用程序旨在安全地管理这些资源,并介导用户空间应用程序对它们的访问。
当一个新的用户空间应用程序进程启动时,内核会为其提供虚拟内存空间。这个虚拟内存空间对应用程序来说就像实际内存一样,但实际上是内核用来保护实际内存的安全保护转换层。每个应用程序的虚拟内存空间就像一个专用于该应用程序的并行空间。这使得一个进程不可能查看或修改另一个进程,其他应用程序根本无从寻址。
Heartbleed,Cloudbleed和进程边界
Heartbleed是OpenSSL库中的一个漏洞,它是许多Web服务器应用程序的一部分。这些Web服务器像任何普通应用程序一样在用户空间中运行。这个漏洞导致web服务器在响应一个特别设计的入站请求时,会返回最多2千字节的内存。
Cloudbleed也是一个内存泄露漏洞,尽管这是Cloudflare特有的一个漏洞,但其因与Heartbleed非常相似而得名。Cloudbleed发生时,漏洞不存在于OpenSSL中,而是在用于HTML解析的辅助web服务器应用程序中。当这段代码解析特定的HTML序列时,它最终将一些进程内存插入到它所服务的web页面中。
需要注意的是,这两个漏洞都发生在用户空间而不是内核空间中运行的应用程序中。这意味着该漏洞暴露的内存必然是应用程序虚拟内存的一部分。即使该漏洞要暴露兆字节的数据,它也只会暴露特定于该应用程序的数据,而不会暴露系统上其他应用程序的数据。
为了让web服务器通过加密的HTTPS协议为流量提供服务,它需要访问证书的私钥,而私钥通常保存在应用程序的内存中。这些密钥被Heartbleed暴露在互联网上。Cloudbleed漏洞影响了一个不同的进程,即HTML解析器,它不支持HTTPS,因此不能在内存中保留私钥。这意味着HTTPS密钥是安全的,即使HTML解析器的内存空间中的其他数据并不安全。
HTML解析器和web服务器是不同的应用程序,这使我们不必撤销和重新颁发客户的TLS证书。但是,如果在web服务器中发现另一个内存公开漏洞,这些密钥将再次面临风险。
将密钥移出面向互联网的进程
并非所有Web服务器都在内存中保留私钥。在某些部署中,私钥被保存在称为硬件安全模块(HSM)的单独计算机中。HSM旨在抵御物理入侵和篡改,并且通常旨在满足严格的合规性要求。它们通常可能体积庞大且昂贵。Web服务器被设计成利用HSM中的密钥通过物理电缆连接到它们,并与名为PKCS#11的专用协议进行通信。这允许Web服务器在物理上与私有密钥分离的同时提供加密的内容。
在Cloudflare,我们构建了自己的方法来将web服务器与私有密钥分离:无密钥SSL。密钥不是保存在通过电缆连接到服务器的单独物理机中,而是保存于客户在自己基础架构上操作的密钥服务器中(也可以由HSM支持)。
最近,我们推出了Geo Key Manager,该服务允许用户仅在选定的Cloudflare位置存储私钥。与无法访问私钥的位置的连接将使用无密钥SSL,并将密钥服务器托管在具有访问权限的数据中心中。
在无密钥SSL和Geo Key Manager中,私有密钥不仅不属于web服务器的内存空间,而且常常不在同一个国家/地区!这种极端的分离程度对于防止下一次发生Heartbleed是不必要的。只需要Web服务器和密钥服务器不属于同一应用程序即可。这就是我们所做的。我们称之为处处无钥匙。
无密钥SSL由内部提供
重新定义Cloudflare持有私钥的无密钥SSL的概念是很容易的,但是从概念到实际应用的路径并不那么好走。无密钥SSL的核心功能来自于客户在其基础设施上运行的开源gokeyless,但在内部,我们将其作为库使用,并使用适合我们需求的实现替换了主软件包(我们创造性地将其称为gokeyless-internal)。
与所有主要的架构更改一样,明智的做法是先用一些新的、低风险的东西测试模型。在我们的例子中,测试平台是我们的实验性TLS 1.3实现。为了快速迭代TLS规范的草案版本并在不影响大多数Cloudflare客户的情况下发布版本,我们在Go中重新编写了自定义nginx web服务器,并将其与现有的基础设施并行部署。该服务器被设计为从一开始就不持有私钥,而仅利用内部gokeyless-internal。目前,TLS 1.3流量仅有一小部分,并且它们都来自于浏览器的beta版本。这使我们能够处理最初的gokeyless-internal缺陷,而不会使大多数访问者面临由于gokeyless-internal而导致的安全风险或中断。
实现TLS 1.3完全无秘钥的第一步是识别和实现我们需要添加到gokeyless-internal的新功能。无密钥SSL被设计为在客户基础设施上运行,预期只支持少量的私有密钥。但我们的边缘必须同时支持数百万个私钥,因此我们实现了与Web服务器相同的延迟加载逻辑——nginx。此外,典型的客户部署会将关键服务器置于网络负载均衡器之后,以便将它们从服务中取出来进行升级或其他维护。这与我们的优势形成了对比,在我们的优势中,通过在软件升级期间提供流量服务来最大化我们的资源是很重要的。我们在Cloudflare其他地方使用的表现非凡的tableflip package解决了这个问题。
下一个无钥匙的项目是Spectrum,该功能默认支持gokeyless-internal。有了这些小小的胜利,我们就有信心去应对巨大的挑战,那就是将我们现有的nginx基础架构移植到完全无密钥的模型中。在实现了新功能并对我们的集成测试感到满意之后,剩下的就是在生产中启用它,然后正式收工,不是吗?任何具有大型分布式系统经验的人都知道“开发中”与“完成”的距离有多远,这个故事也不例外。值得庆幸的是,我们已经预见到了问题,并且在gokeyless-internal路径遇到任何问题时,在nginx中构建了一个后备来完成握手。这使我们能够向生产流量公开gokeyless-internal,如果我们对nginx逻辑的重新实现不是100%无bug的,则会面临停机的风险。
当代码回滚时问题依旧存在
我们的部署计划是在所有地方启用无密钥,找到最常见的回退原因,然后修复它们。然后我们可以重复这个过程,直到消除所有的回退源,然后我们可以从nginx删除对私有密匙的访问(因此也删除了回退)。Fallback(回退)的早期原因之一是gokeyless-internal返回ErrKeyNotFound,这表明它在存储中找不到所请求的私钥。这应该是不可能的,因为nginx只在第一次于存储中找到证书和密钥对后才向gokeyless-internal发出请求,而且我们总是将私钥和证书一起写入。结果是,除了为真正没有找到的秘钥的预期情况返回错误之外,我们还在遇到临时错误(如超时)时返回错误。为了解决这个问题,我们更新了那些瞬时错误条件以返回ErrInternal,并将其部署到我们的canary数据中心。奇怪的是,我们发现一个数据中心中的少数实例开始遭遇高频率的回退,来自nginx的日志表明这是由于nginx和gokeyless-internal之间的超时造成的。超时并没有立即发生,但是一旦系统开始记录某些超时,它就永远不会停止。即使我们回滚版本,回退仍然在旧版本的软件中继续!此外,当nginx抱怨超时时,gokeyless-internal看起来非常健康,并且报告了合理的性能指标(请求延迟的中值在亚毫秒级)。
为了调试这个问题,我们在nginx和gokeyless中都添加了详细的日志记录,并在遇到超时时反向跟踪事件链。
➜ ~ grep 'timed out' nginx.log | grep Keyless | head -5
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015157 Keyless SSL request/response timed out while reading Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015231 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015271 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015280 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:50.000 29m41 2018/07/25 05:30:50 [error] 4525#0: *1015289 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
您可以看到第一个记录超时的请求的id是1015157。同样有趣的是,第一个日志行是“在读取时超时”,而其他所有日志行都是“在等待时超时”,后面的消息将一直持续下去。下面是gokeyless日志中的匹配请求:
➜ ~ grep 'id=1015157 ' gokeyless.log | head -1
2018-07-25T05:30:39.000 29m41 2018/07/25 05:30:39 [DEBUG] connection 127.0.0.1:30520: worker=ecdsa-29 opcode=OpECDSASignSHA256 id=1015157 sni=announce.php?info_hash=%a8%9e%9dc%cc%3b1%c8%23%e4%93%21r%0f%92mc%0c%15%89&peer_id=-ut353s-%ce%ad%5e%b1%99%06%24e%d5d%9a%08&port=42596&uploaded=65536&downloaded=0&left=0&corrupt=0&key=04a184b7&event=started&numwant=200&compact=1&no_peer_id=1 ip=104.20.33.147
啊哈!该SNI值显然是无效的(SNI类似于主机请求头,即它们是域,而不是URL路径),并且它也很长。我们的存储系统基于两个指标对证书进行索引:对应哪个SNI,对应哪个IP地址(对于不支持SNI的老客户端)。我们的存储接口使用memcached协议,而gokeyless-internal使用的客户端库拒绝长度超过250个字符(memcached的最大密钥长度)的密钥请求,而nginx逻辑只是简单地忽略无效的SNI,把请求当作只有一个IP。我们在新版本中的更改将这个条件从ErrKeyNotFound转移到了ErrInternal,这在nginx中引发了级联问题。它遇到的“超时”实际上是由于丢弃了所有在一个连接上执行多路复用的运行中的请求,而该连接恰好返回了单个请求的ErrInternal。这些请求被重试,但是一旦触发这个条件,nginx就会因为重试请求的数量以及带有坏SNI的连续的新请求流而超载,并且无法恢复。这解释了为什么回滚gokeyless-internal无法解决问题。
这一发现最终将我们的注意力引向了nginx,由于多年来nginx一直可靠地与客户密钥服务器一起工作,所以到目前为止它还没有受到指责。然而,通过本地主机与多租户密钥服务器进行通信,和通过公共互联网与客户的密钥服务器进行通信是完全不同的,我们必须进行以下更改:
与客户密钥服务器的长连接超时和相对较短的响应超时不同,非常短的连接超时和较长的请求超时适用于本地主秘密钥服务器。
类似地,如果我们超时等待客户密钥服务器响应,那么重试(使用backoff)也是合理的,因为我们不能信任网络。但是在本地主机上,只有当gokeyless-internal超载并且请求仍在排队等待处理时,才会发生超时。在这种情况下,重试只会导致gokeyless-internal请求更多的总工作,从而使情况变得更糟。
最重要的是,如果单个连接遇到错误,nginx绝不能丢弃在该连接上多路复用的所有请求,因为单个连接不再代表单个客户。
实现至关重要
边缘的CPU是我们最宝贵的资产之一,它被我们的性能团队(又名CPU警察)严密保护着。在我们的一个canary数据中心中开启了“处处无秘钥”后不久,他们注意到gokeyless使用了每个实例约50%的核心。我们将符号操作从nginx转移到gokeyless,所以它现在会占用更多的CPU。但是nginx应该已经看到CPU使用量相应减少了,对吧?
不对。在Go中,椭圆曲线操作非常快,但是众所周知,RSA操作比他们的BoringSSL副本慢得多。
尽管Go 1.11包含了对RSA数学运算的优化,但是我们需要更快的速度。为了匹配BoringSSL的性能,我们需要经过优化的汇编代码,因此来自我们加密团队的Armando Faz通过在Go的内部分支中使用与平台相关的汇编重新实现了math/big软件包的某些部分,帮助弥补了一些CPU损失。Go 最新的汇编策略更倾向于使用Go可移植代码,而不是汇编代码,因此这些优化未在上游进行。我们还有更多的优化空间,因此,尽管cgo有许多缺点,我们仍然在评估是否要将签名操作迁移到cgo + BoringSSL。
更换我们的工具
进程隔离是保护内存中秘密的强大工具。我们向处处无秘钥的转变表明,这不是一个简单的工具。重新架构一个现有的系统(如nginx),使用进程隔离来保护秘密是耗时且困难的。另一种提高内存安全性的方法是使用一种内存安全语言,比如Rust。
Rust最初是由Mozilla开发的,但是现在已经开始得到更广泛的应用。与C/C ++相比,Rust的主要优点在于它具有内存安全功能,并且没有垃圾收集器。
用新语言(例如Rust)重写现有应用程序是一项艰巨的任务。话虽如此,许多新的Cloudflare特性,从强大的防火墙规则特性到我们的1.1.1.1 with WARP应用程序,都是用Rust编写的,以利用其强大的内存安全特性。到目前为止,我们对Rust非常满意,并计划在将来更多地使用它。
总结
Heartbleed造成的惨痛后果给业界上了一课,这个教训本应是显而易见的:在可以通过互联网远程访问的应用程序中保留重要秘密是一种冒险的安全措施。在接下来的几年里,我们做了大量的工作,利用流程分离和无密钥SSL来确保下一个Heartbleed不会将客户密钥置于危险之中。
但是,这还不是终点。最近,我们发现了一些可以绕过应用程序进程边界的内存泄露漏洞,比如NetSpectre,所以我们会继续积极探索新的方法来保证密钥的安全。