スマートフォン・タブレットからインターネットサーバーオペレーション

APPW.jp
 
root@appw:~# ipset list blk4 | head -n 7
bot と人を分ける ① / 止める

止める場所を、手前へ

ipset と .htaccess で、PHP に届く前に落とす

appw.jp · server-ops notes · Ubuntu 26.04 / Apache 2.4 / PHP 8.5

§0止めたのに、減らなかった

8月の「攻撃を、止める」では、.htaccess に IP と UA のルールを書いて、403 を返すようにしました。止めた相手には、確かに 403 が返ります。

ところがログを見ていると、相手は 403 を受け取ってもやめません。同じ IP から、同じような探索が、毎日のように届きます。

403 を返すには、Apache が接続を受け、リクエストを読み、.htaccess を評価し、応答を書いて、access.log に1行残す必要があります。止めているつもりで、実は毎回きちんと応対していたのです。

「氷山ビュー」で水面の下を数えられるようになってから、この3か月で、止める場所を少しずつ手前へ移してきました。今では、接続の段階で落とした分が、403 や 404 と並ぶか、上回る日が多くなりました。

この記事から3回に分けて、5つのサイトのログを「人の閲覧として信頼できる数字」にするまでを書きます。1回目は、いちばん手前の「止める」です。

書き方の方針
判定の具体的な条件やしきい値は書きません。書けば、相手にとっての手直しの手順書になるからです。考え方と、どのサーバーでも役に立つ設定の注意を中心にします。IP アドレスは、例示用に予約された範囲に置き換えています。

§1層の地図

リクエストは、手前から順に5つの層を通ります。手前の層ほど処理は軽く、奥の層ほど詳しく調べられます。止めるのは手前で、見分けるのは奥で、というのがこの連載の基本の考え方です。

1
接続(ipset と ufw)連載①
名指しで止めた相手を、接続の段階で落とす。Apache は何も知らない。
2
Apache(Host と 301)連載①
http・https、www の有無を、正式な名前へ1回の 301 で揃える。
3
.htaccess連載①
「誰か」ではなく「何を送ってきたか」で、形の決まった攻撃を 403・410 にする。
4
ページ(記録と判定)連載②
UA やヘッダーから bot を見分けて記録する。APCu のレート制限もここ。
5
ビーコン連載②
ページを開いたあとに届く合図で、人であることを確かめる。記録のあとの集計は連載③。
← 軽い(止める)詳しい(見分ける)→

§2ipset:接続の段階で断る

なぜ .htaccess から移したか

  • 止めた相手にも、Apache が毎回応対していた。403 は「断る」という応答で、応答するには処理が要ります。
  • 一覧が伸びると、書き方の限界に近づく。.htaccess の1行には 8,190 文字の上限があり、IPv4 と IPv6 では書き方も違います。
  • 5つのサイトで、同じ一覧を持ちたかった。サイトごとの .htaccess に同じ IP を書き写すのは、漏れのもとです。

ipset は、Linux のファイアウォールが参照する「名簿」です。名簿に載った相手からの接続は、Apache に渡る前に落ちます。相手には応答すら返らないので、「つながらない」としか見えません。

名簿を作る

# IPv4 と IPv6 で1つずつ。hash:net なら、1つの IP も範囲も同じ名簿に入る
sudo apt install ipset
sudo ipset create blk4 hash:net family inet  timeout 0 maxelem 131072
sudo ipset create blk6 hash:net family inet6 timeout 0 maxelem 131072

timeout 0 で作っておくと、名簿そのものは無期限のまま、載せる相手ごとに期限を付けられます。

ufw に組み込む:順番に注意

ufw の /etc/ufw/before.rules に、名簿を見て落とす規則を足します。before6.rules にも同じものを、blk6 で足します。

# 1. 自分自身への通信(ループバック)を先に許す
-A ufw-before-input -i lo -j ACCEPT
-A ufw-before-output -o lo -j ACCEPT
# 2. 名簿の相手を記録する(記録が溢れないよう回数を絞る)
-A ufw-before-input -m set --match-set blk4 src -m limit --limit 60/min --limit-burst 120 -j LOG --log-prefix "[UFW IPSET4] "
# 3. 名簿の相手を落とす
-A ufw-before-input -m set --match-set blk4 src -j DROP
つまずいたところ
最初は、落とす規則をループバックの許可より前に置いていました。すると、サーバーの中の通信(Mosquitto、wp-cron の自分自身への呼び出し)まで止まりました。名簿に自分が載っていなくても、規則の順番しだいで巻き込まれます。許可 → 記録 → 落とすの順を守ります。

期限には上限がある

ipset の期限(timeout)は、最大で 2,147,483 秒、約24.8日です。そこで、次のように分けました。

止める単位期限理由
1つの IP24日持ち主が変わる。クラウドの1台は貸し出しが終われば別の人のものになり、家庭の回線も割り当てが変わる。
範囲無期限ホスティングの網そのものを止めるので、長く止めても人を巻き込みにくい。

再起動しても消えないように

ipset の名簿はメモリの中にあるので、再起動すると消えます。保存したファイルから戻すサービスを、ufw より先に動かします。

# /etc/systemd/system/ipset-restore.service(例)
[Unit]
Description=Restore ipset lists
Before=network-pre.target ufw.service
Wants=network-pre.target

[Service]
Type=oneshot
ExecStart=/usr/sbin/ipset restore -exist -file /etc/ipset.conf

[Install]
WantedBy=multi-user.target

systemctl enable で有効にし、毎日1回、cron で ipset save を書き出しておきます。

1行で登録する

毎回 ipset add と保存を打つのは面倒で、保存を忘れると再起動で消えます。小さなスクリプトにまとめました。

#!/bin/sh
# /usr/local/bin/blk ── sudo blk <IP か範囲> [日数]
# 1つの IP は24日、範囲(/ か - を含む)は無期限
set -e
t=$2
case "$1" in */*|*-*) t=${t:-0} ;; *) t=${t:-24} ;; esac
[ "$t" -gt 24 ] && t=24
sec=$((t * 86400))
case "$1" in *:*) set_name=blk6 ;; *) set_name=blk4 ;; esac
ipset add -exist "$set_name" "$1" timeout "$sec"
ipset save > /etc/ipset.conf
echo "added $1 to $set_name (timeout ${t}d), saved"

期限が来たら、どうするか

24日で切れる登録を、そのまま切れさせるか、延ばすか。毎朝、期限の近いものについて「止めたあとも、接続を試み続けているか」を数えます。続いていれば延ばし、来なくなっていれば切れるに任せます。

国内の一般の回線や、大勢で共有する出口(プライベートリレーのような中継)は、機械では決めずに「保留」にして、人が見て決めます。範囲で止めている中の1つの IP は、重なりとして整理します。

こうしておくと、名簿は「叩き続ける相手」だけが残る形で、自然に細っていきます。止めた数を誇るより、止める必要のある相手だけが載っている名簿のほうが、管理しやすく、巻き込みも減ります。

止め方は3通り

単位向いている相手範囲の作り方
1つの IP(24日)一般の回線、大手クラウドの1台そのまま blk
AS ごと(無期限)自前の網を持つホスティングIP と AS の対応表から範囲を作る
登録組織ごと(無期限)範囲を借りて貸し出す業者whois の登録組織から範囲を作り、ときどき取り直す

範囲を貸し出す業者は、自分の AS を持たず、借りた範囲を転々とすることがあります。AS で探しても見つからないときは、whois の登録組織で探します。

§3.htaccess:形の決まった攻撃

IP を ipset に移したあとの .htaccess には、「誰か」ではなく、「何を送ってきたか」で止めるルールだけが残りました。相手の IP が毎回変わっても、送ってくる形が同じなら、ここで止まります。

クエリ文字列

よく知られた攻撃の形を、種類ごとに1行ずつ書きます。例として2つだけ載せます。

# PHP の設定の書き換えや、フレームワークの関数呼び出しを狙うもの
RewriteCond %{QUERY_STRING} (allow_url_include|auto_prepend_file|invokefunction|call_user_func_array) [NC,OR]
# 上の階層へさかのぼるもの
RewriteCond %{QUERY_STRING} (\.\./){3,}
RewriteRule .* - [F,L]
  • 種類ごとに行を分け、最後の行以外に [OR] を付けます。1行にまとめると、あとで直すときに読めません。
  • 行の途中にコメントは書けません。# を行の途中に書くと、サイト全体が 500 になります。説明は # の行に分けます。
  • SQL インジェクションの決まった形も入れていますが、ここでは載せません。

UA への注入

UA の中に、SQL のコメント記号や引用符を混ぜてくる攻撃があります。本物のブラウザの UA には、どちらも入りません。

# \x22 は "、\x27 は '
RewriteCond %{HTTP_USER_AGENT} (/\*\*/|[\x22\x27<>])
RewriteRule .* - [F,L]

WordPress の守り

WordPress を使う4つのサイトには、次を足しています。どれも # BEGIN WordPress より前に置きます。後ろに置くと、WordPress が起動してから判断することになります。

# uploads の中の PHP は実行させない(置かれた裏口の確認と、実行の両方を止める)
RewriteRule (^|/)wp-content/uploads/.*\.php$ - [F,L]
# 直下の存在しない .php は、WordPress を起動せずに 404
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^[^/]+\.php$ - [R=404,L]

存在しない .php に、WordPress が自分の 404 ページを作って返すのは、重いうえに、ソフト404 の温床にもなります(「404を、正しく返す」)。直下に限っているのは、マルチサイトの読み替えのパスを止めないためです。

ユーザー名の下調べ

ログインを試す前に、ユーザー名を調べに来る相手がいます。入口は3つありました。

  • ?author=1:投稿者のページへ転送させて、URL からユーザー名を読む
  • _fields=slug:API の結果からユーザー名だけを抜き出す
  • REST API のユーザー一覧(/wp/v2/users)
# ユーザー名の下調べ(投稿者のページを使うサイトでは author= を外す)
RewriteCond %{QUERY_STRING} ((^|&)author=[0-9]|(^|&)_fields=) [NC]
RewriteRule .* - [F,L]

REST の一覧は、mu-plugins に小さなファイルを置いて、ログインしていない人には返さないようにしました。

<?php
/**
 * Plugin Name: REST ユーザー一覧の非公開
 */
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
    if (!is_user_logged_in() && preg_match('#^/wp/v2/users#', $request->get_route())) {
        return new WP_Error('rest_user_cannot_view', 'Not allowed', ['status' => 401]);
    }
    return $result;
}, 10, 3);
落とし穴
/wp-json/ を 404 にしていても、/?rest_route=/wp/v2/users という書き方では、同じ一覧が読めてしまいます。パスで止めるのではなく、API の中で止めるのが確実です。

ただし、投稿者のページを読者に見せているサイト(ibe.tokyo)では、author= は止めていません。止めるルールは、サイトの使い方しだいで変わります。5つのサイトで同じ .htaccess を使い回せないのは、こういうところです。

§4記録の外にある入口を閉じる:410

iseeit.jp の開設から数年間のページには、アクセスを記録する仕組みが入っていませんでした。トップページからのリンクはもうないのに、ファイルはドキュメントルートに残っていて、bot だけが通い続けていました。

記録の外にある入口は、何が来ているのかすら分かりません。調べると、500 ファイルを超えていました。フォルダ単位で 410 にしました。

# 記録の仕組みを通らない古いページ(# BEGIN WordPress より前に置く)
RewriteRule ^(old-dir-a|old-dir-b|old-dir-c)(/|$) - [G,L]
RewriteRule ^old-page-(1|2|3)\.html$ - [G,L]
  • 404 ではなく 410 にした理由:「もうない」とはっきり伝えると、検索エンジンは見に来るのを早めにやめます。404 は「今は見つからない」なので、また来ます。
  • 置く場所:# BEGIN WordPress より前です。後ろに置くと、WordPress が起動して、自分の 404 を作ってしまいます。
  • 巻き込まないもの:Search Console の所有確認のファイルと、今のページが古いフォルダの CSS を読んでいる場合のその CSS です。フォルダごと 410 にする前に、今のページから参照されていないかを確かめます。

記録の仕組みを通らないファイルは、記録の関数名を含まないファイルとして探せます。

# 記録の関数を呼んでいない .php・.html を探す(WordPress の中は除く)
grep -rL --include='*.php' --include='*.html' -e 'record_function_a' -e 'record_function_b' . \
  | grep -v -e '/wp-' -e '/vendor/'

§5小さな抜け道:ポート付きの Host

このサイトでは、www. の有無を、Apache の段階で正式な名前へ 301 で揃えています。ところがある日、揃えたはずの www. 付きの名前が、アクセスの記録に残っていました。

試してみると、普通の www. は 301 になるのに、Host を少し変えるだけで 200 が返りました。

$ curl -sI --http1.1 https://example.jp/ -H 'Host: www.example.jp:443' | head -1
HTTP/1.1 200 OK
$ curl -sI --http1.1 https://example.jp/ -H 'Host: www.example.jp.' | head -1
HTTP/1.1 200 OK

原因は、条件の書き方でした。

  • %{HTTP_HOST} は、相手が送ってきた Host をそのまま使います。ポート(:443)や末尾の点が付いていれば、それも含みます。
  • 一方、PHP の SERVER_NAME は、Apache が整えたあとの名前です。ポートは外れています。
  • そのため、^www\.example\.jp$ という条件には合わずに 301 をすり抜け、記録には整った www.example.jp として残っていました。
# 直したあと:ポートと末尾の点を許す
RewriteCond %{HTTP_HOST} ^www\.example\.jp\.?(:\d+)?$ [NC]
RewriteRule ^ https://example.jp%{REQUEST_URI} [R=301,L]

5つのサイトを、3通りの Host でまとめて確かめるコマンドです。すべての行が 301 になれば大丈夫です。

# "正式な名前|揃える前の名前" を並べる
for p in "example.jp|www.example.jp" "www.example.net|example.net"; do
  u=${p%%|*}; h=${p##*|}
  for v in "$h" "$h:443" "$h."; do
    printf '%-24s %s\n' "$v" "$(curl -s -o /dev/null -w '%{http_code}' --http1.1 "https://$u/" -H "Host: $v")"
  done
done

この抜け道を使っていたのは、トップページだけを定期的に見て回る道具1つでした。害はほとんどありませんでしたが、分析の画面で同じページが2つに分かれて数えられていました。

§6止めすぎない

LINE のプレビューを止めていた

クローラの名乗りが本物かどうかを、公表されている範囲で確かめる仕組みを入れたときのことです。LINE でページを共有したときにプレビューを作るサーバーは、ある大手 SNS のクローラと同じ名前を UA に含めています。仕組みはそれを「範囲の外から、その SNS を名乗る偽物」と判定し、私はその IP を ipset で止めてしまいました。

止めると、LINE でページを共有しても、プレビューが出なくなります。気づいて名簿から外し、LINE は LINE として、別に確かめるようにしました。

「bot は、3種類いる」で書いたのと同じ種類の失敗を、別の形でもう一度やったことになります。「確かめられない」と「偽物」は違う。本物の確かめ方を持たない相手を、偽物と決めてはいけませんでした。

止めてはいけない相手を、先に書き出す

止める名簿と同じくらい、止めない名簿が大事です。種類としては、次のようなものです。

  • 共有のプレビュー:メッセージアプリでリンクを送ったときに、見出しや画像を取りに来るもの
  • 大勢で共有する出口:プライベートリレーや CDN のように、多くの人の通信がまとめて通るもの
  • サイトの同期:WordPress のプラグインが使う外部サービスなど
  • 本物と確かめられた検索エンジン
  • 自分のサーバー:延長の仕組みは、名簿に自分の IP があれば警告を出すようにしています

範囲で止める前に、人を数える

範囲で止めると、その中のすべてが止まります。止める前に、その範囲から、ビーコンで人と確かめられた閲覧が来ていないかを数えます。1件でもあれば、範囲ではなく1つずつ止めるか、止めずに見張ります。

順番
見つける → 人がいないか数える → 止める → 止めたあとも来ているか見る。止める作業の半分は、止めないための確認です。

§7デモ:リクエストはどこで止まるか

送るものを選んで「送る」を押すと、層を順に通り、どこで止まるかを表示します。Apache や PHP が動いたか、どこに記録が残るかも見てください。値は説明用の例で、実際の判定はもっと多くの条件を見ています。

// request simulator
送るものを選んでください。

§8まとめ

層止めるもの返すものApache
接続(ipset)名指しで止めた相手何も返さない動かない
Apache(Host)正式でない名前301動く
.htaccess形の決まった攻撃、ユーザー名の下調べ403動く(PHP は動かない)
.htaccess記録の外にある古いページ410動く(PHP は動かない)

止める場所を手前へ移すと、サーバーの負担が減るだけでなく、ログが読みやすくなります。PHP まで届いた記録に、攻撃や探索が混ざらなくなるからです。

ただ、入口で止められるのは、形の分かる相手だけです。本物のブラウザを自動で動かす相手は、きれいなリクエストを送ってきて、どの層もそのまま通り抜けます。UA も、普通の Chrome そのものです。

次回は、通り抜けてきた相手を、ページの中で見分けます。UA は自己申告にすぎませんが、本物のブラウザは、UA 以外のところまで細かく揃っています。

// series:bot と人を分ける
  1. 止める場所を、手前へ(この記事)
    ipset・.htaccess・410
  2. UA の外側で、見分ける
    細部の整合・ビーコン・どちらでもないもの
  3. 数字を信頼できて、見えたもの
    見直し・確かめ・見張りと、人の動き

関連記事

『止める場所を、手前へ — ipset と .htaccess で PHP に届く前に落とす』を公開しました。