原创分享 一行 Golang 代码引发的血案——全网最详细分析 2020 年 3 月 Let’s Encrypt 证书吊销事故

originator · 2020年03月08日 · 最后由 codjust 回复于 2020年03月12日 · 3217 次阅读
本帖已被设为精华帖!

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 凭借正当身份注册了http://example.comHTTP),对于自签名证书,前面提到了浏览器会拦截,而对于 HTTP 的降级同样有 HSTS 协议可以保证其绝对不可能发生。的证书,那么所有访问我网站的用户都可以通过证书中的信息(例如申请单位、签发单位等)了解这个证书的可信性。如果有攻击者企图拦截数据,就一定会破坏证书(比如使用自签名证书或降级到

但不要忘了,CA 的本质也是企业。不同的 CA 之间不太可能共享信息,也就是说假如有一个乙 CA 对客户信息鉴别不充分,攻击者就可以在乙 CA 上假冒我的身份也注册一个http://example.comhttp://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.com 或 some-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,再在列表进行查询。 go 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 :
  2. 这个网站输入你的域名即可检查域名所带证书是否受到影响。

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

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

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

certbot-auto renew --force-renewal

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

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

0x06 避免事故

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

Improve TestGetValidOrderAuthorizations2 unittest (3 weeks).
Implement modelToAuthzPB unittest (3 weeks).
Productionize automated logs verification for CAA behavior (8 weeks).
Review code for other examples of loop iterator bugs (4 weeks).
Evaluate adding stronger static analysis tools to our automated linting suite and add one or more if we find a good fit (4 weeks).
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 证书的理解就已经完全不同于写作前。阅读和写作都是学习的一种过程,前者被动学习、后者主动学习,除此之外并无高下之分。

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

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

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

求学若渴

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

原文地址:https://untitled.pw/software/1846.html

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 03月08日 15:39

虽然结论是正确的,但其实这个的问题并不是在于引用了 v,而是在于 modelToAuthzPB 里面有的 field 进行了 copy,有的却直接引用了。

pb: = & corepb.Authorization {
        Id: & id, # ok 
        Status: & status, # ok 
        Identifier: & am.IdentifierValue, # bad  
        RegistrationID: & am.RegistrationID, # bad
        Expires: & expires, # ok 
}

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

resp 中的 authzPB 并不是都是最后一个,只是 pb 中的指针字段都是最后一个 v 的指针字段值。

对于 for 循环中,如果不是循环之后使用或者启动了 goroutine,并不用总是要 copy 的,但确实在日常开发中减少不必要的指针传递,确实会少点 bug,另外单元测试不能只是为了覆盖率,不然写了也没用的。

for 循环如果嵌套了调用其他方法,并且该方法的参数是引用类型,那就要 copy,因为不知道那个方法内部是怎么使用这个引用的

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册