ドキュメント
/
レシピ
/

LLM の解析から関数名を隠す

LLM の解析から関数名を隠す

問題

vmObfuscation: true を有効にして、validateLicense のような関数を含むファイルに対して実行したところ、 難読化された出力にまだリテラルのテキスト validateLicense が含まれていることに気づきました。本体は消えて バイトコードに置き換わっているのに、名前そのものは丸見えの状態でそこに残っています。

// Input
function validateLicense(token) {
    const decoded = decodeBase64(token);
    return verifySignature(decoded);
}

// Output - name is preserved, body is bytecode
function validateLicense(b) {
    return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
}

これを実際のコードベース全体に広げると、validateLicensedecryptPayloadprocessPaymentcheckSubscription といった 関数名のリストが手に入ります。LLM は、プログラムが何をするかを理解するためにバイトコードを解読する必要はありません。 名前だけで、モジュールの挙動について自信を持った正確な要約を作るのに十分です。バイトコードは不透明ですが、 目次はそうではありません。

なぜ VM 難読化はこれらの名前を残すのか

vmTargetFunctionsMode: 'root'(デフォルト)では、難読化ツールはすべてのルートレベル関数の本体を VM バイトコードに 変換しますが、名前は意図的にそのままにします。ルートレベルの関数宣言は、意味的には周囲のスコープへの バインディングです。スクリプトにとってそれはグローバルオブジェクトを意味し、モジュールにとってはモジュールの 名前空間を意味します。難読化ツールはそれを安全にリネームできません。他の誰がそれを参照しているか(別のバンドル、 インラインの <script>、HTML の onclick="validateLicense(...)" 属性、動的な window['validateLicense'] の参照など)を 知る術がないからです。

つまり、デフォルトが行うトレードオフは、実装を保護し、公開されるインターフェースを保持することです。これは統合を 壊さずに保ちますが、同時に LLM がすべてのエントリポイントの索引をタダで手に入れることも意味します。

なぜこれが LLM 支援のリバースエンジニアリングにおいて重要なのか

数百行のバイトコードディスパッチに直面した人間の攻撃者は、たいてい諦めます。同じファイルを与えられた LLM は バイトコードを攻撃しようともしません。名前を読み、見える数少ない文字列リテラルと突き合わせ、次のようなものを 出力します:

その要約は、攻撃者が VM に一切触れることなく、狙いを定めたバイパスを計画するのに十分です。名前が漏洩源なのです。

修正方法: コードを IIFE でラップする

この漏洩を取り除く最もシンプルで堅牢な方法は、機密性の高い関数をスコープツリーの 1 段階深くへ押し込むことです。 別の関数の内部で宣言された関数はルートレベルではないため、難読化ツールはそれらを自由にリネームし、その宣言を 他の任意の文と同じようにバイトコードに取り込めます。

IIFE(即時実行関数式)は、それを行う最も軽量な方法です。一度だけ実行され、名前によって何も公開しない 単一のラッパー関数を追加します。

ビフォー - 名前が露出

function validateLicense(token) {
    const decoded = decodeBase64(token);
    return verifySignature(decoded);
}

function checkExpiry(license) {
    return Date.now() < license.expiresAt;
}

document.querySelector('#activate').addEventListener('click', () => {
    const token = document.querySelector('#token').value;
    if (validateLicense(token)) {
        unlockUI();
    }
});

VM 難読化後も、validateLicensecheckExpiry の両方が名前を保ったまま出力に残ります。

アフター - 名前が IIFE の背後に隠れる

(function () {
    function validateLicense(token) {
        const decoded = decodeBase64(token);
        return verifySignature(decoded);
    }

    function checkExpiry(license) {
        return Date.now() < license.expiresAt;
    }

    document.querySelector('#activate').addEventListener('click', () => {
        const token = document.querySelector('#token').value;
        if (validateLicense(token)) {
            unlockUI();
        }
    });
})();

これで両方の関数宣言が IIFE の本体の中に収まりました。IIFE 自体が唯一のルートレベルの構文であり、 無名の IIFE には漏洩する名前がありません。VM 難読化後は、本体全体 - その内部のすべての宣言を含めて - が バイトコードに変換されてエンコードされます。IIFE の中に読めるテキストとして残るものは何もありません。

関数が本当にグローバルである必要がある場合は?

関数が本当に公開のエントリポイントであることもあります。インラインのイベントハンドラ、JSONP コールバック、 サードパーティ SDK のフックなどです。選択肢は 2 つあります:

  • 薄いトランポリンを公開し、ロジックは IIFE の中に保つ。 IIFE スコープの実装を呼び出すだけの小さな グローバルラッパーを宣言します。トランポリンの名前は依然として漏洩しますが、意味的な情報は一切持ちません。 __entry1 のような名前を付ければ、意味のあるロジックはすべて隠されたままになります。

    var __entry1;
    (function () {
        function validateLicense(token) { /* … */ }
        __entry1 = validateLicense;
    })();
    // Outside code calls __entry1(token) instead of validateLicense(token).
  • 制御できない呼び出し箇所を書き換える。 グローバルが存在する理由が、インラインの onclick="validateLicense(...)" がそれを必要とすることだけなら、そのインラインハンドラを IIFE 内部からの addEventListener に置き換えます。 HTML は関数を名指しするのをやめ、関数はグローバルである必要がなくなり、漏洩は完全に消えます。

トップレベルの変数初期化子: vmWrapTopLevelInitializers

ファイルのルートに存在するのは関数宣言だけではありません。トップレベルの変数初期化子 - 文字列 定数、設定オブジェクト、ルックアップテーブル - も、デフォルトでは出力内で同じように読めます。 const API_BASE = '/api/v2/license' のような行は、function validateLicense と同じくらい LLM に情報を伝えます。

vmWrapTopLevelInitializers オプション(真偽値、デフォルトは false)は、対象となるトップレベルの初期化子を IIFE でラップし、 値そのものがソース内にリテラルとして存在する代わりに、実行時に VM バイトコードによって計算されるようにします。

オプションなし

// Input
const MY_STRING = 'my-string';

// Output - string is visible
const MY_STRING = 'my-string';

vmWrapTopLevelInitializers: true あり

// Input
const MY_STRING = 'my-string';

// Output - initializer is now a VM call, the string lives inside bytecode
const MY_STRING = (() => {
    return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
})();

バインディング名(MY_STRING)は、関数名がそうであるのと同じ理由で依然としてルートレベルです。ファイルの外部の 何かがそれを参照しているかもしれないからです。しかし、それが保持するは今や VM によって生成され、読めるテキストとしては もう現れません。

これでは不十分な場合

  • 他のモジュールからインポートされた名前。 複数のファイルをバンドルし、あるモジュールが validateLicense を別のモジュールがインポートするためにエクスポートしている場合、バンドラはその名前を、ルートレベル関数が見えるのと同じようにバンドル出力内で見える状態に保ちます。バンドル自体を IIFE でラップする(ほとんどのバンドラはこれができます)か、エクスポートを IIFE の中に移し、意味のないトランポリンを介して再公開します。