資安指南
X-Forwarded-For(XFF)偽造是什麼 — 信任代理設定的陷阱與防禦
X-Forwarded-For(XFF)是用戶端可以隨意偽造的 HTTP 標頭。本文以個人開發中常見的事例,從防禦視角講解:在偽造的 XFF 裡夾帶 SQL 注入或 XSS 探測的自動掃描,以及『信任所有代理』這一設定的陷阱(不提供攻擊步驟)。多層防禦為什麼有效、即便如此還有什麼本該修復,將分成對症處理與根本對策來總結。
X-Forwarded-For(XFF)是即便經過代理,也用來告訴應用程式『真正的訪客 IP 是這個』的標頭。然而這個值用戶端可以隨意偽造。本文把個人開發中常見的『遭到 XFF 偽造掃描』事例隱去專有名詞,轉化為教訓(不提供攻擊的重現步驟)。
- 種類
- X-Forwarded-For 偽造 + 注入探測(自動掃描)
- 實際損害
- 無(被多層防禦擋下)
- 發現方式
- 攻擊請求變成 500,異常通知郵件大量飛來
- 生效的盾
- ①佔位符(值的繫結)②DB 的字元編碼驗證
- 殘留的疏漏
- 『信任所有代理』的設定(前端並無代理的架構)
- 處置
- 在邊界對 IP 標頭做淨化(對症)→ 把信任代理收斂到合適範圍(根本)
先做區分:是『攻擊』還是『自己的失誤』
看到錯誤通知就立刻斷定『是攻擊』,是要避免的鐵律。如果剛剛做過架構變更,就應以同等分量去懷疑自己的改動。
懷疑自己的改動
用值的內容作為證據
是定向攻擊還是掃描
本站的視角:從症狀反推原因時要『用資料來排除』
『最近動過的地方可疑』這種直覺,作為出發點是有效的。但是,如果證據(這裡是值的內容)予以否定,就要放手。立多個假設,不靠成見而用資料一個個排除,誤診就會大幅減少。
攻擊的真面目:在 XFF 偽造裡搭載注入探測
攻擊機器人在 XFF 裡『以 IP 之名』塞入探測用字串,只在末尾留一段像樣的 IP。目的只有一個——
『這個應用程式,會不會相信 XFF 的值,把它生拼進 DB 查詢、或原樣輸出到 HTML 裡?』
如果應用程式做得馬虎,就會經由 XFF 中招 SQL 注入 或 XSS。這是把『承載 IP 的位置』濫用為任意字串的注入口的手法。
為什麼實際損害為零:兩道盾
盾① 值的繫結(佔位符)
沒有把 IP 值做字串拼接,而是用佔位符把它作為資料交給 DB。輸入沒有變成 SQL『指令』的餘地 → 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 保存的前一刻檢查 → 無論中途怎樣被汙染都是『必經的一點』 → 無法繞過
於是把思路掉轉 180 度,決定在出口(寫入 DB 的前一步)守住。繼承工作階段保存類,只替換產生保存值的那部分(概念上):保存用 IP『若非合法 IP 則為 null』,保存用 User-Agent『剔除非法位元組並限制長度』。無論用戶端 IP 判定在內部怎樣翻車,寫入 DB 的前一刻都必經檢查,所以無從繞過。把兩條攻擊路線各連打十幾次,錯誤也沒有增加,正常使用者的工作階段也一切正常。
本站的視角:最後的堡壘是『出口(使用的地方)』
安全的定式是『不要相信輸入,在使用的地方(出口)務必驗證・跳脫』。入口的檢查在『盡早攔截』上有效,但對於框架會在內部解析・加工的那類資訊(用戶端 IP 判定等),它可能繞過入口而復活。在傳給 DB・HTML・命令等危險位置的前一刻=必經的一點做驗證・跳脫,才是最後的堡壘。入口與出口是兩回事,兩者都要做。
最大的收穫:被吵鬧地攔下來更安全
這一次,攻擊不僅失敗,還以大量錯誤通知的形式被可視化了。聽上去諷刺,但這其實不壞。比起被悄無聲息地通過,被吵鬧地攔下來更安全。不要把錯誤通知當成『雜訊』丟掉,而要把它當作『異常偵測的感測器』來用——不止步於『實際損害已防住』,而是去修復『為什麼會一路抵達內部』,這才是本站的立場。
接下來閱讀
- 術語:SQL 注入是什麼 / XSS 是什麼 / SSRF 是什麼
- 入門:安全超入門:.env 與 API 金鑰
FAQ
QX-Forwarded-For 可以信任嗎?
原樣是不能信任的。XFF 是用戶端可以隨意填值的標頭,攻擊者既能偽造成看似真實的 IP,也能填入注入探測的字串。可以信任的,只有『自己放在前端、由信任代理附加的 XFF』。
Q沒有使用反向代理或 CDN 時該怎麼辦?
完全不信任 XFF(保持框架的預設值)。前端並沒有代理卻設成『信任所有代理』,誰都能把偽造的 IP 餵給應用程式。沒有相應架構時,什麼都不信任才是正解。
Q對 IP 標頭做淨化就安全了嗎?
注入探測和錯誤洪流會被止住,但那只是衛生處理(止血的對症療法)。即便能整理成『合法的 IP』,也不保證它就是『真實的 IP』。真實 IP 的信任,始終要靠信任代理的設定來保證。
Q明明改好了 IP 標頭,怎麼又冒出同樣的錯誤?
因為同一個惡意酬載被換裝到了另一個入口(例如 User-Agent 標頭),最終又抵達了同一個保存欄。一個個去堵標頭會變成打地鼠。請從『不可信的值最終會抵達哪個 DB 欄/輸出位置』反推,把通向那裡的入口(IP 系+User-Agent 等)一併淨化。