F5攻撃対策

仕組み

XOOPSに使われている Protector.class を参考につくりました。

  • 短時間に同一のURIに対して同一のIPアドレスから、繰り返しリクエストがあった場合に攻撃とみなす。
  • F5攻撃と判定した場合は、F5攻撃ですよ画面を出力して、しばらくスリープする。
  • 同一IPについて F5判定が複数回なされると、アクセス禁止処理を行なう。
  • アクセス禁止は、.htaccess にておこなうので復帰させるには、直接.htaccessを編集する必要があります。
ログ記録用のテーブルの作成
-- -*-sql-*-
-- マニュアル校正管理アプリケーション
-- DoS攻撃対策用ログテーブル
--
-- @author  hoge@example.jp
-- @description table_name = DosAttacker
--
--

-- クライアント側の文字コードを知らせる。
-- データベースは Unicode(UTF-8)でつくりますよ。
SET CLIENT_ENCODING TO EUC_JP;

CREATE TABLE DosAttacker (
        logSeq          serial          PRIMARY KEY,

        ip              varchar(255)    Not Null default '0.0.0.0',
        timestamp       timestamp       default now(),
        agent           varchar(255)    Not Null default '',
        uri             varchar(255)    Not Null default ''

);

-- PHPからアクセスするユーザに対して GRANT する
GRANT insert, update, delete, select ON DosAttacker to www;
GRANT update, select ON DosAttacker_logSeq_seq to www;


CREATE TABLE DenyCounter (
        ip              varchar(255)    PRIMARY KEY,
        expire          int             Not Null default 1
);
-- PHPからアクセスするユーザに対して GRANT する
GRANT insert, update, delete, select ON DenyCounter to www;


-- PL/pgSQL を使えるようにするには、以下のコマンドを実行する。
--  % createlang plpgsql 'database_name'
--
-- 同じ ip があれば DenyCounter.expire をカウントアップして
-- 同じ ip がなければ新しいレコードを INSERT する関数
--
CREATE OR REPLACE FUNCTION set_ip( text ) RETURNS int AS $$
        DECLARE
         aIp ALIAS FOR $1;
         cont INTEGER;
        BEGIN
         cont:= count(ip) FROM DenyCounter WHERE ip = aIp;
        IF cont = 0 THEN
          INSERT INTO DenyCounter( ip ) VALUES( aIp );
          RETURN 1;
        ELSE
          UPDATE DenyCounter SET expire = expire+1 WHERE ip = aIp;
          cont = cont+1;
          RETURN cont;
        END IF;
        END;
        $$ LANGUAGE 'plpgsql';
DosAttacker.class

flush()とsleep()を組み合わせて、F5ですよ画面を表示させようとしているのだが、狙ったように動いてくれない。なんでやろ?
F5攻撃判定をしたら、画面をエラー表示に過ぐに切り替えて欲しいのだが、そういうふうにはいかないみたい。
apacheへの接続で Keep-Aliveってあるけどあれってどういう意味なんだろ?

<?php /* -*-java-*- */
require_once('DbData.class.php');
/**
 * 管理アプリ用のDoS攻撃対策クラス
 * 
 * 使い方:
 *   Authコンストラクタ内などのリクエストに対して必ず処理をする場所に入れる。
 * <pre>
 * </pre>
 * 
 * @todo
 * @package kuli/Manuals
 * @see
 * @create  2007-11-11
 * @author  hoge@example.jp
 */
class DosAttacker {
    
    var $_timeThreshold;
    var $_countThreshold;
    var $_hta_deny_count;
    var $_allow_host_addrs;
    
    var $_db;
    var $_table;
    
    /**
     * constructor DosAttacker
     * 
     * @param mixed $auto 引数がないもしくは null の場合は、自動で処理する。
     */
    function DosAttacker ( $auto=null ) {
	
	$this->_init();
	
	if ( !is_null($auto) )
	    return null;
	
	if ( true === ($res=$this->setLog()) ) { // ログを記録する
	    if ( !$this->checkDoS() ) {   // DoSでないか ckeck する
		if ( ($res=$this->countDoS()) ) { // DoSした回数を取得する
		    if ( $res > $this->_hta_deny_count_threshold ) {
			// アクセス禁止にする閾値を超えていたら.htaccess
			$this->denyHtaccess();
		    }
		}
		$this->resetLog();    // DoSログをリセットする
		$this->displayDoS();  // DoS画面を表示して終了
	    }
	}
	
    }
    
    
    function _init() {
	
	/* 
	 * DoS 判定につかう期間(秒)
	 */
	if ( !defined( '_DOS_TIME_THRESHOLD_' ) ) {
	    define( '_DOS_TIME_THRESHOLD_', 10 );
	}
	
	/*
	 * DoS 判定とみなすリクエスト回数(回)
	 */
	if ( !defined( '_DOS_COUNT_THRESHOLD_' ) ) {
	    define( '_DOS_COUNT_THRESHOLD_', 20 );
	}
	
	/*
	 * .htaccess で拒否するまでの判定回数
	 */
	if ( !defined( '_DOS_ACCESS_DENY_COUNT_THRESHOLD_' ) ) {
	    define( '_DOS_ACCESS_DENY_COUNT_THRESHOLD_', 20 );
	}
	
	/*
	 * .htaccess で拒否しない ipアドレス カンマで区切って複数可能
	 */
	if ( !defined( '_ALLOW_HOST_ADDR_' ) ) {
	    define( '_ALLOW_HOST_ADDR_', '' );
	}
	
	$this->_timeThreshold = _DOS_TIME_THRESHOLD_;
	$this->_countThreshold = _DOS_COUNT_THRESHOLD_;
	$this->_hta_deny_count_threshold = _DOS_ACCESS_DENY_COUNT_THRESHOLD_;
	
	$array = explode(",", _ALLOW_HOST_ADDR_ );
	$this->_allow_host_addrs = array();
	foreach ( $array as $addr ) {
	    $this->_allow_host_addrs[] = trim($addr);
	}
	
	$this->_table = 'DosAttacker';
	
	$this->_connect();
    }
    
    
    function _connect() {
	
	/*
	if ( 'dbtable' == strToLower(get_class($this->_db)) )
	    return true;
	
	if ( isset($GLOBAL['db'])
	     && 'dbtable' == strToLower(get_class($GLOBAL['db']))
	     ) {
	    $this->_db =& $GLOBAL['db'];
	}
	else {
	    include_once('DbData.class.php');
	    $this->_db =& new DbTable($this->_table);
	}
	*/
	global $db;
	$this->_db =& $db;
	
	$this->_db->setTableName($this->_table);
	$this->_db->setProperties();
    }
    
    /**
     * リクエストをログとして保存する
     * 
     * @access public
     * @return mixed  成功なら true, 失敗なら DB_Error を返す。
     */
    function setLog() {
	
	$this->_connect();
	$this->_db->clearError();
	
	$array = array( 'ip'    => $_SERVER['REMOTE_ADDR'],
			'agent' => $_SERVER['HTTP_USER_AGENT'],
			'uri'   => $_SERVER['REQUEST_URI']
			);
	
	$this->_db->setTcArray( $array );
	if ( !$this->_db->isError() ) {
	    $this->_db->prepare( DB_AUTOQUERY_INSERT );
	}
	if ( !$this->_db->isError() ) {
	    $this->_db->execute();
	}
	
	if ( $this->_db->isError() ) {
	    return $this->_db;
	}
	
	return true;
    }
    
    /**
     * DoS attack であるかをチェックする
     * 
     * @access public
     * @return mixed   DoSでなければ true を返す、DoSなら false を返す。
     */
    function checkDoS () {
	
	$this->_connect();
	$this->_db->clearError();
	
	$sql = " SELECT count(logSeq) FROM ".$this->_table;
	$sql .= " WHERE ip = ? AND uri = ? ";
	$sql .= " AND timestamp > CURRENT_TIMESTAMP + '-";
	$sql .= $this->_timeThreshold." sec'";
	
	$res = $this->_db->getOne( $sql, array( $_SERVER['REMOTE_ADDR'],
						$_SERVER['REQUEST_URI'] ) );
	if ( DB::isError($res) || !is_numeric($res) ) {
	    return true;
	}
	
	if ( $res > $this->_countThreshold ) {
	    return false;
	}
	
	return true;
    }
    
    /**
     * テーブル DenyCounter に記録する。
     * 
     * @access public
     * @return mixed   これまでの DenyCounter の数:DoS攻撃回数を返す。
     *                 エラーがあると false を返します。エラーの場合は
     *                 $this->_db->toString() でエラーを取得できます。
     */
    function countDoS () {
	
	$this->_connect();
	$this->_db->clearError();
	
	$sql = "SELECT set_ip( ? )";
	$res = $this->_db->getOne( $sql, array( $_SERVER['REMOTE_ADDR'] ) );
	
	$sql = "SELECT expire FROM DenyCounter WHERE ip = ?";
	$res = $this->_db->getOne( $sql, array( $_SERVER['REMOTE_ADDR'] ) );
	if ( DB::isError($res) || !is_numeric($res) ) {
	    return false;
	}
	
	return $res;
    }
    
    /**
     * リクエストログをリセットする
     * F5判定に使用する時間を超えたデータは削除する。
     * 
     * @access public
     */
    function resetLog () {
	
	$this->_connect();
	$this->_db->clearError();
	
	$sql = " DELETE FROM $this->_table WHERE timestamp < ";
	$sql .= "CURRENT_TIMESTAMP + '-$this->_timeThreshold sec'";
	
	return $this->_db->_con->query( $sql );
    }
    
    
    /**
     * .htaccess に Deny 宣言してアクセスを禁止する。
     * 
     * @access public
     * @param  string  $ip ipアドレス
     * @return bool    .htaccessに書き込んだら true、そうでなければ false
     */
    function denyHtaccess ( $ip=null ) {
	
	if ( is_null($ip) )
	    $ip = $_SERVER['REMOTE_ADDR'];
	if ( empty($ip) )
	    return false;
	
	if ( in_array( $ip, $this->_allow_host_addrs ) )
	    return false;
	
	$target_htaccess = _SYSTEM_HTTPD_ROOT_.'/.htaccess' ;
	$ht_body = file_get_contents( $target_htaccess );
	
	// new .htaccess
        if( $ht_body === false ) {
	    $ht_body = '';
        }
	
	if( preg_match( "/^(.*)#PROTECTOR#\s+(DENY FROM .*)\n#PROTECTOR#\n(.*)$/si" , $ht_body , $regs ) ) {
	    if( substr( $regs[2] , - strlen( $ip ) ) == $ip )
		return true;
	    $new_ht_body = $regs[1] . "#PROTECTOR#\n" . $regs[2] . " $ip\n#PROTECTOR#\n" . $regs[3];
        } else {
	    $new_ht_body = "#PROTECTOR#\nDENY FROM $ip\n#PROTECTOR#\n" . $ht_body;
        }
	
	echo $new_ht_body;
	if ( ($fw=fopen( $target_htaccess , "w" )) ) {
	    @flock( $fw , LOCK_EX );
	    fwrite( $fw , $new_ht_body );
	    @flock( $fw , LOCK_UN );
	    fclose( $fw );
	    return true ;
	}
	
	return false;
    }
    
    
    /**
     * DoSです画面を表示して、指定秒数だけスリープして、スクリプトを終了する
     * デフォルトで $sleep_time は DoS判定につかう期間である。
     * 
     * @access public
     * @param  string  $str メッセージ表示のHTML文字列
     * @param  int     $sleep_time 処理を遅滞させる時間(秒)
     */
    function displayDoS ( $str=null, $sleep_time=null ) {
	
	if ( is_null($sleep_time) )
	    $sleep_time = $this->_timeThreshold;
	
	if ( is_null($str) ) {
	    $str = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 ';
	    $str .= 'Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD';
	    $str .= '/xhtml1-transitional.dtd">';
	    $str .= '<html xmlns="http://www.w3.org/1999/xhtml" ';
	    $str .= 'xml:lang="ja" lang="ja">';
	    $str .= '<head><meta http-equiv="Content-Type" ';
	    $str .= 'content="text/html; charset='.mb_http_output().'" />';
	    $str .= '<title>Unauthorized Access</title>';
	    $str .= '<script language="JavaScript">function initRedirect() ';
	    $str .= '{setTimeout(function() {location.href="';
	    $str .= $_SERVER['REQUEST_URI'].'";}, '.$this->_timeThreshold;
	    $str .= '*1000);}</script></head>';
	    $str .= '<body class="redirectBody" onload="initRedirect();">';
	    $str .= '<H1>unauthorized access:F5 Attack</H1>';
	    $str .= '<p>ページが自動的に更新されない場合は';
	    $str .= '<a href="'.$_SERVER['REQUEST_URI'].'">ここ</a>';
	    $str .= 'をクリックしてください</p></body></html>';
	    $str = mb_convert_encoding( $str, mb_http_output(), 'auto' );
	}
	
	mb_http_output("pass");
        ini_set( 'output_buffering', 0 );

	echo $str;
	ob_flush();
	flush();
	
	// sleep( $sleep_time *10 );
	exit();
    }
}
?>