eval と new Function を含むコードの VM 難読化
VM 難読化が動的なコード生成(直接 eval と Function コンストラクタ)をどう扱うか、何がバイトコード化され何がスキップされるか、難読化ツールが出力する警告、そして実行時の ReferenceError を診断する方法を解説します。
なぜ問題になるのか
VM 難読化は関数本体をバイトコードにコンパイルし、ランタイム内のインタプリタを通じてディスパッチします。周囲のスコープにある識別子もリネームされます。どちらの変換も、実行時に文字列から組み立てられるコード、つまり eval(s)、new Function(...s)、Function(...s) とは相性がよくありません。実行時に組み立てられたコードが、難読化ツールによってリネームされた識別子を参照していると、生成された関数が最初に実行された時点で Uncaught ReferenceError: <renamed-name> is not defined が発生します。
難読化ツールはパターンごとに異なる扱いをします。下の表はその要約で、このページの残りの部分で各行を説明します。
難読化ツールの動作の概要
| ソース内のパターン | 何が起こるか |
|---|---|
eval('literal string')(本体が文字列リテラル) | 正しく動作します。この呼び出しを含む関数と、その内部で定義されたすべての関数は VM のバイトコード化の対象から外れます(直接 eval は周囲のローカル変数を読み取りますが、関数がバイトコードにコンパイルされると VM はそれらを保持しないためです)。 |
eval(dynamicExpression) | 実行時に ReferenceError でクラッシュする可能性があります。 この呼び出しを含む関数と、その内部で定義されたすべての関数も、VM のバイトコード化の対象から外れます。 |
(0, eval)(s) / window.eval(s)(間接) | この呼び出しを含む関数は通常どおり VM でバイトコード化されます。間接 eval はグローバルスコープで実行され、周囲のローカル変数を参照できないため、リネームされたローカル変数によって壊れることはありません。ただし、評価されるコードがリネームまたは削除されたグローバル変数を参照している場合は、失敗することがあります。 |
new Function('a', 'b', 'return a + b')(すべての引数が文字列リテラル) | 正しく動作します。この呼び出しを含む関数は通常どおり VM でバイトコード化されます。 |
new Function(dynamicBody) / Function(dynamicBody) | 実行時に ReferenceError でクラッシュする可能性があります。 この呼び出しを含む関数と、その内部で定義されたすべての関数も、VM のバイトコード化の対象から外れます。 |
コンパイラは eval?.(code) も保守的に検出し、直接 eval と同様に扱います。JavaScript の仕様ではこのオプショナル呼び出しの形式は間接 eval ですが、コンパイラの検出によってその言語上の動作が変わることはありません。
致命的ではない警告が出るのはこれらのパターンの一部だけで、しかも呼び出しが関数の内部にある場合に限られます。動的な eval、new Function、Function の呼び出しは DynamicCodeRenameRisk と VMDynamicCodeSkipped の両方を報告し、静的な eval('...') は VMDynamicCodeSkipped だけを報告します。間接 eval と完全に静的な new Function は何も報告しません。ファイルのトップレベル(どの関数にも含まれない位置)にある動的な呼び出しは、決して報告されません。警告の種類と CI 用のスニペットについては、下の 実行前に問題を検出する を参照してください。
スキップは IIFE を通じて波及します。 トップレベルの IIFE がバンドル全体を包んでいて、その内部のいずれかの関数が動的な eval や new Function を使っている場合、IIFE 全体が VM のバイトコード化から除外されます。影響範囲を限定する IIFE の展開パターンについては、直接 eval の挙動 を参照してください。
静的と動的で扱いが異なる理由
eval(s) は、呼び出された関数のローカル変数を読み書きできます。s が文字列リテラルであれば、難読化ツールは難読化の時点で本体をパースし、周囲のコードと整合するように識別子をリネームできます。s が動的な式の場合、パースは実行時まで行われません。その時点では識別子はすでにリネームされているため、実行時に組み立てられたソースは、もう存在しない古い名前を参照することになります。
new Function(s) と間接 eval は動作が異なります。本体は常にグローバルスコープで実行され、アクセスできるのはグローバル変数だけで、呼び出し周辺のローカル変数には決してアクセスしません。そのため、リネームされたローカル変数に依存することはありません。ただし、リネームまたは削除されたグローバル変数を参照している場合や、リネームされた識別子を連結して本体を組み立てている場合(たとえば、難読化ツールが内部を書き換えた関数の func.toString() を使う場合)は、失敗することがあります。
new Function('a', 'b', 'return a + b') のように自身の引数だけを使う静的な本体には、このような依存関係はありません。本体はリネーム処理が一切調べないただの文字列です。難読化ツールは呼び出しをそのまま残し、周囲の関数は引き続き VM のバイトコード化の対象になります。eval の構文を変えるだけでは、任意の動的コードが安全になるわけではありません。警告を確認し、最終的なバンドルをテストしてください。
実行時に表示されるエラー
よくある症状は、動的に組み立てられた関数が最初に実行されたときの ReferenceError です。
この TU は、難読化ツールがバンドルの IIFE スコープ内に導入した、リネーム後の識別子です。動的な eval や Function コンストラクタの呼び出しはこれを参照する本体を評価しますが、その本体は TU が定義されていないスコープで実行されます。
実行前に問題を検出する
難読化ツールは API を通じて致命的ではない警告を出力するため、出荷前に CI でこれらのパターンの一部を検出できます。関係する警告の種類は 2 つで、どちらも関数内の呼び出しについてのみ報告されます。
DynamicCodeRenameRisk:関数が実行時に文字列からコードを組み立てています。直接のeval、本体が静的でないnew Function/Functionの呼び出し、または<script>や Worker に注入されるfn.toString()です。これはReferenceErrorを 予告する警告です。VMDynamicCodeSkipped:直接のevalまたは動的なnew Function/Functionの呼び出しを含むため、関数とその内部で定義された すべての関数の VM バイトコード化がスキップされました。安全な静的eval('literal')でも出力されるため、実行時のクラッシュではなく VM による保護が失われたことを示します。関数名(取得できる場合)と、スキップの原因となった構文が含まれます。
javascript-obfuscator npm パッケージは結果に警告を公開しないため、CI のゲートは API のレスポンスから警告を読み取ります。
result と chunk_end メッセージには warnings 配列が含まれます。次の例は、API リファレンスのストリームリーダーである
readObfuscationResponse() を使い、DynamicCodeRenameRisk の場合にのみビルドを失敗させます。関数の VM による保護が失われた場合にも
リリースを止めたいときは、フィルターに VMDynamicCodeSkipped を追加してください。
ある警告の種類がビルドで想定内であり、CI で除外するよりも発生源で抑制したい場合は、warnings オプション(v7.8.0 以降)で出力される警告を制御できます。'none' はすべてを抑制し、{ VMDynamicCodeSkipped: false } のような種類ごとのマップは、他の警告を残したまま 1 つの種類だけを抑制します。
回避策
間接 eval(
(0, eval)(s))に切り替えるevalの場合にのみ有効です。間接 eval はグローバルスコープで実行されるため、周囲のローカル変数を参照できず、したがってリネームされたローカル変数を参照することもありません。呼び出しを含む関数は VM でバイトコード化されたままになります。ただし、評価されるコードが、難読化ツールがリネームするグローバル変数に依存していてはいけません。本体を完全に静的にする
new Functionの場合、本体を補間のない 1 つの文字列リテラルまたはテンプレートリテラルとして表現できれば、その呼び出しにリネームのリスクはなく、周囲の関数も引き続きバイトコード化されます。new Function('a', 'b', 'return a + b')は問題ありませんが、new Function('return ' + expr)は問題があります。呼び出しを独立したトップレベル関数に移し、IIFE を展開する
トップレベルの関数はそれぞれ個別にチェックされます。動的コードの呼び出しを独立したトップレベル関数に移せば、バンドル全体を包むトップレベルの IIFE を通じてスキップが波及するのではなく、その 1 つの関数だけが VM のバイトコード化の対象から外れます。
vmTargetFunctionsMode: 'comment'に切り替えるオプトイン方式のモードで、
/* javascript-obfuscator:vm */でマークした関数だけがバイトコード化されます。動的コードの呼び出しを含む関数には注釈を付けず、それ以外にマークを付けてください。これらのコメントは難読化の工程まで残しておく必要があります。対象関数の指定 を参照してください。vmForceCompileDynamicCode: trueでスキップを上書きする(v6.14.0 以降)最後の手段となる回避策です。有効にすると、難読化ツールは周囲の関数をそのままバイトコード化し、
VMDynamicCodeSkipped警告を抑制します。スコープやリネームされた識別子への依存を修復することはできません。実行時に組み立てられる本体が、難読化ツールがリネームする識別子を決して参照しないと保証できる場合にのみ使用してください。そうでなければ、問題のない難読化と引き換えに、実行時のReferenceErrorを抱えることになります。DynamicCodeRenameRiskは引き続き出力されるため、CI でのチェックに使い続けられます。ダッシュボードでは、VM セクションの 上書き設定 グループにある「Force Compile Dynamic Code」スイッチがこれに該当します。
関連ページ
- 直接 eval の挙動:スキップの影響範囲を限定するための IIFE の展開パターン。
- 対象関数の指定:
vmTargetFunctionsModeを使って関数単位でオプトインまたはオプトアウトする方法。
