现在的位置: 首页 > 综合 > 正文

windows网络防火墙开发二三事

2012年12月12日 ⁄ 综合 ⁄ 共 13610字 ⁄ 字号 评论关闭

网络防火墙开发二三事
- 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 的。

抱歉!评论已关闭.