「404が107倍に増えました」
前回、zスコアが使えないと分かって判定を百分位と倍率に作り替えました。 今度はちゃんと検知します。画面を開くと、こう出ていました。
404 探索 ★急増 同時刻帯の中央値 2 → 直近平均 215.3 (107.67 倍)
107倍。さすがに何かおかしい。攻撃が一晩で百倍になることは、まず起きません。 計算は正しいのに、意味がない——そういう数字でした。
まず、計算を疑った
直前にzスコアで失敗していたので、また手法の選択を間違えたのだと思いました。 百分位も倍率も、どこか実装を誤ったのだろうと。
ところが検算しても、すべて正しいのです。 同時刻帯の中央値は確かに2、直近6時間の平均は確かに215.3。割れば107.67倍。 コードにバグはありませんでした。
そこで、数字そのものを見直しました。中央値2という値は、本当にあり得るのか。
中央値が「2」だった理由
404が毎時2件。スキャナが毎日大量に来ている環境で、この数字はあり得ません。
/wp-login.php も /.env も、放っておいても叩かれ続けます。
カレンダーを見て、気づきました。4日前に、自分でソフト404を直していたのです。
| 時期 | 存在しないパスへの応答 | 404として記録 |
|---|---|---|
| 修正前 | 301 → 200(ソフト404) | されない |
| 修正後 | 404 | される |
前々回の記事で直したとおり、 それ以前は存在しないパスが 301 を経て 200 を返していました。 探索は来ていたのに、404として数えられていなかったのです。
ベースラインが、2つの世界にまたがっていた
ベースラインは直近の前の168時間(7日)を取っていました。 その7日間の中に、計測方法が変わった瞬間が含まれていたのです。
├─────────── 修正前 ───────────┤├─── 修正後 ───┤│ 直近6h │
404が化けていた世界 正しく数える世界
(記録は毎時 2件前後) (記録は毎時 200件超)
↑
ここで計測の意味が変わった
同じ「404」という名前がついていますが、前半と後半では中身が別物です。 前半は「ソフト404をすり抜けた分だけの404」、後半は「本来の404」。 違う定義の指標を、ひとつの母集団として混ぜていました。
比較は成立しません。前提が壊れているのだから、どんな統計手法を使っても正しい答えは出ない。 zスコアの件は手法の選択ミスでしたが、今度はもっと手前の問題でした。
窓を動かすと、数字が崩落する
では、ベースラインの長さを変えるとどうなるか。 変更点を「直近の48時間前」に置いた再現データで確かめました。
| ベース長 | 変更前を含む | 同時刻帯の中央値 | 倍率 | 判定 |
|---|---|---|---|---|
| 24h | 0h | 195.5 | 1.0倍 | 変化なし |
| 48h | 0h | 201.3 | 1.0倍 | 変化なし |
| 60h | 12h | 190.3 | 1.1倍 | 変化なし |
| 72h | 24h | 184.5 | 1.1倍 | 変化なし |
| 84h | 36h | 97.5 | 2.1倍 | 増加 |
| 108h | 60h | 3.0 | 68.3倍 | ★急増 |
| 168h | 120h | 3.0 | 68.3倍 | ★急増 |
連続的に悪化しない
注目してほしいのは、倍率がなだらかに動かないことです。 1.0倍のまましばらく続き、ある一点で68倍へ跳ねます。 中央値が「後半の世界」から「前半の世界」へ、一気に切り替わるためです。
中央値は順位で決まります。前半のデータが半数を超えた瞬間、代表値は入れ替わる。 少しずつずれていくのではなく、ある日を境に別の数字になる。
どちらの世界にも存在しない値
さらに厄介なのが、切り替わりの境目です。ベース長84時間のとき、中央値は 97.5 でした。
汚染されていても、正常に見える領域がある
もうひとつ気づいたことがあります。変更点は48時間前なので、 ベース長が48時間を超えればその時点で汚染されています。 ところが60〜72時間の範囲では、倍率は1.1倍。数字は正常に見えます。
「またいでいるのに、まだ壊れていない」状態です。 数字が正常だからといって、ベースラインが健全だとは限らない—— これは画面を見ているだけでは絶対に分かりません。
ベースラインとは、何か
今回の件で、定義を書き直すことになりました。
条件が変われば、いくらデータが残っていても比較には使えません。 そして条件が変わる理由は、いくつかあります。
| 壊れ方 | 例 | データから分かるか |
|---|---|---|
| 収集していない期間 | cron を止めていた、導入前 | 分かる(記録が無い) |
| 計測の意味が変わった | ソフト404の修正 | 分からない |
| 対象が変わった | サイト追加、遮断ルール変更 | 分からない |
1つ目は自動で防げます。記録が存在しないので、集計側で「収集開始より前は母集団に含めない」と決めれば済む。 実際にその処理は入れてありました。
問題は2つ目と3つ目です。データはちゃんと存在している。 欠損もない。数字も正しい。それでも比較には使えない。 データの中を見ても、意味が変わったことは分からないのです。
いま、できること
ベースラインを短くする
いちばん確実で、すぐできる対処です。変更点より後だけを見る。 API にベースラインの長さを渡すパラメータを持たせてあったので、値を変えるだけで済みました。
# 既定は 168 時間(7日) GET /log-api.php?tab=changes&recent=6&base=168 # 変更点をまたがないよう 72 時間に GET /log-api.php?tab=changes&recent=6&base=72
短くすればサンプルは減り、判定は不安定になります。精度と正しさの取り引きです。 ただ、前提の壊れた長い窓より、前提の揃った短い窓のほうがましです。
収集前をゼロと誤認しない
こちらは実装済みでした。集計に存在する最も古いバケットを調べ、それより前は母集団に含めない。 cron を回し始める前の期間を「トラフィックゼロだった」と誤読するのを防ぐ処理です。
// 集計に存在する最古のバケットを取得 const earliest = min(bucket); // 要求された開始位置より後ろなら、そちらを採用する const baseStart = earliest > requestedStart ? earliest : requestedStart;
これで1つ目の壊れ方は防げます。ただし今回の件には効きません。 収集は動いていて、データも揃っていたからです。
ベースラインの窓を、動かす
変更点を48時間前に置いた再現データです。 スライダーでベースラインの長さを変えると、報告される倍率がどう動くか見てください。
168時間から縮めていくと、しばらく 68倍 のまま動きません。 あるところで 2.1倍 を通り、さらに縮めると 1.0倍 に落ち着きます。 同じデータ、同じ計算です。変えたのは窓の長さだけ。
改善するほど、比較できなくなる
この件には、監視という営みの根本的な緊張関係が現れていると思います。
システムを直すたびに、過去との比較が無効になる。 ソフト404を直したのは正しい判断でした。計測が正確になり、攻撃の実態が見えるようになった。 その代償として、修正前のデータが比較に使えなくなったのです。
皮肉なことに、ずっと壊れたままのシステムは、比較しやすい。 条件が変わらないからです。改善し続けるシステムほど、ベースラインは頻繁に無効になります。
この記事のまとめ
- 「107倍」は計算として正しく、意味として無意味だった。バグではなく前提の問題。
- ベースライン168時間の中に計測方法の変更点が入っていた。前半と後半は別の指標。
- 倍率は連続的に悪化しない。中央値が入れ替わる一点で跳ねる。
- 切り替わりの境目では、どちらの世界にも実在しない代表値が出る(二峰分布の谷)。
- 汚染されていても数字が正常に見える領域がある。画面だけでは判断できない。
- ベースラインとは「同じ条件で計測された過去のデータ」。条件が変われば使えない。
- 収集前の誤認は自動で防げるが、意味の変化はデータからは分からない。
- 数字が跳ねたら、まず疑うのは攻撃者ではなく自分の変更履歴。