关键字:截包 应用层 中间层 NDIS Windows
作者:Fang(fangguicheng@21cn.com)
为什么要在应用层截包
引言
截包的需求一般来自于过滤、转换协议、截取报文分析等。
过滤型的应用比较多,典型为包过滤型防火墙。
转换协议的应用局限于一些特定环境。比如第三方开发网络协议软件,不能够与原有操作系统软件融合,只好采取“嵌入协议栈的块”(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协议。因此,性能影响非常大,效率非常低,在100Mbps网络上,只有80%的性能表现。
综合来看,在特定的场合应用还是比较适合的:
台式机上使用,台式机的网络负载相当小,不到100Mbps足以满足要求,尤其是主要用于上网等环境,网络连接的流量不到512Kbps,根本不用考虑性能因素。作为单机防火墙或其他一些协议实现,分析等很容易基于这种方式实现。
方案
模型
上图描述了应用层截包的模型,主要的流程如下:
接收报文过程:
1. 网络接口收到报文,中间层截取,通过2送到应用层处理;
2. 应用层处理后,送回中间层处理结果;
3. 中间层根据处理结果,丢弃该报文,或者将处理后的报文通过1送到IP协议;
4. IP协议及上层应用接收到报文;
发送报文过程:
1. 上层应用发送数据,从而IP协议发送报文;
2. 报文被中间层截取,通过2送到应用层处理;
3. 应用层处理后,送回中间层处理结果;
4. 中间层根据处理结果,丢弃该报文,或者将处理后的报文发送到网络上;
实现细节探讨
IO与通讯
有一个很容易的方式,在驱动程序和应用程序之间用一个事件。
在应用程序CreateFile的时候,驱动程序IoCreateSynchronizationEvent一个有名的事件,然后应用程序CreateEvent/OpenEvent此有名事件即可。
注意点:
1, 不要在驱动初始化的时候创建事件,此时大多不能成功创建;
2, 让驱动先创建,那么此后应用程序打开时,只能读(Waitxxxx),不能写(SetEvent/ResetEvent)。反之,如果应用程序先创建,则应用程序和驱动程序都有读写权限;
3, 用名字比较理想,注意驱动中名字在\BaseNamedObjects\下,例如应用程序用“xxxEvent”,那么驱动中就是“\BaseNamedObjects\xxxEvent”;
4, 用HANDLE的方式也可以,但是在WIN98下是否可行,未知。
5, 此后,驱动对读请求应立即返回,否则就返回失败。不然将失去用事件通知的意义(不再等待读完成,而是有需要(通知事件)时才会读);
6, 应用程序发现有事件,应该在一个循环中读取,直到读取失败,表明没有数据可读;否则会漏掉后续数据,而没有及时读取;
处理线程优先级
应用层处理线程应该提高优先级,因为该线程为其他上层应用程序服务,如果优先级比其他线程优先级低的话,将会发生类似死锁的等待状态。
另外,提高优先级的时候必须注意,线程尽量缩短运行时间,不要长期占用CPU,否则其他线程无法得到服务。优先级不必提高到REALTIME_PRIORITY_CLASS级,此时线程不能做一些磁盘IO之类的操作,而且也影响到鼠标、键盘等工作。
驱动程序也可以动态地提高线程的优先级。
缓存
在驱动程序接收到报文后,至少应该有一个缓冲以便临时存储,等待应用层处理。缓冲不必很大,只要能在应用层得到时间片之前缓冲区不溢出就可以了,实践中大约能存储几十个报文就够了。
缓冲的使用方式,是一个先进先出的队列。考虑方便实现为静态存储的环形队列,也就是说,不必每次分配内存,而是一次性分配好一大块内存,环形的使用。
初始,head==tail==0;
tail和head都是无限增长的。
Tail – head <= size;
放入一个报文时, tail=tail + packetlen;
取出一个报文时,head=head + packetlen;
tail== head表明空;
tail>head表明有数据;
tail + input packet length - head >size表明满;
取数据时:
ppacket GetPacket()
{
ASSERT(tail>=head);
if(tail==head)
return NULL;
//else
ppacket = &start[head % SIZE];
if(head % size + ppacket->length > size )
//数据不连续(一部分在尾部,一部分在头部);
else
//数据是连续的
return ppacket;
}
放入数据:
bool InputPacket(ppacket)
{
if(tail + input packet length - head >size) //满
return false;
//copy packet to &start[tail % SIZE]
//if(tail % SIZE + packet length > SIZE)
//数据不连续(一部分在尾部,一部分在头部);
//else
//数据是连续的
tail = tail + packet length;
return true;
}
上面这种方式采用数组的方式组织,为每个报文提供一个最大报文长度的空间。因为缓冲区数目有限,因此这种方式可以满足需要。如果要考虑到减少空间的浪费,那么可以按每个报文的实际长度存储,上面的算法不能够适应这种方式。
应用层和驱动程序的通信
在网卡接收/IP发送过程中,驱动程序缓存报文,用事件通知应用层有报文需要处理。那么应用层可以通过IO方式或者共享内存方式取得此报文。
实践说明,在100Mbps速率下,以上两种方式都可以满足需要,最为简便的方式就是使用有缓冲的IO方式。
应用层处理完毕,也可以使用以上两种方式之一来向驱动程序递交结果。不过,IO方式因为一次只能发送一个报文,100Mbps网络速度下降为70%~80%网络速度,10Mbps不会有影响。也就是说,主机发出的最大速度只有70%的网络速度,这和应用程序发送不超过MTU的UDP数据报的速度是一样的。对TCP来说,由于是双向通信,损失更加大一些,大约40%~60%速度。
这时候,使用共享内存方式,因为减少了系统调用的开销,可以避免速度下降。
报文发送的速度控制
当IP协议发送报文的时候,一般来说,我们的中间层驱动必须把这些报文缓存起来,告诉IP软件发送成功,然后让应用层处理完毕之后再做决定。显然,存储报文的速度远远超过网卡能够发送的速度,然而IP软件(特别是UDP)将以我们存储的速度发送报文。造成缓存迅速耗尽。后续的报文只好丢弃。这样一来,UDP发送将不能正常工作。TCP由于可以自行适应网络状况,依然可以在这种情况下工作,速度在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方式快多少。
通过精心的设计和比较,完全可以做到100Mbps的收发速度。
这份文章旨在讨论这种应用层截报的工作方式和可行性。也由于驱动程序源代码并没有经过特别严格的测试,不适合商业使用,作为示范,也仅仅对以太网类型的报文进行了拦截。因此示范的驱动程序将不包含源代码。
API说明
第三方开发使用cap.h头文件,capdll.dll包含了下列函数:
BOOL CapInitialize();
VOID CapUninitialize();
BOOL CapStartCapture(PKTPROC PacketProc, ADAPTERS_CHANGE_CALLBACK AdaptChange);
VOID CapStopCapture();
DWORD CapGetAdaptList(PADAPT_INFO pAdaptInfo, DWORD BufferSize);
VOID CapSetRule(HANDLE Adapter, ULONG Rule);
BOOL CapSendPacket(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data);
同时提供了该dll的capdll.lib文件以便在vc工程文件中引入capdll.lib使用更为方便的编译连接方式。
说明
所有函数的返回值都没有指明错误原因。DEBUG版本可以在控制台打印出运行信息,并且在C:\ capture.txt有同样的输出信息。
BOOL CapInitialize();
说明:
通知截报中间层驱动做一些必要的初始化工作。
参数:
无。
返回值:
失败返回FALSE。
VOID CapUninitialize();
说明:
释放驱动程序创建的事件,线程,内存等。
参数:
无。
返回值:
无。
注意:
在调用此函数之前,应当调用CapSetRule将驱动程序截报规则设置成Opcode_PASSTHRU,以便恢复PASSTHRU行为。
BOOL CapStartCapture(PKTPROC PacketProc, ADAPTERS_CHANGE_CALLBACK AdaptChange); 说明:
启动截报。Capdll将会创建一个线程,运行在THREAD_PRIORITY_HIGHEST优先级,并等待网络事件,当有驱动程序接收到报文,或者IP协议发送报文,或者发现网卡启动/禁用/插入/拔除等,将会通过用户提供的回调函数通知用户。
参数:
PacketProc:用户提供的报文处理函数;
AdaptChange:用户提供的网络变化通知函数;
返回值:
VOID CapStopCapture();
说明:
停止截报。销毁创建的线程。
参数:
无。
返回值:
无。
DWORD CapGetAdaptList(PADAPT_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 CapSendPacket(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data);
说明:
将处理后的报文放入缓冲区。也可以自行构造报文。不仅可以发送报文,也可以将报文送给本机IP软件。
参数:
Adapter:指定使用的网卡句柄。
Opcode:Opcode_SND,将报文发送到网络上;Opcode_RCV,将报文传递给本机软件。
Length:报文长度;
Data:报文内容;
返回值:
成功返回TRUE,失败返回FALSE。
Sample
#include "cap.h"
#include <stdio.h>
// global data.
ADAPT_INFO AdaptInfo[16];
int AdapterNum;
VOID PacketProc(HANDLE Adapter, ULONG Opcode, ULONG Length, PUCHAR Data)
{
CapSendPacket(Adapter, Opcode, Length, Data);
}
VOID AdaptersChangeCallback()
{
AdapterNum = CapGetAdaptList(AdaptInfo, sizeof(AdaptInfo));
}
int main(int argc, char* argv[])
{
BOOL bRet;
char cmd[80];
int i;
bRet = CapInitialize();
if(bRet)
{
AdapterNum = CapGetAdaptList(AdaptInfo, sizeof(AdaptInfo));
for(i=0; i<AdapterNum; i++)
{
CapSetRule(AdaptInfo[i].Adapter, Opcode_SND | Opcode_RCV);
}
CapStartCapture(PacketProc, AdaptersChangeCallback);
for(;;)
{
gets(cmd);
if(strcmp(cmd, "quit")==0)
{
break;
}
}
for(i=0; i<AdapterNum; i++)
{
CapSetRule(AdaptInfo[i].Adapter, Opcode_PASSTHRU);
}
CapStopCapture();
CapUninitialize();
}
return 0;
}
应用举例
上述代码做了一个Passthru行为。
作网桥或者NAT,需要在报文处理函数里,将报文内容根据需要修改以太网头部或其他行为,然后从合适的另一块网卡上发出去;
作协议转换,比如IP/UDP隧道或者复杂如IPSEC之类,可以在报文处理函数里将报文内容解开隧道或者解密,重新组报文,放入缓冲区,让驱动程序送到IP软件;
作防火墙,根据规则,丢弃不受欢迎的报文,正常的报文同样PASSTHRU;
作入侵监测/安全审计(当然只能保护本机),PASSTHRU同时纪录网络事件;