前情提要:測試機被臨時收走
台電那邊因為要做白帽滲透測試,得暫時把測試機關掉。但開發團隊三不五時還是需要一個能驗功能的環境,於是我們把程式臨時搬到另一台機器上跑。原本盤算著,IIS 設個 IP 白名單就能擋住外人,半小時搞定。結果這個白名單,前後折騰了我大半天。
問題:白名單一開,全員 403
我在 IIS 管理器的「IP 位址及網域限制」裡,把信任的 IP 一個個加進白名單,存檔,重新整理頁面。畫面回我一個乾脆的 403。
換個設定再試,規律很清楚:
- 設成「允許未指定的用戶端」→ 一切正常
- 設成「拒絕未指定的用戶端,只放行白名單」→ 所有人都 403
白名單裡明明有我自己的 IP,怎麼也被擋?翻 IIS Log 才看懂:
1 | c-ip: 192.168.0.28 |
每一筆請求的來源 IP 都是 192.168.0.28。這是那台 Proxy Relay 伺服器。外部請求全部先打到它,再由它轉發進 IIS。IIS 眼裡只有代理伺服器這一個來源,真正的客戶端 IP 它根本看不到——白名單再怎麼設都是擋自己人。
追查:真實 IP 藏在哪個標頭
代理轉發時,習慣上會把原始客戶端 IP 塞進某個 HTTP 標頭。問題是不同代理塞的標頭名稱不一樣,得先確認這台 Proxy 用的是哪個。我丟了一個測試頁上去,把幾個常見標頭全印出來:
1 | <%@ Page Language="C#" %> |
頁面回傳:
1 | REMOTE_ADDR: 192.168.0.28 |
線索齊了。REMOTE_ADDR 是代理伺服器,真實客戶端 IP 被放在 X-Forwarded-For(以下簡稱 XFF),只是後面接了一個 port,格式是 IP:PORT。接下來的麻煩,多半都從這個格式來。
方案一:用 URL Rewrite 把 REMOTE_ADDR 改寫成真實 IP
先確認 IIS 已裝 URL Rewrite 模組,然後在 web.config 加一條規則,把 XFF 裡的 IP 寫回 REMOTE_ADDR:
1 | <system.webServer> |
這段有兩個地方踩過虧,得講清楚。
第一個是那條 {REMOTE_ADDR} 的條件,少了它整套就是漏洞。如果不檢查請求是不是真的從那台 Proxy 來,任何人只要繞過代理、直連這台 IIS,自己帶一個假的 XFF 進來,REMOTE_ADDR 就被改寫成白名單裡的值——白名單形同虛設。這跟下面程式碼層會再講一次的信任問題是同一個。
第二個是 pattern 的寫法。我一開始抄到的版本是 ^([^:]+),只排除冒號。後來想到標準 XFF 在多層代理下是逗號分隔的 IP 鏈(client, proxy1),只排冒號會把整串都抓進來,所以改成 ^([^,:]+),碰到逗號也斷開,只取最左邊那一段。
但這條 regex 仍然假設第一段是「IPv4 加 port」。如果你的環境是 IPv6,XFF 送出來的格式通常是 [2001:db8::1]:port 或純 2001:db8::1,^([^,:]+) 會在第一個冒號就截斷,把位址切爛。我這台機器是純 IPv4,沒踩到這個雷,但如果你的環境會走 IPv6,建議先用上面那個測試頁確認 Proxy 實際送的格式,針對它調 pattern,或者乾脆跳到方案二在程式碼層處理。
改寫前還得在 IIS 把這個伺服器變數開放出來,否則 <set name="REMOTE_ADDR"> 會被擋下不生效:
- 點選「URL 重寫」
- 右側「檢視伺服器變數」
- 新增
REMOTE_ADDR
設好之後再打一次測試頁:
1 | REMOTE_ADDR: 125.228.130.66 |
改寫成功。但這裡得先說清楚這個方案的邊界,免得跟我一樣空歡喜:URL Rewrite 改寫之後,後面的 ASP.NET 程式碼讀 Request.ServerVariables["REMOTE_ADDR"] 拿到的確實是真實 IP,這部分有效。可是 IIS 內建那個「IP 位址及網域限制」模組,是另一回事——下一節就是栽在這。
為什麼 IIS 內建 IP 限制還是擋不住
改完 REMOTE_ADDR,我滿心以為大功告成,回頭把 IIS 的「IP 位址及網域限制」重新打開,結果照樣 403。
原因在執行順序。IIS 的 IP 限制是原生模組,掛在請求管道很前面的 BeginRequest 階段就先跑;URL Rewrite 對 REMOTE_ADDR 的改寫,要到稍後才對下游生效。也就是說,IP 限制模組做判斷的當下,看到的永遠是改寫前的原始來源 IP——還是那台 Proxy 的 192.168.0.28,不是改寫後的真實 IP。
這也對得上 IIS Log 裡看到的三件事:
c-ip始終是192.168.0.28(IP 限制看的就是這個,所以擋不對人)X-Forwarded-For正確帶著真實 IP- URL Rewrite 改寫成功,但 IIS 內建 IP 限制依然失效
結論很乾脆:在這個場景下,URL Rewrite 救不了 IIS 內建的 IP 限制。要白名單真的生效,只能自己在程式碼層做。
方案二:程式碼層自己做白名單
IIS 內建模組指望不上,那就自己寫。動手前要先想清楚一件最容易出包的事:X-Forwarded-For 只是一個普通的請求標頭,任何人都能自己帶。攻擊者只要不經過 Proxy、直連這台 IIS,丟一個 X-Forwarded-For: 125.228.130.66 進來,白名單就被繞過了。把 XFF 直接當資安依據,等於門沒上鎖。
所以判斷順序必須是:先確認這個請求的「直接來源」(REMOTE_ADDR)真的是我信任的那台 Proxy 192.168.0.28,才去相信它寫進來的 XFF。直接來源不是受信任的 Proxy,一律擋掉。
XFF 的解析也有個坑。這台 Proxy 送來的剛好是單一的 IP:PORT,所以最直覺的寫法是 Split(':')[0] 切掉 port。但標準 XFF 在多層代理下是逗號分隔的 IP 鏈(client, proxy1, proxy2),客戶端 IP 是「最左邊那一段」,不是靠冒號接 port。而且 IPv6 位址本身就含一堆冒號(像 2001:db8::1),Split(':')[0] 會把它截成 2001,合法用戶反而被誤擋。正確做法是先 Split(',') 取第一段,再處理那一段裡可能有的 port。
在 Global.asax.cs 實作:
1 | using System; |
這份比原本的版本多處理了幾個邊界,逐一說。
ExtractClientIP 裡的 colonCount == 1 是為了同時吃兩種格式:IPv4 帶 port(125.228.130.66:12872,剛好一個冒號)切掉 port,純 IPv6(多個冒號)整段保留不截斷。但要老實講,它有個沒補完的洞——遇到「帶 port 的 IPv6」,也就是 [2001:db8::1]:8080 這種帶方括號的格式,多個冒號讓它走「整段保留」分支,結果連方括號和 port 都一起留下,比對白名單時對不上。AWS ALB 之類的負載平衡器在 IPv6 下就是送這個格式。我這台是純 IPv4 沒踩到,但如果你的環境會出現 bracketed IPv6,這個函式得另外處理:先剝掉外層 [...]、再切 port,才比得對。
XFF 為空的情況也補上了。原本的寫法裡,如果信任的 Proxy 因為設定異常沒帶 XFF,clientIP 會是空字串,allowedIPs.Contains("") 回 false,請求被靜默 403——而你從 Log 完全看不出是「IP 不在白名單」還是「根本沒拿到 IP」。所以這裡把空字串獨立出來,留一筆 Trace 警告,事後排查時能一眼分清是哪種情況。
再來是把 Response.End() 換成 CompleteRequest()。Response.End() 內部會丟一個 ThreadAbortException,這是 .NET 的已知行為。在一般頁面裡也許無感,但在 Application_BeginRequest 這種管道早期階段用它,例外會被往上丟、污染你的錯誤記錄,而且 ThreadAbortException 還抓不太住——catch 完 CLR 會自動再丟一次。微軟官方建議的替代就是 HttpContext.Current.ApplicationInstance.CompleteRequest(),它直接把流程帶到 EndRequest,不丟例外、不污染 Log。
環境分離:別把 IP 寫死在程式裡
正式機跟測試機共用同一份程式碼,這個白名單只該在測試機上開。所以開關和 IP 全部抽到 web.config 的 appSettings,程式裡一個 hardcode 的 IP 都不留:
1 | <appSettings> |
部署時各機器各設各的:
- 測試機:
EnableIPRestriction = true - 正式機:
EnableIPRestriction = false
要加 IP、要關白名單,改 config 重啟就好,不必動程式碼、不必重新編譯。
順手把 IIS Log 改成顯示真實 IP
預設的 IIS Log 只記得到 c-ip,也就是 Proxy 的 IP,事後想查「到底是誰打進來的」一律看不出來。把 XFF 加成自訂欄位,Log 才有追查價值:
- IIS 管理器 → 網站 → 「記錄」
- 「選取欄位」→「自訂欄位」
- 新增一筆:
- 欄位名稱:
X-Forwarded-For - 來源類型:
Request Header - 來源名稱:
X-Forwarded-For
- 欄位名稱:
之後 Log 就會兩個 IP 都帶上:
1 | c-ip: 192.168.0.28 X-Forwarded-For: 125.228.130.66:14185 |
踩坑總結
遇到同樣情境,照這份清單走,能少踩我踩過的幾個坑:
- 先確認 Proxy 有沒有正確帶 XFF:用測試頁把所有
HTTP_X_*標頭印出來,看清楚格式是單一 IP、IP:PORT、還是逗號鏈。 - 不要直接信任 XFF:白名單的第一步永遠是「
REMOTE_ADDR是不是我信任的那台 Proxy」,而不是「XFF 是不是白名單裡的 IP」。 - IIS 內建 IP 限制配 URL Rewrite,在這個場景兜不起來:原生模組執行得比 URL Rewrite 早,看的是改寫前的來源 IP。要白名單生效,只能走方案二的程式碼層。
- XFF 解析別只用
Split(':')[0]:先Split(',')取最左段;IPv4 帶 port 再切冒號,IPv6 整段保留;bracketed IPv6 還得另外剝方括號。 - 拒絕用
CompleteRequest()而非Response.End():後者會丟ThreadAbortException污染 Log。 - XFF 為空要獨立處理:信任的 Proxy 沒帶 XFF 跟「IP 不在白名單」是兩回事,分開記 Log 才查得動。
- 多環境用
web.config開關切換:EnableIPRestriction一個鍵搞定,程式裡不留任何 hardcode 的 IP。
後記
一個本來估半小時的 IP 白名單,最後寫成一個 Global.asax.cs 裡的攔截邏輯,加一份 web.config 開關。中間繞的兩段路——以為 URL Rewrite 能讓 IIS 內建 IP 限制生效、以為 Split(':')[0] 就能切乾淨 XFF——其實都是把「代理轉發」這件事想得太簡單。真正值錢的不是那段程式碼,是搞懂了 IIS 請求管道誰先誰後、以及為什麼 XFF 在資安上永遠得配著 REMOTE_ADDR 一起驗。下次再碰到 Proxy 後面的真實 IP 問題,這套順序至少能讓我少走那大半天的彎路。




