Linux/静的IPフィルタの作成その2

自力でログを見て、アクセス元にフィルタをかける例(2019-04-22)


アクセスログを、perlと正規表現で読み込んでみようという例です


特定のIPアドレスからのアクセスを止める例

うちのCentOS 7 環境で試した例ですが、
まずは、firewall-cmd で permanent に締め出す例。
(ただ、これはオススメできません。
 送信元IPが可変であることを踏まえても、なお、
 しつこいアクセスを止めたい場合に...)

※事前に /home/hoge/ban_list.txt ファイルに、対象IPを列挙して下さい

初回は ipsetを新しく作ります
# firewall-cmd --permanent --new-ipset=my_ban --type=hash:ip
# firewall-cmd --reload
 
my_banに対して、 drop するIPアドレス一覧を登録します
# firewall-cmd --add-rich-rule='rule source ipset=my_ban drop' --permanent
# firewall-cmd --ipset=my_ban --add-entries-from-file=/home/hoge/ban_list.txt --permanent
# firewall-cmd --ipset=my_ban --get-entries
# firewall-cmd --reload

これを削除するときは、上記add-entries を remove-entries に変えて実行します。
(firewall-cmd って、ちょうど add と remove が対になるイメージですね)
ここで使った ban_list.txtは無くさないようにするか、または get-entries してリストを作り直して、
remove してからリストを更新して add する、といった運用になると思います。

もう一案、既に fail2ban を使っているなら、そこに追加するという手もあります。

もし、 fail2ban-postfix が有効で、そこにIPを追加する場合
# fail2ban-client set fail2ban-postfix banip 対象のアドレス

なので、前述のリストをshellで全部渡すなら、こう?
for ip in `cat /home/hoge/ban_list.txt`; do
  fail2ban-client set fail2ban-postfix banip $ip
done

この場合は、そのルール(上記だとfail2ban-postfix)で設定した bantimeを経過すると解除されるのと、
systemctl reload fail2ban などでログを読み直すと、上記手動設定分は消えるので注意しましょう。
対象とするポートやbanする期間といったfail2ban側の設定を流用できるのが利点です。

...これは個人的な感想ですが、
メールの不正アクセスは一日に一回〜数回で、毎日来たりするので、
bantimeは「何時間(3600秒 * 時間)」ではなく「何日(3600*24*日数)」で設定してもいいと思います。
(もちろん、どんなキーワードでbanして、どう運用管理するかによりますが...)

あとは、私の環境の場合は squidを使っているので、そこでIPアドレス一覧を読み込んで、
まぁ、その、色々と振り分けています

以下、では、
上記「IPアドレスのリスト」をどう作るのかの話になります

ログからアクセス元IPを絞り込む

はじめに

結局、市販のログ収集・監視ツールでもフリーのやつでも、
どのログを集めて、何を基準で制限や通知をするかは
自分で設定することになります。

というのも、これは、製品の親切度?の問題と言うよりも、
対象になるログは無限にあって、
目的やバージョンで、収集対象も異なっちゃうので、
(何も設定せずに)すぐ使えるツールって、なかなか無いんじゃないかな〜
と、思うのです...
(自動でやってくれるツールでも、誤検知を防ぐために導入後の「学習」が必要ですし)

ツールは、がんばって設定するとして、
それ以前に、
もっと1から自力でログ確認をやりたい酔狂な方むけというか、*1
やらざるをえないんだけど、何をどうすれば良いのやら見当もつかない方へ、
以下、たたき台になればと思い、
postfix(やhttpd など)のアクセスログから、IPアドレスを抜き出す例について、
メモしてみます。

ログからIPアドレスを拾っていく例

では、 perl のスクリプトの例です。read_log.pl として、
これにログを渡すと、こんな感じでIPアドレスの一覧が出ます。

$ ./read_log.pl /var/log/maillog 
37.49.xxx.xxx	1420	4	190421
185.100.xx.xxx	640	4	190421
107.170.xxx.xx	310	4	190421

出力は、IPアドレス(37.49.x.x)、得点(1420点)、フラグ(4)、日付(4/21)、です。
こんな感じの、IPアドレスのリストを出力させます。
一行目の perl のPATHは、 which perl とかで確認して、変えて下さい。

#!/usr/bin/perl -w

#
# ログ監視/集計ツールの簡易版みたいなことをやります 
# 想定している厄介ログから、IPと頻度を集計します
#
# そのまま > なり tee なりでファイルに落として別ツールで使う想定
# ファイルに落とさない情報は、STDERRに出力します
#

sub dbg_printf{
    # デバッグ文、作成過程では入れまくってます。
    #return;  # ここをコメントアウト
    #printf STDERR "dbg:";
    printf STDERR @_ ;
    $|=1;
}

# 月表示を数字二桁にもどすやつ
my %m2d = ('Jan'=>'01', 'Feb'=>'02', 'Mar'=>'03', 'Apr'=>'04', 'May'=>'05',  'Jun'=>'06',
          'Jul'=>'07', 'Aug'=>'08', 'Sep'=>'09', 'Oct'=>'10', 'Nov'=>'11',  'Dec'=>'12');
sub M2D {
  my $month = shift;
  return $m2d{$month};
}

# IPアドレス毎に以下の3つを集計してみます
my %ip_last_access; # 最終検出日 yymmdd (ただしyyの部分は固定値。ログに無い場合があるので)
my %ip_score;       # 点数。厄介に応じて加点
my %ip_flag;        # フラグ。ログやアクセスの種類の記録

sub setIP {
  # 上記のIPアドレスと3つの集計を、各ログから書き込むための関数
  my ($ip, $time_mmdd, $score, $flag) = @_;

  $time = "19" . $time_mmdd; # TODO: ログに西暦が無い場合もあるので、ひとまず固定値。 
  if(! defined($ip_last_access{$ip}) or $time > $ip_last_access{$ip}){
    $ip_last_access{$ip} = $time;
  }
  $ip_score{$ip} = (defined $ip_score{$ip} ? ($ip_score{$ip} + $score) :  $score);
  if($ip_score{$ip} > 99999){
    $ip_score{$ip} = 99999; # 一定数以上は集計しない
  }
  $ip_flag{$ip} = (defined $ip_flag{$ip} ? $ip_flag{$ip} | $flag : $flag);
}

#--- 自分で任意の基準を作りましょう(ここから)--------------------

my %FLAGS;  # ログやアクセス種別やら情報
            # 2の倍数でフラグを立てて、(LOGIN | MAIL) の両方で検出みたいに記録する
$FLAGS{"LOGIN"} = 2;
$FLAGS{"MAIL"} = $FLAGS{"LOGIN"} * 2;
$FLAGS{"WEB"} = $FLAGS{"MAIL"} * 2;

my %LV; # 加点。まぁ、てきとうに。
$LV{"WARN"} = 100;    # 警告
$LV{"ERROR"}= 500;    # エラー
$LV{"CRIT"} = 10000;  # 致命的

#--- 自分で任意の基準を作りましょう(ここまで)--------------------
#--- log_~の関数をひたすら増やしていく(ここから)----------------
sub log_mail01{
  # 他にもいっぱいありますが、一例としてこんなログ...
  #
  # postfix/smtpd[xx]: NOQUEUE: reject: RCPT from unknown[AA.BB.CC.DD]: 554 5.7.1 <xx@xx.xx>: Relay access denied; from=xx
  # おそらく第三者中継狙いじゃないかな
  #
  if(/^(\S+)\s+(\d+) .+ postfix\/(smtpd|smtps\/smtpd)\[\d+\]: NOQUEUE: reject: RCPT from.+\[([\d\.]+)\]: .*Relay access denied;/){
    my $mmdd = sprintf("%s%02d", M2D($1), $2);
    setIP($4, $mmdd, $LV{"ERROR"}, $FLAGS{"MAIL"});
    return 1; # ヒットした場合は 1以上を返す
  }
  # Mar 17 04:55:50 xx postfix/smtps/smtpd[xx]: connect from unknown[AA.BB.CC.DD]: xx
  # unknown で何度も接続されることはありえない、という場合に
  #
  if(/^(\S+)\s+(\d+) .+ (connect|disconnect) from unknown\[([\d\.]+)/){
    my $mmdd = sprintf("%s%02d", M2D($1), $2);
    setIP($4, $mmdd, 5, $FLAGS{"MAIL"});
    return 1; # ヒットした場合は 1以上を返す
  }
  # 
  # 上記と同様の手順で、「正常な」アクセスも if 文で拾って、
  # その場合は何もカウントせずに return 1; する
  #
}
#--- log_~の関数をひたすら増やしていく(ここまで)----------------
#--------------------
# ここからメイン処理
#--------------------
if(! defined $ARGV[0]){
  # /var/log/maillog* のように、ログファイルを渡して起動する
  printf(STDERR "no log files\n");
  exit;
}

my @old_logs=();
my $is_next_oldlog=0;;

#--- 指定したファイルを読み込む
#    次に他のファイル&フィルタ、と繰り返していく
foreach my $logfile (@ARGV){
  if($logfile eq "-o"){
    # "-o 古いログ" で前回の結果を渡された場合、今回分とマージする
    $is_next_oldlog++;
    next;
  }
  if($is_next_oldlog > 0){
    push(@old_logs, $logfile);
    $is_next_oldlog=0;
    next;
  }

  open($list, "< $logfile") or die "$! [$logfile]\n";
  while(<$list>){
    $hit=0;
    if($logfile =~ /maillog/){  # ログファイル名に合わせてフィルタをかける
      $hit += log_mail01 $_;
      #$hit += log_mail02 $_;  # どんどん追加
    }
    #if($logfile =~ /access.log/){  # ログファイル名に合わせてフィルタをかける
    #  $hit += log_web01 $_;
    #}
    if($hit<=0){
      # 想定外のログがあった場合は、必要に応じてフィルタを追加する
      printf(STDERR "%s\n", $_);
    }
  }
}
my %old_last_access; # 過去の最終検出日
my %old_score;       # 過去の点数
my %old_flag;        # フラグ

foreach my $old_logfile (@old_logs){
  if(defined($old_logfile)){      # 過去の統計とマージ
    open($old_file, "< $old_logfile") or die "$! [$old_file]\n";
    while(<$old_file>){
      if(/^(\S+)\s+(\d+)\s+(\d+)\s+(\S+)/){
        my ($o_ip, $o_score, $o_flag, $o_access) = ($1, $2, $3, $4);
        #printf("%16s, %6s, %3s, %s\n", $o_ip, $o_score, $o_flag, $o_access);

        # 過去分と今回分をマージする
        # 運用方法に合わせて更新するのだけど、今回の例としては
        # 最高点、最終アクセス日で更新、フラグはマージする
        if( defined($ip_score{$o_ip})){
          if($ip_score{$o_ip} < $o_score){
            $ip_score{$o_ip} = $o_score;
          }
          if($ip_last_access{$o_ip} < $o_access){
            $ip_last_access{$o_ip} = $o_access;
          }
          $ip_flag{$o_ip} =  (0+$ip_flag{$o_ip}) | (0+$o_flag); # 0+は強制的に文字から数字に戻す意図です
        }else{
          # 古い方にしかない場合は、そのまま今回分に追加
          $ip_score{$o_ip} = $o_score;
          $ip_last_access{$o_ip} = $o_access;
          $ip_flag{$o_ip} = $o_flag;
        }
      }else{
        printf("ERR: %s\n", $_);
      }
    }
  }
}

# 結果の出力。以下ではタブ区切り(\t)だけど、
# あとで加工しやすい書式で出す。これを tee や > で、ファイルへ残す
while ( ( $ip, $score ) = each ( %ip_score ) ) {
  printf("%s\t%d\t%d\t%s\n", $ip, $score, $ip_flag{$ip}, $ip_last_access{$ip});
}

長くなってしまいましたが、肝心なところは、

  1. 「open($list, "< $logfile")」でログを開いて、
  2. 「while(<$list>)」で一行ずつ読み取って、
  3. (途中 log_mail01 で関数化しましたが)
  4. 「if(/^(\S+)\s+(\d+) .+ (connect|disconnect) from unknown\[([\d\.]+)/)」のように
    if文から時刻($1と$2)とIPアドレス($4)を読み取る

という作業です。
色々付け足すうちに、おおきくなっちゃった感じです。

もう1つのキモは、
log_mail01 で return 1 して $hit に渡していますが、
これによって、まだ if文を作って「いない」ログが登場した場合に、その行を出力します。
すべてのログを仕分けする if文を作っていく(新しく想定外のログが出たら、また作る)
というのがこのツールの目的です。

この例の「log_mail01」をひたすら作り込んで、
何度も実行して、ログに合うif文と正規表現をひたすら追加していくという、
長い、長い戦いが始まると思います...
(...時間のある時に、気長にやってみて下さい
 一度作ってしまえば、あとは実行するだけになりますし)
ある程度書けてきたら、
これを > list.txt みたいにリダイレクションします。

./read_log.pl /var/log/maillog > list.txt

想定外の(if文で拾えていない)ログはSTDERRに出るので、
最終的に全てのログを if 文で拾えるようにがんばりましょう。

IPアドレスの偏りっぷりを確認してみる例

上記スクリプトで作ったログを、サブネットで集約を目指します。
せっかくだから国も確認してみます。
正直、IPアドレス集約が実用的にどこまで効果が有るのかはさておき...
「これ、いつも同じ場所からきているんじゃないか?」
という疑問を解消するのには役立つと思います。

国ごとのIPアドレス割り振り情報として、事前にRIR一覧をwgetなりで取得して、
このスクリプトと同じ場所の list/ ディレクトリに置いておいて下さい。

ftp://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest
ftp://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest
ftp://ftp.apnic.net/pub/stats/apnic/delegated-apnic-extended-latest
ftp://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest
ftp://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest

以下がIPを集約してみようの例です。

#!/usr/bin/perl -w

# read_log.pl で作った以下のログ情報
#  printf("%s\t%s\t%s\t%s\n", $ip, $score, $ip_flag{$ip}, $ip_last_access{$ip});
#
# これに国情報の付与と、サブネット集約を試みます
# 出力はそのまま > などでファイルにリダイレクトする想定
# 集計情報以外は STDERR に分けて出力しています(これもファイル出力なら 2>&1 する)
#

my $SUBNET_INFO=8;  # 情報として表示するネットマスク集約最小値
my $SUBNET_BAN=28;  # これより大きいネットマスクで集約する

sub dbg_printf{
    # デバッグ文、作成過程では入れまくってます。
    #return;  # ここをコメントアウト
    #printf STDERR "dbg:";
    printf STDERR @_ ;
    $|=1;
}

sub number2ip {
  # 数値変換したIPアドレスを文字列へ戻す
  my $b = shift;
  my $ip = sprintf("%d.%d.%d.%d",
          unpack("C", pack("B8", substr($b, 0, 8))),
          unpack("C", pack("B8", substr($b, 8,16))),
          unpack("C", pack("B8", substr($b,16,24))),
          unpack("C", pack("B8", substr($b,24,32)))
        );
  return $ip;
}

#--------------------
# ここからメイン処理
#--------------------
if(! defined($ARGV[0])){
  printf("no args\n");
  exit;
}

# まず、サブネット 国名 一覧を読み込む
use File::Basename;
my $base_dir = dirname(__FILE__) .  "/list/";
@rir_lists = (
$base_dir . "delegated-afrinic-extended-latest",
$base_dir . "delegated-apnic-extended-latest",
$base_dir . "delegated-arin-extended-latest",
$base_dir . "delegated-lacnic-extended-latest",
$base_dir . "delegated-ripencc-extended-latest"
);
foreach my $r_list (@rir_lists){
  open($list, "< $r_list") or die "$! [r_list]\n";
  while(<$list>){
    # 上記の、こんな感じのファイルを読みたい
    #  apnic|JP|ipv4|1.0.16.0|4096|20110412|allocated|A92D9378
    if(/^[^\|]+\|(\w+)\|ipv4\|([\d\.]+)\|(\d+)\|/){
      $ip=$2; $num=$3; $cnt=$1;
      if($ip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/){
          $bin=$1*256*256*256
          +$2*256*256
          +$3*256
          +$4; # IP表記から数値に戻す
          $ip_range{$bin} = $num;
          $ip_cntry{$bin} = $cnt;
      }
    }
  }
}

my %ip_n;
my %ip_text;
my %score;
my %flag;
my %access;
my %country; # 国判定を行わない場合は、この変数に関する処理を削ります

dbg_printf("make country table\n");

open($old_file, "< $ARGV[0]") or die "$! [$old_file]\n";
while(<$old_file>){
  if(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/){
    my ($o_ip, $o_score, $o_flag, $o_access) = ($1, $2, $3, $4);
    my $o_bin;
    if($o_ip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/){
          $o_bin=$1*256*256*256
          +$2*256*256
          +$3*256
          +$4; # IP表記から数値に戻す
    }

    my $where = "??";
    foreach my $start (keys(%ip_range)){
      if($start <= $o_bin && $o_bin <= ($start + $ip_range{$start})){
        $where = $ip_cntry{$start};
        last;
      }
    }
    $bin32 = sprintf("%032b", $o_bin); # 二進数表記へ
    $ip_n{$bin32} = $o_bin;
    $ip_text{$bin32} = $o_ip;
    $score{$bin32} = $o_score;
    $flag{$bin32} = $o_flag;
    $access{$bin32} = $o_access;
    $country{$bin32} = $where;

   }else{
    printf(STDERR "ERR: %s\n", $_);
  }
}

dbg_printf("sort and dump netmask\n");

my %subnet_mask;
my %subnet_32bit;
my %subnet_range;
my %subnet_score;
my %subnet_flag;
my %subnet_access;

my $last_ip = sprintf("%032b", -1);
for my $ip (sort keys %score) {
    my ($pre, $suf, $mask) = ("", $ip, "");
    my $i;
    for($i=$SUBNET_INFO; $i<=32; $i++){  #---- ここでサブネット集約する範囲を指定
      #--- ソート済みの状態から、
      #--- 一つ前のIPアドレスと二進数で文字列比較する
      if(substr($last_ip, 0, $i) eq substr($ip, 0, $i)){ #---- 一致するなら、集約
        $pre = substr($ip, 0, $i);  #--- 一致部分
        $suf = substr($ip, $i, 32); #--- 不一致部分
        $mask = "0" x (32 - $i);    #--- 不一致部分をゼロでパディング
      }
      else {
        last;
      }
    }

    my $subnet;
    if(length($pre) > 0){
      $subnet = number2ip($pre . $mask) . "/" . $i;
    }else{
      $subnet = number2ip($suf);
    }
    if(length($pre) >= $SUBNET_BAN){
      # 赤で表示(\e[31m)サブネットを添えて
      dbg_printf("\e[31m%s\e[0m%s %19s %s\t%s\t%s\t%s\n", $pre, $suf, $subnet, $country{$ip}, $score{$ip}, $flag{$ip}, $access{$ip});
    }else{
      # 緑で表示(\e[32m)IPアドレスを添えて
      dbg_printf("\e[32m%s\e[0m%s %19s %s\t%s\t%s\t%s\n", $pre, $suf, $ip_text{$ip}, $country{$ip}, $score{$ip}, $flag{$ip}, $access{$ip});
    }
    if(length($pre) > $SUBNET_BAN){
      if($subnet =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/.+/){
        my $sub_n=$1*256*256*256
          +$2*256*256
          +$3*256
          +$4; # サブネットを数値にして、ハッシュのキーとして使用
        $subnet_mask{$sub_n} = $subnet;
        $subnet_32bit{$sub_n} = $pre . $suf;
        $subnet_range{$sub_n} = length($pre);
        $subnet_flag{$sub_n} = (defined $subnet_flag{$sub_n} ? $subnet_flag{$sub_n} | $flag{$ip} : $flag{$ip});
        if(! defined $subnet_score{$sub_n} || $subnet_score{$sub_n} < $score{$ip}){
          # 集約する場合は大きい方のスコアで更新。...あるいは合算でも、お好みで。
          $subnet_score{$sub_n} = $score{$ip};
        }
        if(! defined $subnet_access{$sub_n} || $subnet_access{$sub_n} < $access{$ip}){
          # 最終アクセス日に更新
          $subnet_access{$sub_n} = $access{$ip};
        }
      }
    }
    $last_ip = $ip;
}

# まず、サブネット同士で集約する
my %s_mask;
my %s_32bit;
my %s_range;
my %s_score;
my %s_flag;
my %s_access;

foreach my $sa (keys %subnet_mask){
  my $hit=0;
  foreach my $sb (keys %subnet_mask){
    if($sa == $sb){
      next; # 自分自身と比較はしない
    }
    my $len=$subnet_range{$sb};
    if($subnet_range{$sa} <= $len){
      next; # 自分の方が短い場合、比較はしない
    }
    if(substr($subnet_32bit{$sa}, 0, $len) eq substr($subnet_32bit{$sb}, 0, $len)){
      dbg_printf("# %s -> %s\n", $subnet_mask{$sa}, $subnet_mask{$sb}); # ここはあとで削除。確認用。
      $hit++; # 他のサブネットで集約される
      last;
    }
  }
  if($hit==0){
    $s_mask{$sa}   = $subnet_mask{$sa};
    $s_32bit{$sa}  = $subnet_32bit{$sa};
    $s_range{$sa}  = $subnet_range{$sa};
    $s_score{$sa}  = $subnet_score{$sa};
    $s_flag{$sa}   = $subnet_flag{$sa};
    $s_access{$sa} = $subnet_access{$sa};
  }
}

my %s_include; # 集約範囲がいくつの(不正な?)IPアドレスを含むかカウントする

for my $ip (sort keys %ip_n) { # ここはsortで無くても可。処理速度に応じて。
  my $hit=0;
  foreach my $mask (keys %s_mask){
    my $len=$s_range{$mask};
    if(substr($ip, 0, $len) eq substr($s_32bit{$mask}, 0, $len)){
      dbg_printf("# %s -> %s\n", $ip_text{$ip}, $s_mask{$mask}); # ここはあとで削除。確認用。
      $s_include{$mask} = (defined($s_include{$mask}) ? $s_include{$mask} +1 : 1);
      $hit++;
      last;
    }
  }
  if($hit>0){ # 既に集約済みならば、出力せずにスキップ
    next;
  }
  printf("%s\t%s\t%s\t%s\n", $ip_text{$ip}, $score{$ip}, $flag{$ip}, $access{$ip});
}

for my $n (sort keys %s_mask) { # ここはsortで無くても可。処理速度に応じて。
  printf("%s\t%s\t%s\t%s\t(%d)\n", $s_mask{$n}, $s_score{$n}, $s_flag{$n}, $s_access{$n}, $s_include{$n});
}

なんだか、長くなっちゃいましたね...
このスクリプトに、前述の read_log.pl で作ったリストを引数として与えると、
以下のようなイメージで出力されます。

(イメージです。実際は赤と緑で表示されます)
make country table
sort and dump netmask
01000111000001101110100000000100          xx.x.232.4 US	640	12	190320
01000111000001101110100000000101       xx.x.232.4/32 US	630	4	190418
01000111000001101110100000000111       xx.x.232.4/31 US	4	8	190402
(中略)
37.49.227.xxx/32	2130	4	190418	(2)
37.49.230.xxx/30	710	4	190312	(2)

前半で、集約が発生した場合に(赤か緑で)色をつけながらエラー出力(STDERR)へ表示して、
最終的にそのIPアドレス一覧を標準出力(STDOUT)へ表示します。
STDERRとSTDOUTへ分けたのは、 > ban_list.txt 2> info_list.txt みたいにリダイレクション先を分けるためです。
上記スクリプトはsortでかなりの時間がかかるので、
実際に運用する場合は上記の read_log.plと合わせて cronで動くように仕込むと楽だと思います。


IP集約するにせよ、しないにせよ、
ここで取得したIPアドレスは、例えばそのままつかうなら、

awk '{print $1;}' ログファイル名 > /home/hoge/ban_list.txt

のように、一番左のIP列だけ抜き出してもいいですし、
日付と得点で、fairewall-cmd 等に渡すか否かを決めてもいいと思います。

検出したIPアドレスをどうするかはさておき、
まずは一ヶ月〜数カ月分の情報を見てみると、何か発見があるかもしれません。
というか、私も目下、観察中といった感じです。

スパムリファラーのIPアドレスを抜き出す例

長くなってしまいますが、さらにもう少し...

前述のように、アクセスログからIPアドレスを抜き出せれば、あとはどうとでも応用はできます。

Webサイトをブラウザで見る時って、複数のファイルをダウンロードすることになると思います。
例えば、うちのサイト、PukiWiki使っていますが、

 index.html --+--- skin/pukiwiki.css.php (モバイルだと keitai.css.php)
              |     -> link rel=stylesheet で読み込んでいる部分
              +--- image/rss.png
              |     -> サイトのフッターに、img src で表示している画像
              +--- ...

大抵のWebサイトなら、各ページで共通するCSSや画像を読み込む作りになっていると思いますが
(Webサイトをブラウザで右クリックして「ソースを表示」して、linkタグとか探せば分かるかも)
これがbot や スパムリファラーだと、CSS やら画像ファイルやらはダウンロードせずに、
連続で index.htmlや特定の htmlファイルだけへアクセスしてくるはずです。
具体的には index.html だけに同じリファラーで3回連続でアクセスしてくるのが
リファラースパムの作法?の1つみたいですね、Webのアクセスログを見ると。

なので、

  1. htmlやcgiへのアクセスで-1 ($hash{"IPアドレス"} の値を -1 する)
  2. それと対になるスタイルシート等へのアクセスで+1 ($hash{"IPアドレス"} の値を +1する)

みたいに、IPアドレス毎にカウントアップ or ダウンすれば、
スパムリファラーを撒く目的のIPアドレスなんかは -3 くらいになると思います。
あと、Googlebotとかの正規の?クローラーもマイナスの値ですね。
拾ったIPアドレスは「dig -x IPアドレス」とかで逆引きしてみると多少の情報がつかめます。

そのIPアドレスの一覧を作っておけば、
ログを見る時に grep -vf で除外したり、squid で別のダミーサイトへ誘導したり、
いろいろと応用できると思います。
もちろん1つのアクセスログではなく、複数日のログから判断すれば精度も増すと思います。

例として、最新2ファイルを拾いたい場合。-1 は数字の1です。Lではなく。
$ cat `ls -1r /var/log/httpd/access_log* | tail -2`

...まぁ、いたちごっこ、ではありますが。
スパム側も、そうbotでアクセスすれば良いだけなので。
ただ、
bot側は世界中のWebサイトを効率良く回らないといけないので、
攻撃に使うパターンはある程度決まっているようで、
それを止めるか避けるかするだけでも、煩わしいアクセスをだいぶ減らせます。


*1 まぁ、自分なんですが...いつかログを一通り確認しないとな〜と思いつつ数年が経過してしまいました...