見つけたのに、見えなかった
前回、UAは自己申告にすぎないという話を書きました。 Googlebot と名乗るだけでホワイトリストを通過してしまうので、 逆引き→正引きの往復確認を実装した。
cron のログに、こう出ました。
[WARN] クローラ詐称の疑い: 108.165.181.162 PTR=108-165-181-162.ips.acedatacenter.com (既知クローラのドメインではない) UA=Mozilla/5.0 (compatible; Googlebot/2.1; ...)
検出できています。ところがダッシュボードの遮断候補を開くと、そのIPがありません。 エラーも出ていない。ただ、無いのです。
「検出できた」と「画面に出た」は、別のことでした。
経路を、分解する
検出から画面までは一続きではありません。データは4つの段階を通ります。
| 段階 | やること | 落とす基準 |
|---|---|---|
| ① 検出 | cron が逆引きで確かめる | — |
| ② 保持 | IP別の指標を上位60件に絞って保存 | 404+403 の多い順 |
| ③ 取得 | API が集約して上位N件を返す | 同上 |
| ④ 表示 | スコアで足切り | 45点以上 |
②③④はすべて「絞り込み」です。 IPは無数にあるので、全部を保存・送信・表示するわけにはいきません。 データ量を抑えるために入れたもので、それ自体は妥当な判断でした。
4段すべてで、落ちていた
問題のIPの数字は、こうでした。
| 項目 | 値 |
|---|---|
| 404 | 0 件 |
| 403 | 0 件 |
| 総アクセス | 3 件 |
| 攻撃パス | 0 種 |
| スコア(詐称 +40 のみ) | 40 点 |
静かなのです。404を1件も出していない。 Googlebot を名乗って、数回アクセスしただけ。 攻撃者としては極めて地味で、だからこそ従来の物差しでは拾えませんでした。
| 段階 | この相手の値 | 結果 |
|---|---|---|
| ① 検出 | 逆引きが往復しない | ✓ 通過 |
| ② 保持 | 404+403 = 0件 → 最下位 | ✗ 上位60件から脱落 |
| ③ 取得 | クローラが枠を占有 | ✗ 押し出される |
| ④ 表示 | 40点 < 45点 | ✗ 5点足りない |
①だけが正しく動いていました。 検出ロジックには一切の問題がなく、 その後ろで3回捨てられていたのです。
なぜ4回も見落としたか
一度で直せませんでした。直すたびに「直った」と思ったのが問題でした。
1回目:スコアの閾値
40点では45点に届かない。詐称は点数によらず表示するようにしました。 1時間ビューで確認すると、確かに出ました。直った、と思いました。
2回目:24時間ビューで消えた
期間を広げたら、また見えない。1時間ではIP数が少なく上位に残っていただけで、 24時間では絞り込みで切られていたのです。
3回目:7日ビューで通常の候補が0件に
クローラを優先的に残すよう並べ替えを変えたら、今度は逆の事故が起きました。 クローラが枠を占有し、本来の遮断候補が全部押し出されて0件になった。 期間を広げたのに候補が減るという、あり得ない表示です。
4回目:古いデータには新しい印が無い
「クローラを名乗った」という印(フラグ)を付けて優先保持する仕組みを入れましたが、 それ以前に記録されたデータには、その印がありません。 過去分は相変わらず沈んだままでした。
各段を、どう直したか
④ 閾値 — 決定的な証拠を、加点に混ぜない
スコアの加点方式は、弱い証拠を積み上げるための仕組みです。 404が多い、成功率が低い、攻撃パスを叩く——どれも単独では決め手にならないので、足し合わせる。
ところが詐称は単独で決定的です。積み上げる必要がない。 それを40点という数字にして加点の枠組みに入れた時点で、 閾値と競わせてしまったのが誤りでした。
// 詐称は点数によらず必ず表示する if ($excl === null && !$spoofed && $score < 45) continue; // ^^^^^^^^^ これを足した
②③ 絞り込み — 目的の違うものを、同じ枠で競わせない
「404の多い順」は攻撃者を探す並べ方です。詐称は404が0でも成立するので、 同じ物差しでは必ず沈みます。集約を2本に分け、種類ごとに枠を確保しました。
$picked = array_merge( array_slice($bySort['spoof'], 0, $limit), array_slice($bySort['normal'], 0, $limit), array_slice($bySort['excluded'], 0, $limit) );
② 保持 — 順位に、運を任せない
優先フラグを付ける方式は、過去のデータには効きません。 そこで発想を変えました。検証結果は既に別のコレクションに記録されているのだから、 詐称と判明しているIPは名指しで取りに行けばいい。 並べ替えの結果に依存する必要はありませんでした。
同じデータを、違う物差しで扱っていた
根っこにあった間違いは、これだと思います。
| 何を見ていたか | |
|---|---|
| 検出 | 名乗りと実体の不一致(件数は無関係) |
| 保持・取得・表示 | 件数の多さ |
検出は「質」で判定し、それ以降は「量」で並べていました。 量が少ない質の高い検出は、経路の途中で必ず失われます。
そして絞り込みの基準は、たいてい後から足したものです。 データ量が増えてきたから上位60件に、応答が重いから150件に—— 性能のために入れた判断が、後から追加した検出と噛み合わなくなる。 足したときには存在しなかった検出のことを、絞り込みは知りません。
検出は、どこで消えたか
3種類の相手を、4段階のパイプラインに流します。 どこで落ちるかを見てください。
偽Googlebot は、修正前だと②で止まります。 そこから先の段階は、そもそも到達すらしていません。
注目してほしいのは「通常のスキャナ」です。 404が1870件、攻撃パス12種、スコア100点——この相手は 修正前でも4段すべてを通過します。
検出は、経路の入口にすぎない
今回、検出ロジックには最初から問題がありませんでした。 逆引きの往復確認は正しく実装され、正しく詐称を見つけていた。 それでも4日間、画面には何も出ませんでした。
そして怖いのは、例外もエラーも出ないことです。 cron は正常終了し、APIは200を返し、画面は正常に描画される。 ただ、そこに無いだけ。
この記事のまとめ
- 検出から表示までは4段階ある。検出・保持・取得・表示。
- 後ろ3つはすべて絞り込み。何かを捨てる判断が埋まっている。
- 絞り込みの基準は「件数の多さ」だった。詐称は404が0でも成立するので沈む。
- 確定した事実を、優先度の仕組みに乗せない。スコアも順位も、不確かなものを捌く道具。
- 優先フラグは過去のデータには効かない。既にある事実は名指しで取りに行く。
- よくある攻撃者でテストすると気づけない。新しい検出には、それが通る相手を用意する。
- 例外もエラーも出ない。画面に出るところまで確認して、初めて完了。