こんにちは。みなさんもウェブアプリをリリースしたあとに同業者にソースごとパクられたことってありますよね。難読化しても難読化されたまま同業者のサーバで動くので困ったものです。そこで、私がとった解析しずらい対策をまとめてみたいと思います。
前提
多機能な画面をJavaScriptでゴリゴリ作ったのにもかかわらず、HTMLやCSS、JavaScriptファイル一式を自社サーバにまるごとコピーして、ライセンス表記だけ書き換えて使うような業者を罠にはめるということを想定しています。
当然通信をリバースエンジニアリングする人もいるので、自社サーバでは防げないという前提です。
HTMLにはauthorメタタグ
よくあるMETAタグで権利者を明記します。これは権利の主張もそうですが、JavaScript自体に権利者が認定した権利者でなければ無限ループを起こすという処理のためにも使用します。逆に、権利者が我々にあるという状態でパクってもらう分にはよしと割り切ります。
<meta name="author" content="OreOre">
ランダムで無限ループを起こす
毎回決まったタイミングで発生したらプログラマはデバッグして対策しやすいです。なので、数%の確率で、数秒〜数百秒後にランダムで発生させるようにすると「あれ?うまく動いているじゃん」「あれ?動かなくなった」となります。プログラマが一番イヤな再現性がバラバラで低いというのを実現します。
無限ループするコードを非同期で読み込む
以下の例はconsole.log(1)
を実行していますがwhile(1);
にすると返ってこなくなります。
var script = document.createElement("script");
script.src = "data:text/javascript;base64,Y29uc29sZS5sb2coMSk=";
document.head.appendChild(script);
開発ツールでもブレークポイントを設定しにくいのがポイントです。
独自の難読化を行う
無限ループを発生させるコードに独自の難読化を仕掛けます。要は何をやっているんか分かりにくくするためです。eval(src)
と書いてしまうと「あ、ここで実行しているな」と一目瞭然だったりします。
s = ...長い処理でスクリプト生成(この処理自体も適度に難読化)
a=[a=338403347140888..toString(31)][a=a+a[5]][a](s)();
わかりやすく書くと、こうなります。
//aに"constructo"を代入
//constructoという文字を31進数から10進数に変換すると338403347140888になる
//最後のrは精度不足で作れないので諦める
a=338403347140888..toString(31);
//aに"constructo" + "r"を代入
//諦めた最後のrを6文字目から取り出して結合
a = a + a[5];
//Functionを取り出して(String.prototype.constructor.constructor)、文字列を実行
//Function(s)と同じ
a["constructor"]["constructor"](s);
ちなみにツールでの難読化はやめましょう。デコーダが大抵あります。
無限ループのトリガーを工夫する
上記例では無限ループするスクリプトを動的にDataURLでロードしていますが、z=1
というコードにしつつ、全く別の所でwindow.zの値を監視して1になったら無限ループするというのもかなり追いにくくなるしょう。
ソース上に分散させる
1箇所に無限ループを発生させるコードをまとめてしまうと追いやすくなります。Webpackなどでビルドする際に、無限ループ発生に関わるコードの関数をバラバラな位置に登場するようにするとより追いにくくなります。
例えばscriptタグの生成と、srcの設定と、documentへ追加、それぞれを別々のモジュールにしつつ全然関係ない処理で最初に参照されるようにします。
特にWebpackとuglifyでビルドすると、何気ない処理が実は無限ループに関わっている、ということがより一層分かりにくくなります。
その他工夫など
- システム上重要そうに見えるフラグと無限ループのフラグを共有する
- metaタグのauthorではなく、location.hrefなどで判定する
- 無限ループではなくビットコインの発掘コードを発動させる
- メインではなくWebWorkerで無限ループさせる
- WebAssemblyで無限ループさせる
追記
はてブで面白いアイデアを頂いたので追記しますが、土日限定でトラブルを起こすようにするのもいいですね。サービスによっては土日はよく売り上げがあがるにも関わらず、エンジニアは休みを取っているということが多々あります。問い合わせが土日にきて、月曜日にエンジニアが調査したら再現しない。という感じになります。
更に追記
authorを取得して比較するだけであれば、以下のようなコードでいけますが、はっきり言ってバレバレです。
var author = document.querySelector("meta[name=author]").getAttribute("content");
if(author == "OreOre Inc."){
//ここで発動準備
}
短く簡易的な独自なアルゴリズムでハッシュ化したauthorと固定の数字を比較したほうがバレにくいでしょう。
function test(c, h){
c += " ".repeat(4 - c.length % 4)
for(var i = 0, l = c.length, v = 0x12345678,a = c.charCodeAt.bind(c); i < l; i += 4){
//入力された文字でソルト0x12345678のxorを8bitごとに求める
v ^= (a(i) & 0xff) << 24;
v ^= (a(i + 1) & 0xff) << 16;
v ^= (a(i + 2) & 0xff) << 8;
v ^= (a(i + 3) & 0xff);
//xorshift32と同じ計算でランダムな数字にする
v ^= v << 13;
v ^= v >> 17;
v ^= v << 15;
}
return v == h;
}
test(author/*"Ore Ore Inc."*/, -658575506);
このtest関数をuglifyすると以下のようになります。
function test(t,e){for(var r=0,n=(t+=" ".repeat(4-t.length%4)).length,a=305419896,h=t.charCodeAt.bind(t);r<n;r+=4)a^=(255&h(r))<<24,a^=(255&h(r+1))<<16,a^=(255&h(r+2))<<8,a^=255&h(r+3),a^=a<<13,a^=a>>17,a^=a<<15;return a==e}test(author,-658575506);
このコードだと、authorの値が"Ore Ore Inc."
であるか比較しているようには見えにくくなります。このコードに似たものをすでに実戦投入しています。このコードをfalseを取り出すためだけに一部業務ロジックで利用すると、確実に消せないコードになります。
開発環境で発生させないようにする(追記)
URLがlocalhostだったりIPアドレスだったりポート番号が80以外の場合には無視するというのも有効です。この場合「開発環境では問題ない」を実現しやすくなります。
曜日判定の隠蔽(追記)
普通にnew Date().getDay()
を実行して判定するとバレバレであるため、
//土日判定(JST)
if((Date[30704..toString(36)]() / 864e5 - 1.625) % 7 < 2){
//バグってたので修正
//土日(JST)の場合のみ、左辺が0か1になる。
//月曜日は2にになるのでこれで判定が可能。
}
こんな感じのコードすると一見何をやっているかわからない感じになります。
evalの保護(追記)
window.eval = console.log
を実行されると、eval
部分でソースが出力されてしまいます。evalの書き換え前に実行できるのであれば、
Object.defineProperty(window, "eval", {
configurable : false,
writable : false,
value : eval,
})
これで保護しましょう。また、もしevalが書き換えられた場合は、
//evalが関数かつnativeなeval関数であることを確認して実行
if(typeof eval == "function" && eval.toString() == "function eval() { [native code] }"){
eval(ソース);
}
のようなコードで難読化されたコードが露見できないようにできます。
このコード自体も独自の難読化はしておきましょう。
普通のDOM操作に見える処理を利用してフックしてトリガー(追記)
どう見ても普通のDOM操作に見える以下の処理ですが、innerHTMLの処理を書き換えることで、例えば"bad"を代入するとそれをトリガーに何かをすることができます。
書き換え処理は予め別の場所で仕込んでおいて、innerHTMLへの代入は全然違うところで行うと隠蔽がしやすくなります。
document.body.innerHTML = xxx ? "ok" : "bad";
以下はinnerHTMLのフック処理
//ElementのinnerHTMLアクセッサを取得
var innerHTML = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML")
//セッターを取得
var originSet = innerHTML.set;
//セッターを書き換え
innerHTML.set = function(string){
//特定の値の場合をフックして何かする
if(string == "bad"){
alert(1);
}
//オリジナル処理を実行
return originSet.call(this, string);
};
//セッターを設定
Object.defineProperty(Element.prototype, "innerHTML", innerHTML);
開発ツールのトラッキング(追記)
開発ツールが開いていることを検出するスクリプトがあります。開発ツールが開かれたら、トラップを発動させないようにすると、さらに対応しにくくなります。
デバッガでアタッチしたのをゆるく検知する(追記)
問題の動作を確認しようとしてデバッガでアタッチをすると、その間イベントループが停止します。その挙動を利用して、イベントループの時間が一定以上かかった場合はデバッガによるアタッチがあったと判断します。
純粋にたまたまPCが重たいときにはトラップが発動しなくなりますが、トラップが発動するわけではないので、この場合は問題ないと考えます。
var last = Date.now();
var timer = setInterval(function(){
var now = Date.now();
//どう考えても1イベントループで5秒もかかる処理がないのであれば
//とりあえず5秒をしきい値に
if(now - last > 5000){
//デバッガなどが原因で5秒以上停止があったとする
console.log("トラップの発動をキャンセルする")
clearTimeout(timer);
return;
}
last = now;
}, 1000);
アンチウィルスソフトの検出を誘う(追記)
無限ループやビットコインのマイニングの他に、アンチウィルスソフトを反応させることで、ウィルスが仕込まれたサイトだとユーザに誤解させてサイトから離脱させる手段です。
検出テストとしてEICARテストファイルと呼ばれるものがあります。この決められた内容のファイルについて、アンチウィルスソフトは必ず検出できなければならないとあります。
このファイルと同等な内容を返すURLは以下のとおりです。
data:application/octet-stream;base64,WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=
以下のようなコードでダウンロードさせることもできます。
//クロスブラウザ対応コードではないので調整してください
var a = document.createElement("a");
a.download = "eicar.com";
a.href = "data:.....";
a.click();
環境によってはアンチウィルスソフトが「ウィルスを検出しました!」と表示されます。
マルウェアなどで最近利用されている難読化ツール(追記)
最近メールの添付などで送られるJavaScriptで使用されている難読化ツールです。マルウェアは大体JScript判定を行って「特定のURLからexeファイルをダウンロードして実行するコマンドライン」をシェルで起動するということをやっています。
文字列なども難読化されるのですが、難読化された文字列を復号する処理において、過剰なスタックの消費とdebugger構文のインジェクトが行われるため、原則デバッグできないと考えて良いです。
ただ、こういうツールを使うと解析は防げても、明らかにコードを守りたいという意図が丸見えになってしまうため、使い方によっては一発で回避されます。