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

APPW.jp
 
AH00124 / internal redirect loop

500の犯人は、「?」ひとつだった
AH00124 と rewrite の自己ループ

攻撃の「見えていない部分」を可視化するダッシュボードを作った。そうしたら見つかったのは、攻撃ではなく 自分のサーバの不具合だった——全リクエストの15%が 500。しかも原因は、WordPress マルチサイト用の RewriteRule に書かれた ? ひとつ。診断から修正までの、実記録です。

Apache 2.4 mod_rewrite AH00124 AH01071 ソフト404

[ § 0 ] 導入

見えないものを見ようとしたら、自分が見えた

うちのアクセスログは PHP のモジュールが MongoDB に書いています。ただしこの方式には穴があって、 .htaccess で遮断した 403 や、存在しないパスへの 404 が一切残りません。 PHP に到達する前に終わっているからです。

そこで Apache のログを時間帯ごとに集計し、「PHPまで届いた分」と「Apacheで止まった分」を並べて見る 氷山ビューを作りました。水面上が今まで見えていた部分、水面下が見えていなかった部分、という発想です。

ところが動かしてみると、水面下から出てきたのは攻撃ではありませんでした。

[ § 1 ] 発見

5xx が、全体の15%

ステータスの全内訳を出した最初の画面が、これでした。

ステータス件数割合
2xx 正常応答7,56631.1%
3xx リダイレクト10,98545.2%
403 遮断1,0264.2%
404 探索4241.7%
その他 4xx8533.5%
5xx サーバエラー3,44214.2%

7件に1件がサーバエラー。403遮断(1,026)と404探索(424)を足した数の、2倍以上です。 攻撃を可視化するために作ったツールが、真っ先に映し出したのは自分の不具合でした。

もうひとつ、おかしな点。 404 が 424件しかない。スキャナが毎日大量に来ている環境で、これは少なすぎます。 本来404になるべきものが、どこかへ消えている——この違和感が、後で効いてきます。
[ § 2 ] 切り分け①

コードは 500 のみ

5xx をステータスコード別に割ってみました。ここで一気に容疑者が絞れます。

コード件数疑うべきもの
500全部アプリ層、または Apache の設定
502 / 5040PHP-FPM の枯渇・タイムアウト
5030リソース枯渇・MaxRequestWorkers 到達

2GB しかない VPS なので、まず pm.max_children の設定ミスによる PHP-FPM の枯渇を疑っていました。 ですが 502も504も503もゼロ。プロセス不足でもタイムアウトでもリソース枯渇でもない。

コード別に割る、という一手

5xx をひと括りで見ていたら、ここで詰まっていました。500 と 502 では原因も対処もまったく別物です。集計を作るときは、最初から細かく割れる形で持っておくと、こういう場面で助かります。

[ § 3 ] 切り分け②

PHP のエラーは、1行も出ていなかった

500 なのだから、アプリが例外を投げているはずだ——そう思って error.log を見ました。

PHP の致命的エラーを探す
sudo tail -200 /var/log/apache2/error.log | grep -iE "PHP (Fatal|Parse|Warning)"
# → 0行

ゼロ。 PHP は何も悪さをしていません。仮説が外れました。 では何が 500 を出しているのか——同じログを、条件を広げて見直します。

error.log AH00124: Request exceeded the limit of 10 internal redirects due to probable configuration error. Use 'LimitInternalRecursion' to increase the limit if necessary. AH01071: Got error 'Primary script unknown' ※多数

AH00124。 Apache が内部リダイレクトを10回繰り返し、上限に達して 500 を返していました。 アプリではなく Apache の設定の問題です。 そして、以前に扱った AH01071 も再発していました。

LimitInternalRecursion を上げてはいけません。 エラーメッセージ自身がそう提案してきますが、これは上限を10から20にしてループを長引かせるだけです。 CPU を余計に食って、結局500を返します。直すべきは、ループを作っているルールの方です。
[ § 4 ] 犯人

「?」ひとつが、無限ループを作っていた

対象サイトの .htaccess にあったのが、WordPress マルチサイト用の定番ルールです。

.htaccess — 修正前
RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L]
RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ $2 [L]        ← 犯人

意図は「sub/foo.php のようなサブディレクトリ付きのパスから、先頭のディレクトリを剥がす」こと。 ですが先頭部分 ([_0-9a-zA-Z-]+/) には ?(省略可)が付いています。

すると wxfyf.php のようなディレクトリの無い .php にもマッチしてしまう。 剥がすディレクトリが無いので、書き換え結果 $2入力とまったく同じ

何が起きるか
wxfyf.php  →  マッチ  →  wxfyf.php
wxfyf.php  →  マッチ  →  wxfyf.php
wxfyf.php  →  マッチ  →  wxfyf.php
     …(10回)…
→ AH00124 → 500

[L] があるのに、なぜ止まらないのか

ここが引っかかりやすい点です。[L] は「このラウンドの評価を終える」という意味でしかありません。 書き換えが発生すると、Apache はその結果を新しいリクエストとして最初から評価し直します(内部リダイレクト)。 同じルールにまたマッチし、また同じ結果になり、また評価し直す。[L] はループを止めません

[ § 5 ] 体感デモ

ループを、1ステップずつ追う

パスとルールを選ぶと、内部リダイレクトが1回ずつ進んでいきます。 ?+ を切り替えて、どこで止まり、どこで止まらないかを見てください。

Interactive · internal redirect tracer
rewrite ループ追跡
実際の Apache と同じく、書き換えが起きたら先頭から再評価します。.htaccess のパターンは先頭スラッシュ無しのパスに対して評価されます。
ルール
リクエストされたパス

wxfyf.php? で流すと、1回目からいきなり出力=入力になり、そのまま10回で 500。 + にするとそもそもマッチせず、次のルールへ抜けて 404 に落ち着きます。

.js まで500になっていた理由。 デモで「wp- ルール」を選ぶと分かりますが、上の行も同じ構造で自己ループしますwp-content/… は先頭ディレクトリを剥がそうとしても $2 が自分自身になるためです。 これが、.php ですらない wpp.js が 5xx のリストに並んでいた理由でした。 直すべきは1行ではなく、2行だったわけです。
[ § 6 ] 修正

?+ にする

書き換え前後が同じになる可能性を消します。先頭のディレクトリを必須にするだけです。

.htaccess — 修正後
RewriteRule ^([_0-9a-zA-Z-]+/)+(wp-(content|admin|includes).*) $2 [L]
RewriteRule ^([_0-9a-zA-Z-]+/)+(.*\.php)$ $2 [L]
#                            ↑ ? を + に変えただけ

.htaccess なので再起動は不要。保存した瞬間から効きます。確認はこれだけ。

確認
curl -sI https://example.jp/nonexistent-test-12345.php | head -1
# 500 が返らなくなればループ解消

sudo grep AH00124 /var/log/apache2/error.log | tail -3
# 最終行の時刻が修正前で止まっていれば完全に解決

もうひとつ残っていた:ソフト404

500 は消えましたが、存在しないパスが今度は 301 → 200 を返していました。 末尾スラッシュが付けられ、catch-all の RewriteRule . /index.php [L] が受けて、 WordPress が 200 を返している状態です。これはソフト404と呼ばれ、こちらも実害があります。

影響内容
計測存在しないパスへの探索が 200 として記録され、404 に計上されない
SEO検索エンジンが実在しないURLを有効ページと認識する
防御スキャナに「そのパスは在る」と誤認させ、探索を誘発しうる

catch-all よりに、実在しない .php をその場で終わらせる一行を置きます。

.htaccess — ソフト404の始末
# catch-all より前に置く
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule \.php$ - [R=404,L]
結果。 500 は 1時間ビューで 0件。AH00124 は停止。 そしてAH01071(Primary script unknown)も同時に止まりました—— ループ中に存在しない .php が繰り返し PHP-FPM へ渡されていたのが、1回で 404 に落ち着くようになったためです。 別件だと思っていた2つのエラーは、同じ根から出ていました。
[ § 7 ] まとめ

計測が正確になると、数字は「悪化」する

修正後、404 のリストにこれまで見たことのないパスが並ぶようになりました。 ランダムな名前の .php を大量に試す、典型的なバックドア探索です。 以前は 500 や 200 に化けていて、404 として数えられていなかったものです。

正直に書いておくと、増分は測れていません。 集計が時間帯ごとの累積なので、修正前後で「404が何件増えたか」を厳密に切り出せませんでした。 言えるのは「これまで現れなかった種類のパスが、404として現れるようになった」という事実だけです。 数えられないことを、数えられたように書かないでおきます。

ここが今回いちばん腑に落ちた点でした。404 が増えるのは、悪化ではなく計測が正確になった印です。 攻撃の量は変わっていない。変わったのは、こちらが正しく数えられるようになったこと。 壊れた計測は、実態より綺麗な数字を出してしまいます。

この記事のまとめ

  • 5xx はコード別に割る。500だけならアプリか設定、502/504ならFPM、503ならリソース。
  • 500 なのに PHP Fatal が0行なら、疑うのはApacheの設定
  • AH00124 は内部リダイレクトのループ。LimitInternalRecursion を上げても解決しない
  • ^([_0-9a-zA-Z-]+/)?(.*\.php)$ → $2 は、ディレクトリが無いと書き換え前後が同一になり自己ループする。?+ へ。
  • [L] はラウンドを終えるだけ。書き換えが起きれば最初から再評価される。
  • ソフト404(存在しないのに200)は、計測・SEO・防御の三方に効く。catch-all の前で止める。
  • 404が増えたら、計測が直った合図。数字の悪化を、改善と読み替える。

攻撃を見るために作った道具が、最初に見せてくれたのは自分の設定ミスでした。 しかもその不具合は、攻撃者のスキャンが引き金で顕在化していた。 可視化とは、相手を見ることであると同時に、自分を見ることでもある——そう実感した一件でした。

関連記事

『500の犯人は「?」ひとつだった — AH00124 と rewrite の自己ループ』を公開しました。