ぼちぼち日記

おそらくプロトコルネタを書いていることが多いんじゃないかと思います。

Let's EncryptがはまったGolangの落とし穴

0. 短いまとめ

300万以上の証明書の失効を迫られたLet's Encryptのインシデントは「Golangでよくある間違い」と書かれているようなバグが原因でした。

1. はじめに、

Let's Encryptは、無料でサーバ証明書を自動化して発行するサービスを行う非営利団体として2014年に設立されました。

2015年にサービス開始されると証明書の発行数はぐんぐん伸び、先月末のプレスリリースでは累計10億枚のサーバ証明書を発行したことがアナウンスされました「Let's Encrypt Has Issued a Billion Certificates」。CTLogの調査から、2020年2月末の時点では有効な全証明書の38.4%がLet's Encryptの証明書であるとみられています「Certificate Validity Dates」。

無料の証明書を提供してもらえるのは非常に嬉しいのですが、認証局の業務やシステムの運用には当然大きなコストが掛かります。私も正式公開前のベータサービスの時から個人ドメインでLet's Encryptの証明書を利用し始めましたが、はたして寄付金だけを頼り、非営利で無料のままこのようなサービスを長期に継続して提供できるのだろうか?

正直言ってこのプロジェクトを初めて聞いた時 Let's Encrypt の先行きに少し不安を持っていました。もしくは将来、有料のEV証明書とかをきっと売り始めるだろうとも予想していました。

それからもう4年半も経ちました。Let's Encryptが主体となってドメイン認証と証明書発行の自動化を行うACME(Automatic Certificate Management Environment)プロトコルの仕様化も完了し、現在はACME v2が運用中です。 これによってDV証明書限定になりますが、従来の認証局が積極的に進めようとしなかった証明書発行システムの完全自動化に成功し、現在わずか13名のフルタイムスタッフと年間USD3.35M(約3億5千万円)の予算でLet's Encryptのシステムが運用され、大規模な証明書発行サービスを実現できています。本当に驚きです。

日本企業からは、時雨堂さん・さくらインターネットさんなどがLet's Encryptのスポンサーとして貢献されています。もうホント感謝しかありません。

Let's Encryptは間違いなく世界のWebサービスのHTTPS化を大きく進めたもの思います。

しかしこういった高い貢献に対する評価の反面、Let's Encryptによって無料証明書を使ったHTTPSのフィッシングサイトが大幅に増加しているといった負の側面も指摘されています。

これはLet's Encryptだけが悪いとは思いません。結局Web PKIをめぐる歴史的経緯の中で生まれた様々な歪がLet's Encryptによって今あぶり出されたものだと私は思っています。

2. Let's Encryptのインシデント

そんな今では世界1位のシェアを持つLet's Encryptですが、先日証明書発行に関するインシデント発生のアナウンスがCommunity Supportに投稿されました「2020.02.29 CAA Rechecking Bug」。 Mozillaには「Let's Encrypt: CAA Rechecking bug」のチケットで報告されています。

この報告によると、Let's Encryptがgithub上で開発を続けている認証システム boulder でバグが見つかり、一部の証明書でドメインのCAA(Certification Authority Authorization)を再チェックせずに発行してしまったとのこと。

証明書の発行の際に記載ドメインすべてのCAAレコードをチェックすることは、CA/Browser Forum のBR(Baseline Requirements)を基としたLet's EncryptのCP(Certificate Policy)に規定されており、証明書発行時の必須要件です。

この規定要件に反して証明書発行が行われた場合、以下のCP規定に従い5日以内に対象の証明書を失効させなければなりません。

4.9.1.1 Reasons for revoking a subscriber certificate
The CA SHOULD revoke a certificate within 24 hours and MUST revoke a Certificate within 5 days if one or more of the following occurs:
7. The CA is made aware that the Certificate was not issued in accordance with these Requirements or the CA's Certificate Policy or Certification Practice Statement;

4.9.1.1 加入者の証明書を取り消す理由
CAは次のインシデントが1つ以上が発生した場合、24時間以内に証明書を取り消すべきであり(SHOULD)、5日以内に証明書を取り消さなければなりません(MUST)。
7. 証明書がここに記載されている要件またはCAの証明書ポリシー・認証実施規定に従って発行されていないことをCAが認識した時

証明書の失効方針とユーザへ証明書の再発行をメールで要請したことについて、「Revoking certain certificates on March 4」 のアナウンスも直ちに投稿されました。

対象となったのはおよそ305万の証明書、Let's Encryptから発行済みで有効な証明書のおよそ2.8%にあたる多さです。これをインシデント発見から5日以内、2020年3月5日3:00(UTC)までに全部失効させなければなりません。

Let's Encryptからの要請を受け、この期限日までにユーザによって170万以上の証明書が再発行されました。

しかしまだ130万証明書証明書が未更新のままで、その半数以上(約65%)が現在利用中であることがわかりました。このまま稼働中の証明書を強制的に失効させると、多数のWebサービスに重大な影響を与えることが予想されます。

結局、Let's Encryptはその影響度を考慮し、CP規定の5日以内に未更新の証明書を失効させることを止め、残り83日のExpireを待つ方針としました。引き続き証明書モニターと連絡を継続。今後この様な大規模インシデントに対応できるよう、失効通知するプロトコルの開発を進めるということです「Let's Encrypt: Incomplete revocation for CAA rechecking bug」。

この方針に対して各ブラウザーベンダーが今後どう反応するのか、気になるところです。

今回のインシデントは、Go言語で開発されているboulderのバグによるものです。このバグの詳細についてIncident Reportで細かく言及されていました。

このレポートを読んでみると、驚いたことにこのバグは、GoのWikiページで「CommonMistakes/Using reference to loop iterator variable(よくある間違い/iterator変数をloopする際に参照を使う場合)」で書かれてある間違いが直接の原因でした。

Wikiではこの間違いを、

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])
}

のようなコードで実例として挙げています。

これは、ループ内でループ変数iの参照を配列に入れてしまうことで、ループ終了後の出力値が

$ ./test_gomistake
Values: 3 3 3
Addresses: 0xc0000160a0 0xc0000160a0 0xc0000160a0

のように配列が全て同じ値になってしまう問題です。

for や range などで扱うループ変数が同じ参照になることを知っていないとやってしまいそうな初心者的な間違いです。

しかし実際にboulderコードを読むと、Let's Encryptのエンジニアは決してこの間違いを知らなかったわけではなく、ある変数ではきちんと対応していたのに他の変数ではうっかりこの間違いを見逃してしまったことが原因のようでした。

これはひょっとしたら自分もいつかこのようなバグを仕込んでしまうかも、と背筋が寒くなりました。

今回、300万以上の証明書を失効させるほどの要因となったCAAとはどういうものなのか? なぜ再チェックが必要なのか? このGoではよくある問題と書かれている程のバグは、なぜどのように発生したのか? について、このブログでまとめてみたいと思います。

3. CAAとはなにか?

CAA(Certification Authority Authorization)は、ドメインの管理者がDNSレコードに証明書発行を許可する認証局情報を記載し、証明書の不正発行や誤発行を防ぐ技術です。

認証局による証明書の誤発行や不正発行が問題となった2013年にRFC6844でCAAの仕様化が行われました。2019年11月に探索アルゴリズムのバグ修正をした改訂版RFC8659が発行されています。

ブラウザベンダや認証局が参加するCA/Browser Forumの規約で2017年9月よりCAAがサポートすることが必須化されました。この規定により、証明書発行する際には必ずCAAレコードのチェックが入ります。

認証局は、証明書発行時に証明書の subjectAltName フィールドに記載されている全てのドメインに対してCAAのチェックを行わなければなりません。

CAAレコードに記載されているドメインが自社指定のものであるなら証明書を発行することができますが、そうでなければ発行せずエラーを返します。

だからと言って焦ってドメインにCAAレコードを追加する必要はありません。CAAレコードの登録自体はオプション扱いです。CAAレコードが引けなかった場合でも証明書は発行されます。 「CAAレコードが設定されておらず、存在しなかった場合も証明書は発行されます。」

2020年3月9日18:00更新 DNSエラー時のCAAチェックについて

Yasuhiro Morishita (@OrangeMorishita) | TwitterさんよりDNSエラー時の説明が間違っていることを指摘していただきました。ご指摘の通りですので記載内容を取り消しました。

この辺、私が規定を読み間違え、コードの確認も怠っていました。詳細については以下のtogetterをご確認ください。 ご指摘本当に感謝いたします。 togetter.com

次の図では、普段 example.com ドメインが認証局1(ca1.example.net)から証明書発行を受けている場合を例にします。 f:id:jovi0608:20200309042603j:plain example.com のドメイン管理者は、DNSサーバに ca1.example.net のCAAレコードを登録し、認証局1から証明書の発行が可能であることを示しておきます。

攻撃者は、別の認証局2に対してなんらかの穴をついて www.example.com の証明書を発行しようとします。その際認証局2は、 www.example.com のCAAレコードをチェックにいきます。

CAAレコードでは www.example.com の証明書発行可能であるのは認証局1だけであることが記載されているため、認証局2では www.example.com の証明書発行要求を拒否してエラーとして返します。

よって攻撃者による www.eample.com の不正証明書入手は失敗に終わります。

CAAのレコードに記載するデータフォーマットは以下のような項目が入ります。 f:id:jovi0608:20200309042559j:plain 例えば実際のドメインでは以下のようなCAAレコードが登録されているのがわかります。 f:id:jovi0608:20200309042625j:plain このドメインは、CyberTrust、DigiCert、GlobalSignの認証局3社からしか証明書が発行できないよう指定されているのがわかります。

SSL Labs の統計では、2020年3月3日時点で7.1%のサイトがCAAの設定をしていると報告されています。やはりオプション扱いなので、まだそれほど高い普及率とは言えません。

CAAで証明書の不正発行や誤発行をすべて防ぐことはできません。むしろ限定されたケースでしかCAAによって守ることはできないと言っていいでしょう。

例えば、認証局システムのCAAチェックを無効化するまで完全に乗っ取られたり、(DNSSECを利用していない場合に)DNSへの攻撃などを合わせられるようなケースに対してはCAAは無力です。

実際にCAAを設定しているのにドメインレジストラへの攻撃によって証明書の不正発行防げなかった事例がありました。

Googleのドメインには自社認証局 pki.goog の CAA が設定されていますが、2017年にGoogleのtg(トーゴ)ドメイン(google.tg)がLet's Encryptから不正発行が発覚しています。 https://crt.sh/?id=245397170 f:id:jovi0608:20200309042607j:plain この証明書は直ちに失効されました。

RFCではアプリケーションがCAAレコードを参照して証明書を検証することを禁止しています。そのためCAAレコードを付与することによってサービスが直接影響を受ける可能性は非常に少ないです。

サービスに与えるリスクが少なく、手軽に証明書の不正発行の対策ができることがCAAのメリットです。ただドメインにCNAMEが付与されている場合、CAAレコードの探索が少し複雑になりますので注意しましょう。

4. Let's Encryptの証明書発行を支えるboulder

boulder のアーキテクチャを下図に示します。以下 github repoのdocumentから引用した図を付けています。 f:id:jovi0608:20200309042555p:plain boulderのシステム要素を全て解説するわけにいきませんので、今回関連する部分だけを書きます。各システム要素(Authority)は protocol buffer v2 を使った gRPC で連携して動作しています。

ACME v2 ではおおよそ以下のステップで証明書発行を行います。

  1. Subscriber(ユーザ or クライアント)は、letsencrypt(旧certbot)コマンド等を通じて ACME v2プロトコルを使ってWFE(Web Front End)サーバと通信します。
  2. ユーザ認証系が整っていればクライアントは、証明書発行のための Order をWFE経由でRA(Registration Authority)に送ります。
  3. http-01とかdns-01等の指定された Challenge 形式の手順に従い、RA経由でVA(Validation Authority)がユーザへのサーバにアクセスしてドメイン認証(Challenge Response)を行います。
    この時VAは、発行ドメインのCAAレコードをチェックして証明書発行が許可されているかどうかを確認します。 f:id:jovi0608:20200309042547j:plain ここで一度認証されたドメイン認証情報は、Let's Encryptの場合30日間有効です。FAQ Technical Questionには以下の通り記載されています。

    Once you successfully complete the challenges for a domain, the resulting authorization is cached for your account to use again later. Cached authorizations last for 30 days from the time of validation. If the certificate you requested has all of the necessary authorizations cached then validation will not happen again until the relevant cached authorizations expire
    ドメインに対するチャレンジが正常に完了すると、認証された結果がアカウントに対してキャッシュされ、後で再び使用できるようになります。 認証のキャッシュは検証時から30日間継続します。 発行要求した証明書が必要な認証を全てキャッシュされている場合には、その認証が期限切れになるまでvalidationは再度行われません。

  4. クライアントはWFEとRAを通じてCA(Certificate Authority)に証明書の発行要求(Order Final)を行います。この際ドメイン認証から8時間以上経っていればRAは再度CAAのチェックを行います。 f:id:jovi0608:20200309042552j:plain なぜ8時間以上経ったらRAによるCAAの再チェックが必要なのか? 実はCPの下記規定により、CAAチェック結果は最大でも8時間しか有効とみなせない規定があるからです。

3.2.2.8. CAA Records If the CA issues, they must do so within the TTL of the CAA record, or 8 hours, whichever is greater.

CAが発行する際は、CAAレコードのTTLまたは8時間のいずれか大きい方の時間内で証明書を発行しなければならない。

ドメイン認証は30日間有効ですので、ドメイン認証から8時間後を過ぎるとその時のCAAチェック結果は無効になります。ドメイン認証から8時間後かつ30日以内で証明書発行を要求されると、ドメイン認証でのCAAチェックはスキップされるので、RAは再度CAAをチェックしないといけないわけです。

5. boulderのバグ

今回バグは、当初ユーザからエラーが99個の同一のメッセージを出しているとのレポートによって発覚しました「Rechecking caa fails with 99 identical subproblems」。100のドメインを含んだ証明書発行を要求した際に、同一ドメインのCAAのrecheckエラーが99個含まれたメッセージが返ってきたのです。

このissueに反応した別のコミュニティユーザが現象を確認し、Let's Encryptスタッフに対して以下の問いかけます。

any confirmation? (I’m wary that this might actually be possible to apply as a CAA re-checking bypass … maybe I should delete and send to security@ …)

確認できます? (これが実際にCAAの再チェックをバイパスできるかもしれないと心配しています… おそらくこれを削除して security@ に連絡すべきかもしれませんが…)

なんと聡明なユーザでしょう。ですが残念なことにLet's Encryptのスタッフは当初これをエラー表示の問題と捉えていました。しかし数日後パッチを作って確認している際に間違いであることに気づきました。

問題はRAがOrder Finalの際にCAAの再チェックを行うところです。そこではRAとSA(Storage Authority)の間のgRPCで以下のやり取りがされていました。 f:id:jovi0608:20200309042612j:plain

  1. RAは、クライアントからの証明書発行要求(Order Final)を受け認証情報を確認します(checkAUthorizations)。その際RAは、CSRのsubjectAltNameに記載されている各ドメインの認証情報をSAに対してgRPCを通じて入手します(GetValidAuthorizations2)。
  2. SAは、自身が管理している認証情報(AuthzModel)をProtocol Buffer v2形式にして、ドメインと認証情報のMapを配列に入れRAに返します(authzModelMapToPB, modelToAuthzPB)。
  3. RAは、その認証情報に記載されているドメイン情報を見てCAAレコードをチェックします(checkAuthorizationCAA/recheckCAA)。

バグに関連する該当部分のコード(authzModelMapToPB, modelToAuthzPB)を示します。説明がわかりやすくなるよう便宜的に行番号をふっています。また一部説明に関係ない部分のコードを省略しています。

1.func modelToAuthzPB(am *authzModel) (*corepb.Authorization, error) {
2.      expires := am.Expires.UTC().UnixNano()
3.      id := fmt.Sprintf("%d", am.ID)
4.      status := uintToStatus[am.Status]
5       pb := &corepb.Authorization{
6.              Id:             &id,
7.              Status:         &status,
8.              Identifier:     &am.IdentifierValue,
9.              RegistrationID: &am.RegistrationID,
10.             Expires:        &expires,
11.     }
12.    (snip)
13.     return pb, nil
14.}
15.
16. // authzModelMapToPB converts a mapping of domain name to authzModels into a
17. // protobuf authorizations map
18. func authzModelMapToPB(m map[string]authzModel) (*sapb.Authorizations, error) {
19.     resp := &sapb.Authorizations{}
20.     for k, v := range m {
21.             // Make a copy of k because it will be reassigned with each loop.
22.             kCopy := k
23.             authzPB, err := modelToAuthzPB(&v)
24.             if err != nil {
25.                     return nil, err
26.             }
27.             resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
28.     }
29.     return resp, nil
30.}

ありました「よくある間違い/iterator変数をloopeする際に参照を使う場合」です。20行目から28行目に渡るfor loopが該当します。

authzModelMapToPBに渡されたmap m の key k と value v を参照しています。
kに関しては、ちゃんとバグにならないよう kCopyに代入して別の参照に渡しています。
vに関しては、modelToAuthzPBに参照を渡していますが一見問題がなさそうです。でも渡されたmodelToAuthzPBにおいてIdentifierとRegistrationIDのフィールドにvの参照を渡しています。そしてその返り値を配列に代入しています。

この部分だけ切り出して動作するようにして試してみます(動作が変わらない程度に若干コードに変更をかけています)。

package main
import (
        "fmt"
        "time"
        apb "./proto"
        )

type authzModel struct {
        ID               int64
        IdentifierType   uint8
        IdentifierValue  string
        RegistrationID   int64
        Status           uint8
        Expires          time.Time
}

func modelToAuthzPB(am *authzModel) (*apb.Authorization, error) {
        expires := am.Expires.UTC().UnixNano()
        id := fmt.Sprintf("%d", am.ID)
        status := "valid"
        pb := &apb.Authorization{
                Id:             &id,
                Status:         &status,
                Identifier:     &am.IdentifierValue,
                RegistrationID: &am.RegistrationID,
                Expires:        &expires,
        }
        // snip
        return pb, nil
}

func authzModelMapToPB(m map[string]authzModel) (*apb.Authorizations, error) {
        resp := &apb.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, &apb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
        }
        return resp, nil
}

func main() {
        authzModels := [...]authzModel{
                authzModel{1,1,"www.example1.com",1,1, time.Date(2020, time.January, 1, 1, 1, 1, 1, time.UTC)},
                authzModel{2,2,"www.example2.com",2,2, time.Date(2020, time.February, 2, 2, 2, 2, 2, time.UTC)},
                authzModel{3,3,"www.example3.com",3,3, time.Date(2020, time.March, 3, 3, 3, 3, 3, time.UTC)},
        }
        authzModelMap := make(map[string]authzModel)
        for _, am := range authzModels {
                authzModelMap[am.IdentifierValue] = am
        }
        resp, _ := authzModelMapToPB(authzModelMap)
        fmt.Printf("%+v, Identifier:%p, RegistrationID:%p\n", resp.Authz[0], resp.Authz[0].Authz.Identifier, resp.Authz[0].Authz.RegistrationID)
        fmt.Printf("%+v, Identifier:%p, RegistrationID:%p\n", resp.Authz[1], resp.Authz[1].Authz.Identifier, resp.Authz[1].Authz.RegistrationID)
        fmt.Printf("%+v, Identifier:%p, RegistrationID:%p\n", resp.Authz[2], resp.Authz[2].Authz.Identifier, resp.Authz[2].Authz.RegistrationID)
}

ここでは、テスト用のmapデータwww.example[1-3].comの3つのドメインを渡された場合を模擬しています。

実行してみます。

$ ./le_bug
domain:"www.example1.com" authz:<id:"1" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1577840461000000001 > , Identifier:0xc0000c8060, RegistrationID:0xc0000c8070
domain:"www.example2.com" authz:<id:"2" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1580608922000000002 > , Identifier:0xc0000c8060, RegistrationID:0xc0000c8070
domain:"www.example3.com" authz:<id:"3" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1583204583000000003 > , Identifier:0xc0000c8060, RegistrationID:0xc0000c8070

あぁ、identifier と registrationIDは同じ参照になっているため同じ値(www.example3.com, 3)になっています。recheckCAAはregistrationIDを参照するため、これではwww.example3.comの1ドメインしかCAAの再チェックを行いません。バグが再現できました。

このバグは、次のPR(Pass authzModel by value, not reference)で修正されました。単純に参照渡しを値渡しに変えるだけです。ここでも同じ修正をしてみます。

diff --git a/main.go b/main.go
index e4fa2a1..828401d 100644
--- a/main.go
+++ b/main.go
@@ -15,7 +15,7 @@ type authzModel struct {
         Expires          time.Time
 }

-func modelToAuthzPB(am *authzModel) (*apb.Authorization, error) {
+func modelToAuthzPB(am authzModel) (*apb.Authorization, error) {
         expires := am.Expires.UTC().UnixNano()
         id := fmt.Sprintf("%d", am.ID)
         status := "valid"
@@ -36,7 +36,7 @@ func authzModelMapToPB(m map[string]authzModel) (*apb.Authorizations, error) {
         for k, v := range m {
                 // Make a copy of k because it will be reassigned with each loop.
                 kCopy := k
-                authzPB, err := modelToAuthzPB(&v)
+                authzPB, err := modelToAuthzPB(v)
                 if err != nil {
                         return nil, err
                 }

試してみましょう。

$ ./le_bug
domain:"www.example1.com" authz:<id:"1" identifier:"www.example1.com" registrationID:1 status:"valid" expires:1577840461000000001 > , Identifier:0xc0000b8060, RegistrationID:0xc0000b8070
domain:"www.example2.com" authz:<id:"2" identifier:"www.example2.com" registrationID:2 status:"valid" expires:1580608922000000002 > , Identifier:0xc0000b8100, RegistrationID:0xc0000b8110
domain:"www.example3.com" authz:<id:"3" identifier:"www.example3.com" registrationID:3 status:"valid" expires:1583204583000000003 > , Identifier:0xc0000b81a0, RegistrationID:0xc0000b81b0

無事、それぞれのドメインに応じた値になっています。

22行目のkCopy変数の処理コメントを見ると、iterator変数をloopする際に参照を使う問題についてちゃんと意識してコードを書いていたことがわかります。本当に惜しい。

レポートでは kCopy は問題を回避しているのに v について見逃したのは、protocol buffer ver2 におけるフィールド値の代入が全て参照渡しになっていることも一因にあると分析しています。そのためつい参照渡しにしてしまったのでしょう。

再発防止策として、テストやログの充実、静的解析やレビューの実施、protocol buffer ver3のアップグレードなどが挙げられています。

ハマるところをちゃんと理解して回避したつもりがホントちょっとの思い込みで他の手当を忘れてしまう、胸に手をあてて見ても過去そんなことがあった覚えがありますし、これからも絶対に自分に起こらないとは言えません。怖いことです。

今回のインシデントは対岸の火事とはとても思えません。自分もこういうインシデントを将来起こさないよう本当に気をつけたいとレポートを読んでしみじみ思うのでした。