前情提要:測試機被臨時收走

台電那邊因為要做白帽滲透測試,得暫時把測試機關掉。但開發團隊三不五時還是需要一個能驗功能的環境,於是我們把程式臨時搬到另一台機器上跑。原本盤算著,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
2
3
4
5
6
7
8
9
10
11
12
<%@ Page Language="C#" %>
<!DOCTYPE html>
<html>
<head><title>IP 偵測</title></head>
<body>
<%
Response.Write("REMOTE_ADDR: " + Request.ServerVariables["REMOTE_ADDR"] + "<br>");
Response.Write("HTTP_X_FORWARDED_FOR: " + Request.ServerVariables["HTTP_X_FORWARDED_FOR"] + "<br>");
Response.Write("HTTP_X_REAL_IP: " + Request.ServerVariables["HTTP_X_REAL_IP"] + "<br>");
%>
</body>
</html>

頁面回傳:

1
2
REMOTE_ADDR: 192.168.0.28
HTTP_X_FORWARDED_FOR: 125.228.130.66:12872

線索齊了。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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<system.webServer>
<rewrite>
<rules>
<rule name="Extract Real IP" stopProcessing="false">
<match url=".*" />
<conditions>
<!-- 先確認請求確實來自受信任的 Proxy,再改寫 REMOTE_ADDR -->
<add input="{REMOTE_ADDR}" pattern="^192\.168\.0\.28$" />
<add input="{HTTP_X_FORWARDED_FOR}" pattern="^([^,:]+)" />
</conditions>
<serverVariables>
<set name="REMOTE_ADDR" value="{C:1}" />
</serverVariables>
<action type="None" />
</rule>
</rules>
</rewrite>
</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"> 會被擋下不生效:

  1. 點選「URL 重寫」
  2. 右側「檢視伺服器變數」
  3. 新增 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
using System;
using System.Linq; // allowedIPs 的 Select 需要
using System.Configuration; // ConfigurationManager
using System.Web;

protected void Application_BeginRequest(object sender, EventArgs e)
{
bool enableIPRestriction =
ConfigurationManager.AppSettings["EnableIPRestriction"] == "true";

if (!enableIPRestriction)
return;

if (!Request.Url.AbsolutePath.ToLower().StartsWith("/stationmanager"))
return;

// 第一道:直接來源必須是受信任的 Proxy,否則 XFF 一律不可信
string trustedProxy = ConfigurationManager.AppSettings["TrustedProxy"] ?? "";
string remoteAddr = Request.ServerVariables["REMOTE_ADDR"];
if (remoteAddr != trustedProxy)
{
DenyAccess();
return;
}

string[] allowedIPs = (ConfigurationManager.AppSettings["AllowedIPs"] ?? "")
.Split(',')
.Select(ip => ip.Trim())
.Where(ip => ip.Length > 0)
.ToArray();

string clientIP = ExtractClientIP(Request.ServerVariables["HTTP_X_FORWARDED_FOR"]);

// clientIP 為空 = 受信任的 Proxy 沒帶 XFF,或格式異常。
// 這種請求不該放行,但也別跟「IP 不在白名單」混為一談,方便事後從 Log 區分。
if (string.IsNullOrEmpty(clientIP))
{
System.Diagnostics.Trace.TraceWarning(
$"信任的 Proxy {remoteAddr} 未帶 XFF,無法判定真實 IP,拒絕。");
DenyAccess();
return;
}

if (!allowedIPs.Contains(clientIP))
{
DenyAccess();
return;
}

// 通過白名單,繼續後續處理...
}

// XFF 取最左段,再視情況切掉 port。回傳空字串代表無法判定。
private static string ExtractClientIP(string forwardedFor)
{
if (string.IsNullOrEmpty(forwardedFor))
return "";

string firstHop = forwardedFor.Split(',')[0].Trim();

// 這台 Proxy 在 IPv4 後面接 :port(一個冒號)才切;
// 多個冒號視為 IPv6,整段保留不截斷。
int colonCount = firstHop.Length - firstHop.Replace(":", "").Length;
return colonCount == 1 ? firstHop.Split(':')[0] : firstHop;
}

private void DenyAccess()
{
Response.Clear();
Response.StatusCode = 403;
Response.Write("Access Denied");
// 不要用 Response.End():它內部會丟 ThreadAbortException,
// 在 HttpModule / BeginRequest 階段污染例外記錄、難以攔截。
// 改用 CompleteRequest() 直接跳到 EndRequest,不丟例外。
HttpContext.Current.ApplicationInstance.CompleteRequest();
}

這份比原本的版本多處理了幾個邊界,逐一說。

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
2
3
4
5
6
7
8
<appSettings>
<!-- IP 限制總開關 -->
<add key="EnableIPRestriction" value="true" />
<!-- 放行的真實客戶端 IP,逗號分隔 -->
<add key="AllowedIPs" value="125.228.130.66,203.0.113.50" />
<!-- 受信任的 Proxy,只有從這台轉發來的請求,才會去信它寫的 XFF -->
<add key="TrustedProxy" value="192.168.0.28" />
</appSettings>

部署時各機器各設各的:

  • 測試機:EnableIPRestriction = true
  • 正式機:EnableIPRestriction = false

要加 IP、要關白名單,改 config 重啟就好,不必動程式碼、不必重新編譯。

順手把 IIS Log 改成顯示真實 IP

預設的 IIS Log 只記得到 c-ip,也就是 Proxy 的 IP,事後想查「到底是誰打進來的」一律看不出來。把 XFF 加成自訂欄位,Log 才有追查價值:

  1. IIS 管理器 → 網站 → 「記錄」
  2. 「選取欄位」→「自訂欄位」
  3. 新增一筆:
    • 欄位名稱: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

踩坑總結

遇到同樣情境,照這份清單走,能少踩我踩過的幾個坑:

  1. 先確認 Proxy 有沒有正確帶 XFF:用測試頁把所有 HTTP_X_* 標頭印出來,看清楚格式是單一 IP、IP:PORT、還是逗號鏈。
  2. 不要直接信任 XFF:白名單的第一步永遠是「REMOTE_ADDR 是不是我信任的那台 Proxy」,而不是「XFF 是不是白名單裡的 IP」。
  3. IIS 內建 IP 限制配 URL Rewrite,在這個場景兜不起來:原生模組執行得比 URL Rewrite 早,看的是改寫前的來源 IP。要白名單生效,只能走方案二的程式碼層。
  4. XFF 解析別只用 Split(':')[0]:先 Split(',') 取最左段;IPv4 帶 port 再切冒號,IPv6 整段保留;bracketed IPv6 還得另外剝方括號。
  5. 拒絕用 CompleteRequest() 而非 Response.End():後者會丟 ThreadAbortException 污染 Log。
  6. XFF 為空要獨立處理:信任的 Proxy 沒帶 XFF 跟「IP 不在白名單」是兩回事,分開記 Log 才查得動。
  7. 多環境用 web.config 開關切換:EnableIPRestriction 一個鍵搞定,程式裡不留任何 hardcode 的 IP。

後記

一個本來估半小時的 IP 白名單,最後寫成一個 Global.asax.cs 裡的攔截邏輯,加一份 web.config 開關。中間繞的兩段路——以為 URL Rewrite 能讓 IIS 內建 IP 限制生效、以為 Split(':')[0] 就能切乾淨 XFF——其實都是把「代理轉發」這件事想得太簡單。真正值錢的不是那段程式碼,是搞懂了 IIS 請求管道誰先誰後、以及為什麼 XFF 在資安上永遠得配著 REMOTE_ADDR 一起驗。下次再碰到 Proxy 後面的真實 IP 問題,這套順序至少能讓我少走那大半天的彎路。