setTimeout() vs ハッカー、仁義なき戦い

 早速ですが、以下のHTMLを見て下さい……。

<!doctype html>
<html>
    <head>
       <meta charset="UTF-8">
       <title>サンプル1</title>
       <style>
        #counter { font-size: 3em; font-family: monospace; color: blue; }
        </style>
       <script type="text/javascript" src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
       <script type="text/javascript">
       (function(){
           $(document).ready(function() {
               $('#start').on('click', function(){
                   var counter = 0;
                   $('#time').text(counter);
                   var timer = setInterval(function() {
                       ++counter;
                       $('#time').text(counter);
                       if (counter == 10) clearInterval(timer);
                   }, 1000);
               });
           });
       })();
       </script>
   </head>
    <body>
        <div>
            <p>
                <button id="start">計測開始!</button>
            </p>
            <p>
                <span id="counter"><span id="time">-</span>秒経過</span>
            </p>
        </div>
    </body>
</html>

 実行してみれば……というか実行しなくてもわかりますが、経過秒数を数えてゆくだけの単純なスクリプトです。  複数回押下への対策をしていないとか、本当はsetInterval()だと正確な秒数を数えられないとかありますが、今回はsetInterval()あるいはsetTimeout()(以下、setTimeout()系関数)の動作について述べたいだけなので気にしない方針で。

 兎に角、ゲームか何かのアニメーション処理を、これらの関数を使って行なおうとしている、とお考え下さい。  この時、もしもユーザーがsetTimeout()系関数が行なう処理を不正に飛ばそうとした時、ウェブサイトはこの事を検出できるでしょうか?  というのが、今回の話題です。

Round 1

 まず、上記のサンプル1を開いたら、JavaScriptコンソールなり、Firebugなり、兎に角デバッグ用コンソールを開きます。  そして、次のJavaScriptを実行してみましょう:

var setInterval2=window.setInterval;window.setInterval=function(a,b){ return setInterval2(a, b/100); }

 なんという事でしょう、「計測開始!」ボタンを押したら、もの凄い速度で処理が進むようになりました!  どうやらwindow.setIntervalは読み取り専用プロパティではないようで、ユーザーが上書きできてしまうようです。

 だがしかし!  デバッグ用コンソールを開いて上記のコードを打ち込むためには、手動でどんなに頑張ったところでたかが知れている!  ページを開いた瞬間に、兎に角window.setIntervalを変数に保存するようにしてはどうか。

(function(){
    var setInterval2 = window.setInterval; // <======
    $(document).ready(function() {
        $('#start').on('click', function(){
            var counter = 0;
            $('#time').text(counter);
            var timer = setInterval2(function() { // <======
                ++counter;
                $('#time').text(counter);
                if (counter == 10) clearInterval(timer);
            }, 1000);
        });
    });
})();

 ふっ……私の才能が怖いな。

Round 2

 ……などと安心していると当然足を掬われまして、世の中にはユーザースクリプトなるものが存在します。  代表的なものはFirefoxGreaseMonkeyですが、他のブラウザにも同じスクリプトを使えるようなアドオン(あるいはブラウザの組み込み機能)が用意されています。  そして、恐ろしい事に……metadataブロックに@run-at document-startを追加すると、HTMLを読み込んだ瞬間(!)にスクリプトが実行されてしまいます!

// ==UserScript==
// @name        VS サンプル2
// @namespace   http://localhost/test/blog/sample
// @include     http://localhost/test/blog/sample*.htm
// @run-at      document-start
// @version     1
// ==/UserScript==

(function(){
    var setInterval2 = window.setInterval;
    window.setInterval = function(a,b){
        return setInterval2(a, b/100);
    };
})();

 この時点では、DOMツリーの構築も完了していません。  DOMツリーが構築されていないという事は、scriptタグも未実行です。  すなわち、サンプル2でwindow.setIntervalを変数に保存した時には、既に書き換え済みのものになっています。console.log(window.setInterval.toString());とでもしてみれば一目瞭然。

 ……んん?  という事は、window.setInterval.toString()でネイティブコードかどうか判別すれば良いのでは!? 

// サンプル3
(function(){
    var setInterval2 = window.setInterval;
    if (!isNativeFunction(setInterval2, "setInterval")) {
        $(document).ready(function() {
            $('body').children().remove() ;
            $('body').append('<p class="error">setInterval()が不正です。</p>');
        });
        return;
    }
    $(document).ready(function() {
        $('#start').on('click', function(){
            var counter = 0;
            $('#time').text(counter);
            var timer = setInterval2(function() {
                ++counter;
                $('#time').text(counter);
                if (counter == 10) clearInterval(timer);
            }, 1000);
        });
    });

    function isNativeFunction(func, name)
    {
        var match = func.toString().match(/^function (\S+)\(\)\s*{\s*\[native code\]\s*}$/);
        return (match && match[1] === name);
    }
})();

Round 3

 ……だが甘い! ならば、window.setIntervalを書き換えると同時に、window.setInterval.toStringや、まで書き換えてしまえばどうなるのか!?

(function(){
    var setInterval2 = window.setInterval;
    window.setInterval = function(a,b){
        return setInterval2(a, b/100);
    };
    window.setInterval.toString = function() { return setInterval2.toString(); };
    window.setInterval.toSource = function() { return setInterval2.toSource(); }; // toSource未対応ブラウザへの対応などは省略
    window.setInterval.hasOwnProperty = function(x) { return setInterval2.hasOwnProperty(x); };
})();

 なんて事! hasOwnPropertyでのチェックまで封じられてしまった!

 ……いいや、我々にはまだ、for inなる力強い味方が残っている。組み込み型のtoStringはDontEnum属性がついているから、toStringfor inで列挙できたならtoStringが改竄されているということ。propertyIsEnumerableメソッドを使った場合にはそれ自身が上書き済みの可能性があるが、for inであればその心配もない!!

function isNativeFunction(func, name)
{
    for (var o in func) {
        if (o === "toString") return false;
    }
    var match = func.toString().match(/^function (\S+)\(\)\s*{\s*\[native code\]\s*}$/);
    return (match && match[1] === name);
}

 勝利!!

Round 4

 ……ところでさ。  たぶん、アドオンを自作したりしたら特定URLでだけwindow.setTimeout系関数が高速に動作するとかできますよね。  仮にアドオンでは無理だとしても、オープンソースのブラウザのソースコードを弄れば簡単ですよね。  他にも、上手いことプロキシを噛ませてチェックコードを取り除いてやったりするとか、とか、いくらでもやりようはありますよね。

 結論:そんなに時間を誤摩化されたくないWebアプリケーションなら、素直にサーバー側プログラムでどうにかしましょう。