X-Forwarded-For(XFF)は、プロキシ経由でも「本当の訪問者IPはこれ」とアプリに伝えるためのヘッダです。ところがこの値はクライアントが自由に詐称できます。個人開発でよくある「XFF偽装スキャンを受けた」事例を、固有名詞を伏せて教訓に変えます(攻撃の再現手順は載せません)。
- 種別
- X-Forwarded-For 偽装 + インジェクション探索(自動スキャン)
- 実害
- なし(多層防御で不成立)
- 気づき方
- 攻撃リクエストが500になり、例外通知メールが大量に飛んだ
- 効いた対策
- ①プレースホルダ(値のバインド)②DBの文字コード検証
- 残っていた設定の不備
- 「すべてのプロキシを信頼」する設定(前段にプロキシは無い構成)
- 対処
- 境界でIPヘッダをサニタイズ(応急処置)→ 信頼するプロキシの適正化(根本対策)
まず切り分ける:「攻撃」か「自分のミス」か
エラー通知を見てすぐに「攻撃だ」と決めつけないことが大切です。直前に構成変更をしていれば、自分の変更を疑う線も同じ重みで検討します。
自分の変更を疑う
値の中身を証拠にする
標的型かスキャンか
当サイトの視点:症状から原因を探るときは、仮説をデータで1つずつ確かめる
「最近いじった所が怪しい」という直感は出発点として有効です。ただし、証拠(この場合は値の中身)が否定するなら手放す。複数の仮説を立て、思い込みでなくデータで一つずつ確かめて否定していくと、原因の取り違えが大きく減ります。
攻撃の内容:XFF偽装とインジェクションの探索
攻撃ボットはXFFに「IPの代わりに」探索用の文字列を入れ、末尾だけ正常に見えるIPにします。狙いは次の1点です。
「このアプリ、XFFの値を信じて、DBクエリに生で混ぜたり、HTMLにそのまま出したりしないか?」
もしアプリが雑なら、XFF経由で SQLインジェクション や XSS が成立します。これは「IPを伝えるヘッダ」を任意の文字列を送り込む場所として悪用する手口です。
被害がなかった理由:2つの対策
対策① 値のバインド(プレースホルダ)
IP値を文字列連結せず、プレースホルダでデータとしてDBへ渡していた。入力がSQLの「命令」として解釈される余地がない → SQLiは構造的に不成立。フレームワークを使う大きな利点の一つ。
対策② DBの文字コード検証
ペイロードの不正バイト(overlong UTF-8 等)は、カラムの文字コードではそもそも保存できない並び。DBが「無効」と判断して書き込みを拒否し、例外を投げた → 攻撃は失敗し、しかも検知できた。
起きたのは「攻撃者自身のリクエストが500になり、例外通知が大量に飛んだ」だけ。漏えい・改ざん・不正ログインはいずれもなし。多層防御が想定どおり機能した好例です。
本当の問題:すべてのプロキシを信頼していた
ここで終われば問題なしですが、見過ごせない点があります。そもそも、なぜ偽XFFがIP判定まで届いたのか?
✗ 全プロキシ信頼(ワイルドカード)
クライアントの偽XFF → 「本物のIP」として採用 → ログ・セッション・IP判定に偽の値が入る
✓ 何も信頼しない(初期値)
偽XFFは無視 → 直接接続元の実IPを採用 → 詐称が効かない
この構成はリバースプロキシもCDNも挟まないWebサーバー直結でした。つまりXFFを信頼する必要が無いのに「全部信頼」にしていた。「とりあえず全許可」は、開発時の仮の設定がそのまま本番に残るよくある例です。
見落としやすい点:XFF偽装の害はインジェクションだけではない
クライアントIPを信じる処理は、すべて偽の値で動いてしまいます。レート制限の回避・IP許可リストの突破・BAN回避・監査ログの偽装が起こりえます。インジェクション対策が十分でも、これらは別の問題として残ります。「IPを信じている場所」を棚卸しして、その信頼の根拠(=プロキシ設定)を確認してください。
対策:境界でサニタイズ → 信頼を適正化
境界でIPヘッダをサニタイズ(応急処置・すぐ効く)
信頼するプロキシを適正化(根本対策)
http/https 判定にも関わるため、変更でリダイレクトループ等の副作用が出うる → まず1環境で先行検証。“妥当なIP”を“本物のIP”と混同しない
検証:直したら攻撃を再現して確かめる
「直したつもり」を「確かに止まった」に変えます。①対策を1環境にだけ適用 → ②同じ不正ヘッダを自分で送る → ③エラー件数が増えないことを確認 → ④正常アクセスも従来どおり動くことを確認 → ⑤問題なければ全環境へ。段階適用が事故を防ぎます。
続き:IPを遮断した後、User-Agent経由で攻撃された
サニタイズを入れて数十分後、また同じ種類のエラーが来ました。今度エラーになったのは別のカラムで、ip_address ではなく user_agent です。IPヘッダで拒否された攻撃者が、同じ不正ペイロードを User-Agent ヘッダに入れて送ってきたのです。ここで発想を変える必要があります。
正しい問いは「どのヘッダが危険か」ではなく「信頼できない値が、最終的にどのDBカラムに保存されるか」です。保存先が ip_address と user_agent の2つだけなら、その2つに届く入口(IP系ヘッダ+User-Agent、念のため Referer も)を無害化すれば、セッション由来のエラーは原理的に止まります。保存されない他のヘッダに移されても、同じエラーは起きません。正規の User-Agent は ASCII/正常なUTF-8 なので、無効バイトだけを落とす処理を足しても無影響です。
当サイトの視点:保存先(出口)から逆算し、ヘッダ(入口)を1つずつ塞がない
ヘッダを1個ずつ塞いでも、攻撃者が使えるヘッダは他にいくらでもあります。出口(保存先カラム・出力先)から逆算して、そこに至る入口を一度に全部洗い出すのが正解。IPヘッダを対策したら User-Agent を使われた今回の経験のとおり、応急処置でヘッダを塞ぐときは同じ保存先に届く他の入口も同時に塞ぐ。そして2回目も、再現して止まったことを確認してから展開しました。
さらに続き:保存する直前の処理で防いだ
User-Agent も塞ぎ「今度こそ」と思った直後、また ip_address カラムで同じエラーが来ました。入口のサニタイズはデプロイ済みで、攻撃と同じ不正ヘッダを自分で送るテストでは再現しない。なのに本番では再発する。ここがこの事例でいちばん重要な点です。
理由は、フレームワークの「クライアントIP判定」が内部で複雑だったこと。複数のヘッダ表記(X-Forwarded-For・Forwarded・独自ヘッダ)、プロキシ信頼設定との組み合わせ、パース順やキャッシュ……これらの内部経路のどこかで、こちらが無害化したつもりの値とは別の経路から、不正な値が再び「クライアントIP」として使われていました。入口の検査だけでは、すべては防げないのです。
✗ 入口で守る
リクエストのヘッダを浄化 → 内部の別経路で不正なクライアントIPが再び使われる → 防げない
✓ 出口で守る
DB保存の直前で検査 → 途中の経路にかかわらず必ず通る場所 → 防げる
そこで考え方を変え、出口(DBに書く一歩手前)で守ることにしました。セッション保存クラスを継承し、保存値を作る部分だけ差し替えます(概念):保存用IPは「妥当なIPでなければ null」、保存用User-Agentは「不正バイトを除去し長さを制限」。クライアントIP判定が内部でどう処理されても、DBに書く直前で必ず検査を通るので、回避されません。2つの攻撃経路で十数回ずつ同じ不正ヘッダを送ってもエラーは増えず、正規ユーザーのセッションも正常でした。
当サイトの視点:最後の防御策は、値を使う場所(出口)での検証
セキュリティの基本は「入力を信用せず、使う場所(出口)で必ず検証・エスケープする」ことです。入口の検査は早い段階で不正な値を拒否するために有効ですが、フレームワークが内部で値を解決・加工する種類の情報(クライアントIP判定など)は、入口の検査を経ずに再び現れることがあります。DB・HTML・コマンドなどへ渡す直前、つまり必ず通る場所で検証・エスケープするのが最後の防御策です。入口と出口は別物として両方やります。
教訓:不正な入力は、エラーとして目に見える形で拒否されるほうが安全
今回、攻撃は失敗したうえに大量のエラー通知という形で可視化されました。これは悪い結果ではありません。気づかれずに通過されるより、エラーとして拒否されて通知が来るほうが安全です。エラー通知を不要な通知として無視せず、異常に気づく手がかりとして活かしてください。そして「実害は防げた」で終わらせず、「なぜ内部まで届いたのか」を直すのが当サイトの立場です。
次に読む
- 用語:SQLインジェクションとは / XSSとは / SSRFとは
- 使いどころ:レート制限と濫用対策(どのIPを単位に数えるかがXFFの扱いで変わる)
- 入門:セキュリティ超入門:.env と APIキー
- 傾向:Next.jsとReactは脆弱性が多いのか(フレームワークのプロキシ機能に何が見つかっているか)