多層防御 : Wasm コンパイラバグによるトラブルを未然に防止
後付けのセキュリティ対策では Web 攻撃に効果的に対応することはできません。今日のクラウドアプリケーションでは、プロダクトや環境のあらゆる側面において保護対策を考慮し、セキュリティを組み込む必要があります。また、新機能やの継続的なテスト、ファジング、セキュリティ分析など、セキュリティを評価するための継続的なプロセスも欠かせません。セキュリティに対するこの姿勢は、Fastly のセキュリティのあり方に関する基本理念の一部でもあります。そして最近、この重要性を身をもって体験する事態が発生しました。
先日、Fastly では Compute@Edge で使用している WebAssembly コンパイラである Cranelift の一部にバグがあることが判明しました。このバグによって、WebAssembly モジュールによるサンドボックス化されたヒープ外部のメモリへのアクセスが可能になってしまう恐れがありましたが、幸い、十分な人材やプロセス、ツールなどの対策のおかげで、悪用される前にバグを発見し、インフラ上でパッチを適用することができました。今回は、バグに遭遇した経緯や発生の理由、バグによって生じる可能性のあった問題、そしてインフラ上で悪用されていないことを検証した方法をご紹介します。
この記事の目的は、今回のバグについて包み隠さず公開すると同時に、統合されたセキュリティ対策は、ツールだけでなく、プロセスにも組み込む必要があるということをお伝えすることです。Compute@Edge には、強力なセキュリティ境界が実装されています。WebAssembly のサンドボックスに加え、OS レベルで実装されたセキュリティメカニズムも採用しています。しかし、バグのないソフトウェアは存在しません。そのため、問題が発生した場合の対応について考えることも、Fastly のセキュリティ体制に欠かせない対策の一つです。では早速、その対策について見ていきましょう。
技術的背景 : Cranelift とヒープサンドボックス
Compute@Edge では、WebAssembly (Wasm) モジュールに含まれるお客様のコードを、インバウンドリクエストごとにサーバー上で実行します。Compute@Edge の設計における重要な点は、お客様による全てのリクエストがモジュールの新しいインスタンスで実行されることです。つまり、他のリクエストハンドラや他のお客様のコードにメモリが共有されることはありません。
WebAssembly の設計における重要な特性であるこのメモリの隔離、すなわちヒープサンドボックスが、WebAssembly ならではの強力なセキュリティを維持しています。そのため、ヒープ間の境界が崩れてしまうと、深刻なセキュリティ問題が発生する可能性があります。そのため、Fastly ではコンパイラの正常性を非常に重要視しており、お客様に危険が及ぶことがないよう、複数のプロセスと保護のレイヤーを設けています。
Fastly では、お客様の Wasm モジュールをコンパイルし、サーバー上で Cranelift を使って実行しています。Cranelift は、Wasm のバイトコードを x86 マシンコードに事前に変換するコンパイラです。これにより、コードはリクエストの到着次第すぐ実行できるようになるため、コールドスタート時間の大幅な短縮が可能になるのが、Compute@Edge の主な利点の1つです。また、Cranelift はヒープアクセスもネイティブの x86 コードに変換します。各 WebAssembly インスタンス (リクエストごとに1つ) は、仮想メモリ空間内に独自の領域を持ち、実行時にこの領域へポインタを移動します。Wasm のバイトコードがヒープにアクセスすると、Cranelift はこれをヒープベースからオフセットでアクセスに変換します。Wasm のポインタは32ビット幅であるため、このオフセットは 4 GiB (4バイナリギガバイト、つまり232バイト) を超過することはありません。仮想メモリ領域のサイズをこれよりも大きく設定することで、Wasm インスタンスが他のインスタンスのメモリに到達できないようにします。ランタイムの境界チェック無しで行われるこのプロセスは、Compute@Edge のパフォーマンスを最大限に引き出すための方法の1つです。領域の間にはガードページが配置されています。これはマップされていない仮想アドレスで、アクセスされると Wasm インスタンスが終了するようになっています。
バグの発生
この設計では、コンパイラがコードを忠実に変換することを前提としています (通常のコードでは正常な動作が前提になっています)。Lucet の一部である Wasm サンドボックスは、ベースポインタと Wasm ヒープアドレスを加算するために、Cranelift の内部表現に加算命令を生成します。では、この整数の加算が誤った結果を引き起こした場合は、どうなるのでしょう。
実際、このエラーが問題を引き起こしました。この問題を把握するには、以下を理解する必要があります。
コンパイラが異なる幅の値 (例えば32ビットと64ビット) をどのように扱うか
コンパイラがどのように計算を実行するマシン命令を選択しているか
値を配置するレジスタの選び方 (レジスタ割り当て)
では、1つずつ順に見ていきましょう。
x86-64 では、整数レジスタの幅はすべて64ビットですが、32ビットの Wasm ポインタなどでは、一部の値の幅が狭くなります。Cranelift を含むほとんどのコンパイラは、レジスタの下位ビットに狭い値を格納し、上位ビットは未定義のままにします。Wasm ヒープのアドレスを計算するコードが生成される際、32ビットの Wasm ポインタを64ビット加数に変換する zero-extend 演算子を含める必要があります。その後、この演算子はベースアドレスに追加されます。
この一般的な操作を、生成された場所すべてにおいて明示的に実行するのはコストがかかります。そのため、Cranelift の命令セレクタは、32ビット命令によって、上位ビットがクリアされた64ビット値を生成されることがあるという x86-64 の特性を利用することで、extend 演算子が不要になり、削除することができます。
ここまではすべて上手くいきましたが、ここでレジスタアロケータの登場です。コンパイラバックエンドの一部であるレジスタアロケータは、値を格納する場所を選択します。プログラムに一度に多くのアクティブな変数がある場合は、データの一部をプロセッサースタックにスピルさせ、後で必要なときに再ロードします。この操作は正常で、プログラムからは見えません。これによって、プログラマーはレジスタよりも多くの変数を使用することができるようになります。
ここで、ようやくバグの登場です。レジスタアロケータがレジスタをスピルする際、レジスタアロケータは値の型を把握しており、Cranelift では実際の型のビットのみの保持を保証しています。不要な32ビットから64ビットへの拡張を削除したために、実際の値が32ビット幅であるにもかかわらず64ビットとして扱ってしまうと、その値がスピルされた場合、リロード後の値が間違っている可能性があります。残念ながら今回のケースでは、32ビット値をリロードするために符号拡張のロード命令が、レジスタアロケータによって使用されていました。つまり、0x8000_0000 より大きい32ビット整数は、元のプログラムで64ビットにゼロ拡張された後、誤った符号拡張によって負の値になってしまう可能性があるということです。
ヒープオフセットが関与している場合、負のオフセットは問題になります。
影響範囲
つまり、稀な状況下では、サンドボックス化されたヒープの開始前に、システム上の Wasm モジュールがメモリにアクセスすることが可能であることを意味します。Fastly のシステムでは、非常に高速な起動とレスポンス時間を提供するため、単一のオペレーティングシステムプロセス内で複数のリクエストを処理しています。つまり理論上、任意のメモリを読む込むことができるということは、お客様のデータの漏洩につながる可能性もあるということです。
幸い、Fastly のシステム設計によってその影響が軽減されたことが判明しました。Compute@Edge デーモンのメモリレイアウトでは、仮想メモリ空間内ではインスタンスヒープを 4 GiB 以上離して配置し、その間にガード領域 (マップされていないメモリ) を設けています。つまり、Wasm のインスタンスが他のインスタンスのヒープ (リニアメモリ) にアクセスすることはできません。最大の後方オフセットが 2 GiB であるため、前のインスタンスヒープの先頭に到達することはできませんでした。
しかし、悪意ある Wasm モジュールが、巧妙に構築されたロードやストアを利用して、前のインスタンスのスタックやグローバルを含む、ヒープの開始直前の重要なデータにアクセスする可能性がありました。(レイアウトの詳細については、Lucet のドキュメントを参照してください。)これが判明した時点で、そのリスクは非常に深刻であることが明らかになりました。本番環境での WebAssembly の実行をサポートする Lucet では、ランタイムが依存する構造体やポインタがデータに含まれており、これらの構造体を変更されることは、より複雑な悪用につながる可能性がありました。
偶然にも、同時に発見された別のバグ (セキュリティ上の問題がないもの) がこのコンパイラバグと連結されていたため、悪用には大きな静的オフセットを使ったロードやストアが必要になります。つまり、検出しやすくなるわけです。この詳細については、以下の付録で詳しく説明しています。
つまり、バグを悪用することは理論的に可能でしたが、特定のオフセットでのロードあるいはストアが必要であることが分かりました。このオフセットが正しくない場合、別のインスタンスのガード領域がヒットされることでデーモン全体がクラッシュする可能性があったため、データには影響が及ばないというわけです。たとえクラッシュに至らなかったとしても、高頻度でモニタリングしているログに、大幅な領域外アクセスの関する異常報告が上がったでしょう。それでも、問題があったことには変わりありません。さらに潜在的なリスクを特定するべく、調査を続けました。
まず、バグがシステム内に存在している間に Compute@Edge にアップロードされたすべての Wasm モジュールを分析するプログラムを記述し、脆弱な範囲のオフセットを持つロード命令とストア命令を検索しました。お客様のプライバシーを守るため、手動でのモジュールへのアクセスや調査は一切行いませんでした。この特別なタスクは、Compute@Edge のモジュール構築と同様、隔離されたコンパイルパイプラインで実行されました。この分析により、Wasm モジュールには悪用につながる可能性のあるオフセットがなかったことが判明しました。したがって、システム上の Wasm モジュールを使って、他のお客様のデータにアクセスすることは不可能であったことが分かりました。
またこの調査と同時に、直ちに Cranelift のバグにパッチを適用し、インフラを再デプロイしました。過去にさかのぼった分析とバグ修正を行った結果、Fastly はお客様のデータは安全であると確信しています。
問題を発見した経緯
このバグの存在が明らかになった経緯も、非常に興味深い話です。
ことの始まりは、いくつかの異常なログエントリーでした。ある朝、エンジニアの1人が、ある PoP で Compute@Edge デーモンが何度かクラッシュしており、アクセスできないはずのメモリアドレスに複数回アクセスしていることに気づきました。これは、明らかな問題の兆候でした。説明のつかないメモリアクセスは、深刻な問題の表れである可能性があるからです。
クラッシュの原因となった Wasm モジュールは、KTH Royal Institute of Technology のセキュリティ研究者、Javier Cabrera Arteaga 氏によるものであることがすぐにわかりました。同氏は、Fastly との合意の下、セキュリティ研究のために Compute@Edge を使用していました。Fastly は Wasm モジュールのコピーを入手し、その動作を再現するための入力を理解するべく、Javier 氏に連絡を取りました。彼はすぐさま実験内容を Fastly と共有し、モジュールのソースコードへのアクセスを許可してくれました。
クラッシュの原因となった Wasm モジュールの正確なバージョンを Fastly システムから取得した後、クラッシュを再現し、デバッガで問題を把握することができました。逆アセンブリを見れば、コンパイラのバグは一目瞭然でした。問題を把握した時点で Cranelift にパッチを当て、インフラの安全性を確保することができました。
しかし、これで全てが解決したわけではありません。バグの影響を理解し、潜在的な漏えいとレスポンスを特定する必要がありました。Fastly は、ヒープレイアウトや境界チェックスキームを検証し、異なるコンパイラやランタイム設定下でのバグの影響を正確に計りました。そして、どのような設定やユースケースで Cranelift にてバグが露呈するのか、Fastly のインフラにどのような影響を与えるのかを調査しました。バグが悪用される条件が明確になった時点で、先ほどの説明にもあった特定のロードおよびストアの静的オフセットを持つ Wasm モジュールを探しました。このプロセスの一環として、オープンソースコミュニティ、特に Bytecode Alliance に対し、オープンソースの Wasmtime と Lucet ランタイムへの影響を確認する作業も行いました。この調査の結果は、Fastly の脆弱性公開記事に追加されました。
また調査中に、Compute@Edge デーモンにて実際にバグを悪用する手口を開発しました。身が引き締まる思いだった反面、問題を実際に悪用するためには何が必要なのかを正確に把握できたため、自信を深めることができました。その過程で、Wasm ヒープのロードとストアの位置が正しくなければ、デーモン全体がクラッシュしてしまうことが分かりました。つまり、このような内部情報がなければ、悪用は難しいことが分かりました。このようなクラッシュはリアルタイムログでは見られなかったほか、既存の Wasm モジュールをすべて分析した結果、Fastly の環境ではこの悪用手口は使われなかったと結論づけることができました。
多層防御 : 安全なプロセス、徹底したモニタリング、積極的な対策
今回のインシデントにおいて、重要な教訓の多くはセキュリティプロセスから得られました。ログにて異常が検出された時点で、バグを捕捉するプロセスが正常に機能することが確認できました。また、プロダクトエンジニア、セキュリティエンジニア、コミュニケーション担当者、法務担当者などのスタッフを全員招集し、脆弱性を迅速かつ効率的に修正できることが分かりました。
また、新しい学びもいくつかありました。今回のバグは、Compute@Edge 発表以来、Cranelift で初めて発生したセキュリティ脆弱性でした。これを機に、社内プロセスを見直し、今後の準備をさらに強化させることができました。また、今回初めて Bytecode Alliance と連携し、影響を受けたソフトウェアのユーザーを探し出し、セキュリティ勧告を発表しました。Fastly は、ソフトウェアのセキュリティを確保する上で、Bytecode Alliance のメンバーと協力できたことに大きな意味があったと考えています。今後も様々な手法を用い、バグの早期発見・修正に積極的に取り組むことでお客様への安全に注力していきます。
セキュリティバグは無いに越したことはありません。しかし、バグは現代のソフトウェアにはつきものであるため、徹底した対策が欠かせません。より多くのお客様が、安全で汎用性の高いプラットフォームとして Compute@Edge を使用するようになるにつれ、この重要性はますます高まります。Fastly は、お客様の安全を確保するべく、この記事に述べたあらゆる方法で、セキュリティに焦点を当てたエンジニアリング活動を続けていきたいと考えています。
付録 : バグの詳細な仕組み
この記事の最後に、バグがどのように動作するのか、そしてシステムエンジニアやコンパイラエンジニアが実際に誤ったコンパイルを目にした場合、どのように表示されるかについて、もう少し詳しくご紹介します。
このバグをほぼ忠実に再現した場合、次のような逆アセンブルになります。
; function prologue, storing a few register-based arguments
push rbp
mov rbp,rsp
sub rsp,0xe0
mov QWORD PTR [rsp],r12
mov QWORD PTR [rsp+0x8],r13
mov QWORD PTR [rsp+0x10],r14
mov QWORD PTR [rsp+0x18],rbx
mov QWORD PTR [rsp+0x20],r15
mov r12,rdi ; bug-relevant details begin here!
; rdi is the first argument, the WebAssembly "VMContext".
; Lucet sets VMContext to the heap base, with critical structures
; placed in the (4k) page before the heap.
mov r11,rsi ; rsi is the second argument, the first one from user-controlled
; WebAssembly code. call it "heap_offset".
mov rsi,rcx ; rcx is the third argument, a user-controlled i64 - call it "user_qword".
mov QWORD PTR [rsp+0x40],rsi ; spill "user_qword", just a quirk of this PoC .
...
mov QWORD PTR [rsp+0x30],r11 ; spill "heap_offset", again just a quirk.
movsxd rsi,DWORD PTR [rsp+0x30] ; reload "heap_offset".
add esi,edx ; this add helps convince Cranelift to spill in a way it later incorrectly sign extends.
; edx is also an argument, which is set to 0 in our PoC - this add does not change "heap_offset".
mov QWORD PTR [rsp+0x30],rsi ; the spill! we'll revisit this in a moment.
...
movsxd r11,DWORD PTR [rsp+0x30] ; the incorrect sign-extended load of "heap_offset"!
mov rdi,QWORD PTR [rsp+0x40] ; reload "user_qword"
mov QWORD PTR [r12+r11*1+0x0],rdi ; store "user_qword" to "VMContext" + "heap_offset".
; since "heap_offset" was sign-extended r11 might be a number like -4096,
; this store might write "user_qword" over critical structures Lucet relies on.
ここでは、セキュリティ上の問題がはっきり分かります。ヒープベースの直前に重要な構造体がある場合、小さな負のオフセットによってそれら構造体に非常に容易にアクセスでき、コンパイラはこのパターンのバグコードを出力するのが困難であるということです。なお、説明を簡潔にするために、ここではコンパイラが WebAssembly のヒープオフセットをスピルするために十分なレジスタプレッシャーを得るために必要な格納、加算、乗算、および組み合わせた多数のローカルを含めていません。
先ほど話にもあった、悪用の試行を複雑にする2つ目のバグは、実際に悪用を実行しようとした場合に明確な指標を与えてくれました。ヒープ設定の解析に使用している設定パーサーは「4 GB」のパラメータを「4,000,000,000」バイト、つまりバイナリの「ギビバイト」ではなく10進数の「ギガバイト」と解釈しました。ヒープの最大サイズが 4 GiB 以下の「4,294,967,296」に設定されていたため、コンパイルされた WebAssembly モジュールは、最後の294,967,296バイトのヒープ領域に対して境界チェックを行いました。これによって、逆アセンブルの調査中に予期しない命令がもたらされました。
mov edi, 0xee6b27fe ; an entirely unexpected constant: 3,999,999,998
movsxd rax, DWORD PTR [rsp+0x88] ; the incorrect sign-extended load
cmp eax, edi ; compare against the heap bound
jae ff0 <guest_func_4+0x360> ; and branch to a trap site if out of bounds
攻撃者は、0xfffff000 のようなヒープオフセットを使って少しだけ逆戻りし、Lucet が依存する重要な構造体を変更すると思われるので、これは幸運な出来事でした。その場合、境界チェックは失敗し、異常に大きいオフセットによるヒープ領域外へのアクセスによって、プログラムはトラップします。最大の (ゼロに近い) 後方ヒープポインタは 0xee6b27fd であるため、このバグに到達する WebAssembly インスタンス直前の294,967,297バイトは改ざんされずに済むことになります。しかし残念ながら、その他にも問題があることがわかりました。
WebAssembly のロード
とストア
命令には、構造体を扱うロードとストアを簡素化するための即値オフセット
が含まれています。通常、構造体のメモリ内のレイアウトはプログラム全体を通じて同じです。例えば、struct size
の st_size
フィールドは、`struct size` 自体がどこにあっても、常に同じオフセットになります。コンパイラはこのオフセットを即値として記述することができ、1つの構造体に対する繰り返し操作は、構造体ポインタを再利用するだけで済みます。しかし、オフセットは WebAssembly で定義されているため、攻撃者は低いヒープオフセットを選び、さらにロードやストアで大きなオフセットを追加し、インスタンスのヒープの直前に領域に到達することで、領域チェックを完全に回避することができます。
この時点で、Lucet のサンドボックスのセキュリティ特性に違反する方法で、インスタンスのメモリを改ざんする概念実証を構築することが可能です。例えば、悪意あるインスタンスの前にあるインスタンスのメモリを読んだり、ポインタを上書きしてコントロールフローを乗っ取ったりすることができます。攻撃者が脆弱性をどのように悪用するか想像がつかない場合でも、セキュリティ問題を真剣に受け止める必要があるということを、今回の件で身をもって体験することができたのは大きな収穫でした。