「うちのサイト、攻撃されていませんか?」と聞かれたとき、答えはたいていサーバのアクセスログの中にあります。ただ生ログを tail で眺めても、1 日数万行の中から異常だけを拾うことはできません。
この記事は、サーバに SSH で入れる Web 担当者・サイト運営者・制作会社向けに、アクセスログを 7 本のコマンドに通して「見るべき数行」まで絞り込み、見つけた相手を fail2ban で自動遮断するまでをまとめたものです。前提は nginx または Apache の combined 形式ログ(WordPress 特有の見どころにも触れます)。
手順 0: ログに「本当のクライアント IP」が入っているか確認する
ここを確認しないと以降の集計は全部むだになります。Cloudflare・ロードバランサ・リバースプロキシの配下では、何もしなければ記録されるのはプロキシの IP です。上位が全部同じ IP になり、そのまま fail2ban に食わせると自サイトへの入口ごと遮断してしまいます。
tail -1 /var/log/nginx/access.log
先頭のアドレスが訪問者の IP ではなく CDN・ロードバランサのものなら、復元設定が必要です。
nginx の場合
combined 形式の先頭は $remote_addr です。これをプロキシが渡すヘッダの値に差し替えるのが realip モジュールです。
# 信頼するプロキシのレンジを列挙する(Cloudflare の全レンジは https://www.cloudflare.com/ips/ )
set_real_ip_from 173.245.48.0/20;
# ... 公開されているレンジをすべて記載する
real_ip_header CF-Connecting-IP; # 既定は X-Real-IP。経路に合わせる
real_ip_recursive off; # 既定 off = ヘッダの最後の値を採用
モジュールの有無は nginx -V 2>&1 | tr ' ' '\n' | grep realip で確認できます(パッケージ版には通常含まれます)。set_real_ip_from に書いていない相手からのヘッダは無視されます。 ここを 0.0.0.0/0 のように全開にすると誰でも IP を詐称できるので、必ず経路のレンジだけを書きます。
Apache の場合
mod_remoteip で復元し、ログの書式を %h から %a に変えます(%a = 復元後のクライアント IP、%{c}a = 直前の接続元=プロキシ)。
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined_realip
CustomLog ${APACHE_LOG_DIR}/access.log combined_realip
反映後、自分の回線からサイトを開いて tail -1 に自分の IP が出れば準備完了です。
集計の共通部分
combined 形式は nginx・Apache ともフィールド位置が同じなので、同じ awk がそのまま使えます。
203.0.113.10 - - [10/Sep/2026:07:12:33 +0900] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "curl/8.5.0"
$1 $2$3 $4 $5 $6 $7 $8 $9 $10 $11 $12
$1 = 送信元 IP、$6 = メソッド(先頭に " が付く)、$7 = パス、$9 = ステータス、$10 = 応答サイズ。ローテート済みの過去分も見たいので、対象を変数にまとめます。GNU の zcat -f は非圧縮ファイルもそのまま流すので、.gz と生ログを混ぜて渡せます。
LOGS="/var/log/nginx/access.log /var/log/nginx/access.log.1 /var/log/nginx/access.log.*.gz"
zcat -f $LOGS | wc -l # 対象行数の確認
Apache は /var/log/apache2/access.log*(RHEL 系は /var/log/httpd/access_log*)に読み替えます。
集計 1: 送信元 IP の上位
zcat -f $LOGS | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
まず全体像です。上位に自社の監視サービス・クローラ・CDN が並ぶのは正常。知らない IP が桁違いのリクエスト数で 1 位に来ていたら、以降の集計で中身を見ます。
集計 2: ログイン系エンドポイントへの POST
zcat -f $LOGS | awk '$6=="\"POST" && $7 ~ /wp-login\.php|xmlrpc\.php/ {print $1}' \
| sort | uniq -c | sort -rn | head -20
最も分かりやすい兆候です。正規の運用で wp-login.php に POST が飛ぶのは担当者がログインした回数だけなので、1 つの IP から数百〜数千回出ていれば総当たりです。xmlrpc.php は使っていなければ本来 0 件で、数字が立つこと自体が異常です。
集計 3: 404 を量産している IP
zcat -f $LOGS | awk '$9==404 {print $1}' | sort | uniq -c | sort -rn | head -20
存在しないパスを片端から叩いている相手、つまり脆弱性スキャナの動きです。1 つの IP から数百件の 404 が出ていれば、サイトを狙って調べられていると考えて構いません。
集計 4: 「何を探されているか」の一覧
zcat -f $LOGS | awk '$9==404 {print $7}' | sed 's/?.*//' \
| sort | uniq -c | sort -rn | head -30
狙われている対象がそのまま並びます。/.env、/.git/config、/wp-config.php.bak、/phpinfo.php、使っていないプラグインのパスなど。**このリストは「自分のサイトで絶対に 200 を返してはいけないパスの一覧」**でもあります。
集計 5: そのうち 2xx で返ってしまっているもの(最重要)
zcat -f $LOGS | awk '$9 ~ /^2/ && $7 ~ /\.env|\.git|\.bak|\.old|\.sql|\.zip|wp-config|phpinfo|adminer|phpmyadmin|\.DS_Store/ {print $7}' \
| sed 's/?.*//' | sort | uniq -c | sort -rn | head -30
いちばん先に確認すべき集計です。ここに 1 行でも出たら **「攻撃されている」ではなく「すでに取られた」**を意味します。バックアップや環境変数ファイルが実際に配信されたということなので、遮断より先に、対象ファイルの削除とそこに書かれていた認証情報の変更が必要です。出力が空であることを確認するのがゴールです。
集計 6: 転送量の多い送信元
zcat -f $LOGS | awk '$9 ~ /^2/ {b[$1]+=$10} END {for (i in b) printf "%15.0f %s\n", b[i], i}' \
| sort -rn | head -20
応答サイズ($10)を IP ごとに合計します。1 件ずつは目立たなくても、合計が突出している相手はコンテンツの大量取得やファイル持ち出しの可能性があります(Apache の %b は 0 バイトのとき - ですが、awk の加算では 0 扱いになるので影響ありません)。
集計 7: User-Agent の偏り
zcat -f $LOGS | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -20
ダブルクォート区切りで 6 番目が User-Agent です。空の - やツール名が上位に来ていたら集計 1〜3 と突き合わせます。ただし User-Agent は自由に名乗れるので単独の根拠にはしないでください。
兆候を「1 日 1 回の差分」にする
手で叩くのは最初の 1 回だけにして、あとは日次で回し、前日との差分だけを見ます。
#!/bin/bash
# /usr/local/bin/access-log-digest.sh
set -euo pipefail
LOG=/var/log/nginx/access.log.1 # logrotate 直後に回すので前日分を見る
OUT=/var/backups/access-digest
install -d -m 700 "$OUT"
today=$(date +%F)
{
echo "== 404を出しているIP 上位10 =="
awk '$9==404 {print $1}' "$LOG" | sort | uniq -c | sort -rn | head -10
echo "== 2xxで返っている要注意パス(空であること) =="
awk '$9 ~ /^2/ && $7 ~ /\.env|\.git|\.bak|\.sql|wp-config|phpinfo/ {print $7}' "$LOG" | sort | uniq -c | sort -rn | head -10
} > "$OUT/$today.txt"
prev=$(ls -1 "$OUT"/*.txt | tail -2 | head -1)
[ "$prev" = "$OUT/$today.txt" ] || diff -u "$prev" "$OUT/$today.txt" || true
# 毎朝 6:05 に前日分のダイジェスト差分をメールで受け取る
5 6 * * * /usr/local/bin/access-log-digest.sh | mail -s "access log digest $(hostname)" you@example.com
毎日全文を読む運用は続きません。差分だけを見て、増えた行があるときだけ元ログを開くのが続けるコツです。
見つけた相手を fail2ban で遮断する
集計 2・3 で常連が特定できたら自動遮断に落とします。fail2ban の jail.conf 既定値は maxretry = 5 / findtime = 10m / bantime = 10m(1.1.0)= 「10 分以内に 5 回で 10 分 BAN」です。
注意点として、同梱の nginx 系 jail(nginx-http-auth / nginx-limit-req / nginx-botsearch)はいずれもエラーログを見ます。wp-login.php への POST はアクセスログにしか出ないので、フィルタを 1 つ自作します。
# /etc/fail2ban/filter.d/wordpress-auth.conf
[Definition]
failregex = ^<HOST> .* "POST [^"]*/(wp-login\.php|xmlrpc\.php)
ignoreregex =
# /etc/fail2ban/jail.local
[wordpress-auth]
enabled = true
filter = wordpress-auth
port = http,https
logpath = /var/log/nginx/access.log
# 正規の打ち間違いを弾かない程度に緩める
maxretry = 10
findtime = 10m
bantime = 1h
# 自社オフィス・監視サービスのIPを必ず入れる
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24
# 繰り返しBANされる常習者を長期BAN(既定 bantime 1w / findtime 1d)
[recidive]
enabled = true
反映前に、必ず実ログでフィルタが当たるかを確認します。
# 手元のログに何件マッチするかを確認(BAN はしない)
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-auth.conf
fail2ban-client reload
fail2ban-client status wordpress-auth # 現在の BAN 一覧
fail2ban-client set wordpress-auth unbanip 203.0.113.10 # 誤爆の解除
誤爆させないための 3 点です。
-
ignoreipに自社 IP・監視サービス・決済/予約システムの Webhook 送信元を先に入れる。 自分が締め出されるのが最も多い事故です。 - リバースプロキシ配下では手順 0 を必ず先に。 復元していないと CDN の IP を BAN して全訪問者が落ちます。
-
maxretryはいきなり絞らない。 数日bantime = 10mで様子を見てから伸ばします。
なお、遮断は更新の代わりにはなりません。 BAN は反復を止めるだけで、脆弱性は残ったままです。
ログが残っていない、という落とし穴
-
保持期間: 侵入の発覚は数週間後になることが多いのに、日次・十数世代のままの環境が目立ちます。
/etc/logrotate.d/nginxのrotateを確認し、compress併用で 90 日程度まで伸ばします。 -
バッファリング:
access_log ... combined buffer=64k flush=5m;としていると直近の行はすぐ出ません。調査中はflushを意識します。 - ノイズ: 死活監視で 1 日数万行が埋まるなら、その分は除外すると集計が読みやすくなります。
# nginx: 監視のヘルスチェックだけログに書かない
location = /healthz {
access_log off;
return 200;
}
# Apache: 条件付きロギング(env= で除外)
SetEnvIf Request_URI "^/healthz$" dontlog
CustomLog ${APACHE_LOG_DIR}/access.log combined_realip env=!dontlog
まとめ
- 最初にログのクライアント IP が本物かを確認する(realip / mod_remoteip)
- 集計 5(2xx で返っている要注意パス)だけは意味が違う。出たら遮断より先にファイルの削除と認証情報の変更
- 日次のダイジェスト + 差分で回し、増えたときだけ元ログを開く
- 常連は fail2ban へ。
ignoreipを先に埋め、fail2ban-regexで当たりを確認してから有効化する
ログは「攻撃されているか」だけでなく、設定の穴が実際に踏まれたかまで答えてくれる唯一の記録です。まずは集計 5 を 1 回流して、出力が空であることを確認するところから始めてください。
関連記事
- 「.git」「.env」が公開されていないかを 1 行で防ぐ設定
- nginx レート制限で総当たり・スクレイピング・軽量 DoS を緩和する設定
- WordPress ログイン総当たり対策 5 選(wp-login.php 保護・xmlrpc 無効化)
本記事のような設定の抜けは、Web サイトを 9 つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp