Duktapeバイトコード形式
概要
Duktapeには、コンパイルした関数をバイトコードにダンプしたり、バイトコードダンプから関数をロード(再定義)するためのAPI関数があります。バイトコードダンプ/ロードにより、コードをオフラインでコンパイルしたり、コンパイルしたコードをキャッシュして再利用したり、コンパイルしたコードをあるDuktapeヒープから別のヒープに移動させたりすることができます。ただし、Duktapeバイトコード・フォーマットはバージョンに依存するため、Javaバイトコードのようなバージョンに依存しないコード配布フォーマットではありません。(「バイトコード」という用語は、ここや他のDuktapeのドキュメントで使われていますが、少し不正確です:シリアル化フォーマットには、バイトコード命令以外にも多くのフィールドがあります)
Duktapeのバイトコードはバージョン固有であり、(潜在的に)設定オプション固有であり、マイナーリリースでも任意に変更される可能性があります(ただし、パッチリリースでは、設定オプションが同じである限り、変更しないことが保証されています)。言い換えれば、バイトコード形式は通常のバージョン保証の一部ではありません。オフラインでコードをバイトコードにコンパイルする場合、Duktapeのソースが更新されるたびに、そのようなコードが再コンパイルされるようにしなければなりません。この意味で、Duktapeのバイトコードは、バージョン中立の配布形式として使用されるJavaバイトコードなどとは根本的に異なります。
Duktapeのバイトコードはunvalidatedであるため、信頼されていない、あるいは壊れたバイトコードをロードすると、クラッシュや他のメモリの安全でない動作を引き起こし、潜在的に悪用可能な脆弱性につながることがあります。呼び出し側のコードは、異なるDuktapeバージョンのバイトコードがロードされないこと、およびバイトコード入力が切り詰められたり破損したりしないことを保証する責任があります。(バイトコードの検証は非常に難しく、存在しないレジスタや定数を参照したり、境界を飛び越えたりする可能性のある実際のバイトコードも検証する必要があるためです)
バイトコード形式はプラットフォームニュートラルであり、あるプラットフォームでバイトコードをコンパイルし、別のプラットフォームでそれをロードすることが可能である。これはクロスコンパイルにおけるオフラインコンパイルをサポートするために有用である。
どのような種類の関数をバイトコードにダンプできるか、また、その過程でどのような情報が失われるかについては、いくつかの制限があります。以下の制限に関する別のセクションを参照してください。以下のAPIテストケースは、使用方法と現在の制限について具体的な例を示しています:
api-testcases/test-dump-load-basic.c
バイトコードで作業する
関数をバイトコードを含むバッファに変換するには、duk_dump_function()
APIコールを使用します:
duk_eval_string(ctx, "(function myfunc() { print('hello world'); })");
duk_dump_function(ctx);
/* -> stack top contains bytecode for 'myfunc' */
duk_load_function()
はその逆で、バイトコードを含むバッファを関数オブジェクトに変換するAPIコールである:
/* ... push bytecode to value stack top */
duk_load_function(ctx);
/* -> stack top contains function */
また、ファイルをバイトコードにコンパイルするために、Duktapeコマンドラインツール︓「duk」を使用することができます:
./duk -c /tmp/program.bin program.js
入力されたソースはECMAScriptプログラムとしてコンパイルされ、バイトコードは "プログラム関数 "に対応したものになります。コマンドラインツールは、個々の関数のコンパイルには対応しておらず、バイトコードで遊ぶのに便利です。
コマンドラインツールはバイトコード関数を実行することもできます。プログラムの関数が実行されているかのように、関数をロードして引数なしで呼び出すだけです:
./duk -b /tmp/program.bin
バイトコードダンプ/ロードを使用する場合
一般に、バイトコードダンプ/ロード機構を使用する動機は主に2つあります:
- パフォーマンス
- 難読化
Duktapeのバイトコード形式は、コンパイルに比べてパフォーマンスが向上しますが、以下で詳しく説明するように、難読化には適していません。
パフォーマンス
コンパイルの性能が問題でない場合は、バイトコードダンプ/ロードを使用するよりも、ソースから関数をコンパイルすることがほとんど常に望ましいです。ソースからのコンパイルはメモリ安全で、バージョン互換性があり、バイトコードのような意味上の制限もありません。
コンパイルが性能上の問題となるアプリケーションもあります。例えば、ある関数がコンパイルされ、短命のDuktapeグローバル・コンテキスト、あるいは別々のDuktapeヒープ(1つの関数オブジェクトを再利用できない)で何度も何度も実行されることがあります。コンパイルされた関数のバイトコードをキャッシュし、バイトコードをロードして関数をインスタンス化することは、実行のたびに再コンパイルするよりもはるかに高速です。
Obfuscation
難読化は、バイトコードを使用するもう一つの一般的な理由です。バイトコードからソースコードをリバースエンジニアリングするのは、例えば最小化されたコードよりも困難です。しかし、難読化する際には、以下の点に注意する必要があります:
- ミニファイアの中には難読化をサポートするものがあり、バイトコードの制限や欠点を回避することができますので、十分な効果が期待できます。
- ターゲットによっては、難読化のためにバイトコードに依存するよりも、ソースコードの暗号化の方が良い選択肢になるかもしれません。
- Duktapeのバイトコードは現在ソースコードを保存しませんが、一部の関数で必要となるすべての変数名(
_Varmap
)と正式な引数名(_Formals
)は保存されます。また、デバッグをサポートするために、ある時点でバイトコードにソースコードが含まれる可能性もある。言い換えれば、難読化はバイトコードフォーマットの設計目標ではありません。
That said, concrete issues to consider when using bytecode for obfuscation:
_Varmap
プロパティの変数名:これは一般的に簡単に回避することはできませんが、minifierは変数の名前を変更できるかもしれません。name
プロパティの関数名:これは関数をダンプする前に削除または変更することができますが、一部の関数(自己再帰関数など)は、このプロパティが存在し、正しいかどうかに依存する場合があることに注意してください。fileName
プロパティの関数ファイル名:これも関数をダンプする前に削除または変更することができます。関数のコンパイルにduk_compile()
(例えばduk_eval_string()
ではなく) を使用することで、ファイル名を導入することを全く避けることができます。- 行番号情報は
_Pc2line
プロパティに格納されます。この情報は削除または変更することができますし、そもそもこの情報を格納しないように Duktape を設定することもできます(DUK_USE_PC2LINE
オプションを使用)。ライン情報がない場合、トレースバックはもちろん有用ではありません。
バイトコードダンプ/ロードを使用しない場合
Duktape bytecodeは not にマッチしています:
- コードの配布
- コードサイズの最小化
コードの配布
コード配布にバージョン固有のバイトコード形式を使用するのは厄介だ。特にECMAScriptの場合、言語自体が後方互換性のあるコードを書いたり、実行時に機能を検出したりするのに適しているため、この傾向が顕著です。
また、バイトコードロード操作は、ロードされたバイトコードが信頼でき、破損していないことを保証するために呼び出しコードに依存するため、コードの配布には厄介です。実際には、改ざんを防ぐために暗号化署名などが必要です。
コードサイズの最小化
バイトコード形式は、プラットフォームに依存せず、ダンプとロードを高速に行うように設計されています。コンパクトになるようには設計されていません(実際、そうです)。
例えば、単純なマンデルブロ関数(dist-files/mandel.js
のmandel()
)の場合:
Format | Size (bytes) | Gzipped size (bytes) |
---|---|---|
Original source | 884 | 371 |
Bytecode dump | 809 | 504 |
UglifyJS2-minified source | 364 | 267 |
コードサイズを最小化するためには、圧縮または非圧縮のバイトコードに依存するよりも、ミニファイアと通常の圧縮を使用する方がはるかに良いアイデアです。
バイトコードの制限
関数の辞書的環境が失われる
バイトコードからロードされた関数は、常にグローバル環境で定義されたかのように動作し、関数自体にバインドされていない変数のルックアップは、グローバルオブジェクトを介して解決されます。として作成された bar
をシリアライズすると、以下のようになります:
function foo() {
var myValue = 123;
function bar() {
// myValue will be 123, looked up from 'foo' scope
print(myValue);
}
return bar;
}
として作成し、再度読み込むと、元々作成されていたような挙動をします:
function bar() {
// myValue will be read from global object
print(myValue);
}
元の関数が関数宣言を使って確立されていた場合、関数がロードされても宣言自体は復元されません。このため、混乱することがあります。例えば、foo
と宣言されたものをシリアライズした場合:
function foo() {
// Prints 'function' before dump/load; 'foo' is looked up from
// the global object.
print(typeof foo);
}
という挙動になり、それをロードし直すと、そのような挙動になります:
var loadedFunc = (function() {
// Prints 'undefined' after dump/load; 'foo' is looked up from
// the global object. Workaround is to assign loadedFunc to
// globalObject.foo manually before calling to simulate declaration.
print(typeof foo);
});
関数宣言の関数名バインディングがない
関数式の関数名バインディングに対応しており、例えば以下の関数が動作します:
// Can dump and load this function, the reference to 'count' will
// be resolved using the automatic function name lexical binding
// provided for function expressions.
var func = function count(n) { print(n); if (n > 0) { count(n - 1); } };
しかし、技術的な理由により、グローバル宣言として確立された関数は、少し違った働きをします:
// Can dump and load this function, but the reference to 'count'
// will lookup globalObject.count instead of automatically
// referencing the function itself. Workaround is to assign
// the function to globalObject.count after loading.
function count(n) { print(n); if (n > 0) { count(n - 1); } };
(NAMEBINDINGフラグは、関数式の名前バインディングを含む字句環境の作成を制御します。Duktape 1.2では、このフラグは関数インスタンスではなく関数テンプレートに対してのみ設定されます。Duktape 1.3では、バイトコードのロード時にNAMEBINDINGフラグを検出し、そのフラグに基づいて字句環境を作成できるように変更されました)
カスタム内部プロトタイプが失われる
カスタム内部プロトタイプは失われ、バイトコードロード時に Function.prototype
が使用されます。
カスタム外部プロトタイプが失われる
カスタム外部プロトタイプ(.prototype
プロパティ)は失われ、バイトコードのロード時にデフォルトの空のプロトタイプが作成されます。
機能上のファイナライザーが失われる
シリアライズされる関数のファイナライザが失われ、バイトコードロード後にファイナライザは存在しない。
特定の関数オブジェクトのプロパティのみが保持されます
特定の関数オブジェクトのプロパティ、すなわち、関数を正しく復活させるために必要なプロパティのみが保持されます。これらのプロパティには、型と値の制限があります:
- .length: uint32、数値以外の値は0に置き換わる
- .name: 文字列必須、文字列以外の値は空文字列に置き換わる
- .fileName: 文字列必須、文字列以外の値は空文字列に置換されます。
- ._Formals: 内部プロパティで、値は文字列の配列です。
- ._Varmap: 内部プロパティ、値は識別子名とレジスタ番号をマッピングしたオブジェクト。
バウンド機能には対応していません
現在、バインドされた関数オブジェクトをシリアライズしようとすると TypeError
がスローされます。
CommonJS modules don't work well with bytecode dump/load
CommonJSモジュールは、通常、モジュールのソースコードを一時的な関数ラッパーに埋め込んで評価されるため、トリビアルにシリアライズすることはできません(詳細は modules.rst
を参照)。ユーザーコードは、一時的にラップされた関数にアクセスすることができません。これは、次のことを意味します:
- モジュールのソースをコンパイルしてシリアライズすると、モジュールのスコープセマンティクスが不正確になります。
- 関数のラッパーを追加して、代わりにラップされた関数をコンパイルすることができます。
- バイトコードダンプ/ロードに対するモジュールのサポートは、おそらく将来の作業が必要でしょう。
バイトコード形式
関数は、プラットフォームに依存しないバイトストリームにシリアライズされます。多バイトの値はネットワーク順(ビッグエンディアン)であり、アライメント保証はない。
正確なフォーマットはバージョンに依存するため、ここでは完全な詳細については文書化されていません。そうすると、バイトコードが変更されるたびに面倒な文書の更新が必要になり、文書が古くなりやすくなります。正確なフォーマットは、最終的にはソースコードで定義されます:
src-input/duk_api_bytecode.c
tools/dump_bytecode.py
バイトコード形式の簡略化したまとめとして:
- 有効な拡張UTF-8文字列では決して出現しない0xBFのマーカーバイトです。(以前はバージョンバイトがマーカーに続いていたが、Duktape 2.2ではバンプされていないことと、バージョン保証がないことから削除された)
- マーカの後には、シリアライズされた関数が続きます。この関数は、(2バイトのヘッダーを重複させることなく)再帰的にシリアライズされる内部関数を含むことができます。
関数のシリアライズ形式は面倒なので、ソースコードから直接調べるのが一番です。
注:トップレベルの関数は関数インスタンスですが、すべての内部関数は関数テンプレートです。この2つの間には、バイトコード直列化コードで考慮しなければならないいくつかの違いがあります。
セキュリティとメモリの安全性
Duktapeのバイトコードは、信頼できるソースからのみロードする必要があります。壊れたバイトコードや悪意を持って細工されたバイトコードをロードすると、メモリが安全でない動作や、悪用可能な動作につながる可能性もあります。
バイトコードはバージョンに依存するため、ネットワーク・ピアから提供されたバイトコードをロードすることは、Duktapeのバージョン用に特別にコンパイルされたバイトコードであることが何らかの形で確認できない限り、一般的に安全ではありません。
デザインノート
Evalとプログラムコード
ECMAScript仕様では、プログラムコード、evalコード、ファンクションコードの3種類のコードを認識し、スコープと変数バインディングのセマンティクスが若干異なっています。シリアライゼーション機構は、この3つのタイプのすべてをサポートします。
バージョン固有とバージョン中立
Duktapeのバイトコード命令形式は、すでにバージョンに依存し、マイナーリリースでも変更される可能性があります。
バージョンニュートラルなフォーマットの提供は、Duktapeのバイトコードがマイナーバージョンで変更されなくなった時(これがいつになるかは簡単ではない)、あるいはバイトコードに対して何らかの再コンパイルを行うことで可能となる。
コンフィグオプション固有
Duktapeのオプションの中には、どのような関数メタデータが利用できるかに影響を与えるものがあります。例えば、行番号情報(pc2line)を無効にすると、バイトコードダンプから完全に除外される可能性があります。行番号情報を有効にしてコンパイルしたDuktape環境で、このようなダンプをロードしようとすると、フォーマット・エラーで失敗するかもしれません。
(最初のマスターマージでは、設定オプションによるフォーマットの違いはありませんが、Duktapeの後のバージョンでは、都合によりそのような違いが出てくるかもしれません)
エンディアン
ネットワーク・エンディアンが選ばれたのは、Duktapeの他の場所(デバッガ・プロトコルなど)でも、デフォルトで移植可能なエンディアンとして使われているからです。
バイトコードのダンプ/ロードを高速化するには、ネイティブのエンディアンを使用し、(必要に応じて)パディングを使用して適切なアライメントを達成する必要があります。この追加の速度向上は、移植性よりも重要度が低いと考えられていた。
プラットフォーム中立性
クロスコンパイルをサポートすることで、あるプラットフォームで生成されたバイトコードを、同じDuktapeのバージョンが動作する限り、別のプラットフォームで読み込むことができる便利な機能です。
プラットフォームニュートラルであることの代償は、むしろ小さい。本質的な特徴は、エンディアンの正規化とアライメントの仮定を回避することです。この2つは、比較的小さなランタイムコストで非常に簡単に対応することができます。
バイトコードヘッダー
初期値の0xBFバイトは、有効なUTF-8(拡張UTF-8でも)では決して出現しないため、バイトコード入力として誤ってランダムな文字列を使用すると失敗するため、使用されています。
メモリの安全性とバイトコードの検証
バイトコードロードプリミティブは、破損した(切り詰められた、あるいは変更された)バイトコードをロードしようとすると、メモリに安全でない動作(悪用可能な動作さえも)につながる可能性があるため、メモリに安全でない。バイトコードロードを高速かつ単純に保つために、入力バイトコードを解析する際の境界チェックもない。
シリアライズされたデータをロードする際に基本的な構文検証を行うことは簡単ですが、それでもメモリ安全性を保証することはできません。そうするためには、バイトコードのオペコードも検証する必要があります。そうしないと、実行時にメモリ安全でない動作が発生する可能性があります。
ロードされる関数には nregs
100 があり、その関数のために値スタックから100個のスロットが割り当てられると考える。その後、関数のバイトコードが実行された場合:
LDREG 1, 999 ; read reg 999, out of bounds
STREG 1, 999 ; write reg 999, out of bounds
定数についても同様の問題があり、関数に100個の定数があるとします:
LDCONST 1, 999 ; read constant 999, out of bounds
境界外直接参照に加えて、例えばレジスタのインデックスを別のレジスタからロードする「間接参照」オペコードも存在します。これらを検証するのはもっと難しく、基本的な制御フローのアルゴリズムなどが必要です。
全体として、壊れたバイトコードや悪意を持って作られたバイトコードを正しく検出するバイトコード検証を実装するのはかなり難しいでしょう。
それでも、バイトコード用の非常にシンプルなヘッダ署名があり、明らかに不正な値が早期に拒否されるようになっています。この署名は、通常の文字列データが誤ってバイトコードとして読み込まれないようにするものです(最初のバイト0xBFは拡張UTF-8では無効です)。マーカーを超えたバイトは検証されない。
今後の課題
フルバリューシリアライズ
バイトコードダンプ/ロードは、関数値のサブセットに制限されています。汎用的な値のダンプ/ロードをサポートする方がよりエレガントでしょう。しかし、いくつかの実用的な問題があります:
- 任意のオブジェクトグラフをサポートする必要があり、これは非常に困難である。
- ロード時にネイティブの値を復活させるメカニズムも必要だ。例えば、開いているファイルを表すネイティブオブジェクトの場合、復活操作はファイルを開き直し、おそらく正しいオフセットにファイルをシークする。
バウンドファンクションに対応
現在、バインドされた関数に対してTypeErrorが投げられます。最初のステップとして、バウンドチェーンに従って、代わりに最終的なターゲット関数をシリアライズする方が良いかもしれません、つまり、バウンド状態はシリアライズ中に失われます。これは,エラーを投げるよりも,メタデータを失いながらシリアライズすることに近いと思います.
第二段階として、バインドされた this
と引数の値をシリアライズすることができればよいでしょう。しかし、そのためには、適切な汎用値のシリアライズが必要であろう。
CommonJS モジュールのキャッシュ化
CommonJSのモジュールのキャッシュは非常に便利です。モジュール機構を作り直すときに、それを行う方法を考えましょう。
デバッガーのオーバーラップを把握する
デバッガプロトコルは、独自の値のシリアライズフォーマット(やや異なる目標を持つ)を持っています:
- ダンプ/ロードとデバッガ・プロトコルの間で値のシリアライズ形式を共有することは賢明でしょうか?
- デバッガ・プロトコルで関数値をバイトコード・ダンプ/ロード・フォーマットでシリアライズすべきでしょうか?それはデバッガにとって有用でしょうか(その理由はすぐには分かりませんが)?