Linux/Synフラッドの防ぎ方

Synフラッドをサブネット単位で阻止する(2026-04-12)





はじめに

夜間に大量のアクセスが集中してWebサーバにアクセスしづらくなるとか、そんな経験ありませんか?

その原因の1つとしてSynフラッドがあります。
特に原因不明のWebサーバへの負荷(=ログから判別できない負荷)とかは、Synフラッドも疑ったほうが良いです。*1

で、ログに載らないものをどう調べるのかですが。
(参考:ログに残りにくい攻撃について

netstat コマンドを5分おきくらいに実行してログに残せば翌朝にでも確認できます。
以下、あらかじめ mkdir /var/log/netstat/ などでディレクトリを作成した状態で crontab -e などで周期起動しておきます。

   */5 * * * * \netstat -tan > /var/log/netstat/log`date +\%H%M`.txt


cronで5分おきに「log時分.txt」にTCP通信のログ(netstat -tan)を残します。大量のファイルができるので注意。
(netstat のまえに \ をつけているのは念の為、alias とかを無効化する小細工です)

それで、攻撃を受けている場合のログはこちら。

  Active Internet connections (servers and established)
  Proto Recv-Q Send-Q Local Address        Foreign Address         State
  (中略)
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.9.92:58987      SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.9.96:49559      SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.8.197:58838     SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.8.248:19745     SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.9.208:40599     SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.11.212:28493    SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.9.20:59951      SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.9.158:45795     SYN_RECV
  tcp        0      0 aa.bb.cc.dd:443      xxx.189.10.59:26735     SYN_RECV
  (以下省略)

攻撃者が xxx.189.9.0/28 〜 xxx.189.11.0/28のIPアドレスからSynフラッドを仕掛けてきています。

通常はSYN_RECV なんて3ウェイハンドシェイクのうちの一瞬のできごとなので、こうも大量に発生するわけはないのです。
Webサーバ側の受付可能数には限界があるので、このまま放置すればWebサーバにつながらなくなります。

この攻撃の厄介な点は、攻撃元のIPアドレスが常に変わるところです。
fail2ban などのログ監視では特定のIPを検出、阻止はできるのですが、送信元IPがころころ変わるから検出しづらくなります。

とはいえ、
ご覧のとおり、多くの場合は特定のサブネット群から攻撃が発生するものなので・・・

・・・よって今回はサブネット単位で、自力でむりやり阻止します。
xxx.189.9.0/28 〜 xxx.189.11.0/28 を一時的にすべて拒絶する、といった具合に。
これから来ると分かっている攻撃は先に拒絶してしまおう、と。
(参考:複数IPを1サブネットにまとめる

サブネットという広範囲で止めるのは誤爆が怖いので、せめて国内アクセスは除外する努力をします。
(参考:アクセスログの数や国を見る

なので先に地域ごとのIPアドレス割り当て(RIR)のテキストファイルを取得しておいてください。

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



IPアドレスの集約方法

今回の例は作者が理解しやすいように「二進数文字列に変換」なんて手法を取っていますが、ぜひ、もっと冴えたやり方を考えてみてください。

発想だけ、参考にしてもらえれば良いかと。

ともかく。
まず、以下の3つのIPアドレスを二進数に変換してみましょう。

 x.189.9.92  = xxxxxxxx.10111101.00001001.01011100
 x.189.9.96  = xxxxxxxx.10111101.00001001.01100000
 x.189.8.197 = xxxxxxxx.10111101.00001000.11000101

見やすさのためにドット(.)を入れましたが、つまり二進数32文字に変換しています。

1つ目と2つ目のIPアドレスは、26文字目までは同じです。
3つ目も含めたいなら 23文字目までを使うイメージです。
(サブネットマスクの範囲が /26 か /23 か、といった感じ)

つまりサブネットマスク x.189.9.64/26 なら1つ目と2つ目が該当。
(x.189.9.64/26 = xxxxxxxx 10111101 00001001 01000000)
そして x.189.8.0/23 なら3つともすべて該当します。
(x.189.8.0/23 = xxxxxxxx 10111101 00001000 00000000)

・・・わかります?
左から何文字目までを使って、通信をシャットアウトするかというお話です。


シャットアウトする範囲を広げれば広げるほど、誤爆の可能性も高まります。
その範囲に含まれる正常な通信まで阻止されてしまうので、さすがに /23 でシャットアウトは広すぎるかもしれませんが。

とはいえ、どの範囲でサブネットを区切るかは、さじ加減の問題です。できるだけ狭い範囲かつ効率的に攻撃阻止できる広さをさがして調整するしかないでしょう。


IPv6? 知らんがな。*2


実際のコード

後述するperl スクリプトを netstater.pl として、netstat -tan の出力を食わせます

# RIRの5ファイルを rir_list/ 配下に準備し
# mkdir log/ した状態で
# netstat -tan > netstat.log した状態で実行
# (netstat.log ファイルは複数指定可能)
  
$ ./netstater.pl -yes netstat.log
(中略)
xxx.189.8.248    XX 1
xxx.189.9.208    XX 1
xxx.189.8.192/28	(2)
xxx.189.8.224/28	(2)
xxx.189.8.224/28	(2)
xxx.189.9.64/28	(4)
(以下省略。
  XXには国コード、
  カッコ内の数値はサブネット内にいくつのIPが含まれるか、
  そして -yes 指定で  log/netstat_ban.txt が生成される)
 

実行するとSYN_RECV しているIPアドレスをサブネットで抽出します。
あとは firewall-cmd などでサブネット単位で接続拒否します。
(攻撃に使われるIPアドレスの範囲に合わせて、サブネットも自動調整していく仕組み)

既知の問題として、範囲が重なる場合があります。

11.22.33.0/24 と 11.22.33.128 といった具合に、後者は前者のなかに含まれるけど現状は2つとも出力している状態です。
この両方を firewall-cmd で登録しようとすると重複のために INVALID_ENTRY が発生するので、後述の使用例では -quiet 指定しています。


以下、perl スクリプト。
(一行目のPATHや、出力先PATHなどは変えてください)

#!/usr/bin/perl -w

# 前半は国情報の付与のための準備、
# 後半で、netstat -tan のログからサブネット集約を試みます
#
# あらかじめ、このスクリプトと同じ場所に
# mkdir log/
# (変数 ban_file 、info_file を参照。ログ出力先)
# あと rir_list/ 配下に取得済みの RIR ファイルを設置すること
#
# 国別の判定を追加・削除する場合は JP 部分を変更するか、
# 前半の RIR 読み込み処理をごっそり削除してください
#
# 引数で -yes を指定して実行することで
# log/ 配下に netstat_ban.txt が出力されるので
# これを firewall-cmd などで指定して一時的にアクセス拒否する
#
# @2026.04.12 null-i.net
#

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

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

# まず、サブネット 国名 一覧を読み込む
use File::Basename;
my $base_dir = dirname(__FILE__) .  "/rir_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 %f_ip;
my %f_c_code;

dbg_printf("make country table\n");

my $yn_output = "no";
  
foreach my $e_file (@ARGV){
  if($e_file =~ /^-/){
    if($e_file =~ /^-yes/){
      $yn_output = "yes";
    }
    if($e_file =~ /^-dbg/){
      $DEBUG = "yes";
    }
    next;
  }
  dbg_printf("file:%s\n", $e_file);
  open($old_file, "< $e_file") or die "$! [$e_file]\n";
  while(<$old_file>){
    if(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/){
    # Proto Recv-Q Send-Q Local Address    Foreign Address    State
    # tcp        0      0 aa.bb.cc.dd:443  xxx.189.9.92:58987 SYN_RECV
      my ($o_proto, $o_rq, $o_sq, $o_local, $o_foreign, $o_state) = ($1, $2, $3, $4, $5, $6);
  
      if($o_proto ne "tcp"){ next; }
      if($o_state ne "SYN_RECV"){ next; } # まずは syn flood のみで
      if($o_local =~ /(\d+\.\d+\.\d+\.\d+):(\d+)/){ # IPv6未対応
        my ($l_ip, $l_port) = ($1, $2);
        #if($l_port == 25 or $l_port == 443 or $l_port == 80){
        if($l_port == 25){
          next; # 無視するポートがあれば指定する(例としてSMTP)
        }
      }
  
      my $o_ip;
      if($o_foreign =~ /(\d+\.\d+\.\d+\.\d+):(\d+)/){
        my ($o2_ip, $o2_port) = ($1, $2);
        if(defined  $f_ip{$o2_ip}){ # 既にカウント済みの場合
          $f_ip{$o2_ip} = $f_ip{$o2_ip} + 1; # そのままカウントアップ
          next;  # 次へすすむ
        }else{
          $f_ip{$o2_ip} = 1; 
          $o_ip = $o2_ip; # 後続の処理へ続く
          dbg_printf("netstat:%s", $_);
        }
      }
      if(defined  $f_c_code{$o_ip}){ next; } # 国コードが無い場合は後続へ
  
      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;
        }
      }
      $f_c_code{$o_ip} = $where;
    }else{
      printf(STDERR "ERR: %s\n", $_);
    }
  }
}
dbg_printf("--------\n");


$base_dir = dirname(__FILE__);
my $ban_file = $base_dir . "/log/netstat_ban.txt";
my $info_file = $base_dir . "/log/netstat_info.txt";
if($yn_output eq "yes"){
  open($f_info, "> $info_file") or die "$! [$info_file]\n";
  open($f_ban,  "> $ban_file")  or die "$! [$ban_file]\n";
}
foreach my $iip (keys %f_ip){
  printf(STDOUT "%-16s %s %d\n", $iip, $f_c_code{$iip}, $f_ip{$iip});

  if(! defined $f_info){ next; }
  printf($f_info "%-16s %s %d\n", $iip, $f_c_code{$iip}, $f_ip{$iip});
  if($f_ip{$iip} > 11 && $f_c_code{$iip} ne "JP"){ 
    # n 以上かつ国外は当選、一時間?はbanする
    if($iip !~ /\/32/){ # 32 はサブネットではなく普通にアドレス
      printf($f_ban  "%s\n", $iip);
    }
  }
}


my $SUBNET_BAN=26;  #--- これより大きいサブネットマスクで集約する ----

dbg_printf("make binary table-----\n");
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;
}

my %f_bin2n; # 二進数と数字
my %f_bin2ip;# 二進数とIPv4アドレス表記
foreach my $iip (keys %f_ip){
    if($iip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/){
          $o_bin=$1*256*256*256
          +$2*256*256
          +$3*256
          +$4; # IP表記から数値に変換
    }else{
      next;
    }
    
    $bin32 = sprintf("%032b", $o_bin); # 二進数表記へ
    $f_bin2n{$bin32} = $o_bin;
    $f_bin2ip{$bin32} = $iip;
}

$base_dir = dirname(__FILE__);
my $subinfo_file = $base_dir . "/log/netstat_subinfo.txt";
if($yn_output eq "yes"){
  open($f_sbinfo, "> $subinfo_file") or die "$! [$info_file]\n";
}

dbg_printf("sort and dump netmask----\n");
my %subnet_mask;
my %subnet_32bit;
my %subnet_range;

my $last_ip = sprintf("%032b", -1);
for my $ip (sort keys %f_bin2n) {
    my ($pre, $suf, $mask) = ("", $ip, "");
    my $i;
    for($i=$SUBNET_BAN; $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)サブネットを添えて
      if(defined $f_sbinfo){ printf($f_sbinfo "\e[31m%s\e[0m%s %19s\n", $pre, $suf, $subnet );}
    }else{
      # 緑で表示(\e[32m)IPアドレスを添えて
      if(defined $f_sbinfo){ printf($f_sbinfo "\e[32m%s\e[0m%s %19s\n", $pre, $suf, $f_bin2ip{$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);
      }
    }
    $last_ip = $ip;
}

# サブネット同士で集約する----------------------
my %s_mask;
my %s_32bit;
my %s_range;

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)){
      #if(defined $f_sbinfo){ printf($f_sbinfo "# %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};
  }
}

my %s_include; # 集約範囲に含まれるIPアドレスのカウント

for my $ip (sort keys %f_bin2ip) { # ここは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)){
      $s_include{$mask} = (defined($s_include{$mask}) ? $s_include{$mask} +1 : 1);
      #dbg_printf("# %s -> %s[%d]\n", $f_bin2ip{$ip}, $s_mask{$mask}, $s_include{$mask}); # ここはあとで削除。確認用。
      $hit++;
      last;
    }
  }
  if($hit>0){ # 既に集約済みならば、出力せずにスキップ
    next;
  }
}

my $is_sub=0;
for my $n (sort keys %s_mask) { # ここはsortで無くても可。処理速度に応じて。
  printf(STDOUT "%s\t(%d)\n", $s_mask{$n}, $s_include{$n});
  if(defined $f_ban){ printf($f_ban "%s\n", $s_mask{$n}); }
  $is_sub++;
}
if($is_sub <= 0){
  printf(STDOUT "no subnet mask\n");
}

dbg_printf("did it!\n");




参考:netstat -tan のログの例

ひとまず xxx でマスクをかけてますが、適当な数値で変換してから使ってください。

+  ←クリックで表示




使い方の例

まず、事前にファイアウォールのルールを作っておきます。
ban_netstat というルール名で drop させます。*3

# firewall-cmd --permanent --new-ipset=ban_netstat --type=hash:ip
# firewall-cmd --reload
# firewall-cmd --add-rich-rule='rule source ipset=ban_netstat drop' --permanent
# firewall-cmd --reload


前準備ができたら、5分おきくらいで netstat ログを取ってみます。

# crontab -e で以下を登録
# 先に mkdir /var/log/netstat/ してください
# 5分おきに、過去3時間分のログが残るように上書きしています
*/5 0,3,6,9,12,15,18,21 * * * \netstat -tan > /var/log/netstat/log`date +1\%M`
*/5 1,4,7,10,13,16,19,22 * * * \netstat -tan > /var/log/netstat/log`date +2\%M`
*/5 2,5,8,11,14,17,20,23 * * * \netstat -tan > /var/log/netstat/log`date +3\%M`



収録したログから大量のSYN_RECVが発見できたら、試しに前述のスクリプト(netstater.pl)に食わせてみましょう。
(SYN_RECV ログが手に入らない場合は、前述のサンプルを使って動作確認してください)

./netstater.pl -yes /var/log/netstat/log*


うまく動けば、./log/netstat_ban.txt に阻止するべきサブネットが出力されます。

問題なく実行できそうなら、
以下のようなシェルを用意して。

#/bin/sh
 
BASE_DIR="/ツールを置いた場所"
LOG_DIR="/var/log/netstat"
STATER_BAN_LIST="$BASE_DIR/log/netstat_ban.txt"
 
cat /dev/null > ${STATER_BAN_LIST}.old
\cp -f $STATER_BAN_LIST ${STATER_BAN_LIST}.old
${BASE_DIR}/netstater.pl -yes ${LOG_DIR}/log* > /dev/null
 
if [ -s ${STATER_BAN_LIST}.old -a -s ${STATER_BAN_LIST} ]; then
  # 前回分のリストを一旦、削除
  # 範囲重複によるINVALID_ENTRY を消したいので quiet指定
  firewall-cmd --quiet --ipset=ban_netstat --remove-entries-from-file=${STATER_BAN_LIST}.old --permanent > /dev/null
fi
 
if [ -s ${STATER_BAN_LIST} ]; then
  # 今回分のリストを追加
  firewall-cmd --quiet --ipset=ban_netstat --add-entries-from-file=${STATER_BAN_LIST} --permanent > /dev/null
fi
 
firewall-cmd --reload > /dev/null


このシェルを cron_netstat.sh として、
前述の cron を以下のように「&&」でつないでシェル実行する形に追記・変更します。

 */5 0,3,6,9,12,15,18,21 * * * \netstat -tan > /var/log/netstat/log`date +1\%M` && /ツールを置いた場所/cron_netstat.sh
 (同様に残りの二行も追記・変更)



あとは、実際にDoS攻撃を受けはじめた時の問題点として、
netstat コマンドの出力には、ものすごい時間がかかるという欠点があります。(5分以上かかる場合もある)
なので、本気で実装したいなら netstat コマンド以外の方法でSYN_RECV 一覧を取得した方が良いかもしれません。

それと、上記shell スクリプトにあるようにファイアウォールをremove する処理を入れるのもお忘れなく。
攻撃者が「正規のネットワークから攻撃してくる」ことも当然ありうるので、正規の通信まで永久に止めたりしないようにあくまで一時的な措置にしましょう。
(例では3時間ですが、各環境にあわせて調整してみてください)


以上です。
みなさんのサーバが少しでも快適になることを祈ります。



*1 ログに載らないというのは、3ウェイハンドシェイクが完了していない「接続前」状態だからです。夜間に攻撃されるのは、単に地球の裏側が昼だからかと。
*2 やり方は同じで、16進数のIPv6アドレスから、128文字の二進数に変換するだけなのでしょうけれど・・・IPv6対応しても、うちのサーバはIPv6分の攻撃までさばけるほど強くはないので、いまだに未対応のままです。
*3 念の為、攻撃元IPが詐称されている場合も想定してreject ではなくdrop を選びました。無関係の人にRESET通知が飛びまくらないように。SynFlood以外でこのツールを流用するならあえてrejectを選ぶ手もありだと思います。