1. 程式人生 > >windows網路防火牆開發二三事

windows網路防火牆開發二三事

網路防火牆開發二三事
- haoxg - 

花了近一個月的時間研究 Windows 平臺下的網路防火牆相關技術,並實現了一個簡單的防火牆。
在獨自摸索的過程中,由於以往的開發經歷從未涉及此領域,所以碰到了不少困難,也走了些彎路。
現此專案暫告一段,遂將相關心得整理成文。文章以歸納總結為主,沒有創新性技術,高手免看。 

◎ 防火牆的資料包攔截方式小結 

網路防火牆都是基於資料包的攔截技術之上的。在 Windows 下,資料包的攔截方式有很多種,
其原理和實現方式也千差萬別。總的來說,可分為“使用者級”和“核心級”資料包攔截兩大類。 

使用者級下的資料包攔截方式有: 

* Winsock Layered Service Provider (LSP)。
* Win2K 包過濾介面 (Win2K Packet Filtering Interface)。
* 替換 Winsock 動態連結庫 (Winsock Replacement DLL)。 

核心級下的資料包攔截方式有: 

* TDI過濾驅動程式 (TDI-Filter Driver)。
* NDIS中間層驅動程式 (NDIS Intermediate Driver)。
* Win2K Filter-Hook Driver。
* Win2K Firewall-Hook Driver。
* NDIS-Hook Driver。 

在這麼多種方式面前,我們該如何決定採用哪一種作為自己專案的實現技術?這需要對每一種
方式都有一個大致的瞭解,並清楚它們各自的優缺點。技術方案的盲目選用往往會帶來一些技術
風險。以自己為例,我需要在截包的同時得到當前程序檔名,也就是說,需向用戶報告當前是
哪個應用程式要訪問網路。在選用 Win2K Filter-Hook Driver 這一方案之後(很多小型開源項
目都採用這一方案),便開始編碼。但之後發現 Win2K Filter-Hook Driver 的截包上下文處於內
核程序中,即 IRQL >= DISPATCH_LEVEL,根本無法知道當前應用程式的名字。相比之下,
TDI-Filter Driver 和 NDIS-Hook Driver 則可以得知這些資訊。其中 TDI-Filter Driver
比 NDIS-Hook Driver 更能準確地獲知當前應用程式檔名,後者的接收資料包和少數傳送資料
包的場景仍然處於核心程序中。 

下面列出了各種截包方式的特點: 

* Winsock Layered Service Provider (LSP)
  該方式也稱為 SPI (Service Provider Interface) 截包技術。SPI是由 Winsock2 提供的一個
  介面,它需要使用者機上安裝有 Winsock 2.0。Winsock2 SPI 工作在 API 之下的 Driver 之上,
  可以截獲所有基於 Socket 的網路資料包。
  優點:
  * 以DLL形式存在,程式設計方便,除錯簡單。
  * 資料封包比較完整,未做切片,便於做內容過濾。
  缺點:
  * 攔截不夠嚴密,對於不用 Socket 的網路通訊則無法攔截 (如 ICMP),木馬病毒很容易繞過。 

* Win2K Packet Filtering Interface
  這是 Win2K 中一組 API 提供的功能 (PfCreateInterface, PfAddFiltersToInterface, ...)。
  優點:
  * 介面簡單,實現起來沒什麼難度。
  缺點:
  * 功能過於簡單,只能提供IP和埠的過濾,可能無法滿足防火牆的複雜需求。
  * 處於 API 層,木馬病毒容易繞過。
  * 只能在 Win2K 以上(含)系統中使用。 

* Winsock Replacement DLL
  這種方法通過替換系統 Winsock 庫的部分匯出函式,實現資料報的監聽和攔截。
  缺點:
  * 由於工作在 Winsock 層,所以木馬病毒容易繞過。 

* TDI-Filter Driver
  TDI 的全稱是 Transport Driver Interface。傳輸層過濾驅動程式通過建立一個或多個裝置物件
  直接掛接到一個現有的驅動程式之上。當有應用程式或其它驅動程式呼叫這個裝置物件時,會首
  先對映到過濾驅動程式上,然後由過濾驅動程式再傳遞給原來的裝置物件。
  優點:
  * 能獲取到當前程序的詳細資訊,這對開發防火牆尤其有用。
  缺點:
  * 該驅動位於 tcpip.sys 之上,所以沒有機會得到那些由 tcpip.sys 直接處理的包,比如ICMP。
  * TDI驅動需要重啟系統方能生效。 

* NDIS Intermediate Driver
  也稱之為 IM Driver。它位於協議層驅動和小埠驅動之間,它主要是在網路層和鏈路層之間對
  所有的資料包進行檢查,因而具有強大的過濾功能。它能截獲所有的資料包。
  可參考DDK中附帶的例子 Passthru。
  優點:
  * 功能非常強大,應用面廣泛,不僅僅是防火牆,還可以用來實現VPN,NAT 和 VLan 等。
  缺點:
  * 程式設計複雜,難度較大。
  * 中間層驅動的概念是在 WinNT SP4 之後才有的,因此 Win9X 無法使用。
  * 不容易安裝,自動化安裝太困難。 

* Win2K Filter-Hook Driver
  這是從 Win2K 開始提供的一種機制,該機制主要利用 ipfiltdrv.sys 所提供的功能來攔截網路
  資料包。Filter-Hook Driver 的結構非常簡單,易於實現。但是正因為其結構過於簡單,並且
  依賴於 ipfiltdrv.sys,微軟並不推薦使用。
  可參考 CodeProject 上的例子:http://www.codeproject.com/KB/IP/drvfltip.aspx
  優點:
  * 結構簡單,易於實現。
  * 能截獲所有的IP包(包括ICMP包)。
  缺點:
  * 工作於核心程序中,無法取得當前應用程式程序的資訊。
  * 雖能截獲所有IP包,但無法取得資料包的以太幀(Ethernet Frame)。
  * 只能在 Win2K 以上(含)系統中使用。 

* Win2K Firewall-Hook Driver
  這是一種和 Win2k Filter-Hook Driver 差不多的機制,所不同的是,Firewall-Hook Driver
  能在 IP Driver 上掛接多個回撥函式,所以和前者相比,它引起衝突的可能性更小一些。
  可參考 CodeProject 上的例子:http://www.codeproject.com/KB/IP/FwHookDrv.aspx
  這種方式的優缺點和 Win2K Filter-Hook Driver 基本相同。 

* NDIS-Hook Driver
  這是一種要重點講述的截包方式。它是目前大多數網路防火牆所使用的方法。這種方式的做法
  是安裝鉤子到 ndis.sys 中,替換其中的某些關鍵函式,從而達到截包的目的。在下一節中我
  們將詳細地介紹它的實現方法。
  優點:
  * 安裝簡單,可即時安裝和解除安裝驅動,無需重啟系統。
  * 能截獲所有的IP包,同時能取得資料包的以太幀(Ethernet Frame)。
  * 安全性高,木馬病毒不容易穿透。
  * 在大多數情況下,能獲取到當前應用程式的程序資訊。
  * 能在 Win98 以上(含)系統中使用。
  缺點:
  * 接收資料包、或偶爾傳送資料包時,驅動工作在核心程序中,無法獲得應用程式程序資訊。 

◎ NDIS-Hook 技術 

微軟和 3COM 公司在1989年制定了一套開發 Windows 下網路驅動程式的標準,稱為 NDIS。
NDIS 的全稱是 Network Driver Interface Specification。NDIS為網路驅動的開發提供了一套
標準的介面,使得網路驅動程式的跨平臺性更好。 

NDIS提供以下幾個層次的介面: 
1. NDIS 小埠驅動 (NDIS Miniport Driver)。
   這也就是我們常說的網絡卡驅動。
2. NDIS 協議驅動 (NDIS Protocol Driver)。
   用來實現某個具體的協議棧,如 TCP/IP 協議棧,並向上層匯出 TDI 介面。
3. NDIS 中間層驅動 (NDIS Intermediate Driver)。
   這是位於小埠驅動和協議驅動之間的驅動。 

NDIS為了給出上述三種介面,提供了一個系統的、完整的 Wrapper。這個 Wrapper 即 ndis.sys。
上面提到的 Miniport Driver、Protocol Driver、Intermediate Driver 均屬於插入到這個
Wrapper 中的“模組”,它們呼叫 Wrapper 提供的函式,同時也向 Wrapper 註冊回撥函式。 

在簡單瞭解了NDIS的機制之後,不難得知,網路防火牆只需要將自己的函式掛鉤(Hook)到 ndis.sys
中即可截獲網路資料包。NDIS-Hook 技術有兩種實現方案: 

1. 修改 ndis.sys 的 Export Table。 

   在 Win32 下,可執行檔案(EXE/DLL/SYS)都遵從PE格式。所有提供介面的驅動都有 Export Table,
   因此只要修改 ndis.sys 的 Export Table,就可實現對關鍵函式的掛接。在實現步驟中,首先
   需要得到 ndis.sys 的記憶體基址,再根據PE格式得到DOS頭部結構(IMAGE_DOS_HEADER),進一步得
   到NT頭部結構(IMAGE_NT_HEADER),最後從頭部結構中查得 Export Table 的地址。 

   由於協議驅動程式(NDIS Protocol Driver)在系統啟動時會呼叫 NdisRegisterProtocol() 來向
   系統註冊協議,因此這種方法關鍵在於修改 ndis.sys 所提供的 NdisRegisterProtocol、
   NdisDeRegisterProtocol、NdisOpenAdapter、NdisCloseAdapter、NdisSend 這幾個函式的地址。
   對於處於系統核心的 ndis.sys 而言,要修改它的記憶體區域,只有驅動程式才能做到,所以我們
   必須編寫驅動程式來達到這個目的。 

   該方案的缺點是載入或解除安裝驅動後無法立即生效,必須重啟系統。且掛鉤方法較為複雜。早期凡
   使用 NDIS-Hook 的防火牆都採用這一方法,包括著名的費爾防火牆的早期版本(v2.1)。
   直到 2004 年,www.rootkit.com 上一名黑客公佈了一種全新的 NDIS-Hook 技術(即下文即將提
   到的第2種方法),諸多防火牆產品才都悄悄對自己的核心技術進行了升級。由於新的掛鉤技術更
   好,故本文不打算詳述修改 Export Table 這一方法的具體細節。 

2. 向系統註冊假協議(Bogus Protocol)。 

   NDIS提供了一個API: NdisRegisterProtocol(),這個API的職責是向系統註冊一個協議(如TCPIP),
   並將該協議作為一個連結串列節點插入到“協議連結串列”的頭部,最後返回該連結串列頭節點(即新節點)的
   地址。正常情況下,只有NDIS協議驅動程式(NDIS Protocol Driver)才會呼叫這個API。 

   既然如此,如果我們也呼叫 NdisRegisterProtocol() 向系統註冊一個新的協議,我們也就能輕
   易地得到“協議連結串列”的首地址,通過走訪這個連結串列,就能修改其中的某些關鍵資訊,比如關鍵
   函式的地址。修改完畢後,再呼叫 NdisDeRegisterProtocol() 登出掉新協議。這看似一切都沒
   發生,但事實上目的已經達到了。這個新協議我們稱之為假協議(Bogus Protocol)。 

   通過這種方法,我們可以不用重啟系統就能輕鬆掛接截包函式。當今大多數網路防火牆都採用了
   這一方法。近來網上又有人提出了獲取協議連結串列首地址的新的怪異途徑,比如獲取 tcpip.sys 
   中全域性變數 _ARPHandle 值的方法。不管怎樣,相比之下,註冊假協議仍不失為一種經典且簡單
   的方法。 

本文將詳細敘述第2種方案的內部原理和實現細節,即通過註冊假協議獲取協議連結串列首地址,遍歷
連結串列並修改其中的函式地址,掛鉤自己的函式,從而實現網路截包。在這麼做之前,需要先對NDIS
內部維護的幾個結構有清楚的認識。另外,由於歷史原因,NDIS存在諸多並不完全向下相容的版本,
不同的版本中關鍵資料域的偏移地址也不盡相同。微軟並沒有以文件形式提供這些變化的列表。本
文稍後給出這些變化。 

* NDIS_PROTOCOL_BLOCK 和 NDIS_OPEN_BLOCK 

在NDIS中,所有已註冊的協議是通過一個單向的協議連結串列來維護的。這個單向連結串列儲存了所有已註冊
的協議,每個協議對應一個節點。連結串列節點由 NDIS_PROTOCOL_BLOCK 結構來描述,在這個結構中儲存
了註冊協議驅動時所指定的各種資訊,如支援協議即插即用的回撥函式地址等。同時,每個協議驅動
還對應一個 NDIS_OPEN_BLOCK 節點結構的單向連結串列來維護其所繫結的網絡卡資訊,協議驅動傳送和接收
資料包的回撥函式地址就儲存在這個結構中,是我們要重點修改的物件。 

協議與網絡卡繫結的示意圖如下: 

              ┌───┐
              │ Head │
              └─┬─┘
                  ↓
    ┌──────────────┐      ┌───────────┐
    │ TCPIP Protocol Block       ├──→│ RTL8168 Open Block   │
    └──────┬───────┘      └─────┬─────┘
                  ↓                                  ↓
    ┌──────────────┐      ┌───────────┐
    │ TCPIP_WANARP Protocol Block│      │ Wireless Open Block  │
    └──────┬───────┘      └─────┬─────┘
                  ↓                                  ↓          
              ┌───┐                          ┌───┐
              │ NULL │                          │ NULL │
              └───┘                          └───┘ 

* 得到 NDIS_PROTOCOL_BLOCK 連結串列的首地址 

上文已提到,通過向系統註冊假協議,我們即可得到協議連結串列的首地址。
從DDK中可查到 NdisRegisterProtocol() 的原型: 

    EXPORT
    VOID
    NdisRegisterProtocol(
        OUT PNDIS_STATUS   Status,
        OUT PNDIS_HANDLE   NdisProtocolHandle,
        IN  PNDIS_PROTOCOL_CHARACTERISTICS ProtocolCharacteristics,
        IN  UINT           CharacteristicsLength
        ); 

可以看出,我們在呼叫它時需要傳入一個結構 NDIS_PROTOCOL_CHARACTERISTICS,這個結構是我們
在註冊協議時必須填寫的一張表格,這個表格描述了協議的相關資訊。不過既然我們註冊的是一個
假協議,所以可以儘量簡單地填寫它。 

    NDIS_STATUS 
    DummyNdisProtocolReceive(
        IN NDIS_HANDLE ProtocolBindingContext,
        IN NDIS_HANDLE MacReceiveContext,
        IN PVOID HeaderBuffer,
        IN UINT HeaderBufferSize,
        IN PVOID LookAheadBuffer,
        IN UINT LookAheadBufferSize,
        IN UINT PacketSize
        )
    {
        return NDIS_STATUS_NOT_ACCEPTED;
    } 

    NDIS_HANDLE 
    RegisterBogusNdisProtocol(void)
    {
        NTSTATUS Status = STATUS_SUCCESS;
        NDIS_HANDLE hBogusProtocol = NULL;
        NDIS_PROTOCOL_CHARACTERISTICS BogusProtocol;
        NDIS_STRING ProtocolName; 

        NdisZeroMemory(&BogusProtocol, sizeof(NDIS_PROTOCOL_CHARACTERISTICS));
        BogusProtocol.MajorNdisVersion = 0x04;
        BogusProtocol.MinorNdisVersion = 0x0; 

        NdisInitUnicodeString(&ProtocolName, L"BogusProtocol");
        BogusProtocol.Name = ProtocolName;
        BogusProtocol.ReceiveHandler = DummyNdisProtocolReceive; 

        NdisRegisterProtocol(&Status, &hBogusProtocol, &BogusProtocol,
            sizeof(NDIS_PROTOCOL_CHARACTERISTICS)); 

        if (Status == STATUS_SUCCESS) return hBogusProtocol;
        else return NULL;
    } 

函式 RegisterBogusNDISProtocol() 的返回值即是我們想要的協議連結串列首地址。不過須注意的是,
在函式掛鉤完成後,應呼叫 NdisDeregisterProtocol() 將假協議登出。另外,在遍歷協議連結串列
進行函式掛鉤時,應從首節點的下一個節點開始,因為首節點是我們的假協議節點。 

* 修改原有函式地址值實現函式掛鉤 

上文已提到了和NDIS相關的三個結構:
NDIS_PROTOCOL_BLOCK,
NDIS_OPEN_BLOCK,
NDIS_PROTOCOL_CHARACTERISTICS。 

那麼我們要替換的函式在哪兒呢?答案是在 NDIS_OPEN_BLOCK 和 NDIS_PROTOCOL_CHARACTERISTICS 
這兩個結構中,而且重點是前者,因為前者是協議驅動和網絡卡繫結的紐帶。現在的主流網絡卡都只
呼叫 NDIS_OPEN_BLOCK 中的收發函式進行傳送和接收資料包。但據試驗,虛擬機器 VMware 有時會
呼叫 NDIS_PROTOCOL_CHARACTERISTICS 中的函式進行資料包收發。所以為了嚴謹,我們應該對兩
個結構中的函式進行替換。關於這兩個結構的定義,讀者可以自行查閱DDK文件和標頭檔案。 

下面給出示意性程式碼。簡單起見,下列程式碼均假設當前NDIS的版本為5.0。 

    BOOLEAN
    InstallHook(void)
    {
        NDIS_STATUS nStatus;
        NDIS_HANDLE hBogusProtocol = NULL;
        BYTE *pProtocolChain; 

        // Get the address of the first NDIS_PROTOCOL_BLOCK node.
        hBogusProtocol = RegisterBogusNDISProtocol();
        if (hBogusProtocol == NULL) return FALSE; 

        pProtocolChain = (BYTE*)hBogusProtocol;
        while (TRUE)
        {
            // Get the address of the next node.
            DWORD dwOffset = 0x10;  // for NDIS 5.0
            pProtocolChain = ((BYTE **)(pProtocolChain + dwOffset))[0];
            if (!pProtocolChain) break; 

            HookNdisProtocolBlock(pProtocolChain);
        } 

        NdisDeregisterProtocol(&nStatus, hBogusProtocol);
        return TRUE;
    } 

    void 
    HookNdisProtocolBlock(
        IN  BYTE *pProtocolBlock
        )
    {
        PNDIS_PROTOCOL_CHARACTERISTICS pProtoChar;
        PNDIS_OPEN_BLOCK pOpenBlock; 

        pProtoChar = (PNDIS_PROTOCOL_CHARACTERISTICS)(pProtocolBlock + 0x14); 

        HookNdisProc(MyReceive, (PVOID *)&pProtoChar->ReceiveHandler);
        HookNdisProc(MyReceivePacket, (PVOID *)&pProtoChar->ReceivePacketHandler);
        HookNdisProc(MyBindAdapter, (PVOID *)&pProtoChar->BindAdapterHandler); 

        pOpenBlock = ((PNDIS_OPEN_BLOCK *)pProtocolBlock)[0];
        while (pOpenBlock)
        {
            HookNdisProc(MySend, (PVOID *)&pOpenBlock->SendHandler);
            HookNdisProc(MyReceive, (PVOID *)&pOpenBlock->ReceiveHandler);
            HookNdisProc(MyReceivePacket, (PVOID *)&pOpenBlock->ReceivePacketHandler);
            HookNdisProc(MySendPackets, (PVOID *)&pOpenBlock->SendPacketsHandler); 

            pOpenBlock = pOpenBlock->ProtocolNextOpen;
        }
    } 

    void
    HookNdisProc(
        IN  PVOID pMyProc, 
        IN  PVOID *ppOrgProc
        )
    {
        // TODO: Save the address of the original proc. 

        *ppOrgProc = pMyProc;
    } 

InstallHook() 首先得到協議連結串列的首地址,接著遍歷連結串列,針對系統中的每個(第一個除外) 
NDIS_PROTOCOL_BLOCK 呼叫 HookNdisProtocolBlock() 函式。
HookNdisProtocolBlock() 對 NDIS_PROTOCOL_BLOCK 中 NDIS_PROTOCOL_CHARACTERISTICS 和
NDIS_OPEN_BLOCK 連結串列的每個節點進行函式掛接。
HookNdisProc() 用於替換函式地址。給出的程式碼中它只是簡單地替換函式地址,在實際應用中,
它還應當儲存原始函式的地址值,以供新的函式呼叫。 

* 關鍵資料域在不同NDIS版本中的差異 

由於 NDIS-Hook 並非受微軟官方支援的技術,所以相關文件非常缺乏。不僅如此,作業系統的
每次升級,都會同時升級NDIS,而NDIS中的某些資料結構並沒有保持向下相容。最需要注意的
是 NDIS_PROTOCOL_BLOCK。 

在 Win9x/Me/NT 的DDK中,NDIS_PROTOCOL_BLOCK 都有明確的定義,但在 Win2K/XP 的DDK中,
並沒有該結構的詳細定義,也就是說該結構在 Win2K 以後(含)的系統中是非公開的。因此開發
人員只能利用各種除錯工具來發掘該結構的詳細定義。也正是因為如此,NDIS-Hook 方法對平臺
的依賴性比較大,需要在程式中判斷不同的作業系統版本而使用不同的結構定義。 

NDIS_PROTOCOL_BLOCK 的定義可大致認為是這個樣子: 

    typedef struct _NDIS_PROTOCOL_BLOCK
    {
        PNDIS_OPEN_BLOCK              OpenQueue;
        REFERENCE                     Ref;
        UINT                          Length;
        NDIS_PROTOCOL_CHARACTERISTICS ProtocolChars; 

        struct _NDIS_PROTOCOL_BLOCK*  NextProtocol;
        ULONG                         MaxPatternSize; 

        // ...
    } NDIS_PROTOCOL_BLOCK, *PNDIS_PROTOCOL_BLOCK; 

其中 OpenQueue 為 PNDIS_OPEN_BLOCK 連結串列的首節點地址,NextProtocol 指向下一個
NDIS_PROTOCOL_BLOCK 節點。 

在不同的NDIS版本中,該結構中的某些域的偏移地址是不同的,現列於下: 

  ┌───────┬───────────┬───────────┐
  │ NDIS Version │ ProtocolChars offset │ NextProtocol offset  │
  ├───────┼───────────┼───────────┤
  │   3.XX       │        0x14          │        0x04          │
  │   4.XX       │        0x14          │        0x60          │
  │   4.01       │        0x14          │        0x8C          │
  │   5.XX       │        0x14          │        0x10          │
  └───────┴───────────┴───────────┘ 

* 如何在驅動中得到當前NDIS版本? 

有兩種方法可得到當前NDIS版本。一種是先取得當前作業系統的版本資訊,在根據作業系統
的版本得到NDIS的版本。作業系統版本和NDIS版本有一個對映關係,讀者可在DDK幫助中查到。 

  ┌───────┬───────┐
  │ OS Version   │ NDIS Version │
  ├───────┼───────┤
  │ Win95        │     3.1      │
  │ Win95 OSR2   │     4.0      │
  │ Win98        │     4.1      │
  │ Win98 SE     │     5.0      │
  │ WinMe        │     5.0      │
  │ WinNT 3.5    │     3.0      │
  │ WinNT 4.0    │     4.0      │
  │ WinNT 4.0 SP3│     4.1      │
  │ Win2K        │     5.0      │
  │ WinXP        │     5.1      │
  │ WinVista     │     6.0      │
  └───────┴───────┘ 

還有一種方法,通過呼叫 NdisReadConfiguration() 直接獲取NDIS版本。程式碼如下: 

    BOOLEAN
    GetNdisVersion(
        OUT DWORD *pMajorVersion,
        OUT DWORD *pMinorVersion
        )
    {
        NDIS_STATUS nStatus;
        NDIS_STRING VersionStr = NDIS_STRING_CONST("NdisVersion");
        PNDIS_CONFIGURATION_PARAMETER ReturnedValue;
        BOOLEAN bResult; 

        NdisReadConfiguration(
            &nStatus,
            &ReturnedValue,
            NULL,
            &VersionStr,
            NdisParameterInteger); 

        bResult = ((nStatus == NDIS_STATUS_SUCCESS)? TRUE : FALSE);
        if (bResult)
        {
            // 
            // The returned value has the NDIS version of the form
            // 0xMMMMmmmm, where MMMM is major version and mmmm is minor
            // version so 0x00050000 is 5.0
            // 
            DWORD dwVersion = ReturnedValue->ParameterData.IntegerData;
            if (pMajorVersion)
                *pMajorVersion = dwVersion >> 16;
            if (pMinorVersion)
                *pMinorVersion = dwVersion & 0xFFFF;
        } 

        return bResult;
    } 

須注意的是,GetNdisVersion() 必須在 PASSIVE_LEVEL 下執行。所以此函式適合於在
驅動的 DriverEntry() 中呼叫,因為 DriverEntry() 一定是處於 PASSIVE_LEVEL 的。