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')(本体が文字列リテラル) | 正しく動作します(vmForceCompileDynamicCode が有効な場合を除きます。回避策を参照してください)。この呼び出しを含む関数と、その内部で定義されたすべての関数は 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 の両方を報告し(VMDynamicCodeSkipped は、本来 VM がコンパイルするはずの関数、つまり除外されておらず、comment モードではマークされている関数についてのみ報告されます。ネストされた関数の場合は、スキップされたトップレベルの祖先関数の名前が示されます)、静的な eval('...') は VMDynamicCodeSkipped だけを報告します。間接 eval と完全に静的な new Function は何も報告しません。ファイルのトップレベル(どの関数にも含まれない位置)にある動的な呼び出しは、決して報告されません。警告の種類と CI 用のスニペットについては、下の実行前に問題を検出するを参照してください。
スキップは IIFE を通じて波及します。 トップレベルの IIFE がバンドル全体を包んでいて、その内部のいずれかの関数が直接の eval(静的か動的かを問わず)または動的な new Function / Function の呼び出しを使っている場合、IIFE 全体が VM のバイトコード化から除外されます。影響範囲を限定する IIFE の展開パターンについては、直接 eval() による VM 難読化の無効化を参照してください。
静的と動的で扱いが異なる理由
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 つです。どちらも関数内の呼び出しについてのみ報告されますが、.toString() のケースは例外で、シリアライズされる関数が別の関数の内部にネストされている場合や解決できない場合は、呼び出しの位置に関係なく報告されます(VM ビルドのみ)。
DynamicCodeRenameRisk:関数が実行時に文字列からコードを組み立てています。直接のeval、本体が静的でないnew Function/Functionの呼び出し、または<script>や Worker に注入されるfn.toString()です。これはReferenceErrorを予告する警告です。VMDynamicCodeSkipped:直接のevalまたは動的なnew Function/Functionの呼び出しを含むため、関数の VM バイトコード化がスキップされました。root モード(デフォルト)では、その内部で定義されたすべての関数もスキップされます。comment モードでは、個別にマークしたネストされた関数は、それ自体がその呼び出しを含まない限り引き続き仮想化されます。安全な静的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警告を抑制します。スコープを修復することはできません。強制的にコンパイルされた関数の内部では、コードが文字列リテラルであっても、直接evalはその関数のローカル変数やパラメーター、同じく仮想化される外側の関数の変数を読み書きできません。読み取りはReferenceErrorをスローし、書き込みは何も知らされずに失われます。この状況の静的なeval('...')は警告をまったく報告しないため、CI のゲートでは検出できません。DynamicCodeRenameRiskが出力されるのは、引き続き本体が動的な場合だけです。評価されるコードがグローバル変数以外を何も参照しない場合にのみ使用してください。ダッシュボードでは、VM セクションの 上書き設定 グループにあるForce Compile Dynamic Code スイッチがこれに該当します。
関連ページ
- 直接 eval() による VM 難読化の無効化:スキップの影響範囲を限定するための IIFE の展開パターン。
- 特定の関数を対象にする:
vmTargetFunctionsModeを使って関数単位でオプトインまたはオプトアウトする方法。
