DoS攻撃をはじくためのPHPスクリプト

絨毯爆撃ブラウザという単語を目にしたのはこの記事が最初だった気がする。

このところはてなブックマークへの過度なアクセスがよく見られます。User-Agent などを見ていても特殊な bot などのものではなく、その多くが Internet Explorer や Firefox などの一般のブラウザのそれを名乗っています。
中には、目立たないようにそういった User-Agent を敢えて名乗っているリクエストもありそうですが、どうもそれらの挙動を見てるに、巡回系のソフトウェアや先読み系のブラウザのような振る舞いが多そうです。ページ内に表示されたリンクを節操なくすべて辿るということで、僕は勝手に絨毯爆撃系ブラウザなんて呼んでますが。
naoyaのはてなダイアリー - 絨毯爆撃系ブラウザ (2005/11)

今も(たぶんこれからも)相変わらず「絨毯爆撃ブラウザ」は存在し、ときどきやってきてはサーバーの負荷を急上昇させてくれるので始末が悪いというか、困る。

困る度合いというのはWebサイトの性質やサーバー構成、内部のアプリのつくりによって千差万別なのだが、筆者の知る某サイトの場合、ページアクセスがあるたびに背後のDBサーバーでインデックスの効きにくいSQLが走ってしまうという厄介な構成(それをまずどうにかしろという話はまあアレだオトナの事情という奴だ)であるため、絨毯爆撃系ブラウザが来襲するとWebサーバではなくDBサーバのほうが悲鳴をあげる。

その悲鳴も一瞬ですむのならまあ別にほっといてもよいのだが、最近どうも、5分、10分とその状態が続くことがあるらしく、その間負荷あがりっぱなし&他の閲覧者も巻きぞえ食って「なんか重いよ?!」の苦情が。。。

という相談を受けてためしに作ってみたのが下記のスクリプト。

class_antidos.php:
<?php
require_once('Cache/Lite.php'); 
/**
 * DoSまがいの連続アクセスであるかどうかを判定するクラス
 *
 * 任意の識別コード(IPアドレスとか)毎に
 * アクセス時刻のリストをキャッシュしておき、
 * それを判定材料とする
 */
class antidos {

    /**
     * インターバル秒。
     * この秒数の間のアクセス数をカウントする
     */
    var $interval;

    /**
     * インターバル秒あたりの最大アクセス数。
     * これを超えるとDoSと判断させる
     */
    var $accesslimit;

    /**
     * Cache_Liteのインスタンス
     */
    var $cacheobj;

    /**
     * Cache_Liteのグループid
     */
    var $cache_groupid;

    /**
     * Cache_Liteの有効期間(秒)
     */
    var $cache_lifetime;

    /**
     * Cache_Liteが使うキャッシュファイルの
     * 保存先ディレクトリ。
     */
    var $cache_dir;

    /**
     * コンストラクタ
     * @param string
     */
    function antidos() {
        // 以下では20秒間の間に15回以上アクセスして
        // きたIPアドレスをはじく
        $this->interval = 20;
        $this->accesslimit = 15;

        // interval < lifetimeでなければならない。
        // とりあえず60秒
        $this->cache_lifetime = 60;

        // キャッシュのグループid。必須ではないが
        // 念のため割り当て。名前は何でも良い
        $this->cache_groupid = "antidos";

        // キャッシュ保存先ディレクトリ。
        // /dev/shm などのtmpfsなファイルシステムに入れると
        // 速くなってよいかもしれないが、
        // 実際のところ小さなファイル群なので
        // 通常のext3なんかでもOSのページキャッシュに
        // 全部乗れちゃうかもしれない。
        $this->cache_dir = "/tmp/";

        // Cache_Liteのインスタンス
        $this->cacheobj =& new Cache_Lite(array(
            'cacheDir' => $this->cache_dir, 
            // 数が多いかもしれないので2層にしておく
            'hashedDirectoryLevel' => 2,
            'lifeTime' => $this->cache_lifetime
        ));
        return TRUE;
    }

    /**
     * DoSかどうか判定する。
     * @param string $id 端末特定のための何らかの文字列。IPアドレスなど
     * @return boolean DOSならtrue、でなければfalseを返す
     */
    function doscheck($id) {
        // Web画面ではなくCLI(コマンドライン)で実行され
        // ている場合は無条件にfalseを返す
        if (PHP_SAPI == "cli") { return false; }

        // 過去のアクセス状況をキャッシュから取得
        $utimelist = $this->getdata($id);

        // 現在時刻をアクセス状況に追加
        $utimelist[] = time();

        // アクセス状況をキャッシュに保存しておく
        $this->savedata($utimelist, $id);

        // まだアクセス制限数ぶんもカウントが
        // 記録されていない場合は当然false
        if (count($utimelist) < $this->accesslimit) {
            return false;
        }

        // 念のため逆順ソート
        rsort($utimelist, SORT_NUMERIC);

        // アクセス数カウンタ
        $cnt = 0;
        // 初回アクセス時刻
        $ts = array_shift($utimelist);
        // 判定処理
        foreach ($utimelist as $t) {
            $cnt++;
            if ($ts - $t < $this->interval 
                && $cnt > $this->accesslimit) 
            {
                return true;
            }
        }
        return false;
    }

    /**
     * キャッシュを取得。過去のアクセス時刻utimeの配列として返す。
     * キャッシュがヒットしなければ空の配列を返す。
     * 
     * @return array
     */
    function getdata($cacheid) {
        $data = $this->cacheobj->get($cacheid, $this->cache_groupid);
        if (empty($data) == true) {
            return array();
        }
        $ar = explode(",", $data);
        return $ar;
    }

    /**
     * キャッシュに配列を保存する
     *
     */
    function savedata($utimelist, $cacheid) {
        // 逆順ソートしておく
        rsort($utimelist, SORT_NUMERIC);
        $ar = array();
        $c = 0;

        // アクセス制限数プラスアルファ程度の個数を
        // キャッシュ保存すればよいんじゃないかと
        foreach($utimelist as $t) {
            $c++;
            $ar[] = $t;
            if ($c > $this->accesslimit + 10) {
                break;
            }
        }
        // キャッシュの保存。配列はカンマ区切りに変換
        return $this->cacheobj->save(
            implode(",", $ar), 
            $cacheid, 
            $this->cache_groupid
        );
    }

}

?>
こんな感じで呼び出して使う↓
<?php
require_once('antidos_class.php'); 
$obj =& new antidos();
// 端末特定にはとりあえずIPアドレスを使う
if ($obj->doscheck($_SERVER["REMOTE_ADDR"]) == true) {
    // header("Service Unavailable", TRUE, 503); //とかやるのもいいかも
    echo "too many access!!!!!";
    exit; //強制終了!
} else {
    echo "welcome!";
    //通常処理を続ける。。。
}
?>

注意点、その他。

  1. mod_cbandとかmod_throttleとかmod_limitipconnとかそういうApacheモジュールもあるよ

    そのとおりである。しかし、事情によってはApacheモジュールの追加よりもアプリ改造のほうが試しやすいということもありうるだろう。いずれにせよそうしたApacheモジュールも、このスクリプトも、それぞれ一長一短あるのでご利用は計画的に。
  2. PHPでやったんじゃ、Webサーバに負荷がかかるのは変わらなくね?

    そのとおりである。が、PHP上でものすごいでかい処理をしているとか、あるいは上に書いたようにDB側に負荷がかかってしまうとかいう場合にはその直前で止めることができるわけだからそこそこ効果がある。
  3. 端末の特定にIPアドレスを使うってのはちょっと。プロキシ経由のアクセスとかが巻き添えにされがち?

    そのとおりである。たとえば企業内LAN上の端末が外部のひとつのWebサイトにアクセスするときはアクセス元IPがその企業のプロキシサーバ(あるいは単なるNATルーターかもしれない)のIPに集約されるため、その企業内の複数人が普通にアクセスしてるだけなのにそれをdos攻撃と誤認するような状況はありうる。が、端末の特定は別にIPアドレスに限らない。「IPアドレス+なんらかのユニークなCookie値」とかそういうのでもいいはずである。例えば、GoogleAnalyticsを入れてるWebサイトは多いが、それ用のCookie値(utmaとかutmbとかいろいろ)を勝手に拝借することも可能だろう。
  4. 携帯端末は?

    とりあえずキャリアのゲートウェイのIPアドレス一覧は各社から公開されているのでそれらのIPはDoS判定から除外するようなコードを追加すればよいと思う。 (絨毯爆撃ブラウザ搭載の携帯端末なんて登場されたらそれはそれで困るが)
そんな感じ。

トラックバックURL

このエントリーのトラックバックURL:
http://www.ywcafe.net/mt/mt-tb.cgi/732

コメントする

(初めてのコメントの時は、コメントが表示されるためにこのブログのオーナーの承認が必要になることがあります。承認されるまでコメントは表示されませんのでしばらくお待ちください)


画像の中に見える文字を入力してください。