
キッカケ
日頃から、Webサイトを閲覧する際に「このサイトはどのようなコードで動作しているのか」という観点で、DevToolsを開いて実装を確認することが多い。
ある日、そうした調査の中で、通常のJavaScriptとは明らかに異なる構造を持つコードに遭遇した。確認してみると、それは単なる難読化ではなく、仮想マシンそのものをJavaScript上に実装し、その上で独自の命令列を実行するという手法が用いられていた。
Javaのように、元のコードを一度独自バイトコードへコンパイルし、それを仮想マシン上で解釈実行する構成になっており、ブラウザ上で動作するJavaScriptとしては非常に異質なものである。
構造を理解しようとコードを追い始めたものの、静的に読むだけでは処理内容を把握するのは困難だった。しかし同時に、「これは解析対象として非常に興味深い」と感じ、本格的に挙動を調査してみることにした。これが、今回の解析を始めたきっかけである。
仮想マシンベースの難読化とは
通常の難読化といえば、変数名を意味不明な文字列に置き換えたり、文字列リテラルを暗号化したりする手法が一般的です。しかし、仮想マシンベースの難読化は全く異なるアプローチを取ります。
- 元のJavaScriptコードを独自の命令セットにコンパイル
- その命令を実行する仮想マシンをJavaScriptで実装
- 実行時に仮想マシンが命令を解釈・実行する
実際のコード例
実際に見つけたコードは、概ね次のような構造をしていました
// デコード処理 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コードに解析用のコードを挿入して、動的に解析することにしました。つまり、仮想マシンを実際に動かしながら、その挙動をトレースしていくわけです。
- メインループに命令の実行ログを挿入
var _ = P[B[e.d[0]++]]; var evaluatePosition = e.d[0] - 1; var o = _(e, ...); console.debug(JSON.stringify({ "evaluatePosition": evaluatePosition, "instructionText": _.toString() }));
- オペランド読み取り処理にログを挿入
function b(e) { var witnessedValue = zx(E, e.d, I); console.debug(JSON.stringify({ "evaluatePosition": e.d[0], "accessDirection": "read", "witnessedValue": witnessedValue })); return witnessedValue; }
- 値書き込み処理にログを挿入
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を用いた難読化は強力です。コードを見ただけでは、何をしているのか全く理解できません。 しかし、今回の解析を通して得られた知見は、「完全に理解しなくても、動かして観察すれば大体わかる」 ということでした。 静的解析は困難でも、動的解析なら十分に追跡可能です。ログを仕込んで実行すれば、仮想マシンが「実際に何をやっているか」は見えてきます。 難読化されたコードと向き合うときは、「完璧に理解しよう」とするよりも、「必要な情報だけ抽出しよう」という姿勢の方が、結果的に近道になることが多いのかもしれません。