应用层截包方案与实现

应用层截包方案与实现(修订)  作者 Fang(fangguicheng@21cn.com)
<script language="VBScript" type="text/javascript"> document.title="应用层截包方案与实现(修订) "&amp;document.title </script>

关键字 截包 应用层 中间层 NDIS Windows
原作者姓名 Fang(fangguicheng@21cn.com)

读者评分 6 评分次数 2

正文
关键字:截包 应用层 中间层 NDIS Windows
作者:Fang(fangguicheng@21cn.com)

关于修订的说明

    《应用层截包方案与实现》中讨论了在Passthru基础上实现一个应用层截包驱动的实现方式和一些细节。第一版实现采用共享内存方式,但是实现效果并不理想,使用应用层截包驱动后,网络性能只有100Mbps网络的70%。
    通过第一次实现分析认为,IO通讯方式相比共享内存方式而言对CPU性能的影响是非常显著的,但是网络性能的下降的直接原因在于丢报文,TCP认为下层网络繁忙,主动降低收发速度以适应网络状况。
    相比IO通讯方式,共享内存方式由于不使用频繁的IO操作,大大降低对CPU性能的影响,但是共享内存方式实现管理要复杂的多。
第二次实现采用了IO通讯方式,并且改良了缓存方式,获得相对较好的网络性能,不会导致网络性能的下降。但是,显而易见的,对CPU的性能影响非常显著。
对于应用层截包这样一个方案而言,主要用于一些特定的场合,如个人防火墙等方面。这种应用一般没有很大的网络流量,对CPU的性能影响也相应的大大降低,效率应该不是一个问题。

为什么要在应用层截包

引言

截包的需求一般来自于过滤、转换协议、截取报文分析等。
过滤型的应用比较多,典型为包过滤型防火墙。
转换协议的应用局限于一些特定环境。比如第三方开发网络协议软件,不能够与原有操作系统软件融合,只好采取“嵌入协议栈的块”(BITS)方式实施。比如IPSEC在Windows上的第三方实现,无法和操作系统厂商提供的IP软件融合,只好实现在IP层与链路层之间,作为协议栈的一层来实现。第三方PPPOE软件也是通过这种方式实现。
截取包用于分析的目的,用“抓包”描述更恰当一些,“截包”一般表示有截断的能力,“抓包”只需要能够获取即可。实现上一般作为协议层实现。
本文所说的“应用层截包”特指在驱动程序中截包,然后送到应用层处理的工作模式。

截包模式

用户态下的网络数据包拦截方式有
1.    Winsock Layered Service Provider;
2.    Windows 2000 包过滤接口;
3.    替换系统自带的WINSOCK动态连接库;

利用驱动程序拦截网络数据包的方式有
1.    TDI过滤驱动程序(TDI Filter Driver)
2.    NDIS中间层驱动程序(NDIS Intermediate Driver)
3.    Win2k Filter-Hook Driver
4.    NDIS Hook Driver

用户态下拦截数据包有一些局限性,“很显然,在用户态下进行数据包拦截最致命的缺点就是只能在Winsock层次上进行,而对于网络协议栈中底层协议的数据包无法进行处理。对于一些木马和病毒来说很容易避开这个层次的防火墙。”
    我们所说的“应用层截包”不是指上面描述的在用户态拦截数据包。而是在驱动程序中拦截,在应用层中处理。要获得一个通用的方式,应该在IP层之下进行拦截。综合比较,本文选用中间层模式。

为什么要在应用层处理截取的报文

一般来说,网络应用如防火墙,协议类软件都是工作在内核,我们为什么要反过来,提出要在应用层处理报文呢?理由也可以找出几点(哪怕是比较牵强):
    众所周知,驱动程序开发有一定的难度,对于一个经验丰富的程序员来说,或许开发过程中不存在技术问题,但是对初学者,尤其是第一次接触的程序员简直是痛苦的经历。
    另外,开发周期也是一个不得不考虑的问题。程序工作在内核,稳定性/兼容性都需要大量测试,而且可供使用的函数库相对于应用层来说相当少。在应用层开发,调试修改相对要容易地多。
    不利的因素也有:
    性能影响,在应用层工作,改变了工作模式,每当驱动程序截到数据,送到应用层处理后再次送回内核,再向上传递到IP协议。因此,对CPU性能影响非常大,效率非常低。
    综合来看,在特定的场合应用还是比较适合的:
    台式机上使用,台式机的网络负载相当小,不到100Mbps足以满足要求,尤其是主要用于上网等环境,网络连接的流量不到512Kbps,根本不用考虑性能因素。作为单机防火墙或其他一些协议实现,分析等很容易基于这种方式实现。

方案

模型


    上图描述了应用层截包的模型,主要的流程如下:

接收报文过程:
1.    网络接口收到报文,中间层截取,通过2送到应用层处理;
2.    应用层处理后,送回中间层处理结果;
3.    中间层根据处理结果,丢弃该报文,或者将处理后的报文通过1送到IP协议;
4.    IP协议及上层应用接收到报文;

发送报文过程:
1.    上层应用发送数据,从而IP协议发送报文;
2.    报文被中间层截取,通过2送到应用层处理;
3.    应用层处理后,送回中间层处理结果;
4.    中间层根据处理结果,丢弃该报文,或者将处理后的报文发送到网络上;

实现细节探讨

IO与通讯

有一个很容易的方式,在驱动程序和应用程序之间共享一个事件。
在应用程序CreateFile的时候,驱动程序IoCreateSynchronizationEvent一个有名的事件,然后应用程序CreateEvent/OpenEvent此有名事件即可。
注意点:
1,    不要在驱动初始化的时候创建事件,此时大多不能成功创建。在2003的DDK中已经有这方面的原因的解释,由于这时候Win32还没有初始化,/BaseNamedObjects/还没有创建。
2,    让驱动先创建,那么此后应用程序打开时,只能读(Waitxxxx),不能写(SetEvent/ResetEvent)。反之,如果应用程序先创建,则应用程序和驱动程序都有读写权限;
3,    用名字比较理想,注意驱动中名字在/BaseNamedObjects/下,例如应用程序用“xxxEvent”,那么驱动中就是“/BaseNamedObjects/xxxEvent”;
4,    用HANDLE的方式也可以,但是在WIN98下是否可行,未知。
5,    此后,驱动对读请求应立即返回,否则就返回失败。不然将失去用事件通知的意义(不再等待读完成,而是有需要(通知事件)时才会读);
6,    应用程序发现有事件,应该在一个循环中读取,直到读取失败,表明没有数据可读;否则会漏掉后续数据,而没有及时读取;

效率更好的使用方式,应该考虑“电平触发”的事件方式,只要内核有报文,事件就一直保持有信号,一直到内核中报文被取光,事件才处于无信号状态。

处理线程优先级

    应用层处理线程应该提高优先级,因为该线程为其他上层应用程序服务,如果优先级比其他线程优先级低的话,将会发生类似死锁的等待状态。
    另外,提高优先级的时候必须注意,线程尽量缩短运行时间,不要长期占用CPU,否则其他线程无法得到服务。优先级不必提高到REALTIME_PRIORITY_CLASS级,此时线程不能做一些磁盘IO之类的操作,而且也影响到鼠标、键盘等工作。
    驱动程序也可以动态地提高线程的优先级。

缓存

    在驱动程序接收到报文后,至少应该有一个缓冲以便临时存储,等待应用层处理。缓冲不必很大,只要能在应用层得到时间片之前缓冲区不溢出就可以了,实践中大约能存储几十个报文就够了。    
应用层和驱动程序的通信
在网卡接收/IP发送过程中,驱动程序缓存报文,用事件通知应用层有报文需要处理。那么应用层可以通过IO方式或者共享内存方式取得此报文;应用层处理完毕,也可以使用以上两种方式之一来向驱动程序递交结果。
实践说明,在100Mbps速率下,以上两种方式都可以满足需要,最为简便的方式就是使用有缓冲的IO方式。IO方式对性能的影响是显而易见的,每一个报文的收发都有额外的IO通讯开销。这时候,使用共享内存方式,因为减少了系统调用的开销,可以避免CPU性能下降。

报文发送的速度控制

    当IP协议发送报文的时候,一般来说,我们的中间层驱动必须把这些报文缓存起来,告诉IP软件发送成功,然后让应用层处理完毕之后再做决定。显然,存储报文的速度远远超过网卡能够发送的速度,然而IP软件(特别是UDP)将以我们存储的速度发送报文。造成缓存迅速耗尽。后续的报文只好丢弃。这样一来,UDP发送将不能正常工作。TCP由于可以自行适应网络状况,依然可以在这种情况下工作,速度在100Mbps网络的70%左右。在Passthru里,可以转发至低层驱动,然后用异步或同步方式返回,从而达到网卡的发送速度一致。
    因此,必须有一个办法避免这种状况。中间层驱动把这些报文缓存起来,告诉IP软件发送状态未决(Pending)。等到最后处理完毕,告诉IP软件发送完成。从而协调了发送速度。这种方式带来一个问题,就是驱动程序必须在发送超时的情况下放弃对这些缓冲报文的所有权。具体来说,就是MiniportReset被调用的时候,就有可能是NDIS察觉到发送超时,从而放弃所有未完成的发送操作,如果没有正确处理这种情况,将会导致严重问题。如果中间层在Miniport初始化的时候通过调用NdisMSetAttributesEx函数设置了NDIS_ATTRIBUTE_IGNORE_PACKET_TIMEOUT标志,那么中间层驱动程序将不会得到报文超时通知,中间层必须自行处理缓存的报文。

与Passthru协同工作

    当上层应用不再需要截包时,驱动程序应该完全是Passthru行为。这就要求所有发送/接收函数应该正确处理在截包与非截包状态,不至于做出危害行为。
    具体来说,在从NIC上接收/发送,向IP协议提交数据包/接受IP协议发送四个方向上正确处理所有接收/发送函数。

其它辅助设施

    添加一些控制功能提供更细粒度的控制,让应用程序获得更多的自由。比如,可以控制截取哪一个网卡,可以控制截取某个方向上的流量,网络是否有变化(网卡卸载/Disable)等等。

两次实现的比较

    两次实现选择Passthru源代码,在其上进行修改,主要修改包括:
1.    修改接收函数
2.    修改发送函数
3.    增加报文缓存
4.    增加IO部分
5.    增加控制功能
6.    增加应用层处理后的后续处理

两次实现主要的区别在于应用层和驱动程序通讯方式的不同。第一次实现使用了共享内存方式,具有一个处理前缓冲池和一个应用程序处理后的缓冲池。由于接收报文和待发送报文使用同一个缓冲池,也因为其他一些原因,这个实现的发送效率并没有比用IO方式快多少。
第二次实现采用了IO通讯方式,并且改良了缓存方式,获得相对较好的网络性能,不会导致网络性能的下降。但是,显而易见的,对CPU的性能影响非常显著。

“电平触发”的共享事件

    第二次实现采用了“电平触发”的事件方式,只要内核有报文,事件就一直保持有信号,一直到内核中报文都被取走,事件才处于无信号状态。
实现上,用IoCreateNotificationEvent来创建一个“人工重置”的事件。每次有报文进入缓存时,将事件设置为有信号状态;每次应用程序来取报文时,拷贝报文后返回应用程序之前,如果缓冲区已经没有报文,就将事件设置为无信号状态。

缓存策略

    在驱动程序接收到报文后,至少应该有一个缓冲以便临时存储,等待应用层处理。缓冲不必很大,只要能在应用层得到时间片之前缓冲区不溢出就可以了,实践中大约能存储几十个报文就够了。
    缓存的方式有几种可选的方式。最简单的就是每次分配一块内存,拷贝报文,然后将此内存放入一个先进先出的队列,等待应用程序来取走。
    可以用后备列表(Lookaside List)来改良这种方式,不过后备列表每次分配的内存都是固定尺寸的,只能用最大报文尺寸来初始化后备列表。考虑到网络平均报文大约只有200字节,远远低于以太网最大报文1514字节,会有一些浪费。不过一般最多存储也就几十个报文,100Mbps网络下,十几个报文存储量就可以了。因此这种方式是最简单可靠易于实现的,是首选的缓存方式。
    复杂一点的可以事先分配一大块内存,自己管理内存的使用。比如采用环状的缓冲区。实现为静态存储的环形队列,也就是说,不必每次分配内存,而是一次性分配好一大块内存,环形的使用。考虑简单起见,可以让每一块内存的尺寸大小相同,这样使用数组方式就可以管理了。为节约内存,可以让每一块内存的尺寸大小可变,会更加复杂。
    第二次实现使用了简单的缓存策略,依次拷贝报文到缓冲区,应用程序一次全部取走。每个报文前面加上长度,以便找出下一个报文的起始位置。

一次IO取多个报文

    为改善IO效率,在一次IO中可以携带多个报文。实际的测试表明,100Mbps的网络情况下,TTCP测试,平均每1次IOCTL可以携带1.1个报文,在10Mbps网络上,为1.05个。可见这个方式并没有多大作用。

测试数据

    以下数据都是在PIII 1G CPU,256MB内存,用PCATTCP测试。

1,对网络性能的影响
由于很少因为缓冲区溢出而丢包,所以不会引起网络性能恶化。

2,对CPU性能的影响
不使用应用层截包驱动程序,TTCP测试,100Mbps网络,CPU占用率在30%;使用了截包驱动程序后,CPU占用率上升到85%!截包驱动程序占用了55%。可见IO方式对CPU性能影响非常大。
10Mbps网络上,不使用截包驱动程序,CPU占用率在17%;使用截包驱动程序后,CPU占用率上升到30%。截包驱动程序占用了13%。
可以认为,在一个普通的上网环境下,比如512Kbps~2Mbps的宽带,对CPU的影响不大。截包驱动程序占用率估计小于10%。

API说明

第三方开发使用cap.h头文件,capdll.dll包含了下列函数:

BOOL  CapInitialize();
VOID  CapUninitialize();

BOOL  CapStartCapture(PKTPROC PacketProc, ADAPTERS_CHANGE_CALLBACK AdaptChangeProc);
VOID  CapStopCapture();

DWORD CapGetAdapterList(PADAPTER_INFO pAdaptInfo, DWORD BufferSize);
VOID  CapSetRule(HANDLE Adapter, ULONG Rule);
BOOL  CapGetStatInfo(PCAPTURE_STAT pCaptureStat);
BOOL  CapClearStatInfo();

BOOL  CapSendPacket(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data);
BOOL  CapQueuePacket(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data);
BOOL  CapSendAllQueuePackets();

同时提供了该dll的capdll.lib文件以便在vc工程文件中引入capdll.lib使用更为方便的编译连接方式。

说明

所有函数的返回值都没有指明错误原因。在 capdll.dll所在的目录下的capture.txt有少量的输出信息。

BOOL CapInitialize();
说明:
通知截报中间层驱动做一些必要的初始化工作。
参数:
无。
返回值:
失败返回FALSE。

VOID CapUninitialize();
说明:
释放驱动程序创建的事件,线程,内存等。
参数:
无。
返回值:
无。
注意:
    在调用此函数之前,应当调用CapSetRule将驱动程序截报规则设置成Opcode_PASSTHRU,以便恢复PASSTHRU行为。

BOOL  CapStartCapture(PKTPROC PacketProc, ADAPTERS_CHANGE_CALLBACK AdaptChangeProc);
说明:
启动截报。Capdll将会创建一个线程,运行在THREAD_PRIORITY_HIGHEST优先级,并等待网络事件,当有驱动程序接收到报文,或者IP协议发送报文,或者发现网卡启动/禁用/插入/拔除等,将会通过用户提供的回调函数通知用户。
参数:
    PacketProc:用户提供的报文处理函数;
    AdaptChangeProc:用户提供的网络变化通知函数;
返回值:

VOID CapStopCapture();
说明:
    停止截报。销毁创建的线程。
参数:
    无。
返回值:
    无。

DWORD CapGetAdapterList(PADAPTER_INFO pAdaptInfo, DWORD BufferSize);
说明:
    获取网络适配卡列表。
参数:
    pAdaptInfo ADAPT_INFO结构数组,用户应该提供足够的空间。
    BufferSize 缓冲区尺寸。
返回值:
    网络适配卡数目。

VOID CapSetRule(HANDLE Adapter, ULONG Rule);
说明:
    设置截报规则。
参数:
    Adapter:指定截取的网卡句柄。
    Rule:为Opcode_PASSTHRU:PASSTHRU行为;Opcode_SND:截取所有发送报文;Opcode_RCV:截取所有接收报文。可以使用Opcode_SND | Opcode_RCV表示截取所有报文。
返回值:
    无。

BOOL  CapGetStatInfo(PCAPTURE_STAT pCaptureStat);
说明:
    获得性能统计数值。
参数:
    pCaptureStat: 指向CAPTURE_STAT结构的指针。
返回值:
    成功返回TRUE,失败返回FALSE。

BOOL  CapClearStatInfo();
说明:
    将性能统计数值清零。
参数:
    无。
返回值:
    成功返回TRUE,失败返回FALSE。

BOOL CapSendPacket(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data);
说明:
    发送一个报文。也可以自行构造报文。不仅可以发送报文,也可以将报文送给本机IP软件。
参数:
    Adapter:指定使用的网卡句柄。
    Opcode:Opcode_SND,将报文发送到网络上;Opcode_RCV,将报文传递给本机软件。
    Length:报文长度;
    Data:报文内容;
返回值:
    成功返回TRUE,失败返回FALSE。

BOOL  CapQueuePacket(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data);
说明:
    将处理后的报文放入缓冲区。也可以自行构造报文。不仅可以发送报文,也可以将报文送给本机IP软件。
参数:
    Adapter:指定使用的网卡句柄。
    Opcode:Opcode_SND,将报文发送到网络上;Opcode_RCV,将报文传递给本机软件。
    Length:报文长度;
    Data:报文内容;
返回值:
    成功返回TRUE,失败返回FALSE,失败表示缓冲区空间不足,应该调用CapSendAllQueuePackets将缓冲区中的报文发出去。

BOOL  CapSendAllQueuePackets();
说明:
    将缓冲区中的报文发送出去。
参数:
    Adapter:指定使用的网卡句柄。
    Opcode:Opcode_SND,将报文发送到网络上;Opcode_RCV,将报文传递给本机软件。
    Length:报文长度;
    Data:报文内容;
返回值:
    成功返回TRUE,失败返回FALSE。

Sample

#include "cap.h"
#include <stdio.h>


/************************************************************************/
/* Global data.                                                         */
/************************************************************************/

ADAPTER_INFO AdapterInfo[APAPTS_MAX_NUM];
int AdapterNum;

CAPTURE_STAT LastStatInfo;
DWORD LastStatTime;


/************************************************************************/
/* Callback process functions.                                          */
/************************************************************************/

VOID PacketProc(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data)
{
    CapQueuePacket(Adapter, Opcode, Length, Data);
}

VOID AdaptersChangeProc()
{
    AdapterNum = CapGetAdapterList(AdapterInfo, sizeof(AdapterInfo));
}


/************************************************************************/
/* Print stat info, help to analyze performance.                        */
/************************************************************************/

VOID Stat()
{
    CAPTURE_STAT StatInfo;
    DWORD StatTime;

    CapGetStatInfo(&StatInfo);
    StatTime = GetTickCount();

    printf("Stat information list:/n");

    printf("%d/tTotal error while queue sended packets/n",
        StatInfo.QueueSendPacketErrorStat);

    printf("%d/tTotal error while queue recved packets/n",
        StatInfo.QueueRecvPacketErrorStat);

    printf("%d/tTotal success while sending packets/n",
        StatInfo.SendSuccessStat);

    printf("%d/tTotal success while indicating packets/n",
        StatInfo.IndicateSuccessStat);

    printf("%d/tTotal packets number in send queue/n",
        StatInfo.SendQueuePacketsStat);

    printf("%d/tTotal packets number in recv queue/n",
        StatInfo.RecvQueuePacketsStat);
    
    printf("%d/tTotal IOCTLs of send/indicate packets/n",
        StatInfo.SendIndicateStat);
    
    printf("%d/tTotal IOCTLs of retrieve packets/n",
        StatInfo.RetrieveStat);

    printf("%d/tPackets number queue sent packets per second/n",
        (StatInfo.SendQueuePacketsStat - LastStatInfo.SendQueuePacketsStat)
        * 1000
        / (StatTime - LastStatTime));
    
    printf("%d/tPackets number queue recved packets per second/n",
        (StatInfo.RecvQueuePacketsStat - LastStatInfo.RecvQueuePacketsStat)
        * 1000
        / (StatTime - LastStatTime));

    printf("%d/tPackets number send/indicate packets per 100 IOCTLs/n",
        ((StatInfo.RecvQueuePacketsStat - LastStatInfo.RecvQueuePacketsStat)
        + (StatInfo.SendQueuePacketsStat - LastStatInfo.SendQueuePacketsStat))
        * 100
        / (StatInfo.SendIndicateStat - LastStatInfo.SendIndicateStat +1));

    printf("%d/tPackets number retrieve packets per 100 IOCTLs/n",
        ((StatInfo.RecvQueuePacketsStat - LastStatInfo.RecvQueuePacketsStat)
        + (StatInfo.SendQueuePacketsStat - LastStatInfo.SendQueuePacketsStat))
        * 100
        / (StatInfo.RetrieveStat - LastStatInfo.RetrieveStat +1));

    printf("%d/tMax packet number in recv queue/n", StatInfo.MaxPacketNumInRecvQueue);
    printf("%d/tMax queue size in recv queue/n", StatInfo.MaxRecvQueueSize);
    printf("/n/n");
    
    memcpy(&LastStatInfo, &StatInfo, sizeof(CAPTURE_STAT));
    LastStatTime = StatTime;
}

/************************************************************************/
/* An example like passthru with user capture.                          */
/************************************************************************/

int main(int argc, char* argv[])
{
    BOOL bRet;
    char cmd[80];
    int i;

    bRet = CapInitialize();
    if(bRet)
    {
        CapClearStatInfo();
        CapGetStatInfo(&LastStatInfo);
        LastStatTime = GetTickCount();

        AdapterNum = CapGetAdapterList(AdapterInfo, sizeof(AdapterInfo));
        
        for(i=0; i<AdapterNum; i++)
        {
            CapSetRule(AdapterInfo[i].Adapter, Opcode_SND | Opcode_RCV);
        }

        CapStartCapture(PacketProc, AdaptersChangeProc);

        for(;;)
        {
            gets(cmd);
            if(stricmp(cmd, "quit")==0)
                break;

            else if(stricmp(cmd, "stat")==0)
                Stat();
        }

        for(i=0; i<AdapterNum; i++)
        {
            CapSetRule(AdapterInfo[i].Adapter, Opcode_PASSTHRU);
        }

        CapStopCapture();
        CapUninitialize();
    }

    return 0;
}


应用举例

    上述代码做了一个Passthru行为。
    作网桥或者NAT,需要在报文处理函数里,将报文内容根据需要修改以太网头部或其他行为,然后从合适的另一块网卡上发出去;
    作协议转换,比如IP/UDP隧道或者复杂如IPSEC之类,可以在报文处理函数里将报文内容解开隧道或者解密,重新组报文,放入缓冲区,让驱动程序送到IP软件;
作防火墙,根据规则,丢弃不受欢迎的报文,正常的报文同样PASSTHRU;
作入侵监测/安全审计(当然只能保护本机),PASSTHRU同时纪录网络事件;

正文完

附件:


  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值