导语:如果您的源代码是以Go语言编写的,并且它使用了单向或双向TLS身份验证,那么该程序将很容易受到CPU拒绝服务(DoS)攻击。

概述

如果您的源代码是以Go语言编写的,并且它使用了单向或双向TLS身份验证,那么该程序将很容易受到CPU拒绝服务(DoS)攻击。攻击者可以通过某种特定的方式来格式化输入内容,使得Go的crypto/x509标准库中的验证算法,在尝试验证客户端提供的TLS证书链的过程中,占用了所有可用的CPU资源。

如果要保护您的服务,请立即升级到Go 1.10.6或更高版本,或者升级至1.11.3或更高版本。

背景

42Crunch的API安全平台的后端,已经使用了微服务的架构来实现。这一微服务使用Go语言编写,通过gRPC相互通信,并且具有用于外部调用的REST API网关。为了确保安全性,我们遵循着“能用TLS就尽量用”的宗旨,广泛依赖于双向TLS身份验证。

Go在其标准库中提供本地的SSL/TLS支持,以及用于操作连接、验证、身份验证和证书的大量x509及TLS原语。这一本地支持,通过使用标准、经过检查、定期维护的TLS实现,来避免外部依赖性,并降低风险。

那么自然,42Crunch就可能会受到影响。因此,我们必须认真分析这一TLS漏洞,以确保42Crunch平台的安全性。

42Crunch安全团队发布了关于这一漏洞的分析以及详细信息。

问题

TLS链验证中的拒绝服务漏洞,最初由Netflix发现并报告,如Golang的问题跟踪器中所述:

crypto/x509包负责解析并验证X.509编码的密钥和证书。该包在处理攻击者提供的证书链时,本应该进行使用资源是否合理的判断。crypto/x509包没有限制为每个链验证执行的工作量,这可能允许攻击者制作导致CPU拒绝服务的恶意输入。Go TLS服务器接受客户端证书,TLS客户端将验证证书是否受到影响。

这里的问题之处,在于调用路径crypto/x509 Certificate.Verify()函数,该函数负责授权控制和验证证书。

漏洞详细分析

为了简化这一部分内容,并且使读者能够清楚了解,我们仅以TLS客户端连接到验证客户端证书的TLS服务器为例,展现这一过程的具体细节。

TLS服务器持续监听TLS客户端的8080端口,并根据一个受信任的证书颁发机构(CA)对客户端的证书进行验证:

caPool := x509.NewCertPool()
ok := caPool.AppendCertsFromPEM(caCert)
if !ok {
        panic(errors.New("could not add to CA pool"))
}
 
tlsConfig := &tls.Config{
        ClientCAs:  caPool,
        ClientAuth: tls.RequireAndVerifyClientCert,
}
 
//tlsConfig.BuildNameToCertificate()
server := &http.Server{
        Addr:      ":8080",
        TLSConfig: tlsConfig,
}
 
server.ListenAndServeTLS(certWeb, keyWeb)

在一个标准的TLS验证方案中,TLS客户端连接到TLS服务器的8080端口,并提供其“信任链”,其中包括客户端证书、根CA证书以及所有中间CA证书。TLS服务器处理TLS握手,并验证客户端证书,检查客户端是否可信(客户端证书是否由服务器信任的CA进行签名)。下图展现了TLS握手时的简化版流程:

1.png

通过Go的crypto/x509库,我们最终进入到x509/tls/handshake_server.go:doFullHandshake(),具体如下:

...
if c.config.ClientAuth >= RequestClientCert {
        if certMsg, ok = msg.(*certificateMsg); !ok {
                c.sendAlert(alertUnexpectedMessage)
                return unexpectedMessageError(certMsg, msg)
        }
        hs.finishedHash.Write(certMsg.marshal())
 
        if len(certMsg.certificates) == 0 {
                // The client didn't actually send a certificate
                switch c.config.ClientAuth {
                case RequireAnyClientCert, RequireAndVerifyClientCert:
                        c.sendAlert(alertBadCertificate)
                        return errors.New("tls: client didn't provide a certificate")
                }
        }
 
        pub, err = hs.processCertsFromClient(certMsg.certificates)
        if err != nil {
                return err
        }
 
        msg, err = c.readHandshake()
        if err != nil {
                return err
        }
}
...

在这里,服务器处理其收到的客户端证书,并调用函数x509/tls/handshake_server.go:processCertsFromClient()。如果必须要验证客户端证书,那么服务器会创建一个包含以下内容的VerifyOptions结构:

1、根CA池,配置为验证客户端证书的受信任CA列表,由服务器控制。

2、中间CA池,接收的中间CA列表,由客户端控制。

3、签名的客户端证书,由客户端控制。

4、其他字段,可选。

if c.config.ClientAuth >= VerifyClientCertIfGiven && len(certs) > 0 {
        opts := x509.VerifyOptions{
                Roots:         c.config.ClientCAs,
                CurrentTime:   c.config.time(),
                Intermediates: x509.NewCertPool(),
                KeyUsages:     []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
        }
 
        for _, cert := range certs[1:] {
                opts.Intermediates.AddCert(cert)
        }
 
        chains, err := certs[0].Verify(opts)
        if err != nil {
                c.sendAlert(alertBadCertificate)
                return nil, errors.New("tls: failed to verify client's certificate: " + err.Error())
        }
 
        c.verifiedChains = chains
}

要理解上述过程中哪里存在问题,我们需要首先了解证书池的组织方式,从而以有效的方式来验证证书。简而言之,证书池是一个证书列表,可以根据需要,通过三种不同的方式访问。下图展示了这一示例:池中分组的证书,可以通过索引数组(名为“Certs”)访问,并且由CN、IssuerName、SubjectKeyId进行哈希处理。

2.png

验证过程

服务器使用客户端证书上的VerifyOptions调用函数Verify()(链中的第一个证书:certs[0])。

然后,Verify()将根据提供的链,验证客户端证书。但是,必须首先使用buildChains()函数来构建并检查验证链:

var candidateChains [][]*Certificate
if opts.Roots.contains(c) {
        candidateChains = append(candidateChains, []*Certificate{c})
} else {
        if candidateChains, err = c.buildChains(make(map[int][][]*Certificate), []*Certificate{c}, &opts); err != nil {
                return nil, err
        }
}

buildChains()函数依次调用一些消耗CPU较高的函数,并在其找到的链的每个元素上递归调用其自身。

BuildChains()函数依赖于辅助函数findVerifiedParents(),该函数通过IssuerName或AuthorityKeyId,对证书池进行映射访问,从而标识父证书,并返回可选证书的索引,然后根据客户端控制的池,对其进行验证。

在正常情况下,IssuerName和AuthorityKeyId将会被填充,并且预计它们都是唯一的,只会返回一个证书来进行验证:

func (s *CertPool) findVerifiedParents(cert *Certificate) (parents []int, errCert *Certificate, err error) {
       if s == nil {
              return
       }
       var candidates []int
 
       if len(cert.AuthorityKeyId) > 0 {
              candidates = s.bySubjectKeyId[string(cert.AuthorityKeyId)]
       }
       if len(candidates) == 0 {
              candidates = s.byName[string(cert.RawIssuer)]
       }
 
       for _, c := range candidates {
              if err = cert.CheckSignatureFrom(s.certs[c]); err == nil {
                     parents = append(parents, c)
              } else {
                     errCert = s.certs[c]
              }
       }
 
       return
}

BuildChains()函数在客户端发送到TLS服务器的整个证书链上调用以下内容:

1、根CA池(服务器端)上的findVerifiedParents(client_certificate),用于查找已验证证书的签名机构(如果它是根CA),并检查证书的AuthorityKeyId(如果不为nil)或所有可能的原始颁发者的签名(如果为nil)。

2、中间CA池(客户端提供)上的findVerifiedParents(client_certificate),用于查找已验证证书的签名机构(如果它是根CA),并检查证书AuthorityKeyId(如果不为nil)或所有可能的原始颁发者的签名(如果为nil)。

3、获取负责签名的父级中间CA。

4、使用新找到的父级中间CA,调用buildChains(),并重复前面所述的整套签名检查流程。

3.png

DoS攻击方式

针对主CPU的DoS攻击,将由buildChains()和findVerifiedParent()函数在未预期的情况下触发,其中所有中间CA证书使用相同的名称,并且具有为nil的AuthKeyId值。findVerifiedParent()函数返回与该名称匹配的所有证书,也就是整个池,然后检查所有证书的签名。完成后,再次为找到的父级证书调用buildChains()函数,直至到达根CA。其中,每一次检查都会验证中间CA池的全部证书,因此只需要一次TLS连接,就可能会消耗掉全部可用的CPU。

4.png

漏洞影响

攻击者可以构建证书链,使客户端证书验证过程消耗掉所有CPU资源,从而降低主机的响应速度。上述影响只需要使用一个连接就可以实现。根据Go调度程序的规则,只有两个CPU核心会受到影响,并以100%占用率使用,创建新的连接,会强制调度程序分配更多资源来处理签名检查,这样一来可以导致整个服务或者整个主机无响应。

安全建议

Go语言社区建议通过实施以下更改,来缓解这一漏洞:

1、将签名检查移出findVerifiedParent()证书池查找过程;

2、将签名检查的数量限定在最多100个中间CA(这是一个不切实际的信任链)。

要实现针对这一漏洞的修复,需要立即升级到Go 1.10.6或更高版本,或者1.11.3或更高版本。

源链接

Hacking more

...