安全路透社
当前位置:安全路透社 > 安全客 > 正文

【技术分享】如何发现F5 BIG-IP设备的TicketBleed漏洞

http://p1.qhimg.com/t01914b84c8de8a5121.png

前言


Ticketbleed(CVE-2016-9244)是存在F5产品的TLS堆栈中的软件漏洞,它允许远程攻击者一次性提取多达31个字节的未初始化内存数据,像Heartbleed一样,它可以包含任意的敏感信息。

如果您不确定是否会受到此漏洞的影响,您可以在ticketbleed.com(包含在线测试)或F5 K05121675文章中找到详细信息和缓解说明。

http://p4.qhimg.com/t0156ee5aa470efd5dd.png

在这篇文章中,我们将讨论如何找到,验证和披露Ticketbleed。


JIRA RG-XXX


这要从CloudFlare Railgun产生的一个错误报告说起。

rg-listener <> 原始请求失败,错误命令是"local error: unexpected message"

rg-listener <> 原始流量包被记录并显示在握手之间触发了一个TLS警告.

值得注意的是客户在Railgun 和原始服务器之前使用了一个F5的负载均横: visitor > edge > cache > rg-sender > F5 > rg-listener > F5 > origin web server

Matthew不可能在Go中使用一个基本的TLS.Dial 来复现它,所以问题似乎很棘手

Railgun的位置:Railgun通过建立永久优化的连接并对HTTP响应执行增量压缩来加速Cloudflare edge和原始网站之间的请求。

http://p2.qhimg.com/t010bf10946cd929e7e.png

Railgun连接使用基于TLS的自定义二进制协议,两个终端都是Go程序:一个终端位于Cloudflare edge,另一个安装在客户服务器上。这意味着整个连接都要通过Go TLS栈,crypto/tls。

连接失败的错误代码是:local error: unexpected message,这意味着客户端发送了一些Railgun的Go TLS堆栈无法处理的数据。由于客户端在Railgun和我们之间运行着F5负载均衡,这也表明Go TLS栈和F5之间存在不兼容性。

但是,当我的同事Matthew试图使用crypto/tls.Dial连接到负载均衡上来重现错误时,它成功了。

深入分析PCAP

由于Matthew正坐在我对面,他知道我一直在使用Go TLS协议来实现TLS 1.3。于是我们很快完成了联合调试。

下面是我们分析的PCAP。

http://p3.qhimg.com/t0125878a10edba6e27.png

上图中有ClientHello和ServerHello数据包,然后马上发送ChangeCipherSpec消息。在TLS 1.2中,ChangeCipherSpec代表的意思就是”让我们开始加密吧”。只有一种情况,ChangeCipherSpec会在握手之前先发送,那就是会话复用。

事实上,通过观察ClientHello,我们可以发现Railgun客户端发送了一个Session Ticket。

http://p2.qhimg.com/t01d3d66c2ef227142b.png

Session Ticket携带着先前会话的一些加密密钥信息,来告诉服务器复用先前会话,而不是协商新的会话。

http://p4.qhimg.com/t013cc4784077efa14d.png

要了解有关TLS 1.2会话复用的更多信息,请阅读Cloudflare Crypto Team TLS 1.3Take的第一部分,阅读副本或Cloudflare博客上的“TLS会话复用”的帖子

在发送ChangeCipherSpec消息之后,Railgun和Wireshark变的不知所错(HelloVerifyRequest?Umh?)。所以我们有理由确定这个问题与Session Ticket有关。

在Go中,您需要在客户端上设置ClientSessionCache来显式开启Session Ticket。我们验证Railgun开启了这个功能,并写了这个小测试:

package main
 
import (  
    "crypto/tls"
)
 
func main() {  
    conf := &tls.Config{
        InsecureSkipVerify: true,
        ClientSessionCache: tls.NewLRUClientSessionCache(32),
    }
 
    conn, err := tls.Dial("tcp", "redacted:443", conf)
    if err != nil {
        panic("failed to connect: " + err.Error())
    }
    conn.Close()
 
    conn, err = tls.Dial("tcp", "redacted:443", conf)
    if err != nil {
        panic("failed to resume: " + err.Error())
    }
    conn.Close()
}

这足以证明错误的发生(local error: unexpected message)与Session Ticket有关。


深入分析crypto/tls


只要我们能在本地重现它,就能弄懂它。crypto/tls的错误消息缺少详细的信息,但是快速的调整允许我们精确定位错误在哪里发生。

每次发生错误时,都会调用setErrorLocked记录错误,并确保所有后续操作失败。该函数通常从错误的站点调用。

我们应该在panic(err)处进行堆栈跟踪,它会告诉我们消息在哪出现异常。

diff --git a/src/crypto/tls/conn.go b/src/crypto/tls/conn.go  
index 77fd6d3254..017350976a 100644  
--- a/src/crypto/tls/conn.go
+++ b/src/crypto/tls/conn.go
@@ -150,8 +150,7 @@ type halfConn struct {
 }
 
 func (hc *halfConn) setErrorLocked(err error) error {
-       hc.err = err
-       return err
+       panic(err)
 }
 
 // prepareCipherSpec sets the encryption and MAC states
panic: local error: tls: unexpected message
 
goroutine 1 [running]:  
panic(0x185340, 0xc42006fae0)  
    /Users/filippo/code/go/src/runtime/panic.go:500 +0x1a1
crypto/tls.(*halfConn).setErrorLocked(0xc42007da38, 0x25e6e0, 0xc42006fae0, 0x25eee0, 0xc4200c0af0)  
    /Users/filippo/code/go/src/crypto/tls/conn.go:153 +0x4d
crypto/tls.(*Conn).sendAlertLocked(0xc42007d880, 0x1c390a, 0xc42007da38, 0x2d)  
    /Users/filippo/code/go/src/crypto/tls/conn.go:719 +0x147
crypto/tls.(*Conn).sendAlert(0xc42007d880, 0xc42007990a, 0x0, 0x0)  
    /Users/filippo/code/go/src/crypto/tls/conn.go:727 +0x8c
crypto/tls.(*Conn).readRecord(0xc42007d880, 0xc400000016, 0x0, 0x0)  
    /Users/filippo/code/go/src/crypto/tls/conn.go:672 +0x719
crypto/tls.(*Conn).readHandshake(0xc42007d880, 0xe7a37, 0xc42006c3f0, 0x1030e, 0x0)  
    /Users/filippo/code/go/src/crypto/tls/conn.go:928 +0x8f
crypto/tls.(*clientHandshakeState).doFullHandshake(0xc4200b7c10, 0xc420070480, 0x55)  
    /Users/filippo/code/go/src/crypto/tls/handshake_client.go:262 +0x8c
crypto/tls.(*Conn).clientHandshake(0xc42007d880, 0x1c3928, 0xc42007d988)  
    /Users/filippo/code/go/src/crypto/tls/handshake_client.go:228 +0xfd1
crypto/tls.(*Conn).Handshake(0xc42007d880, 0x0, 0x0)  
    /Users/filippo/code/go/src/crypto/tls/conn.go:1259 +0x1b8
crypto/tls.DialWithDialer(0xc4200b7e40, 0x1ad310, 0x3, 0x1af02b, 0xf, 0xc420092580, 0x4ff80, 0xc420072000, 0xc42007d118)  
    /Users/filippo/code/go/src/crypto/tls/tls.go:146 +0x1f8
crypto/tls.Dial(0x1ad310, 0x3, 0x1af02b, 0xf, 0xc420092580, 0xc42007ce00, 0x0, 0x0)  
    /Users/filippo/code/go/src/crypto/tls/tls.go:170 +0x9d

让我们看看异常的消息警报会发送到哪里conn.go:672。

 670     case recordTypeChangeCipherSpec:
 671         if typ != want || len(data) != 1 || data[0] != 1 {
 672             c.in.setErrorLocked(c.sendAlert(alertUnexpectedMessage))
 673             break
 674         }
 675         err := c.in.changeCipherSpec()
 676         if err != nil {
 677             c.in.setErrorLocked(c.sendAlert(err.(alert)))
 678         }

所以异常的消息是ChangeCipherSpec。让我们检查上一级的堆栈,看看是否有线索。让我们看看handshake_client.go:262。

 259 func (hs *clientHandshakeState) doFullHandshake() error {
 260     c := hs.c
 261
 262     msg, err := c.readHandshake()
 263     if err != nil {
 264         return err
 265     }

这是doFullHandshake函数。等等,这里的服务器显然正在进行会话复用(在Server Hello之后立即发送一个Change Cipher Spec),而客户端正在尝试进行完整握手?

看起来情况是,客户端提供Session Ticket,服务器接受它,但是客户端并不知道并继续执行下去。


深入RFC


在这一点上,我查阅了TLS 1.2的相关信息,以了解服务器是如何表示接受Session Ticket?

RFC 5077,过时的RFC 4507:

当携带一个ticket时,客户端会在TLS ClientHello中生成并包含一个Session ID. 如果服务器接收了ticket并且Session ID不为空,它必须马上返回与ClientHello相同的Session ID.

因此,客户端不应该猜测是否Session Ticket会被接受, 客户端应该发送一个Session ID并在服务器的回显中查找这个Session ID。

 crypto/tls中的代码很明显的说明了这一点。

func (hs *clientHandshakeState) serverResumedSession() bool {  
    // If the server responded with the same sessionId then it means the
    // sessionTicket is being used to resume a TLS session.
    return hs.session != nil && hs.hello.sessionId != nil &&
        bytes.Equal(hs.serverHello.sessionId, hs.hello.sessionId)
}

深入分析Session IDs


一定是这里出错了。让我们加入一些基于打印输出的调试。

diff --git a/src/crypto/tls/handshake_client.go b/src/crypto/tls/handshake_client.go  
index f789e6f888..2868802d82 100644  
--- a/src/crypto/tls/handshake_client.go
+++ b/src/crypto/tls/handshake_client.go
@@ -552,6 +552,8 @@ func (hs *clientHandshakeState) establishKeys() error {
 func (hs *clientHandshakeState) serverResumedSession() bool {
        // If the server responded with the same sessionId then it means the
        // sessionTicket is being used to resume a TLS session.
+       println(hex.Dump(hs.hello.sessionId))
+       println(hex.Dump(hs.serverHello.sessionId))
        return hs.session != nil && hs.hello.sessionId != nil &&
                bytes.Equal(hs.serverHello.sessionId, hs.hello.sessionId)
 }
00000000  a8 73 2f c4 c9 80 e2 ef  b8 e0 b7 da cf 0d 71 e5  |.s/...........q.|
 
00000000  a8 73 2f c4 c9 80 e2 ef  b8 e0 b7 da cf 0d 71 e5  |.s/...........q.|  
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

 F5服务器将Session ID填充到它的最大长度32字节,而不是当客户端发送它时再返回它。crypto / tls在Go中使用16字节会话ID。

从这里看错误就很明显了:服务器认为它告诉客户端使用Ticket而客户端认为服务器启动了新会话,于是意外就发生了。

在TLS空间中,我们发现了一些不兼容性。为了不与某些服务器实现发生冲突,ClientHellos必须小于256字节或大于512字节。

00000000  79 bd e5 a8 77 55 8b 92  41 e9 89 45 e1 50 31 25  |y...wU..A..E.P1%|
00000000  79 bd e5 a8 77 55 8b 92  41 e9 89 45 e1 50 31 25  |y...wU..A..E.P1%|  
00000010  04 27 a8 4f 63 22 de 8b  ef f9 a3 13 dd 66 5c ee  |.'.Oc".......f\.|

噢哦。等等。这些不是零也不是填充。那是…内存?

在这一点上,和Heartbleed的处理类似。服务器申请和客户端的会话ID一样大的缓冲区,然后总是返回32个字节的数据,在额外的字节里携带着未分配的内存数据。


深入浏览器


我最后一个疑问是:为什么之前没有发现这个漏洞?

答案是:所有浏览器使用32字节的SESSION ID来协商SESSION TICKET。我和Nick Sullivan一起检查了NSS,OpenSSL和BoringSSL来确认这个问题。以BoringSSL为例。

  /* Generate a session ID for this session based on the session ticket. We use
   * the session ID mechanism for detecting ticket resumption. This also fits in
   * with assumptions elsewhere in OpenSSL.*/
  if (!EVP_Digest(CBS_data(&ticket), CBS_len(&ticket),
                  session->session_id, &session->session_id_length,
                  EVP_sha256(), NULL)) {
    goto err;
  }

BoringSSL使用SHA256作为SESSION TICKET,正好是32个字节。

(有趣的是,在TLS中,有人提到使用1字节的SESSION ID,但是没有人对它进行测试。)

至于Go,可能是客户端没有启用SESSION TICKET。


深入披露


在意识到这个问题的影响之后,我们在公司内部进行了分享,我们的支持团队会建议客户禁用SESSION TICKET,并试图联系F5。

我们与F5 SIRT联系,交换PGP密钥,并提供报告和PoC。

报告已提交给开发团队,确定问题是未初始化的内存,但是仅限于Session Ticket功能。

目前还不清楚哪些数据可以通过此漏洞泄露,但是HeartBleed和Cloudflare Heartbleed Challenge告诉我们未初始化的内存是不安全的

在规划时间表时,F5团队面临着严格的发布计划。综合考虑多种因素,包括有效的缓解(禁用Session Ticket),我决定采用由Google's Project Zero发布的业界标准的披露政策:在115天之后,如果漏洞没有被修复,就会被披露。

巧合的是今天正好是计划发布修复补丁的截至日期。

我要感谢F5 SIRT的专业性,透明度和协作性,这和我们在业内经常听到的对抗性形成鲜明对比。

该漏洞已分配CVE-2016-9244。


深入互联网


当我们向F5报告问题时,我已经针对单个主机测试了该漏洞,该主机在禁用Session Ticket后很快变得不可用。这意味着漏洞具有低信度,并且没有办法再现它。

这是进行互联网扫描的绝佳场合。我选择了由密歇根大学授权Censys.io的工具包:zmap和zgrab。

zmap是一种用于检测开放端口的IPv4空间扫描工具,而zgrab是一种Go工具,通过连接到这些端口并收集大量协议详细信息来进行跟踪。

我在zgrab添加对Session Ticket复用的支持,然后让zgrab发送一个31字节的会话ID,并将其与服务器返回的ID进行比较。我写了一个简单的Ticketbleed检测器。

diff --git a/ztools/ztls/handshake_client.go b/ztools/ztls/handshake_client.go  
index e6c506b..af098d3 100644  
--- a/ztools/ztls/handshake_client.go
+++ b/ztools/ztls/handshake_client.go
@@ -161,7 +161,7 @@ func (c *Conn) clientHandshake() error {
                session, sessionCache = nil, nil
                hello.ticketSupported = true
                hello.sessionTicket = []byte(c.config.FixedSessionTicket)
-               hello.sessionId = make([]byte, 32)
+               hello.sessionId = make([]byte, 32-1)
                if _, err := io.ReadFull(c.config.rand(), hello.sessionId); err != nil {
                        c.sendAlert(alertInternalError)
                        return errors.New("tls: short read from Rand: " + err.Error())
@@ -658,8 +658,11 @@ func (hs *clientHandshakeState) processServerHello() (bool, error) {
 
        if c.config.FixedSessionTicket != nil {
                c.resumption = &Resumption{
-                       Accepted:  hs.hello.sessionId != nil && bytes.Equal(hs.serverHello.sessionId, hs.hello.sessionId),
-                       SessionID: hs.serverHello.sessionId,
+                       Accepted: hs.hello.sessionId != nil && bytes.Equal(hs.serverHello.sessionId, hs.hello.sessionId),
+                       TicketBleed: len(hs.serverHello.sessionId) > len(hs.hello.sessionId) &&
+                               bytes.Equal(hs.serverHello.sessionId[:len(hs.hello.sessionId)], hs.hello.sessionId),
+                       ServerSessionID: hs.serverHello.sessionId,
+                       ClientSessionID: hs.hello.sessionId,
                }
                return false, FixedSessionTicketError
        }

选择31字节的原因是我可以确保不泄露敏感信息。

然后,我从Censys网站下载最近的扫描结果,其中包括什么主机支持Session Ticket信息,并使用pv和jq完成了管道。

在11月份的Alexa top 1m列表中的前1,000个主机中有2个存在漏洞,我中断了扫描,避免泄露漏洞,并推迟到了披露日期。

在完成这篇指导时,我完成了扫描,0.1%和0.2%的主机容易受到攻击,0.4%的网站支持Session Ticket。


阅读更多


欲了解更多详情,请访问F5 K05121675文章ticketbleed.com,在那里你会发现一个技术总结,受影响的版本,缓解指令,一个完整的时间表,扫描结果,扫描机器的IP地址,并可以进行在线测试。

否则,你应该关注我的Twitter



原文链接:https://blog.filippo.io/finding-ticketbleed/

未经允许不得转载:安全路透社 » 【技术分享】如何发现F5 BIG-IP设备的TicketBleed漏洞

赞 (0)
分享到:更多 ()

评论 0

评论前必须登录!

登陆 注册