§0止めたのに、減らなかった
8月の「攻撃を、止める」では、.htaccess に IP と UA のルールを書いて、403 を返すようにしました。止めた相手には、確かに 403 が返ります。
ところがログを見ていると、相手は 403 を受け取ってもやめません。同じ IP から、同じような探索が、毎日のように届きます。
403 を返すには、Apache が接続を受け、リクエストを読み、.htaccess を評価し、応答を書いて、access.log に1行残す必要があります。止めているつもりで、実は毎回きちんと応対していたのです。
「氷山ビュー」で水面の下を数えられるようになってから、この3か月で、止める場所を少しずつ手前へ移してきました。今では、接続の段階で落とした分が、403 や 404 と並ぶか、上回る日が多くなりました。
この記事から3回に分けて、5つのサイトのログを「人の閲覧として信頼できる数字」にするまでを書きます。1回目は、いちばん手前の「止める」です。
判定の具体的な条件やしきい値は書きません。書けば、相手にとっての手直しの手順書になるからです。考え方と、どのサーバーでも役に立つ設定の注意を中心にします。IP アドレスは、例示用に予約された範囲に置き換えています。
§1層の地図
リクエストは、手前から順に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つの IP | 24日 | 持ち主が変わる。クラウドの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 が動いたか、どこに記録が残るかも見てください。値は説明用の例で、実際の判定はもっと多くの条件を見ています。
§8まとめ
| 層 | 止めるもの | 返すもの | Apache |
|---|---|---|---|
| 接続(ipset) | 名指しで止めた相手 | 何も返さない | 動かない |
| Apache(Host) | 正式でない名前 | 301 | 動く |
| .htaccess | 形の決まった攻撃、ユーザー名の下調べ | 403 | 動く(PHP は動かない) |
| .htaccess | 記録の外にある古いページ | 410 | 動く(PHP は動かない) |
止める場所を手前へ移すと、サーバーの負担が減るだけでなく、ログが読みやすくなります。PHP まで届いた記録に、攻撃や探索が混ざらなくなるからです。
ただ、入口で止められるのは、形の分かる相手だけです。本物のブラウザを自動で動かす相手は、きれいなリクエストを送ってきて、どの層もそのまま通り抜けます。UA も、普通の Chrome そのものです。
次回は、通り抜けてきた相手を、ページの中で見分けます。UA は自己申告にすぎませんが、本物のブラウザは、UA 以外のところまで細かく揃っています。
- 止める場所を、手前へ(この記事)
ipset・.htaccess・410 - UA の外側で、見分ける
細部の整合・ビーコン・どちらでもないもの - 数字を信頼できて、見えたもの
見直し・確かめ・見張りと、人の動き