FreeBSD14 + Nginx + fail2banで不正なアクセスを自動ブロック

ウェブサーバーへの攻撃が増えてきたので対策したときの覚書。

環境: FreeBSD 14.3-RELEASE-p2, nginx 1.28.0

Gemini 2.5 Proと対話しながら作業している。


1. fail2banとは?

Fail2banは、サーバーのログファイルを監視し、特定のパターン(ブルートフォース攻撃、スキャン活動など)を検知すると、その攻撃元のIPアドレスを自動的にファイアウォールでブロックする、侵入防止ソフトウェア。

動作の仕組み

  • 監視 (Monitor): Nginxのアクセスログやエラーログ、SSHの認証ログなどをリアルタイムで監視。
  • 検知 (Detect): フィルタと呼ばれる正規表現ルールに基づき、ログの中から「存在しないページへの連続アクセス」といった不正な兆候を探します。
  • ブロック (Block): 不正な兆候が一定回数(例: 5分以内に3回)見つかると、アクションを実行して、OSのファイアウォール(FreeBSDの場合はpf)にそのIPアドレスを一時的にブロックするよう命令。
  • 解除 (Unban): 設定された時間が経過すると(例: 1時間後)、自動的にブロックを解除。


fail2banとSSHGuardの違い

項目fail2banSSHGuard
主な用途ログを監視し、不正アクセスをiptables/pf等でブロックSSHや各種サービスのログを監視し、攻撃元IPをブロック
サービス対応範囲汎用的(Nginx, Postfix, Dovecot, SSH など多数のfilter用意)主にSSH向け(追加対応も可能だが限定的)
設定の柔軟性高い(正規表現でログ解析ルールをカスタマイズ可)比較的シンプル、細かいカスタマイズは難しい
導入・設定難易度やや高い(filter/jail設定が必要)低い(導入して有効化するだけで基本動作)
FreeBSDでの利用実績ports/packages で提供、Nginx対策にもよく使われるFreeBSD標準での利用実績が多く軽量
リソース消費やや重い(Python製)軽量(Cで実装)
特徴多機能でNginxなど幅広いサービスを守れるシンプルで軽量、主にSSH向けに効果的



2.fail2banのインストールと設定

pkgでインストール。
# pkg search fail2ban
# pkg install py311-fail2ban

自動起動オン
# sysrc fail2ban_enable="YES"

設定ファイルの編集。
Jailは刑務所の意味。
jail.localファイルにするのがfail2banのお作法らしい。
#  cd /usr/local/etc/fail2ban/
# cp jail.conf jail.local
# less jail.local

# =================================================================================
# Fail2Ban :: jail.local for Nginx on FreeBSD
# =================================================================================
#
# このファイルは `jail.conf` の設定を上書きします。
# Nginxのセキュリティ強化に必要な設定のみを記述しています。
#

# ---------------------------------------------------------------------------------
# [DEFAULT]セクション: 全てのルールに適用される共通設定
# ---------------------------------------------------------------------------------
[DEFAULT]

# --- 基本設定 ---

# 自分自身や信頼できるIPを誤ってブロックしないためのホワイトリスト (最重要)
# 127.0.0.1/8 ::1 は必須。カンマかスペース区切りで、ご自身のオフィスのIPなどを追加してください。
# 例: ignoreip = 127.0.0.1/8 ::1 123.45.67.89/32
ignoreip = 127.0.0.1/8 ::1

# ブロックする時間 (bantime)
#   s: 秒, m: 分, h: 時間, d: 日, w: 週
#   最初は短め(10mなど)でテストし、問題なければ長くする(1h or 1d)のがおすすめです。
bantime  = 1h

# 違反を検知する期間 (findtime)
findtime = 10m

# 上記 `findtime` の間に何回違反したらブロックするか (maxretry)
maxretry = 5


# --- FreeBSD特有の設定 ---

# FreeBSDの標準ファイアウォールである `pf` を使用するよう指定します。
banaction = pf


# ---------------------------------------------------------------------------------
# [JAILS]セクション: Nginx用の個別ルール
# ---------------------------------------------------------------------------------
#
# ここでは、Nginxに関連するルールのみを `enabled = true` に設定します。
# `jail.conf` に存在する他の多くのルール(sshd, apacheなど)は、
# ここで指定しない限りデフォルトで無効(`enabled = false`)のままです。
#

# --- ルール1: Nginxのレート制限超過を検知 ---
# Nginxのlimit_reqディレクティブでブロックされたIPを、Fail2banでさらにブロックします。
# 攻撃的なクローラーや軽度なDoS攻撃の遮断に非常に効果的です。
#
# ※このルールを機能させるには、別途フィルタファイルを作成する必要があります。
#   ( /usr/local/etc/fail2ban/filter.d/nginx-limit-req.conf )
[nginx-limit-req]
enabled  = true
filter   = nginx-limit-req
# Nginxのレート制限エラーはエラーログに出力されます。パスは環境に合わせてください。
logpath  = /var/log/nginx/error.log
# より悪質と判断し、少し厳しめに設定
maxretry = 5
findtime = 5m
bantime  = 2h


# --- ルール2: NginxのHTTPベーシック認証への総当たり攻撃を検知 ---
# .htpasswdなどを使った認証ページへのブルートフォース攻撃をブロックします。
[nginx-http-auth]
enabled = true
# 認証エラーもエラーログに出力されます。
logpath = /var/log/nginx/error.log


# --- ルール3: 存在しないスクリプトへのスキャン攻撃を検知 ---
# 存在しないPHPファイルなどへのアクセスを繰り返す脆弱性スキャナをブロックします。
# (例: /wp-config.php.bak, /admin.php など)
[nginx-noscript]
enabled = true
# 404エラーは通常アクセスログに記録されます。
logpath = /var/log/nginx/access.log


# --- ルール4: 悪意のあるボットによるスキャンを検知 ---
# 既知の悪意あるUser-Agentや、脆弱性を探す典型的なリクエストパターンを検知します。
[nginx-botsearch]
enabled = true
# こちらもアクセスログを監視します。
logpath = /var/log/nginx/access.log

nginx-limit-reqフィルタの作成。
既に存在していたのでGemini先生の言う通りに置き換える。
# less filter.d/nginx-limit-req.conf

[Definition]
failregex = limiting requests, excess:.* by zone ".*", client: <HOST>,
ignoreregex =

構文テスト
# fail2ban-client -d

2025-08-18 18:13:44,654 fail2ban.configreader   [11974]: ERROR   Found no accessible config files for 'filter.d/nginx-noscript' under /usr/local/etc/fail2ban
2025-08-18 18:13:44,654 fail2ban.jailreader     [11974]: ERROR   Unable to read the filter 'nginx-noscript'
2025-08-18 18:13:44,655 fail2ban.jailsreader    [11974]: ERROR   Errors in jail 'nginx-noscript'. Skipping...

Gemini先生にエラー報告して言う通りに対応する。
# vim filter.d/nginx-noscript.conf

[Definition]
# Nginxで存在しないスクリプトへのアクセス試行を検知する
# 例: "GET /nonexistent.php HTTP/1.1" 404
failregex = ^<HOST> -.*GET.*(\.php|\.asp|\.exe|\.pl|\.cgi|\.scgi|\.sh).* 404
ignoreregex =

構文テスト
# fail2ban-client -d

Fail2ban起動
# service fail2ban start


3.PFの設定

/etc/pf.conf の最後に追記
# less /etc/pf.conf

...
# Fail2Ban Anchor
# Fail2Banが作成する全てのアンカー(f2b/で始まるもの)を読み込む
anchor "f2b/*"

構文チェックして再読み込み。
# pfctl -nf /etc/pf.conf
# service pf reload

Fail2banも再起動
# service fail2ban restart


4.fail2banの動作確認

テスト用のIPアドレスを手動でブロック
# fail2ban-client set nginx-limit-req banip 192.0.2.1

ブロックリストを確認。
# pfctl -a f2b/nginx-limit-req -t f2b-nginx-limit-req -T show

   192.0.2.1

追加されたルールを確認。
# pfctl -a f2b/nginx-limit-req -s rules

block drop quick proto tcp from <f2b-nginx-limit-req> to any port = http
block drop quick proto tcp from <f2b-nginx-limit-req> to any port = https

手動でブロックを解除
# fail2ban-client set nginx-limit-req unbanip 192.0.2.1


5. ブロックしたIPアドレスを確認

nginx-limit-reqフィルタのJailを確認する。
# fail2ban-client status nginx-limit-req

Status for the jail: nginx-limit-req
|- Filter
|  |- Currently failed: 3  <-- 現在のfindtime内に検知した違反回数
|  |- Total failed:     57 <-- これまでの累計違反回数
|  `- File list:        /var/log/nginx/error.log
`- Actions
   |- Currently banned: 2   <-- 現在ブロック中のIPアドレス数
   |- Total banned:     5   <-- これまでの累計ブロックIP数
   `- Banned IP list:   198.51.100.10 203.0.113.25  <-- ★ブロック中のIPリスト

すべての有効なJailの概要を確認。
# fail2ban-client status

Status
|- Number of jail:      4
`- Jail list:   nginx-botsearch, nginx-http-auth, nginx-limit-req, nginx-noscript

Fail2ban自体の動作ログを確認。
# less /var/log/fail2ban.log

Gemini先生がフィルタごとに一括表示するコマンドを作ってくれたので、.bashrcを新規作成して登録する。
# vim ~/.bashrc

# Fail2banの全JailのブロックIPをpfテーブルから一覧表示する
function pf-f2b-list() {
    # 有効になっているJailのリストを取得する
    local jaillist=$(fail2ban-client status | grep "Jail list" | sed -E 's/.*Jail list:[[:space:]]+//' | tr ',' ' ')

    # 新しいBashシェルを起動し、Jailのリストを引数として渡す
    bash -c '
    # 引数(Jail名)を一つずつ処理するループ
    for jail in "$@"; do
        echo "--- Table for Jail: $jail ---"

        # pfctlの出力を変数に格納する (エラーは捨てる)
        # "local" を削除し、通常の変数として宣言
        content=$(pfctl -a "f2b/$jail" -t "f2b-$jail" -T show 2>/dev/null)

        # 変数 `content` に中身があるかどうかをチェック
        if [ -n "$content" ]; then
            # 中身があれば表示する
            echo "$content"
        else
            # 中身がなければメッセージを表示する
            echo "(No entries or table not found)"
        fi
        echo
    done
    ' _ $jaillist
}

.bash_profileに.bashrcを読み込むように記述する。
# less ~/.bash_profile

if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

.bash_profileはログインシェルとして起動した時に一度だけ読み込まれる。
.bashrcは非ログインシェルとして起動するたびに読み込まれる。

シェルの再読み込み。
# source ~/.bash_profile

実行
# pf-f2b-list

--- Table for Jail: nginx-botsearch ---
(No entries or table not found)

--- Table for Jail: nginx-http-auth ---
(No entries or table not found)

--- Table for Jail: nginx-limit-req ---
   45.156.129.136
   45.156.129.137
   45.156.129.138
   45.156.129.139
   133.125.37.176

--- Table for Jail: nginx-noscript ---
   13.79.168.144
   52.169.251.174
   104.41.205.21
   135.220.16.255
   172.192.63.224
   196.251.66.105

pf-f2b-listの実行結果を5秒ごとに自動更新(再描画)しながら監視するコマンド。
# while true; do clear; pf-f2b-list; sleep 5; done

Fail2banの動作ログとnginxのアクセスログを別コンソールで監視する。
ブロックすると、nginxのアクセスログに出なくなるはず。
# tail -f /var/log/fail2ban.log
# tail -f /var/log/nginx/access.log


分かってきたのでフィルタの設定は後で見直す。


6. pf-f2b-listに国コードを追加

IPアドレスごとに国コードを出力するようにシェルスクリプトを変更。
参考: FreeBSD14 + Nginx + GeoIP2の設定とIPアドレスを自動更新
# less .bashrc

# Fail2banの全JailのブロックIPをpfテーブルから一覧表示し、国コードを追記する
function pf-f2b-list() {
    # MMDBファイルのパス
    local mmdb_file="/usr/share/GeoIP/GeoLite2-Country.mmdb"

    # MMDBファイルが存在するかチェック
    if [ ! -f "$mmdb_file" ]; then
        echo "エラー: MMDBファイルが見つかりません: $mmdb_file" >&2
        return 1
    fi

    # mmdblookupコマンドが利用可能かチェック
    if ! command -v mmdblookup &> /dev/null; then
        echo "エラー: 'mmdblookup' コマンドが見つかりません。" >&2
        echo "ヒント: 'sudo dnf install libmaxminddb-utils' または 'sudo apt-get install mmdb-bin' を実行してください。" >&2
        return 1
    fi

    # 有効になっているJailのリストを取得する
    local jaillist=$(fail2ban-client status | grep "Jail list" | sed -E 's/.*Jail list:[[:space:]]+//' | tr ',' ' ')

    # 新しいBashシェルを起動し、JailのリストとMMDBファイルのパスを引数として渡す
    bash -c '
    # MMDBファイルのパスを最初の引数から取得
    MMDB_FILE="$1"
    shift # 引数を一つずらす

    # 残りの引数(Jail名)を一つずつ処理するループ
    for jail in "$@"; do
        echo "--- Table for Jail: $jail ---"

        # pfctlの出力を変数に格納する (エラーは捨てる)
        content=$(pfctl -a "f2b/$jail" -t "f2b-$jail" -T show 2>/dev/null)

        # 変数 `content` に中身があるかどうかをチェック
        if [ -n "$content" ]; then
            # ヘッダーを表示
            printf "%-5s %s\n" "CODE" "IP ADDRESS"
            echo "----- ------------------------------"

            # content変数の内容を行ごとに処理
            while read -r ip; do
                # 前後の空白を削除
                ip=$(echo "$ip" | xargs)

                # 空行はスキップ
                [ -z "$ip" ] && continue

                # mmdblookupで国コードを取得し、awkで国コード部分のみを抽出する
                # 結果に含まれるダブルクォートも削除
                country_code=$(mmdblookup --file "$MMDB_FILE" --ip "$ip" country iso_code 2>/dev/null | tr -d \" | awk "{print \$1}")

                # 国コードが取得できなかった場合のデフォルト値を設定 (例: プライベートIPなど)
                country_code=${country_code:-"N/A"}

                # 整形して出力
                printf "%-5s %s" "$country_code" "$ip"
            done <<< "$content" # Here-stringを使ってcontent変数をループの入力にする
        else
            # 中身がなければメッセージを表示する
            echo "(No entries or table not found)"
        fi
        echo
    done
    ' _ "$mmdb_file" $jaillist # bashにMMDBファイルのパスとjaillistを渡す
}

再読み込みして確認する。
# source .bashrc
# pf-f2b-list

--- Table for Jail: nginx-botsearch ---
(No entries or table not found)

--- Table for Jail: nginx-http-auth ---
(No entries or table not found)

--- Table for Jail: nginx-limit-req ---
CODE  IP ADDRESS
----- ------------------------------

AU   3.107.81.96
AU   13.210.100.22
US   20.172.36.113
US   40.85.191.25
NL   45.148.10.152
US   45.201.124.165
SG   47.128.48.113
SG   47.128.96.191
SG   47.128.121.74
FR   51.77.210.64
FR   51.77.211.137
DE   57.129.69.65
AU   58.107.70.151
ID   103.121.182.186
AU   154.82.26.172
MY   156.235.64.81
FR   164.68.102.93
BR   189.79.6.79
--- Table for Jail: nginx-noscript ---
(No entries or table not found)



▼ 関連記事