如何利用心血漏洞来获取网站的私有 crypto 密钥

jopen 10年前

现在OpenSSL的心血(Heartbleed) 漏洞已经是人尽皆知了: 在最流行的TLS实现之一的OpenSSL中由于缺少的一个边界检查导致了数以百万计(或更多)的Web服务器泄露了内存中的各种敏感信息.这会将登录证书, 认证cookie和网站流量泄露给攻击者. 但它能否用于获取站点的私钥? 取得站点的私钥就可以破解以前记录的没有达成完美的向前保密性的流量, 然后就可以在以后的TLS会话中实施中间人攻击.

因为这是心血漏洞的更为严重的后果, 我决定尝试下. 结果证明这个猜想是正确的. 经过几天的攻关, 我能够从一台测试Nginx服务器上提取到私钥. 稍后我会用我的技术来解决CloudFlare Challenge. 与另外几名安全研究人员一起, 我们独立地证明了RSA私钥的确是处于危险之中. 下面我们来详细介绍私钥是怎样提取的和为什么这种攻击是可能的.

注: CloudFlare Challenge 是cloudflare.com发起的一项挑战: 从他们搭建的测试nginx服务器(安装了有heartbleed漏洞的OpenSSL)上窃取私钥.

怎样提取私钥

不熟悉RSA的读者可以在这了解下. 为了简单点, 由两个随机生成的大的素数p和q相乘得到一个大的数(2048 bits) N. N会被公开,但p和q却不会. 找到p或q就可以反算出私钥. 一个通用的攻击方式是对N做因式分解, 但这是很困难的. 然而, 通过像心血(Heartbleed)这样的漏洞, 攻击会变得简单很多: 因为Web服务器需要将私钥保存在内存中来签名TLS的握手协议, p和q必须在内存中而且我们可以试着用heartbleed的网络包来取得它们. 这个问题很简单地就变成了如何从返回的数据中找到它们. 这也很简单,因为我们知道p和q的长度是1024 bit(128字节), 并且OpenSSL在内存中以小字节序表示数据. 一个野蛮方法是将heartbleed数据包中每个连续的128字节做为一个小字节序数值然后测试这个数值是否能整除N, 这个方法足以发现潜在的漏洞. 这也是人们解决CloudFlare challenge的方法.

Coppersmith 改进

但是等等,和我们冷起动内存映像攻击的方案不同。已经有很多通过部分消息恢复RSA的研究。最著名的是一份来自Coppersmith的论文,介绍了通过相关消息或者填充不足消息,以及在格基简化算法帮助下因式分解部分消息来攻击。通过 Coppersmith攻击,只需知道P的上部或下部,N就可以有效地因式分解。基于此,和暴力破解所需的128字节相比,我们只需要上部或下部的64字节就能计算出秘钥。在实践中,Coppersmith的限制是计算开销(但任然比因式分解好很多了),假设已知77字节(60%),我们可以非常迅速地梳理出“心血”包的潜在秘钥。

回想起来,我已收集的超过10000个包(每个64KB)有242个私钥的残余适合Coppersmith攻击。感谢Sage(虽然后来我发现Sage已经实现了Coppersmith攻击)的全面计算机代数积木使Coppersmith攻击的实现变得更容易。

我们能做的更好吗?加入你曾经用openssl ras -text -in server.key命令查看过RSA私钥,你会发现有相当多的数字超过两个素数因子p和q。实际上,他们是为了优化Chinese Remainder Theorem预先计算的值。如果他们中的一些是遗漏了,他们也能被p推演出来。OpenSSL是怎样将p和q的Montgomery representations用于快速乘积的?他们也承认Coppersmith的变体,以便局部的位也是有用的。考虑到这一点,我们开始在我的测试服务器上搜索已收集的包。但是在数据集中甚至没有发现单独出现的部分(大于16个字节)。这怎么可能呢?

注意:我的所有实验和CloudFlare challenge的目标是单线程的Nginx。在一个多线程的web服务器上也是可能的,能观察到更多的泄露。

为什么只会泄露p

当"心血"首次出现时, 有人争辩说 RSA私钥不会泄漏. 毕竟它们只会在Web服务器启动时被加载, 所以它们位于较低的内存地址中. 而且随着堆内存的向上增长, 随后分配的被"心血"泄漏的缓冲区内存是访问不了这些私钥的. 这与我不能发现CRT预先计算的值是一样的, 但不知为何p确实是泄漏了.  如果我们假设这个辩论是正确的, 问题就成了:为什么p被泄漏了?

另外, OpenSSL会清除掉所有用过的临时BigNum. 为了减少动态分配临时值引起的开销, OpenSSL提供了一个以栈格式操作的BigNum池---BN_CTX. 一旦使用结束, 它的上下文会被销毁并且所有分配的缓冲区也会被清除(scrubbed). 这意味着当创建完"心血"数据包后在内存中不会再有任何临时数据(假设是单线程), 因为BN_CTX早就被释放了.

我不会用我查明原因时所经历过的痛苦来阻挠你, 所以下面我给出了答案:

当一个BigNum被扩展到一个更大的缓冲区时, 它原来的缓冲区在释放前不会被置0. 导致p泄漏的控制流路径链变得更细微了. 在初始化TLS握手期间, 服务器密钥的交换是用私钥签名的. CRT签名执行了一次modulo p操作, 这导致了p<<BN_BITS2的结果被储存到了从BN_CTX池分配的临时变量中. 在稍后的CRT 错误注入检查中, 这个临时变量又作为val[0]被重用了(记住BN_CTX的操作与栈类似). 一个有趣的事实是被重新分配的临时变量只把它最低内存置0了, 所以对于p<<BN_BITS2什么都没有被破坏(它的最低内存本身就是0). val[0]马上接收Montgomery-reduced的值, 但因为初始缓冲区不足以储存这个新的值, 它会扩大, 所以p又被释放到了空闲堆空间中, 等待再次被使用. 因为这在每次TLS握手时都会发生, 它会被泄漏得到处都是.

因为很难找出是哪个BigNum会被扩大并引起静态泄漏, 我用工具对OpenSSL做了点实验. 结果证明了一个升级版本的p Montgomery表示也会在泄漏点被释放, 但这只发生在Montgomery上下文初始化的首次RSA幂运算中. 它会一直存在于低内存地址中, 并且我不能在抓取的数据包中找到它.

上面的泄漏bug已经通知了OpenSSL小组. 虽然有点可怕, 但严格来说这并不是安全bug, 因为OpenSSL在设计时就没想过要阻止敏感信息在堆上的泄漏. 

Rubin Xu是一位剑桥大学在计算机实验室安全组攻读博士学位的博士生, 他的论文是关于移动安全, 并且他对密码学也有兴趣.  他是成功攻破CloudFlare Challenge的四人之一. 这篇博文首发于Light Blue Touchpaper blog. Rubin Xu感谢Joseph Bonneau对此文的建议和校对.