スプーキーズのちょっとTech。

SPOOKIES社内のより技工的な、専門的なブログページです。

仮想マシン上で動作するJavaScriptコードに出会った話

キッカケ

日頃から、Webサイトを閲覧する際に「このサイトはどのようなコードで動作しているのか」という観点で、DevToolsを開いて実装を確認することが多い。

ある日、そうした調査の中で、通常のJavaScriptとは明らかに異なる構造を持つコードに遭遇した。確認してみると、それは単なる難読化ではなく、仮想マシンそのものをJavaScript上に実装し、その上で独自の命令列を実行するという手法が用いられていた。

Javaのように、元のコードを一度独自バイトコードへコンパイルし、それを仮想マシン上で解釈実行する構成になっており、ブラウザ上で動作するJavaScriptとしては非常に異質なものである。

構造を理解しようとコードを追い始めたものの、静的に読むだけでは処理内容を把握するのは困難だった。しかし同時に、「これは解析対象として非常に興味深い」と感じ、本格的に挙動を調査してみることにした。これが、今回の解析を始めたきっかけである。

仮想マシンベースの難読化とは

通常の難読化といえば、変数名を意味不明な文字列に置き換えたり、文字列リテラルを暗号化したりする手法が一般的です。しかし、仮想マシンベースの難読化は全く異なるアプローチを取ります。

  1. 元のJavaScriptコードを独自の命令セットにコンパイル
  2. その命令を実行する仮想マシンをJavaScriptで実装
  3. 実行時に仮想マシンが命令を解釈・実行する

実際のコード例

実際に見つけたコードは、概ね次のような構造をしていました

// デコード処理
var B = S("ながーいバイトコード", "C8p6e3HND=F...");

...

// 命令関数一覧
var P = new Proxy("ながーいバイトコード".split("|"), {
    get: function(T, n) {
        return new Function(u(T[r[Number(n)]], "7pjXi...").map(...).join(""))();
    }
});

...

var d = {
    d: [0],
    v: {},
};

function w(e, r) {
    e.d[g(e)] = r;
}

function A(v, u, f, a) {
    var r = v[u[0]++];
    if (r !== f[0]) {
        if (r === f[5]) return !1;
        if (r === f[1]) return !0;
        if (r === f[4]) return null;
        ...
        return u[r >> 5];
    }
};

function main(e) {
    while (true) {
        var _ = P[B[e.d[0]++]];
        var o = _(e, ...);
        if (o === null) {
            break;
        }
    }
}

ざっくり言うと、Eが命令列、Nが命令ハンドラー、dが仮想マシンの状態、lがメインの実行ループになっています。通常のJavaScriptとは全く異なる構造で、一見しただけでは何をしているのか全く分かりません。

解析・デコードをしていく

さて、このコードを解析していくわけですが、今回の目標はシンプルに設定しました。

  • 命令処理の一覧を出力する
  • 制御フローを可視化する

「よし、デコーダーを1から書くぞ!」と意気込んでみたものの、すぐに現実を思い知らされます。仮想マシンの命令セットを完全に解析して、デコーダーを実装するのは途方もない作業です。 そこで方針転換。デコード処理を1から書くのではなく、元のJavaScriptコードに解析用のコードを挿入して、動的に解析することにしました。つまり、仮想マシンを実際に動かしながら、その挙動をトレースしていくわけです。

  1. メインループに命令の実行ログを挿入
var _ = P[B[e.d[0]++]];
var evaluatePosition = e.d[0] - 1;
var o = _(e, ...);
console.debug(JSON.stringify({
    "evaluatePosition": evaluatePosition,
    "instructionText": _.toString()
}));
  1. オペランド読み取り処理にログを挿入
function b(e) {
    var witnessedValue = zx(E, e.d, I);
    console.debug(JSON.stringify({
        "evaluatePosition": e.d[0],
        "accessDirection": "read",
        "witnessedValue": witnessedValue
    }));
    return witnessedValue;
}
  1. 値書き込み処理にログを挿入
function w(e, r) {
    var evaluatePosition = g(e);
    console.debug(JSON.stringify({
        "evaluatePosition": evaluatePosition,
        "accessDirection": "write",
        "witnessedValue": r
    }));
    e.d[evaluatePosition] = r;
}

この状態でJSDOMを用いて実行すると以下のようなログが得られます。

...
{"evaluatePosition":147208,"accessDirection":"read","witnessedValue":"t"}
{"evaluatePosition":147211,"accessDirection":"read","witnessedValue":"t"}
{"evaluatePosition":4,"accessDirection":"write","witnessedValue":true}
{"evaluatePosition":147204,"instructionText":"function(n,e,a){a(n,e(n)===e(n))}"}
...
{"evaluatePosition":375,"accessDirection":"read","witnessedValue":73}
{"evaluatePosition":376,"accessDirection":"read","witnessedValue":165}
{"evaluatePosition":7,"accessDirection":"write","witnessedValue":73}
{"evaluatePosition":374,"instructionText":"function(n,e,a,v,i,r){var o=r[5];a(n,o(n)%o(n))}"}
...
{"evaluatePosition":115656,"accessDirection":"read","witnessedValue":"Object"}
{"evaluatePosition":115659,"accessDirection":"read","witnessedValue":"stringify"}
{"evaluatePosition":115660,"accessDirection":"read","witnessedValue":"function(){var s=t();s.d[3]=arguments;for(var f=0;f<arguments.length;f++)s.d[f+4]=arguments[f];return s.d[1]={Z:this,d:[0],v:[],m:u,o:d,n:u==null?void 0:u.n},s.d[0]=l,b(s),s.d[2]}"}
{"evaluatePosition":115659,"instructionText":"function(n,e){e(n)[e(n)]=e(n)}"}

ログから見えてきたこと

出力されたログを見れば、仮想マシンの動きがある程度わかる。

例えば115659の命令は

  • 115656で"Object"を読み取り
  • 115659で"stringify"を読み取り
  • 115660で関数を読み取り

つまりこれは、Object["stringify"] = function() {...} のようなプロパティの設定処理を行っていることがわかります。 命令自体は抽象的ですが、実際に読み取られている値と組み合わせることで、具体的な動作が見えてくるわけです。

難読化の本質

VMを用いた難読化は強力です。コードを見ただけでは、何をしているのか全く理解できません。 しかし、今回の解析を通して得られた知見は、「完全に理解しなくても、動かして観察すれば大体わかる」 ということでした。 静的解析は困難でも、動的解析なら十分に追跡可能です。ログを仕込んで実行すれば、仮想マシンが「実際に何をやっているか」は見えてきます。 難読化されたコードと向き合うときは、「完璧に理解しよう」とするよりも、「必要な情報だけ抽出しよう」という姿勢の方が、結果的に近道になることが多いのかもしれません。