本文完整阅读约需 31 分钟,如时间较长请考虑收藏后慢慢阅读~

Let’s Encrypt作为一家免费提供SSL证书的组织,旨在推进互联网向更安全的HTTPS迁移,受到了大量小型网站的支持和认可。然而很多站长在3月3日收到了来自Let’s Encrypt名为ACTION REQUIRED: Renew these Let's Encrypt certificates by March 4的邮件,警告站长尽快更新证书。那么为什么需要更新证书?不更新证书有什么危害?如何更新证书?本文将为读者分析本次Let’s Encrypt证书漏洞事故的真相。

0x01 事故概览

首先摘录一下邮件中的部分内容:

We recently discovered a bug in the Let's Encrypt certificate authority code, 
described here:

https://community.letsencrypt.org/t/2020-02-29-caa-rechecking-bug/114591

Unfortunately, this means we need to revoke the certificates that were affected 
by this bug, which includes one or more of your certificates. To avoid 
disruption, you'll need to renew and replace your affected certificate(s) by 
Wednesday, March 4, 2020. We sincerely apologize for the issue.

If you're not able to renew your certificate by March 4, the date we are 
required to revoke these certificates, visitors to your site will see security 
warnings until you do renew the certificate. Your ACME client documentation 
should explain how to renew.

邮件大意为:Let’s Encrypt的证书校验代码中存在一个BUG,部分证书受到了这个BUG的影响。我们将会在3月4日(周三)开始吊销受影响的证书,如果你的证书在吊销列表中,请立即更新证书到最新版本。

那么问题来了:发生了什么BUG导致这一后果?证书吊销是什么?如何更新证书?这就是接下来要讲解的内容。

0x02 事故详情

首先我来讲解一下到底发生了什么BUG引起了如此大的事故。

根据邮件中的链接原文,Let’s Encrypt使用了自行研发的一款证书签发软件称为Boulder,但该软件在2019年7月25日引入了一个BUG,导致CAA记录认证出现错误。

原文如下:

On 2020-02-29 UTC, Let’s Encrypt found a bug in our CAA code. Our CA software, Boulder, checks for CAA records at the same time it validates a subscriber’s control of a domain name. Most subscribers issue a certificate immediately after domain control validation, but we consider a validation good for 30 days. That means in some cases we need to check CAA records a second time, just before issuance. Specifically, we have to check CAA within 8 hours prior to issuance (per BRs §3.2.2.8), so any domain name that was validated more than 8 hours ago requires rechecking.

The bug: when a certificate request contained N domain names that needed CAA rechecking, Boulder would pick one domain name and check it N times. What this means in practice is that if a subscriber validated a domain name at time X, and the CAA records for that domain at time X allowed Let’s Encrypt issuance, that subscriber would be able to issue a certificate containing that domain name until X+30 days, even if someone later installed CAA records on that domain name that prohibit issuance by Let’s Encrypt.

We confirmed the bug at 2020-02-29 03:08 UTC, and halted issuance at 03:10. We deployed a fix at 05:22 UTC and then re-enabled issuance.

Our preliminary investigation suggests the bug was introduced on 2019-07-25. We will conduct a more detailed investigation and provide a postmortem when it is complete.

1. CAA是什么?

那么CAA是什么呢?根据维基百科的解释,CAA全称为『DNS证书颁发机构授权』,用于避免非授权的证书生成。

看完定义,可能一些读者依旧对CAA的概念感到模糊。这里我们要明确一下证书的作用以及证书在SSL数据传输中所起的角色:

SSL数据传输有两个作用,一个是加密,这依靠的是非对称加密,准确说是公私钥构成的加密体系即公开密钥加密,避免互联网中传输的数据被中间人窃听;另一个是鉴证,也就是依靠数字签名证书链实现的身份鉴别,如果出现中间人对数据进行窃听或重定向,由于证书包含数字签名,这样客户就能区分什么证书是可信的、什么是不可信的。

互联网中大部分证书都是由可信第三方,即证书颁发机构(Certificate Authority)简称CA签发。但如果你不需要验证身份,只需要加密数据就可以使用不包含可信第三方的自签名证书,这时候证书的鉴证效果不再存在,只起到了加密的效果。大部分浏览器都不会承认自签名证书,因此会使用醒目的红色标识告诉用户:无法确定证书是否有效。

那么CAA到底是做什么用的呢?我举一个例子好了。如果我在甲CA凭借正当身份注册了example.com的证书,那么所有访问我网站的用户都可以通过证书中的信息(例如申请单位、签发单位等)了解这个证书的可信性。如果有攻击者企图拦截数据,就一定会破坏证书(比如使用自签名证书或降级到HTTP),对于自签名证书,前面提到了浏览器会拦截,而对于HTTP的降级同样有HSTS协议可以保证其绝对不可能发生。

但不要忘了,CA的本质也是企业。不同的CA之间不太可能共享信息,也就是说假如有一个乙CA对客户信息鉴别不充分,攻击者就可以在乙CA上假冒我的身份也注册一个example.com证书。这时候关于example.com的有效证书就有两份,攻击者可以在拦截数据后向用户返回来自乙CA的那一份证书,用户依旧无法鉴别是否遭受中间人攻击。

图1. 两个CA为同一个域名生成了两份证书

图2. 普通的中间人攻击,自签名证书将不被用户信任

图3. 如何使用图1的漏洞实现用户无感知的中间人攻击

从图中可以看出:不同的CA无法共享信息造成了中间人攻击的隐患,而这就是CAA存在的目的。

2. CAA有什么用?

CAA和A记录一样,都是DNS记录的一部分,如果一个CA接受到了证书生成的请求,它首先会访问这个域名在DNS中对应的CAA记录,查看其中包含的信息。如果CAA记录允许这个CA生成证书,它才会进行接下来的操作,否则将会拒绝证书申请。除此之外,CAA记录还支持在证书申请时告知特定邮箱(比如域名持有者),警惕持有者:有用户正在伪造你的身份。

CAA记录并非强制标准,但绝大多数的CA都遵守了这一规定,毕竟因为违反规定导致证书被滥用,浏览器厂商是有权利吊销CA的根证书的。举一个例子:中国沃通因为违规签发证书,导致其根证书被吊销,所有新签发的证书都不再得到主流操作系统和浏览器的承认,相关新闻可以查看这篇知乎问答。根证书被吊销将会毁灭一家CA的信誉和全部业务,这也是为什么CA如此少、证书申请如此麻烦、Let’s Encrypt官方对此次漏洞如此重视的主要原因。

0x03 事故分析

上面提到了,本次事故出在CAA部分代码。那么Let’s Encrypt应该如何校验CAA,又如何进行了错误校验呢?

根据官方说明:Let’s Encrypt的服务器会在用户申请证书的八小时内对证书对应域名的CAA记录进行检查,如果检查通过,接下来的30天内都不会对其进行重新检查。

这里的规则实际上不是Let’s Encrypt自己制定的,而是来源于CA/Browser Forum,一个制定CA和浏览器关于证书处理规范的论坛。CA/Browser Forum提供了一份规范(Baseline Requirements),要求所有CA按照规范中的内容进行证书签发和吊销,其中在§3.2.2.8:CAA Records要求了以下内容:

As part of the issuance process, the CA MUST check for CAA records and follow the processing instructions found, for each dNSName in the subjectAltName extension of the certificate to be issued, as specified in RFC 6844 as amended by Errata 5065 (Appendix A). If the CA issues, they MUST do so within the TTL of the CAA record, or 8 hours, whichever is greater.
This stipulation does not prevent the CA from checking CAA records at any other time.

大体上就是:CA需要在签发证书的八小时内对所签发域名的CAA记录进行核查,除此之外还可以在任何时间进行其他核查以进一步确保安全。

结合上面官方的说明可以了解到,Let’s Encrypt严格遵守了这一标准。但Let’s Encrypt的CA系统犯了一个错误,如果一个证书包含N个域名,CA系统应该对每个域名都单独进行CAA检查,结果却将N个域名中的某一个检查了N次,其他N-1个域名均未被检查而直接通过。

也就是说:如果攻击者发现了这一漏洞,它就可以通过申请多域名证书的方式来绕过CAA记录对证书申请的限制。举例而言,攻击者可以申请包含以下域名的证书:

  • example.com
  • some-domain-controlled-by-hacker.com
  • another-domain-controlled-by-hacker.com

在没有以上漏洞的情况下,CA软件会对三个域名的CAA进行检查,这时如果第一个域名的CAA记录拒绝Let’s Encrypt签发证书,签发流程会因此中止,攻击者无法得到证书。

但如果以上漏洞存在,CA软件可能只会对another-domain-controlled-by-hacker.comsome-domain-controlled-by-hacker.com进行CAA记录检查(而且是检查三次),因为这个域名被攻击者所控制,因此他可以允许Let’s Encrypt进行证书签发,这样就绕过了example.com的CAA记录限制。

当然,这并不意味着Let’s Encrypt的这一漏洞可以让攻击者随意伪造身份进行证书申请,因为解除CAA限制只是破除CA众多检查中的一个,Let’s Encrypt的HTTP验证、DNS验证分别需要对服务器或DNS进行实质性控制。

需要注意的是,利用难度大,也不意味着这一漏洞的存在是合理的。

首先Let’s Encrypt是一家CA,必须遵守相关规定,且为客户的安全负责(尽管客户并未付费);更重要的是,Let’s Encrypt并非一个独立组织,而是隶属于互联网安全研究小组,致力于增强全互联网的信息安全,作为互联网安全的推进者绝对不能首先破除规则。

其次,如果攻击者刚好拿到了服务器控制权,那么有CAA的限制,攻击者依旧无法成功申请证书。但如果Let’s Encrypt未能合理对CAA进行检查,即攻击者不仅发现了此漏洞,还拿到了服务器控制权,那么伪造身份将会变得易如反掌。至于控制DNS,攻击者完全可以删除CAA记录,因此引发的事故属于CA能力范围以外,CA也无需为此负责。


这就是为什么Let’s Encrypt对这一事故的处理如此严肃,甚至在事故发生后立刻关闭了受影响的两台CA服务器,还发布了所受影响300万个证书的Hash(压缩包高达300MB+),同时向所有在申请证书时附带邮件地址的用户紧急发送邮件。作为一家CA,Let’s Encrypt无疑是负责的;作为互联网安全研究小组的项目,Let’s Encrypt对事故的处理态度无疑也为其他CA起到了模范作用。

图4. Let’s Encrypt官方的服务中断公告,在事故发生后立刻关闭了受影响的CA服务器

图5. 用户反馈申请了100个域名的证书后,发现出现了100次一模一样的报错,所有报错都因为其中
一个域名的CAA记录不允许Let’s Encrypt签发证书。
而Let’s Encrypt收到用户的BUG反馈后立刻意识到这是一个安全事故,进行了相关处理。

0x04 一行Golang代码引发的血案

Let’s Encrypt的态度无疑让人对其肃然起敬,但这并不意味着Let’s Encrypt不需要为此负责。

阅读完上面的事故分析,可能还是有很多读者不清楚:明明应该校验每个域名,到底是什么BUG导致了Let’s Encrypt只校验了其中一个呢?

在文章的最开始,我提到了Let’s Encrypt使用了一款叫做Boulder的软件。其实这是一款开放源代码的软件,地址为letsencrypt/boulder

该软件使用Golang开发,旨在实现一个ACME协议的CA服务器,Let’s Encrypt的官方CA服务器运行着该软件。

那么这个软件到底出现了什么问题才会导致如此滑稽的故障?我翻看着Let’s Encrypt最近的commit,找到了一个Pull Request:#4690。看完这个Pull Request后,我马上意识到问题所在:Golang最经典的错误——循环迭代变量陷阱。

对于不熟悉Golang的读者,可能不知道我在说什么,这里我使用C语言举一个例子:

int main() {
    int* arr[3];
    for (int i = 0; i < 3; i++) {
        arr[i] = &i;
    }
    printf("%d %d %d", *arr[0], *arr[1], *arr[2]);
    return 0;
}

大部分读者应该都熟悉C语言,应该可以看出上面的例子返回的结果是3 3 3而非1 2 3,因为arr的三个元素都是i的地址,而i最终的值为3。

作为『21世纪的C语言』,Golang同样存在这一问题:

func main() {
    var out []*int
    for i := 0; i < 3; i++ {
        out = append(out, &i)
    }
    fmt.Println("Values:", *out[0], *out[1], *out[2])
    fmt.Println("Addresses:", out[0], out[1], out[2])
}

输出结果为:

Values: 3 3 3
Addresses: 0x40e020 0x40e020 0x40e020

由于这一问题过于普遍,Golang甚至将其写入了文档的『常见错误』部分:文档

而这一『常见错误』,就出现在Let’s Encrypt的代码中。

我们倒回这个Pull Request之前的代码,来看看这一错误如何在Boulder中重现:

// authzModelMapToPB converts a mapping of domain name to authzModels into a
// protobuf authorizations map
func authzModelMapToPB(m map[string]authzModel) (*sapb.Authorizations, error) {
    resp := &sapb.Authorizations{}
    for k, v := range m {
        // Make a copy of k because it will be reassigned with each loop.
        kCopy := k
        authzPB, err := modelToAuthzPB(&v)
        if err != nil {
            return nil, err
        }
        resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
    }
    return resp, nil
}

// ...

func modelToAuthzPB(am * authzModel)( * corepb.Authorization, error) {
    expires: = am.Expires.UTC().UnixNano()
    id: = fmt.Sprintf("%d", am.ID)
    status: = uintToStatus[am.Status]
    pb: = & corepb.Authorization {
            Id: & id,
            Status: & status,
            Identifier: & am.IdentifierValue,
            RegistrationID: & am.RegistrationID,
            Expires: & expires,
        }
        //...
}

看到这里,眼尖的读者可能已经意识到问题了。对于循环变量k,该函数拷贝了一份(甚至还贴心的加了一个注释),然后再在resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})将其以引用的方式传递出去。需要注意的Golang对于重复声明的变量会使用不同地址,因此每次循环传递出去的地址都不一样。

但滑稽的是,另一个循环变量v却未能得到宠幸,开发者不知道什么原因忘记对其进行拷贝。代码中的authzPB, err := modelToAuthzPB(&v)这部分,传递出去的是未经复制的引用,造成了resp中所有的authzPB数据都被设定为循环的最后一个v,其中包含对应域名的所有信息。

更多代码可以在这里看到。

那么这个BUG是如何引入的呢?我使用git blame对附近的代码进行检查,发现这段代码在2019年4月24日随着Pull Request #4134带入。

这次Pull Request新增代码量高达2750行,而且几乎全是新增功能,在测试不充分的情况下的确容易将这一BUG遗漏。有趣的是:2019年引入BUG的作者和2020年Merge对应代码的人是同一人,即@rolandshoemaker

看来就算是顶尖的程序员,也无法保证写出完全没有BUG的软件🤣。

0x05 解决事故

写到这里,相信大家应该对这次事故有着非常详细的了解了。接下来我们要谈的是如何解决此次事故的影响。

根据官方描述,此次受到影响的域名签发日期在2019-12-04到2020-02-29之间。你可以在浏览器中点击域名左边的小锁图标来查看签发时间:

如果你的域名签发时间在此日期之外,那么基本无需担心,但如果签发时间在此日期之内,请接着往下读:

对于收到警告邮件的读者,请留意邮件中的域名。我收到的邮件中就有我自己博客的域名。

如果没有收到警告邮件,但不确定自己的域名是否受影响,有两种方式可以验证:

  1. 这里下载所有受影响证书列表,解压后在命令行执行以下代码获取域名对应证书hash,再在列表进行查询。
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | openssl x509 -text -noout | grep -A 1 Serial\ Number | tr -d :
  1. 这个网站输入你的域名即可检查域名所带证书是否受到影响。

我个人推荐第二种,第一种更适合代理分发Let’s Encrypt证书的第三方如宝塔面板等。

如果你的域名不在受影响范围内,不用进行任何操作。不过其实就算是在受影响范围内也无需进行操作,因为这一次吊销列表实在太大,几乎所有的浏览器都不会根据这一列表对证书进行吊销,而Let’s Encrypt的证书三个月之后就会过期,过期后重新申请是不会出现任何问题的。

但本着安全起见,最好使用你的证书申请客户端对证书进行强制重申请。这里我以certbot-auto为例:

certbot-auto renew --force-renewal

执行后所有的证书都会重新进行签发。

签发完成后,记得重启你的Web服务器如Nginx,以确保新的证书被正确装载,这样就能让自己的域名彻底免遭此次事故影响。

0x06 避免事故

事故状态更新的帖子下面,Let’s Encrypt官方向用户保证了接下来将会对其他模块进行同样的安全检查:

  1. Improve TestGetValidOrderAuthorizations2 unittest (3 weeks).
  2. Implement modelToAuthzPB unittest (3 weeks).
  3. Productionize automated logs verification for CAA behavior (8 weeks).
  4. Review code for other examples of loop iterator bugs (4 weeks).
  5. Evaluate adding stronger static analysis tools to our automated linting suite and add one or more if we find a good fit (4 weeks).
  6. Upgrade to proto3 (6 months).

作为一家CA,我们其实无需过多担心Let’s Encrypt今后是否会出现类似事故,因为它们对于这次事故的重视程度实在令人惊叹,这也是我决定撰写这篇文章的原因。

经历此次事故后的Let’s Encrypt不仅没有像其他CA一样损失用户,反而更进一步赢得了用户的信赖:无论是开源CA服务器、是公开服务状态、是主动复盘故障、是故障后立刻停止服务且提示受影响用户还是快速解决问题,这都让Let’s Encrypt与其他不负责任的CA不同。

的确,Let’s Encrypt的SSL证书只起到了加密的作用,对于鉴证不如其他商业CA有效,但在过去的数年里,Let’s Encrypt用自己的行动让全互联网变得更加安全——三年前我还在吐槽各个浏览器使用『令人恶心』的方式警告HTTP站点,三年后竟很难找到一个不是HTTPS的网站。

这就是互联网开放之魅力:任何人都可以参与到互联网基础设施的建设中,而这恰恰是在其他传统行业所很难见到的。开放意味着人人平等,意味着每个人都可以发现问题、可以参与到问题的分析中、甚至可以帮助解决问题。互联网的建设者们也和现实世界的官僚不同,他们很少表露出傲慢,无论是规范的制定、是开放源代码软件的开发还是社区的讨论,你的贡献和你所得到的声望永远是对等的。

互联网为什么如此有魅力?魅力在于人人生来平等。


那么,对于我们普通用户,这次事故有哪些值得吸取的教训呢?

最浅显的教训应该是在申请证书时附上自己正确的邮箱地址。在我收到这一封邮件后,我和其他几个好友分享了邮件内容,他们却表示申请时乱填了一个地址,导致没有收到警告。这一次事故可能比较小,但如果下一次事故是可以无条件伪造身份呢?

目前的邮箱都有着很复杂的spam识别规则,因此我的建议不仅是在申请证书时,而是在进行任何注册操作时都附上正确邮箱地址。不用担心spam骚扰——按规则将它们拉入垃圾箱即可。

可能一些读者还会吸取另一个教训:对自己的域名在DNS中增加CAA记录:这是一个非常好的习惯,但如果是小型网站,在确保不包含关键业务,且没有潜在竞争者情况下,基本上无需担心。

但我觉得教训应该不止于此。在我从事软件开发工作的两年半时间里,很多比我资历还要久的前辈经常会感到困惑:为什么我能在这么短的时间里学习这么多东西?我的回答其实很简单:不要放过任何一点细节。

我其实不是非常聪明的人,我一直以来对自己的要求就是『笨鸟先飞』,我在算法、理论、计算机基础方面相比其他同龄从业者都属于底层。但我永远相信勤能补拙,我坚信学习的力量,认为肤浅的了解不如不学,认同终生学习。

实际上在写到这里的时候(本文从收到邮件开始撰写,花了三天时间撰写+校对,写到这里的时候是3月7日)我查了一下国内外的新闻。事故发生已经超过三天,居然没有一个媒体/博客对这个事故的根源进行分析!这太令人感到遗憾了。

但我相信,读到这里的读者一定已经收获了很多新知识。写到这里的我同样学习了非常多——在写这篇文章之前,我其实对于SSL证书的概念处于一知半解状态,于是我边写边理顺思路,边写边查资料,全文写完之后,我对于SSL证书的理解就已经完全不同于写作前。阅读和写作都是学习的一种过程,前者被动学习、后者主动学习,除此之外并无高下之分。

是否只有阅读和写作才能帮助自己?不完全是。因为日常事务太多,我的博客中所分享的其实是日常所掌握知识的很少一部分,也很少阅读那些『计算机经典书籍』。

而学习应该是随时随地的,是不放过任何一点细节的,是不管内容是否与自己所从事事业相关,只要是未知领域都敢于探索的。如果只是为了从事某项特化的岗位而成为软件工程师,未免太没劲了。

有一句成语叫做『求贤若渴』,我想把它改造一下,作为本文的结束,那就是:

求学若渴

感谢读者们能耐心读完本文,也希望读到这里的读者能有所启发。