一行 Golang 程式碼引發的血案——全網最詳細分析 2020 年 3 月 Let’s Encrypt 證書吊銷事故

originator發表於2020-03-08

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

更多原創文章乾貨分享,請關注公眾號
  • 一行 Golang 程式碼引發的血案——全網最詳細分析 2020 年 3 月 Let’s Encrypt 證書吊銷事故
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章