X-Forwarded-For(XFF)是即便經過代理,也用來告訴應用程式『真正的訪客 IP 是這個』的標頭。然而這個值用戶端可以隨意偽造。本文把個人開發中常見的『遭到 XFF 偽造掃描』事例隱去專有名詞,轉化為教訓(不提供攻擊的重現步驟)。
- 種類
- X-Forwarded-For 偽造 + 注入探測(自動掃描)
- 實際損害
- 無(被多層防禦擋下)
- 發現方式
- 攻擊請求變成 500,異常通知郵件大量飛來
- 生效的防護
- ①佔位符(值的繫結)②DB 的字元編碼驗證
- 殘留的設定錯誤
- 『信任所有代理』的設定(前端並無代理的架構)
- 處置
- 在邊界對 IP 標頭做淨化(短期)→ 把信任代理收斂到合適範圍(根本原因)
先做區分:是『攻擊』還是『自己的失誤』
看到錯誤通知不要立刻斷定『是攻擊』。如果剛剛做過架構變更,就應以同等分量去懷疑自己的改動。
懷疑自己的改動
用值的內容作為證據
是定向攻擊還是掃描
本站的視角:從症狀反推原因時,用資料檢驗每個假設
『最近動過的地方可疑』這種直覺,作為出發點是有效的。但是,如果證據(這裡是值的內容)予以否定,就要放手。立多個假設,不靠直覺而用資料一個個排除,就能大幅減少誤判原因。
攻擊的內容:偽造的 XFF 裡夾帶注入探測
攻擊機器人在 XFF 裡『以 IP 之名』放入探測用字串,只在末尾留一段像樣的 IP。它要確認的只有一件事:
『這個應用程式,會不會相信 XFF 的值,把它生拼進 DB 查詢、或原樣輸出到 HTML 裡?』
如果應用程式做得馬虎,SQL 注入 或 XSS 就會經由 XFF 成功。這是把『承載 IP 的標頭』濫用為 傳送任意字串的地方 的手法。
為什麼沒有損害:兩道防護
防護① 值的繫結(佔位符)
沒有把 IP 值做字串拼接,而是用佔位符把它作為資料交給 DB。輸入不會被解讀為指令,所以 SQLi 在結構上不成立。這是使用框架最大的好處之一。
防護② DB 的字元編碼驗證
酬載裡的非法位元組(overlong UTF-8 等),在欄的字元編碼下本就是無法保存的排列。DB 判定為『無效』,拒絕寫入並拋出例外 → 攻擊失敗,而且被偵測到了。
實際發生的只是『攻擊者自己的請求變成 500,異常通知大量飛來』而已。外洩、竄改、非法登入一概沒有。這是多層防禦按預期生效的好例子。
真正的問題:把代理『全部』都信任了
如果到此為止就沒有問題,但有一點不能放過。話說回來,偽造的 XFF 為什麼能抵達 IP 判定?
✗ 信任全部代理(萬用字元)
用戶端偽造的 XFF → 被當作『真實的 IP』採用 → 記錄・工作階段・IP 判定都用了偽造的值
✓ 什麼都不信任(預設值)
忽略偽造的 XFF → 採用直接連線來源的真實 IP → 偽造失效
這個架構是既無反向代理也無 CDN、直連的 Web 伺服器。也就是說根本沒必要信任 XFF,卻設成了『全部信任』。『先全部允許再說』,是開發時常見、又一直留到生產環境的設定。
容易忽略:XFF 偽造的危害不只有『注入』
所有相信用戶端 IP 的處理,最後都會用到偽造的值。rate limit 的繞過、IP allowlist 的突破、BAN 規避、稽核記錄的偽造——即便注入防禦很牢固,這也是另一個問題。請把『相信 IP 的地方』盤點出來,確認其信任的依據(=proxy 設定)。
對策:在邊界淨化 → 把信任收斂到合適範圍
在邊界對 IP 標頭做淨化(短期・立即)
把信任代理收斂到合適範圍(根本原因)
http/https 判定,改動可能帶來重新導向迴圈等副作用 → 先在一個環境裡先行驗證。不要把『合法的 IP』與『真實的 IP』混為一談
驗證:修好後重現攻擊來確認
把『自以為修好了』變成『確實止住了』。①只在一個環境套用對策 → ②自己傳送同樣的非法標頭 → ③確認錯誤數不增加 → ④確認正常存取也照常運作 → ⑤沒問題再推到全部環境。分階段套用能防止事故。
續篇:IP 標頭被擋下後,攻擊轉到了 User-Agent
加上淨化數十分鐘後,又來了同一類錯誤。這次壞掉的是另一個欄——不是 ip_address 而是 user_agent。IP 標頭被擋下的攻擊者,把同樣的惡意酬載換到了 User-Agent 標頭上。這需要換一種做法。
正確的問題不是『哪個標頭危險』,而是『不可信的值最終會被保存到哪個 DB 欄』。如果保存目標只有 ip_address 和 user_agent 這兩個,那麼只要淨化通向這兩個的入口(IP 系標頭+User-Agent,保險起見連 Referer 也加上),源自工作階段的錯誤在原理上就能止住。把酬載換到其他不會被保存的標頭,也不會造成錯誤。正常的 User-Agent 是 ASCII/正常的 UTF-8,所以加上只剔除無效位元組的處理也毫無影響。
本站的視角:從出口反推,而不是一個個封鎖入口
一個個去堵標頭行不通;攻擊者總能改用另一個標頭。從出口(保存欄・輸出位置)反推,把通向那裡的入口一次全部梳理出來。正如這次攻擊從 IP 標頭轉到 User-Agent 所示:修補某個標頭時,要把抵達同一保存目標的其他入口一併處理。而且第二次同樣在重現並確認止住後才推開。
更進一步:在值被保存的前一刻防禦
把 User-Agent 也處理好之後,ip_address 欄又來了同樣的錯誤。入口的淨化已經部署,用自己傳送與攻擊相同的非法標頭來測試也無法重現。可在生產上錯誤卻一再出現——這是這個案例的重點。
原因在於,框架的『用戶端 IP 判定』內部很複雜。多種標頭的寫法(X-Forwarded-For・Forwarded・自訂標頭)、與 proxy 信任設定的組合、解析順序與快取……在這些內部路徑的某處,惡意的值從與我們淨化過的值不同的另一條路線,作為『用戶端 IP』再次出現。只在入口檢查,無法全部攔下。
✗ 在入口守
淨化請求的標頭 → 惡意的用戶端 IP 經內部另一條路線再次出現 → 被繞過
✓ 在出口守
在 DB 保存的前一刻檢查 → 每個值都必經的地方 → 無法繞過
於是改變做法,在出口(寫入 DB 的前一步)防禦。繼承工作階段保存類,只替換產生保存值的那部分(概念上):保存用 IP『若非合法 IP 則為 null』,保存用 User-Agent『剔除非法位元組並限制長度』。無論用戶端 IP 判定在內部如何運作,寫入 DB 的前一刻都必經檢查,所以無從繞過。把惡意標頭從兩條攻擊路線各傳送十幾次,錯誤也沒有增加,正常使用者的工作階段也一切正常。
本站的視角:最後的檢查要放在出口(使用的地方)
安全的基本原則是『不要相信輸入,在使用的地方(出口)務必驗證・跳脫』。入口的檢查在『盡早攔截』上有效,但框架會在內部解析・加工的那類資訊(用戶端 IP 判定等),可能通過入口的檢查後再次出現。在傳給 DB・HTML・命令等危險位置的前一刻,是每個值都必經的地方,所以最後的檢查應該放在那裡。入口與出口是兩回事,兩者都要做。
教訓:不良輸入以看得見的錯誤被拒絕,反而更安全
這一次,攻擊不僅失敗,還以大量錯誤通知的形式被看見了。這並不是壞結果:以看得見的錯誤被拒絕,比在沒人察覺下被放行更安全。不要把錯誤通知當成雜訊丟掉,而要用它來察覺異常——不止步於『實際損害已防住』,而是去修復『為什麼會一路抵達內部』,這才是本站的立場。
接下來閱讀
- 示例:CVE-2026-102895 GPUStack(信任請求來源的認證,讓未認證的一方能在 AI 平臺上冒充管理員)
- 術語:SQL 注入是什麼 / XSS 是什麼 / SSRF 是什麼
- 入門:安全超入門:.env 與 API 金鑰
- 用在哪裡:速率限制與濫用控制(以哪個 IP 計算請求,取決於你如何處理 XFF)
- 全貌:Next.js 和 React 的漏洞很多嗎?(框架內建代理功能中發現的問題)