以前にも書きましたが、WordPress サイトを運用していると wp-login.php へ大量の攻撃アクセスが飛んできて、かなりうんざりします。
特に、ロードバランサや AWS WAF を前段に置いていない、小規模な EC2 インスタンスを直接インターネットに公開している構成の場合、マネージドな防御手段をすぐに適用することができません。
かといって、wp-login.php への POST リクエストを延々と投げ続けてくるようなブルートフォース攻撃を放置しておくのも、ログの汚染やリソース消費の観点から好ましくありません。
そこで今回は、執拗に wp-login.php へのアクセスを繰り返すアクセス元を自動的にブラックリスト化する仕組みを、iptables と ipset を使って自作してみます。
全体構成(ipset方式)
今回の仕組みは、以下のように役割分担を行います。
- Apacheログ解析 → 攻撃元IPの検出
- ipset → IPアドレスを「期限付き」で保持
- iptables → ipset に入っているIPを一括で遮断(ルールは1行だけ)
全体の流れは次の通りです。
- Apache の access log から、過去30分間の
POST /wp-login.phpアクセスを抽出する - 抽出したアクセスを送信元IPごとに回数集計する
- 設定した閾値を超えたIPアドレスを攻撃元として抽出する
- 抽出したIPアドレスを ipset のブラックリストへ追加する(timeout付き)
- 指定した時間が経過すると、ipset の timeout により自動的にブロックが解除される
重要なのは、解除処理を一切書かなくてよい点です。
ipset の timeout 機能を利用することで、指定した時間が経過すると自動的にブラックリストから除外されます。
攻撃アクセスを時限的にブロック解除する必要があるかどうかは議論の分かれるところだと思いますが、一定時間を経過したら「許してやる」という意味合いでなく、ブラックリストが増殖し続けることを避けるために時限解除は入れておくべきだと考えました。
スクリプト作成
スクリプトは以下のパスに作成します。
/path/to/block/wp-login-blocker.sh
スクリプト全文
#!/bin/bash
set -eu
# 英語月名(Decなど)を確実に解釈できるように
export LC_ALL=C
# Apacheログが +0900 前提なので JST に固定(mktimeがローカルTZを使うため)
export TZ=Asia/Tokyo
#####################################
# 引数(動作モード)
#####################################
NO_BLOCK=0
case "${1:-}" in
"" ) ;;
--no-block|--dry-run ) NO_BLOCK=1 ;;
-h|--help )
echo "Usage: $0 [--no-block|--dry-run]"
exit 0
;;
* )
echo "Unknown option: $1" >&2
echo "Usage: $0 [--no-block|--dry-run]" >&2
exit 2
;;
esac
#####################################
# 設定
#####################################
WWW_ROOT="/path/to/www-root"
LOG_REL="logs/access.log"
# 対象 WordPress サイト名(スペース区切り)
TARGET_SITES="sample-site"
WINDOW_MINUTES=30 # 現時点から過去何分間のログを集計するか
THRESHOLD=300 # 何回以上のアクセスをブロック対象にするか
IPSET_NAME="wplogin"
IPSET_TIMEOUT=86400 # 24時間
WORK_DIR="/path/to/block/tmp"
LOCK_FILE="$WORK_DIR/wp-login-blocker.lock"
MAIL_TO="security-team@example.com"
MAIL_SUBJECT="[WP Login Block Alert]"
MAIL_FROM="wp-login-blocker@example.com"
SENDMAIL_BIN="${SENDMAIL_BIN:-/path/to/sendmail}"
#####################################
# 初期化
#####################################
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
now_epoch=$(date +%s)
cutoff=$((now_epoch - WINDOW_MINUTES * 60))
# 中間ファイル
tmp_last="$WORK_DIR/last-span.log"
tmp_wplogin="$WORK_DIR/last-wp-login.log"
# 以降の処理用
tmp_hits="$WORK_DIR/hits.raw"
tmp_result="$WORK_DIR/hits.tsv"
tmp_ips="$WORK_DIR/ips.txt"
: > "$tmp_last"
: > "$tmp_wplogin"
: > "$tmp_hits"
: > "$tmp_result"
: > "$tmp_ips"
#####################################
# ipset 存在確認(ブロックモードのみ)
#####################################
if [ "$NO_BLOCK" -eq 0 ]; then
if ! ipset list "$IPSET_NAME" >/dev/null 2>&1; then
ipset create "$IPSET_NAME" hash:ip timeout "$IPSET_TIMEOUT"
fi
fi
#####################################
# 1) s-access.log → 過去30分だけ抽出
#####################################
# 注: date外部呼び出しをやめて高速化したまま(mktime利用)
for site in $TARGET_SITES; do
log="$WWW_ROOT/$site/$LOG_REL"
[ -f "$log" ] || continue
gawk -v cutoff="$cutoff" '
BEGIN {
mon["Jan"]=1; mon["Feb"]=2; mon["Mar"]=3; mon["Apr"]=4; mon["May"]=5; mon["Jun"]=6;
mon["Jul"]=7; mon["Aug"]=8; mon["Sep"]=9; mon["Oct"]=10; mon["Nov"]=11; mon["Dec"]=12;
}
{
# Apache形式: [15/Dec/2025:00:08:38 +0900]
ts = $4
gsub(/^\[/, "", ts) # 15/Dec/2025:00:08:38
split(ts, a, "/") # a[1]=15, a[2]=Dec, a[3]=2025:00:08:38
day = a[1]
month = mon[a[2]]
split(a[3], b, ":") # b[1]=2025, b[2]=00, b[3]=08, b[4]=38
year=b[1]; hh=b[2]; mm=b[3]; ss=b[4]
epoch = mktime(year " " month " " day " " hh " " mm " " ss)
if (epoch < cutoff) next
print $0
}' "$log" >> "$tmp_last"
done
#####################################
# 2) last-30minutes.log → POST /wp-login.php のみ抽出
#####################################
gawk '
{
method = $6
gsub(/"/, "", method)
if (method != "POST") next
path = $7
# 先頭の // を / に正規化(//// でもOK)
gsub(/^\/+/, "/", path)
# クエリを除去(?以降)
sub(/\?.*$/, "", path)
if (path != "/wp-login.php") next
print $0
}' "$tmp_last" > "$tmp_wplogin"
#####################################
# 3) last-wp-login.php.log → hits.raw (IP<TAB>site)
#####################################
# 今は site 固定(TARGET_SITES="example" 前提)
SITE_NAME="$TARGET_SITES"
gawk -v site="$SITE_NAME" '{ print $1 "\t" site }' "$tmp_wplogin" > "$tmp_hits"
#####################################
# 集計 & 閾値判定(Site をユニーク化)
#####################################
awk '
{
ip = $1
s = $2
count[ip]++
key = ip SUBSEP s
if (!seen[key]++) {
sites[ip] = (sites[ip] ? sites[ip] "," s : s)
}
}
END {
for (ip in count) {
if (count[ip] >= '"$THRESHOLD"') {
print ip "\t" count[ip] "\t" sites[ip]
}
}
}
' "$tmp_hits" > "$tmp_result"
cut -f1 "$tmp_result" > "$tmp_ips"
#####################################
# メール送信(本文に確実に載せる)
#####################################
send_text_mail() {
local subject="$1"
local to="$2"
if [ -x "$SENDMAIL_BIN" ]; then
{
echo "From: ${MAIL_FROM}"
echo "To: ${to}"
echo "Subject: ${subject}"
echo "MIME-Version: 1.0"
echo "Content-Type: text/plain; charset=UTF-8"
echo "Content-Transfer-Encoding: 8bit"
echo
cat
} | "$SENDMAIL_BIN" -t
else
mail -s "$subject" "$to"
fi
}
#####################################
# 通知(常に実施)
#####################################
if [ -s "$tmp_ips" ]; then
mode_text=$([ "$NO_BLOCK" -eq 1 ]
&& echo "【検知のみ:ブロックは実行していません】" || echo "【ブロック実施:ipset に追加しました】")
{
echo "${mode_text}"
echo
echo "条件: 過去 ${WINDOW_MINUTES} 分間の POST /wp-login.php が ${THRESHOLD} 回以上"
if [ "$NO_BLOCK" -eq 0 ]; then
echo "ブロック時間: ${IPSET_TIMEOUT} 秒(ipset: ${IPSET_NAME})"
fi
echo
echo "IP Count Site"
echo "----------------------------------------"
sed $'s/\t/ /g' "$tmp_result"
echo
echo "---- debug files ----"
echo "last span log: $tmp_last"
echo "wp-login only: $tmp_wplogin"
echo
} | send_text_mail "$MAIL_SUBJECT" "$MAIL_TO"
fi
#####################################
# ブロック(ブロックモードのみ)
#####################################
if [ "$NO_BLOCK" -eq 0 ] && [ -s "$tmp_ips" ]; then
while read -r ip; do
ipset add "$IPSET_NAME" "$ip" timeout "$IPSET_TIMEOUT" 2>/dev/null || true
done < "$tmp_ips"
fi
今回は、仕組みの全体像とスクリプトのみを紹介しました。
次回は、初期セットアップの手順や実際の実行方法、運用上の注意点について説明します。