Writing Truly Memory Safe JIT Compilers – How to kill off a top source of browser exploits

原文はこちら。
The original article was written by Mike Hearn.
https://medium.com/graalvm/writing-truly-memory-safe-jit-compilers-f79ad44558dd

先月、V8チームは以下の素晴らしいブログ記事を公開しました。

The V8 Sandbox
https://v8.dev/blog/sandbox

これはJavaScriptコード用のサンドボックスではなく、JITコンパイラ自体のバグに起因するブラウザの脆弱性を緩和することを目的としています。これは重要な作業です。なぜなら、ほとんどのChromeの脆弱性はV8のメモリ安全性のバグから始まるという報告があるからです。

V8はC++で記述されているため、メモリの安全性が確保されていない言語で作業する際に発生するバグのように思えるかもしれません。しかし、残念ながら状況はもっと複雑です。なぜでしょうか? チームの説明によると、以下のようです。

V8の脆弱性は、典型的なメモリ破壊バグ(use-after-frees、つまりメモリ解放後の利用や、範囲外アクセスなど)ではなく、メモリ破壊につながる可能性のある微妙なロジックの問題であることがほとんどです。そのため、既存のメモリ安全ソリューションのほとんどはV8には適用できません。特に、Rustのようなメモリセーフ言語への切り替えや、メモリタギングなどの現在のハードウェアメモリ安全機能や将来のメモリ安全機能を使用しても、V8が現在直面しているセキュリティ上の課題の解決にはなりません。

The Case for Memory Safe Roadmaps
https://www.cisa.gov/resources-tools/resources/case-memory-safe-roadmaps
Memory Safety: How Arm Memory Tagging Extension Addresses this Industry-wide Security Challenge
https://newsroom.arm.com/blog/memory-safety-arm-memory-tagging-extension

V8チームは、VM intrinsicsや JITコンパイルされたマシンコード自体が、メモリに関する誤った前提に依存してしまう可能性があるため、エンジン自体に通常のメモリ安全上の問題がない場合でも、メモリの破損を引き起こす可能性のあるバグの例を挙げています。

このようなバグを設計段階で排除する、言語ランタイムの厳密な作成方法があれば素晴らしいでしょう。

GraalVMにはGraalJSというJavaScriptエンジンがあります。Truffle言語フレームワークを使いJavaで書かれています。

GraalJS – High-performance JavaScript with GraalVM
https://www.graalvm.org/javascript/
Truffle Language Implementation Framework
https://www.graalvm.org/latest/graalvm-as-a-platform/language-implementation-framework/

そのピークパフォーマンスはV8と肩を並べ、いくつかのベンチマーク(レイトレーシングなど)では実際にV8よりも高速です!

Javaで書かれていることでメモリの安全性は向上しますが、V8を安全な言語で書き直しても、V8が解決しようとしている種類のバグには効果がないことが分かりました。そのため、直感的にGraalJSも同じ種類のバグに悩まされるだろうと予想しました。しかし、そうではありませんでした。その理由を見てみましょう。その過程で、Truffleを支える理論的基盤である第一二村射影 (first Futamura projection) について見ていきます。

高速言語の VM はすべて同じ方法で動作します。抽象構文木またはバイトコードのいずれかで表現されたプログラムを表すインメモリデータ構造にディスクからプログラムを読み込みます。プログラムはインタープリタで実行を開始するとすぐに、プログラムが他の部分よりも多くの時間を費やすホットスポット(処理が集中する部分)が見つかります。これらのホットスポットが最適化されたマシンコードに変換するべくJITコンパイラに渡され、実行はインタープリタとコンパイルされたプログラムフラグメントの集合の間を行き来します。これにより、パフォーマンスが大幅に向上します。

このアーキテクチャは標準的なもので、JVMとV8の両方が採用していますが、セキュリティの観点からみると、この設計には欠陥があります。エラーが発生しやすいのです。言語セマンティクスは、インタープリタ用とJITコンパイラ用の2回実装されます。両方の箇所が完全に正しいことはもちろん、完全に一致することも重要です。そうでなければ、VMが攻撃される可能性があるためです。

Truffleは高度な高性能言語ランタイムを構築するためのJavaライブラリです。Truffle フレームワークを使用して作成されたVMは、従来のVMと根本的に異なる方法で動作します。これにより、VM の作成がはるかに容易になるだけでなく、設計上メモリ安全性のバグが排除されます。まずはじめに、Javaで言語のインタープリタを作成します。これは、ターゲット言語をJVMバイトコードにコンパイルするという意味ではありません。実際、バイトコードはこの話にはまったく関係ありません。通常のインタープリタを書くだけです。インタープリタのコードはGCと境界チェックが行われるため、悪意のあるユーザーコードがメモリ安全性のバグを利用して悪用できないのです。

従来のJavaを考えると、これは非常に低速に聞こえるかもしれません。

  • Java 自体はJIT コンパイルされるまで解釈されないのでは?
  • 私たちは…インタープリタを解釈している?

幸いにも、そうではありません。Truffle ベースの言語ランタイムをネイティブ実行ファイルとして出荷できるためです。つまり、Graal コンパイラ(より広範な傘下プロジェクトの名前の由来)を使用して、事前に完全にネイティブコードにコンパイルされます。

Native Image
https://www.graalvm.org/latest/reference-manual/native-image/

つまり、ユーザーのプログラムが開始すると、JavaScript は通常の実行形式バイナリまたは DLL として出荷される通常のインタープリタで実行されますが、それでも Java プログラムの安全特性の恩恵を受けることができます。すぐにいくつかのメソッドがホットスポットになりますが、この時点で、通常とは異なることが起こります。Truffleフレームワークは、どの関数がホットであるかを追跡し、JIT コンパイルのスケジュールを決定します。しかし、従来のVMの設計とは異なり、独自の JIT コンパイラを記述する必要はありません。その代わりに、ユーザーのコードは、インタープリタをネイティブコードに変換するために使用されたのと同じ汎用Graalコンパイラで自動的にコンパイルされ、インタープリタとコンパイル済み関数との間を自動的に切り替えながら実行開始します。これは、部分評価(または第一二村射影)と呼ばれる特殊な技術により可能となっています。

Professor Yoshihiko Futamura
(二村吉彦教授)

二村射影や部分評価という言葉を聞いたことがない人もいるかもしれません。では、この奇妙な言葉は何のことでしょうか?

中心となるアイデアは、インタープリタのコードを自動的に変換し、個々のJITコンパイル済みユーザーメソッドを作成する、というものです。言語のセマンティクスをインタープリタと手作業で作成されたJITの2か所で慎重に実装する必要がなく、1 回だけ実装すれば十分です。インタープリタはメモリ安全であり、変換はインタープリタのセマンティクスを保持するため、ユーザーコードのコンパイル版はインタープリタの動作と一致することが保証され、それゆえに自動的にメモリ安全でもあります。これにより、悪用可能なVMを誤って作成してしまう可能性を大幅に低減できます。

これを可能にするにはいくつかのコツがあります。最も重要なのは、新しい形の不変性で、これはアノテーションを使用して Java に追加されました。通常のプログラミングでは、変数は変更可能か変更不可能かのいずれかです。変更不可能な変数はfinalconstなどの特別なキーワードでマークされ、宣言箇所で一度だけ設定する必要があります。定数は畳み込み可能、つまり、定数への参照はその定数値に置き換え可能なので、コンパイラーにとって非常に便利です。次のコードを考えてみましょう。

class Example {
    private static final int A = 1;
    private static final int B = 2;

    static int answer() {
        return A - B;
    }

    static String doSomething() {
        if (answer() < 0) 
            return "OK" 
        else 
            throw new IllegalStateException();
    }
}

answer()メソッドが常に同じ数値を返すことは明らかです。優れたコンパイラは、1と2をreturn 1-2を得る式に代入し、答えを事前に計算します。そして、answerへの呼び出しをインライン化します(つまり、コールサイトに実装をコピー&ペーストします)。さらに、-1で置き換え、呼び出しのオーバーヘッドも取り除きます。これにより、さらに多くの定数畳み込みが呼び出される可能性があります。例えば、doSomethingメソッドでは、コンパイラが例外が決してスローされないことを証明し、例外を完全に削除します。そうすることで、doSomethingを最適化して単に「OK」に置き換えられる可能性があります。

確かに素晴らしいですが、コンパイル時に定数値が既知であれば、コンパイラならどのコンパイラでも同じことができます。ただTruffleは、compilation finalと呼ばれる3つ目の定数性概念を導入することで、この状況を変えました。もし、あなたのインタープリタ実装で以下のような変数を宣言した場合、

@CompilationFinal private int a = 1;

アクセスされるタイミングに応じてその定数性概念が変わります。インタープリタ内部からは変更可能(mutable)です。このような変数を使用してインタープリタを実装します。ユーザのプログラムをロードする際に設定しますが、実行中にも設定する可能性があります。ユーザーのスクリプト内の関数がホットになった時点で、TruffleはGraalコンパイラと連動して、ユーザーのコードに対応するインタープリタの一部を再コンパイルします。この時点では、aは定数として扱われ、リテラル値1と同じになります。

これは、複雑なオブジェクトを含むあらゆる種類のデータに対して有効です。以下の非常に簡略化された擬似コードを考えてみましょう。

import com.oracle.truffle.api.nodes.Node;

class JavaScriptFunction extends Node {
    @CompilationFinal Node[] statements;

    Object execute() {
        for (var statement : statements) statement.execute();
    } 
}

これは、典型的な抽象構文木インタープリタで見られるようなクラスです。 statements配列はcompilation-finalとマークされています。 プログラムが最初にロードされたとき、配列をユーザーの JavaScript 関数が行うさまざまなことを表すオブジェクトで初期化することができます。なぜならmutable、変更可能だからです。次に、このオブジェクトが表す関数がホットになったと想像してみてください。Truffleはexecute()メソッドの特別なコンパイルを開始し、Graalに thisポインタを暗黙的にcompilation-finalとして扱うように指示します。オブジェクトが定数として扱われるため、this.statementsも定数として扱うことができます。これは、インタープリターヒープ上の特定のJavaScriptFunctionオブジェクトの正確な内容に置き換えられ、これによりコンパイラはexecute内のループを展開し、以下のように変換します。

Object execute() {
    this.statements[0].execute();
    this.statements[1].execute();
    this.statements[2].execute();
}

ここで、Nodeはスーパークラスであり、execute()は仮想メソッドですが、それは大したことではありません。リストがcompilation-finalなので、リストの個々のオブジェクトが定数化されるため、executeメソッドの仮想化を解除(実際の具体的な型に解決する)し、インライン化も可能です。

そして、このプロセスは続きます。最後に、コンパイラはユーザーのJavaScript(またはPython、C++、または実装している言語のいずれでも)のセマンティクスに一致するネイティブ関数を生成します。コンパイルされた特定のJavaScriptFunction.execute()メソッドの呼び出しは回避されるため、インタープリタから呼び出されると、インタープリタからネイティブコード、そしてネイティブコードからインタープリタへと遷移します。例えば、プログラムの動作が変更され、あなたがした楽観的な仮定が無効になったため、インタープリタが@CompilationFinalフィールドを変更する必要があると認識した場合でもまったく問題ありません。Truffleがよろしくやってくれて、プログラムをインタープリタ用に「最適化解除」します。最適化解除は、通常安全に実装するのが非常に難しい高度なテクニックです。これは、最適化されたCPUの状態をインタープリタの状態にマッピングすることを意味しますが、一度ミスがあれば、それが悪用される可能性があるからです(ここでテーマが明らかになっているかもしれません)。しかし、あなたはこれらのコードを一切書く必要はありません。Truffle がすべて処理してくれます。

Seminar: Dynamic Metacompilation with Truffle, with Christian Humer (Oracle Labs)

Why does this work?

部分評価がなぜ処理速度を向上させるのか、一見して理解できないかもしれません。

インタープリタが遅いのは、多くの判断をしなければならないからです。ユーザープログラムは何をしてもかまわないので、インタープリタは絶えず多くの可能性をチェックして、その瞬間にプログラムが何をしようとしているのかチェックしなければなりません。CPUにとって分岐やメモリの読み込みは高速実行が難しいため、プログラム全体が遅くなってしまうのです。この手法では、インタープリタをコンパイルする際に定数畳み込みを強化することで、分岐や読み込みを排除します。さらに、Truffleは高度な最適化や、JavaScriptまたはインタープリタを持つ他の言語向けの機能を簡単に実装できるAPIを構築します。

Language Implementations
https://www.graalvm.org/latest/graalvm-as-a-platform/language-implementation-framework/Languages/

例えば、仮定を使用するためのシンプルな API を提供します。これは、エッジケースを処理するためのコードを含めないことでより高速に実行できるコードを JIT コンパイルする方法です。そのようなエッジケースが発生した場合は、コンパイル済みのコードを破棄し、エッジケースが発生したことを考慮して再コンパイルできます。

Recompilation

「再コンパイル」についてこれまでに簡単に触れましたが、それがどのようにして可能になるのかについては説明しませんでした。インタープリタはネイティブコードである、と述べたのはご存じですよね?

ネイティブイメージとともにインタープリタをAOTコンパイルし、ユーザーのコンピュータへの配布準備が完了したときに、GraalコンパイラはTruffleを使用するプログラムをコンパイルしていることを認識しました。GraalとTruffleは共同開発されているため、それぞれ独立して使用できますが、一緒に使用するとお互いを認識し、連携します。

Graalは、Truffle言語をAOTコンパイルしていると認識すると、いくつかの方法で動作を変更します。まず、出力プログラムに自身のコピーを追加します。次に、プログラムの静的解析を行い、インタープリタメソッドを発見し、その結果として生成された実行ファイルに保存しますが、少し工夫があって、2個以上のバージョンを保存します。1つのバージョンは直接実行可能なマシンコードで通常の汎用インタープリタです。もう 1 つは、Graal の中間表現(IR)を慎重にエンコードしたものです。IR は、ユーザーが書いたソースコードと最終的に実行される機械語コードの中間のようなものです(GraalのIRはオブジェクトグラフです)。Graal はまた、ガベージコレクタもコンパイルします。これは、高度で成熟したG1 GC(Oracle GraalVMを使用している場合)、または純粋なJavaで書かれたよりシンプルなGC(GraalVM Community Edition を使用している場合)のいずれかです。

genscavenge
https://github.com/oracle/graal/tree/master/substratevm/src/com.oracle.svm.core.genscavenge/src/com/oracle/svm/core/genscavenge

ユーザー関数がホットになると、Truffleは埋め込みIRの「ユーザー関数を実行する」ノードを検索し、それを部分評価します。この評価は、グラフIRの解析と交互に行われ、プロセスを可能な限り効率的にします。定数畳み込みにより到達不可能であることが既に証明されている場合は、その関数は実行されないため、デコードもコンパイルによる確認も行われません。これにより、コンパイル中のメモリ使用量を低く抑えることもできます。

My only friend, the end

これで終わりです! このように、GraalJS では、クラス全体の微妙な安全上のバグが排除されます。言語のセマンティクスがメモリセーフなインタプリタによって定義され、部分的に評価されるため、生成されたマシンコードも構造上メモリセーフになるのです。

元のブログ記事で取り上げられているV8サンドボックスについてはどうでしょうか? ポインタをヒープベースからのオフセットとして表現するというアイデアは素晴らしいもので、GraalVM のネイティブコンパイルされたバイナリではすでに使用されています。

Isolates and Compressed References: More Flexible and Efficient Memory Management via GraalVM
https://medium.com/graalvm/isolates-and-compressed-references-more-flexible-and-efficient-memory-management-for-graalvm-a044cc50b67e
https://logico-jp.io/2019/03/27/isolates-and-compressed-references-more-flexible-and-efficient-memory-management-via-graalvm/

しかしこれはパフォーマンス向上のために行われているものです。というのも、他のメモリ安全機構によりヒープ上書きの緩和は必要ないためです。

上記の機能はいずれもJavaScript特有のものではなく、Truffleの利点はセキュリティとパフォーマンスに限ったものでもありません。実際Truffle は、

  • デバッグ(Chrome デバッガーのワイヤプロトコルを使用)
  • Java/Kotlin/その他 Truffle 言語との言語相互運用
  • 高速正規表現エンジン
  • 高速外部関数インターフェース(foreign function interface)
  • プロファイリングツール
  • ヒープスナップショット

など、多くの機能を言語に自動的に追加します。

Truffle Language Implementation Framework
https://www.graalvm.org/latest/graalvm-as-a-platform/language-implementation-framework/

Truffleは、AppleのPkl設定言語など、このような機能があるとは考えられない言語を含む、数十の言語の30以上の言語VMを構築するために使用されてきました。

Language Implementations
https://www.graalvm.org/latest/graalvm-as-a-platform/language-implementation-framework/Languages/
Pkl – Configuration that is Programmable, Scalable, and Safe
https://pkl-lang.org/index.html

この記事を読んで興味を持った方は、その仕組みについて、以下のドキュメントやTech talk動画をご覧ください。

Truffle Language Implementation Framework
https://www.graalvm.org/latest/graalvm-as-a-platform/language-implementation-framework/

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください