時が経つのは早いものです。Heartbleedの脆弱性が発見されたのは5年半前です。Heartbleedの名が広く世間に知れわたったのは、専用のWebページとロゴが作られた最初のバグだったからだけではなく、インターネットそのものの脆さを露呈したからです。暗号ライブラリの小さなバグであるHeartbleedは、オンライン上のほとんどすべてのWebサイトのユーザーの個人情報を漏洩する可能性がありました。
Heartbleedは、リモートからメモリ上のデータを読み取られてしまう脆弱性/バグですが、過小評価されているバグの一例です。Heartbleed以外の注目度の高い脆弱性/バグの例としては、Cloudbleedや最近のNetSpectreが挙げられます。こうした脆弱性により、攻撃者は特別に細工されたパケットを送信するだけで、サーバーから個人情報を抽出できてしまいます。このたび、Cloudflareはこうした類のバグに対して強い耐性を持つプラットフォームを作る複数年にわたるプロジェクトを完了しました。
過去5年間、業界はHeartbleedによる影響を非常に大きなものにした、設計上の欠陥が招いた結果に対処してきました。このブログ記事では、メモリの安全性と、次なるHeartbleedから秘密鍵を保護するCloudflareの主力製品を再設計した方法について深堀りしていきます。
メモリ開示
オンラインコンポーネントを実装した企業が、完璧なセキュリティを実現することはできません。歴史が示すように、いくらセキュリティプログラムが堅牢であっても、予期しない悪用によって会社が危険に晒される可能性はあります。そうした最近の有名な事例の一つがHeartbleedです。Heartbleedは、OpenSSLというよく使われる暗号ライブラリ上で発見された脆弱性であり、数百万のWebサーバーの内部情報がインターネットに接続する第三者に閲覧される可能性があります。Heartbleedのニュースは世界を駆け巡り、数百万ドルの損失が発生し、未だに完全な解決には至っていません。
一般的なWebサービスは、APIと呼ばれる明確に定義したパブリックインタフェースを介してのみデータを返します。通常、プライバシーおよびセキュリティの重大なリスクになるため、クライアントはサーバー内部で何が起こっているのかを確認できません。Heartbleedは、そのパラダイムを破壊しました。Webサーバーによって使用されるオペレーティングメモリをインターネット上の第三者が閲覧できるようにしてしまい、通常はAPIを介して暴露されない個人データが開示されてしまいました。Heartbleedは、パスワードやクレジットカードを含む、サーバーに送信された過去のデータの結果を抽出するのに使用される可能性があります。また、TLS証明書の秘密鍵を含む、内部の仕組みやサーバー内で使用される暗号情報も開示される可能性があります。
Heartbleedは、攻撃者がカーテンの後ろを閲覧できるようにしてしまいます。ただし、カーテンから遠く離れたものは閲覧できません。機密データを抽出することはできましたが、サーバー上のすべてのものが危険に晒されていたわけではありません。たとえば、Heartbleedは、サーバー上で保有されているデータベースのコンテンツを攻撃者が盗むことができるようにはしませんでした。なぜ一部のデータのみが危険に晒されたのでしょうか?その理由は、現代のオペレーティングシステムの構築方法に関係しています。
プロセス分離の簡略化したビュー
現代のオペレーティングシステムのほとんどは、複数の階層に分割されています。これらの階層は、セキュリティクリアランスレベルと似ています。通常、いわゆるユーザー空間アプリケーション(ブラウザーなど)は、ユーザー空間と呼ばれるセキュリティの低い階層にあります。より下位の証明書階層が許可する場合にのみ計算リソース(メモリ、CPU、ネットワーク)にアクセスできます。
ユーザー空間アプリケーションが機能するにはリソースが必要です。たとえば、計算を行うにはコードと作業メモリを格納するメモリが必要です。ただし、アプリケーションを実行しているコンピューターの物理メモリにアプリケーションが直接アクセスできるようにすることは危険です。代わりに、未使用の計算エレメントは、オペレーティングシステムカーネルと呼ばれる下位階層に制限されます。カーネルは、こうしたリソースを安全に管理し、ユーザー空間アプリケーションのアクセスを仲介するよう特別に設計されたアプリケーションのみを実行します。
新しいユーザー空間アプリケーションプロセスが起動されると、カーネルは仮想メモリ空間を提供します。この仮想メモリ領域は、アプリケーションの物理メモリのように動作しますが、実際にはカーネルが物理メモリを保護するために使用する安全に保護された変換階層です。各アプリケーションの仮想メモリ空間は、そのアプリケーション専用の並行宇宙のようなものです。これにより、あるプロセスが別のプロセスを表示または変更することは不可能になり、ほかのアプリケーションはアドレス指定可能ではありません。
Heartbleed、Cloudbleed、およびプロセス境界
Heartbleedは、多くのWebサーバーアプリケーションの一部であったOpenSSLライブラリについての脆弱性でした。そうしたWebサーバーは、一般的なアプリケーションと同様に、ユーザー空間で実行されます。この脆弱性により、Webサーバーは特別に細工された着信要求に応答して最大2 KBのメモリを返します。
Cloudbleedもメモリが開示されるバグです。Cloudflare固有のものですが、Heartbleedに非常によく似ていたことから、その名前が付けられました。Cloudbleedでは、脆弱性はOpenSSLにではなく、HTML解析に使用されるセカンダリWebサーバーアプリケーションに存在しました。このコードが特定のHTMLシーケンスを解析すると、サービスを提供していたWebページにプロセスメモリを挿入します。
これらのバグはどちらも、カーネル空間ではなくユーザー空間で実行されているアプリケーションで発生することに注意してください。つまり、バグによって開示されたメモリが、必ずアプリケーションの仮想メモリの一部だということです。バグがメガバイトのデータを暴露したとしても、そのアプリケーションに固有のデータのみが暴露され、システム上のほかのアプリケーションのデータは暴露されません。
Webサーバーが暗号化されたHTTPSプロトコルを介してトラフィックを処理するには、一般にアプリケーションのメモリ内に格納されている証明書の秘密鍵にアクセスする必要があります。これらの鍵は、Heartbleedによってインターネットに公開されました。Cloudbleedの脆弱性は、異なるプロセスであるHTMLパーサーに悪影響を及ぼしました。HTMLパーサーはHTTPSを使用しないため、秘密鍵をメモリ内に格納しません。つまり、HTMLパーサーのメモリ領域内のほかのデータが安全でないとしても、HTTPS通信で使用される鍵は安全であるということです。
HTMLパーサーとWebサーバーが異なるアプリケーションだったことから、お客様のTLS証明書の失効と再発行を行う必要はありませんでした。ただし、Webサーバー内で別のメモリ開示に関する脆弱性が発見された場合、これらの鍵は再び危険に晒されます。
インターネットに接続するプロセスから鍵を排除する
すべてのWebサーバーが秘密鍵をメモリ内に格納しているわけではありません。一部の実装では、秘密鍵はハードウェアセキュリティモジュール(HSM)と呼ばれる別のコンピューターに保管されます。HSMは、物理的な侵入や改竄に耐えられるように構築されており、厳格なコンプライアンス要件に準拠するように構築されていることが多いです。また、HSMは大型で高価である場合が多いです。HSM内の鍵を利用するように設計されたWebサーバーは、物理的ケーブルを介して鍵に接続し、PKCS#11と呼ばれる特殊なプロトコルを用いて通信します。これにより、Webサーバーは、秘密鍵から物理的に分離されている状態で、暗号化されたコンテンツを提供できます。
Cloudflareでは、Webサーバーと秘密鍵を分離する、Keyless SSLという独自の方法を確立しました。ケーブルでサーバーに接続された別の物理的なマシンに鍵を保管するのではなく、お客様が独自のインフラストラクチャで運用する鍵サーバーに鍵を保管します(これはHSMによってバックアップすることもできます)。
つい最近、Cloudflareは、ユーザーが秘密鍵をCloudflareの特定の場所に保管できるようにするサービスGeo Key Managerをローンチしました。秘密鍵にアクセスできない場所への接続では、アクセス権を持つデータセンターでホストされている鍵サーバーにてKeyless SSLを使用します。
Keyless SSLとGeo Key Managerでは、秘密鍵はWebサーバーのメモリ領域の一部でないだけでなく、多くの場合、同じ国にないことさえあります。この極端なまでの分離は、次なるHeartbleedから保護するのに必要ではありません。必要なのは、Webサーバーと鍵サーバーが同じアプリケーションの一部でないことだけです。まさにそのことを実現したのです。これを「Keyless Everywhere」と呼びます。
Keyless SSLは内部から派生する
Cloudflareが保持する秘密鍵にKeyless SSLを転用することは簡単に概念化できましたが、アイデアから本稼働への道のりは簡単なものではありませんでした。Keyless SSLのコア機能は、お客様が各自のインフラストラクチャにて実行するオープンソースgokeylessから派生したものですが、内部的にはライブラリとして使用し、メインパッケージを当社の要件に適した実装に置き換えました(gokeyless-internalと名付けることにしました)。
すべての主要なアーキテクチャの変更と同様に、新しい低リスクのモデルのテストから始めるのが賢明です。当社の場合、テストベッドはTLS 1.3の実験的実装でした。大部分のCloudflareのお客様に影響を与えることなく、TLS仕様のドラフトバージョンとプッシュリリースを迅速に繰り返すために、Go内でカスタムnginx Webサーバーを書き直し、並行して既存のインフラストラクチャに展開しました。このサーバーは、最初から秘密鍵を保持せずにgokeyless-internalのみを利用するように設計されました。この時点では、ごく少量のTLS 1.3トラフィックがすべてブラウザーのベータ版から派生していたため、大部分の訪問者をセキュリティや停止のリスクに晒すことなく、gokeyless-internalの初期障害に対処することができました。
TLS 1.3を完全にキーレスにするための最初のステップは、gokeyless-internalに追加する必要がある新しい機能を特定して実装することでした。Keyless SSLは、一握りの秘密鍵のみをサポートすることを想定して、お客様のインフラストラクチャ上で実行するように設計されました。しかし、当社のエッジは数百万の秘密鍵を同時にサポートする必要があるため、当社のWebサーバーnginxで使用するのと同じ遅延読み込みロジックを実装しました。さらに、一般的なお客様向けの機能実装では、鍵サーバーはネットワークロードバランサーの背後に配置されるため、アップグレードやほかのメンテナンスのために使用を中止することができます。これは、ソフトウェアのアップグレード時にトラフィックに対応することでリソースを最大化することが重要である当社のエッジと対照的です。この問題は、Cloudflareのほかの場所で優れたtableflipパッケージを使用することで解消できます。
Keyless Everywhereに移行するための次のプロジェクトは、gokeyless-internalをデフォルトにてサポートするSpectrumをローンチすることでした。こうした小さな成功を収めながら、既存のnginxインフラストラクチャを完全なキーレスモデルに移植するという困難な課題に挑むのに必要な自信を持つことができました。新しい機能を実装して、統合テストで満足のいく結果を得たら、残る作業は本番環境での稼働だけだと思いますよね?大規模な分散システムで作業した経験のある人なら誰でも「開発環境での作業」が「完了」とはほど遠いことを知っています。ここでも状況は同じです。ありがたいことに、問題を予測していたので、gokeyless-internalパスで問題が発生した場合にハンドシェイク自体を完了するためのnginxへのフォールバックを構築しました。これにより、nginxロジックの再実装が100%バグのない状態でなかった場合のダウンタイムのリスクなしに、gokeyless-internalを本番環境のトラフィックに晒すことができました。
コードをロールバックしても問題が解決しない場合
展開計画は、Keyless Everywhereを有効にして、フォールバックの最も一般的な原因を特定して対応することでした。このプロセスを、nginxから秘密鍵(そしてフォールバック)へのアクセスを削除した後に、すべてのフォールバックの原因が取り除かれるまで続けました。フォールバックの初期の原因の1つは、ストレージ内で要求した秘密鍵が見つけられなかったことを示すErrKeyNotFoundをgokeyless-internalが返すことでした。nginxはストレージ内で証明書と鍵ペアを見つけられた後にのみ、gokeyless-internalへの要求を行い、秘密鍵と証明書は常に一緒に書き込まれるため、この状況が発生することは可能ではないはずでした。本当に鍵が見つからなかった場合のエラーを返すことに加えて、タイムアウトなどの一時エラーが発生した場合にもエラーを返していたことがわかりました。この問題を解決するために、ErrInternalを返すようにこれらの一時エラー条件を変更し、データセンター内でのカナリア展開を行いました。不思議なことに、1つのデータセンター内の少数のインスタンスが高い率でフォールバックに遭遇し始めたことと、nginxからのログが原因はnginxとgokeyless-internalの間のタイムアウトであることを示していたことを発見しました。タイムアウトはすぐには発生しませんでしたが、システムがいくつかのタイムアウトを記録し始めてからタイムアウトが止まることはありませんでした。以前のリリースにロールバックした後も、古いバージョンのソフトウェアでフォールバックは発動し続けました。さらに、nginxがタイムアウトの発生を報告していた場合でも、gokeyless-internalはまったく問題がないように見え、妥当なパフォーマンス指標(サブミリ秒の中央値)を報告していました。
この問題を解決するために、nginxとgokeylessの両方に詳細なロギングを追加して、タイムアウトが発生したら一連のイベントの遡及的分析をしました。
➜ ~ grep 'timed out' nginx.log | grep Keyless | head -5
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015157 Keyless SSL request/response timed out while reading Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015231 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015271 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:49.000 29m41 2018/07/25 05:30:49 [error] 4525#0: *1015280 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
2018-07-25T05:30:50.000 29m41 2018/07/25 05:30:50 [error] 4525#0: *1015289 Keyless SSL request/response timed out while waiting for Keyless SSL response, keyserver: 127.0.0.1
タイムアウトを記録した最初の要求のIDが1015157だったことがわかります。また、興味深いのは、最初のログ行が「timed out while reading,(読み取り中にタイムアウト)」であるのに、そのほかのすべてが「timed out while waiting,(待機中にタイムアウト)」であり、このメッセージが永遠に続いていることです。gokeylessログ内の一致する要求を以下に示します:
➜ ~ grep 'id=1015157 ' gokeyless.log | head -1
2018-07-25T05:30:39.000 29m41 2018/07/25 05:30:39 [DEBUG] connection 127.0.0.1:30520: worker=ecdsa-29 opcode=OpECDSASignSHA256 id=1015157 sni=announce.php?info_hash=%a8%9e%9dc%cc%3b1%c8%23%e4%93%21r%0f%92mc%0c%15%89&peer_id=-ut353s-%ce%ad%5e%b1%99%06%24e%d5d%9a%08&port=42596&uploaded=65536&downloaded=0&left=0&corrupt=0&key=04a184b7&event=started&numwant=200&compact=1&no_peer_id=1 ip=104.20.33.147
なるほど!SNI値が明らかに無効であり(SNIはホストヘッダーに似たもので、URLパスではなくドメインです)、非常に長いです。当社のストレージシステムは、どのSNIに応答するか、どのIPアドレスに応答するか(SNIをサポートしない古いクライアントの場合)、という2つのインデックスに基づいて証明書に索引を付けます。当社のストレージインタフェースはmemcachedプロトコルを使用し、gokeyless-internalが使用するクライアントライブラリは250文字(memcachedの最大鍵長)を超える鍵に対する拒否要求を使用するのに対して、nginxロジックは無効なSNIを無視してIPのみを持っているかのように要求を処理します。新しいリリースの変更により、この状態がErrKeyNotFoundからErrInternalに切り替わり、nginx内で連鎖的な問題を引き起こしました。発生した「タイムアウト」は、1つの要求に対してErrInternalを返す接続で多重化されたすべてのインフライト要求を破棄した結果でした。これらの要求は再試行されましたが、この状態が引き起こされると、nginxは再試行された要求の数と不良SNIを伴って着信する新しい要求の連続ストリームによって過負荷になり、回復することができませんでした。それで、なぜgokeyless-internalにロールバックすることで問題が解決しなかったのかがわかりました。
この発見によって、ようやくnginxに注意を向けることができました。nginxは、長年にわたってお客様の鍵サーバーと安定して動作していたので、これまで責任を逃れてきたのです。ただし、localhostを介したmultitenant鍵サーバーとの通信は、公衆インターネット経由でのお客様の鍵サーバーとの通信とは根本的に異なるため、次のような変更を行う必要がありました:
お客様の鍵サーバーの場合は、長い接続タイムアウトと比較的短い応答タイムアウトであるのに対して、localhost鍵サーバーの場合は、非常に短い接続タイムアウトと長めの要求タイムアウトです。
同様に、ネットワークを信頼できないため、お客様の鍵サーバーの応答の待機中にタイムアウトした場合は、(バックオフを実行して)再試行するのが妥当です。しかし、localhostでは、gokeyless-internalが過負荷状態で、要求が処理のために依然としてキューに入れられている場合にのみ、タイムアウトが発生します。この場合、再試行しても、より多くの作業がgokeyless-internalに要求されることになり、状況を悪化させるだけです。
最も重要なのは、単一の接続は単一のお客様を表さなくなったため、いずれか1つの要求でエラーが発生した場合に、単一の接続で多重化されたすべての要求をnginxが破棄してはならないことです。
実装上の事項
エッジのCPUは、当社にとって最も貴重な資産の一つであり、パフォーマンスチーム(別名CPU警察)によって厳重に保護されています。カナリアデータセンターの1つでKeyless Everywhereを稼働させた直後に、gokeylessがコアピアインスタンスの50%を使用していることを確認しました。符号操作をnginxからgokeylessに切り替えていたので、言うまでもなく今はより多くのCPUを使用しています。しかし、nginxではCPU使用率がそれ相応に減少したはずですよね?
ところが違うのです。Goでは楕円曲線上での操作は非常に高速ですが、RSA操作はBoringSSL操作よりもはるかに遅いことがわかっています。
Go 1.11にはRSAの数学的演算の最適化が含まれていますが、より速い速度が必要でした。BoringSSLのパフォーマンスに合致する適切に調整されたアセンブリコードが必要なため、CryptoチームのArmando Fazは、Goの内部フォーク内のプラットフォームに依存するアセンブリを用いてmath/bigパッケージの一部を再実装することで、失われたCPUの一部を取り戻すようにしました。Goの最近のアセンブリポリシーは、アセンブリの代わりにGoポータブルコードを使用することをデフォルトにするため、これらの最適化は上流工程ではありませんでした。まだ最適化の余地があることから、cgoには多くの欠点があるにもかかわらず、符号操作についてcgo + BoringSSLに移行することを引き続き検討しています。
ツーリングの変更
プロセス分離は、メモリ内の個人情報を保護する強力なツールです。Keyless Everywhereへの移行は、プロセス分離が簡単に活用できるツールでないことを示しています。プロセス分離を使用して個人情報を保護するために、nginxなどの既存のシステムを再設計することは、時間のかかる困難な作業でした。メモリの安全性を確保するもう1つのアプローチは、Rustなどのメモリセーフな言語を使用することです。
Rustは、もともとMozillaによって開発されたものですが、より広く使用され始めています。C/C++にないRustの主な利点は、ガベージコレクターを使用しないメモリ安全性機能が備わっていることです。
Rustなどの新しい言語で既存のアプリケーションを書き直すのは困難な作業です。しかし、強力なFirewall Rules機能から1.1.1.1 with WARPアプリまで、多くの新しいCloudflare機能は、強力なメモリ安全性機能を活用するためにRustで書かれています。これまでのところRustには十分に満足しており、今後もさらに使用していく予定です。
結論
Heartbleedが招いた悲惨な結果から、業界は振り返ってみると明白なことを教訓として学びました。それは「インターネットを介してリモートでアクセスできるアプリケーションに重要な個人情報を保管することはリスクの高いセキュリティ対策である」ということです。Heartbleed発生後の数年にわたる多くの作業を経て、プロセス分離とKeyless SSLを活用して、次なるHeartbleedがお客様の鍵を危険に晒さないようにしました。
しかし、これで終わりというわけではありません。最近、アプリケーションプロセスの境界チェックをバイパスできるNetSpectreなどのメモリ開示の脆弱性が発見されているので、鍵の安全性を確保する新しい方法を引き続き積極的に模索していきます。