简介:面向C语言与网络编程学习者的VC++网络抓包程序源码包,属于经典sniffer嗅探器实现,适合希望理解网卡数据包捕获原理、NDIS驱动接口及MFC界面开发的读者。压缩包共44个文件,其中16个.h头文件负责数据结构、协议常量和接口声明,8个.cpp文件承载对话框、视图及抓包逻辑;另有dsw/dsp工程配置,bmp/ico图形资源,Packet32的def、dll、vxd等底层支持文件,readme说明以及Release版exe,整体仅78KB,目录清晰。已有780人学习下载。源码包含GetPacket与Packet32两大模块:前者提供MFC界面、列表展示和过滤条件,后者封装对网卡驱动的访问,二者结合可完整梳理从收包、过滤到协议字段解析的流程;代码中使用Packet32库与NDIS接口,对理解Windows下网络数据包捕获原理很有帮助。附带的exe可直接运行观察效果,也能在VC++环境重新编译调试,适合网络编程入门、课程设计甚至毕业设计作为参考。
1. 网络抓包不是玄学:从VC++ Sniffer源码看穿网卡流量
下班前最怕运维同事甩来一句话:“内网怎么突然卡了,你看下是不是哪台机器在狂发包。”这时候手边有个能抓包的工具比什么都管用。VC++ Sniffer这类程序的价值就在这:它把一个网卡上“看不见的流量”变成屏幕上一个一个可读的帧。这份源代码包是典型的Visual C++ 6.0时代作品,前半部分是Packet32驱动源码,后半部分是一个MFC单文档抓包程序,Release目录里还留着编好的GetPacket.exe。对想搞懂网络抓包原理、或者需要在自己程序里嵌入抓包能力的开发者来说,它比装一个现成抓包库更有学习价值,因为你从网卡驱动到应用层解析,一整条链路都在源码里摆着,想改哪一层都能改。
2. 抓包原理与工程骨架:Packet32驱动、MFC界面与源码目录怎么配合
2.1 抓包为什么难在“Adapter”:从网卡到应用层的两次搬运
标准的Socket接口只能收自己进程的包,想让网卡把所有经过的帧都交给你,就必须借道网卡驱动。常见做法是装一套NDIS中间层驱动,网卡驱动把每一帧复制一份给中间层,中间层再通过设备IO把数据搬到用户态。这套源码里的Packet32就是干这件事的。它在Windows 9x下对应zpacket.vxd,在NT系列下对应Packet32.dll加底层驱动。你看到的PACKOFF.H和PACKON.H这两个文件,是打包pragma,用来控制驱动与用户态通信时的结构体对齐,防止两边内存布局对不上。刚读驱动代码的人容易晕,其实只要记住一条主线:网卡驱动、NDIS、设备控制,这三层把原始帧送进你的应用。
在这个工程里,GetPacket.exe是MFC程序,Packet32.dll是驱动在用户态的接口库,zpacket.vxd是Windows 95/98时代的内核虚拟设备驱动。整套代码今天拿来编译、学习、改造成自己的工具都行,但直接拿到Win10/11上跑就得看驱动兼容性,这个到第4章再展开。理解分层是第一步:用户态程序调用Packet32.dll提供的函数,DLL再通过DeviceIoControl这类接口访问内核驱动,驱动把网卡上的包拷到缓冲区。所以抓包程序的性能瓶颈往往不在解析代码,而在这一条搬运链上能不能把缓冲区开得足够大。
2.2 源码目录拆解:哪些文件决定程序能不能跑
我拿到这种源码包,不会急着先编译整个解决方案,而是先把文件角色理一遍。这个包里的文件看起来很多,实际归成四类就清楚了:
| 文件/目录 | 角色 | 建议关注点 |
|---|---|---|
| GetPacket.dsw / GetPacket.dsp | VC++ 6.0工作区与项目文件 | 导入工程时的入口 |
| GetPacket.cpp / GetPacketDoc.cpp / GetPacketView.cpp | MFC单文档程序骨架 | 界面与数据模型怎么配合 |
| Packet32.c / Packet32.dll / Packet32.def | 用户态抓包库 | 核心调用链,抓包的主战场 |
| Packet32.dsp / Packet32.dsw | Packet32库的独立工程 | 单独编库时用 |
| PACKOFF.H / PACKON.H / DEVIOCTL.H / NTDDNDIS.H | 内核通信与对齐控制 | 结构体布局的关键 |
| zpacket.vxd | Windows 9x内核驱动 | 现代系统用不上,但值得读实现 |
| ipaddr.h / ipaddr.cpp | IP地址辅助工具 | 地址转换、掩码判断 |
| protocol.h | 协议头常量与结构定义 | 解析层的入口 |
| FileterDlg.cpp / FileterDlg.h | 抓包过滤对话框 | 注意拼写是Fileter不是Filter |
| res / toolbar1.bmp / Toolbar.bmp | 界面资源 | 图标、工具栏、版本信息 |
我一般会先把Packet32子工程单独编一次,确认驱动库能出DLL,再回头编上层的GetPacket工程。因为这个工程的运行顺序比较讲究:GetPacket.exe启动时需要Packet32.dll在PATH里能找到。你只编GetPacket子工程、不编Packet32,跑起来就会报“找不到Packet32.dll”。Release目录里虽然带了现成的动态库,但你自己改过代码之后,就得两个工程一起编,DLL输出目录也得设到exe旁边,不然新改的逻辑根本不生效。
这里还有一个考古细节:FileterDlg这个类名,是当年作者敲代码时把Filter拼成了Fileter。这种非故意错误在老项目里特别常见。你要是直接拿Filter去搜资料,什么都搜不到;拿文件名FileterDlg去搜,反而能找到同类问题。以后接手任何老代码,遇到类名、文件名拼写可疑的,先按“原样搜索”而不是“按正确单词搜索”,能少走很多弯路。
2.3 编译环境选型:VC++ 6.0工程在VS2008与新版系统下的生存办法
这个工程的.dsp/.dsw是VC++ 6.0的工程格式,自带的老参数跟今天的开发环境差异很大。我实测过的路径有两条:
第一,装Visual Studio 2008(VC++ 9.0)转换工程。VS2008可以直接打开VC6的.dsw,转换向导会生成.sln和.vcproj,代码一般不用大改。不过有两个麻烦:一是MFC头文件的包含路径不同,二是VC6里默认允许的某些隐式转换在VC9里会变成警告甚至错误,比如把WORD直接塞进BYTE变量。所以转换完不能直接按F7,先看输出窗里有几条错误,基本集中在隐式缩窄上。
第二,用更新的Visual Studio 2022打开。转换向导也能走完,但风险更高。这个年代久远的工程里,对库目录的写法和现在差异很大,过时API报错会一批批来。我一般把这个源码包当“读码、学习、提取思路”的对象在VS2022里打开,真要“编译、改动、跑起来”,放VS2008或者装个带VC6的虚拟机更稳。你如果现在用的是Win10/11 64位系统,编译成功之后还会撞上驱动签名的问题——这个放到第4章细说。
具体步骤参考这个顺序:
1. VS2008菜单“文件 → 打开 → 项目/解决方案”,选中GetPacket.dsw 2. 转换向导出现后,保持默认,生成解决方案文件 3. 在解决方案资源管理器里右键Packet32工程,选择“生成” 4. Packet32生成成功后,再右键GetPacket工程,选择“生成” 5. 把生成的Packet32.dll复制到GetPacket.exe输出目录这个顺序其实是所有“一个解决方案里有库工程和应用工程”的老项目共同的规矩:先编依赖、后编入口,动态库必须让应用能找到。很多人栽在第五步,DLL编好了但没复制过去,exe一启动就报缺库。还有一点容易被忽略:Packet32工程里带着Packet32.mak和Packet32.plg,前者是NMake格式的旧式工程描述,后者是VC6编译日志。在VS2008里打开Packet32.dsw时,向导会同时看到.dsp和.mak两套构建描述,选“转换.dsp”即可,两套都转的话生成配置会互相覆盖。
3. 核心代码走读:从打开适配器到IP头、协议头解析的完整链路
3.1 Packet32.dll的调用链:打开适配器、设置缓冲区与循环接收
Packet32的用户态接口,核心调用链可以压缩成四步:打开适配器、设置缓冲区、设置过滤、进入接收循环。以Packet32公开接口的风格为例:
// 1. 打开网络适配器,返回一个句柄 LPADAPTER lpAdapter = PacketOpenAdapter(adapterName); if (!lpAdapter) { // 常见失败原因:权限不足、驱动没装、适配器名写错 return -1; } // 2. 设置内核缓冲区大小,单位字节 // 抓大流量必须开大,抓小流量开太大反而浪费内存 PacketSetBuff(lpAdapter, 512 * 1024); // 3. 设置过滤掩码,NULL/0 表示接收所有帧 PacketSetFilter(lpAdapter, NULL, 0); // 4. 进入抓包循环 while (1) { LPPACKET lpPacket = PacketAllocatePacket(); if (PacketReceivePacket(lpAdapter, lpPacket) && lpPacket->ulUserBytes > 0) { // lpPacket->Buffer 里就是原始以太网帧 DispatchPacket(lpPacket->Buffer, lpPacket->ulUserBytes); } PacketFreePacket(lpPacket); }这里有几个参数值得说清楚。PacketOpenAdapter的第一个参数是适配器名,不是你控制面板里看到的“以太网”这种中文昵称,是Packet32在机器上生成的设备名。怎么拿到这个名字?常见做法是在程序启动时调用PacketGetAdapterNames,把返回的字符串列表解析进下拉框,这个源码包的GetPacket.cpp里应该有类似逻辑,那就是整个程序的入口。PacketSetBuff的第二个参数是内核缓冲区大小,抓大流量时开太小会丢包,开太大内存吃紧,512KB是抓内网流量的起跳值。PacketReceivePacket返回假不一定代表出错,超时也会返回假,所以循环里不要一收到假就退出,要看具体错误码。
这段循环还有一个细节:PacketAllocatePacket和PacketFreePacket在循环里反复调用,是为了每次接收都拿到独立的内存块。如果图省事只分配一次、反复复用同一个LPPACKET,下一帧数据会覆盖上一帧,你解析到一半的内容就是脏数据。老代码里这个坑藏得很深,因为界面看起来还在刷新,但列表里可能一半是坏帧。
3.2 protocol.h与ipaddr.h:从原始帧里抠出MAC、IP和端口
抓包程序里最难看的代码是字节序处理。protocol.h这个文件里一般会定义以太网头、IP头、TCP/UDP头的结构体。注意这些结构体里的字段默认是网络字节序,解析时要做ntohs转换。以经典定义为例:
// 以太网帧头,固定14字节 typedef struct _ETH_HEADER { BYTE DstMac[6]; // 目标MAC,数组顺序就是字节顺序 BYTE SrcMac[6]; // 源MAC WORD Proto; // 上层协议类型,0x0800表示IPv4 } ETH_HEADER; // IP头,最短20字节,长度看VerLen字段的低4位 typedef struct _IP_HEADER { BYTE VerLen; // 高4位版本,低4位首部长度/4 BYTE TOS; // 服务类型,一般不用管 WORD TotalLen; // 整个IP包总长度 WORD Ident; // 标识符 WORD FragOffset; // 分片偏移 BYTE TTL; // 生存时间 BYTE Protocol; // 上层协议,6=TCP,17=UDP WORD Checksum; // 头校验和 DWORD SrcIP; // 源IP,4字节 DWORD DstIP; // 目标IP } IP_HEADER;拿到Buffer之后,前14字节是ETH_HEADER,紧接着才是IP_HEADER。这里有几个老手都容易忘的坑。第一,以太网协议类型字段是网络字节序,判断是不是0x0800之前先ntohs一把,直接拿原始值比对会漏掉所有IPv4包。第二,IP头的真实长度要看VerLen的低4位再乘4,20字节只是起步值,遇到带选项的IP头你跳过头部的字节数就不同。第三,TCP头长度同样是一个可变字段,解析端口之前先算对偏移,否则拿到的全是错位数据。
ipaddr.h和ipaddr.cpp干的事,一般是把DWORD类型的IP转成“1.2.3.4”字符串,顺带判断掩码归属。抓包列表里每一帧都要显示源IP和目标IP,这个转换函数会被高频调用,实现质量直接影响界面刷新速度。有的工程用inet_ntoa一行解决,但这个函数内部用静态缓冲区,多线程下会互相覆盖。这个工程是MFC单文档结构,主线程处理一般没事,如果你以后扩展成“抓包线程+界面线程”分离,就别再调inet_ntoa,改成自己用格式化逐字节拼接,虽然多写几行,但线程安全。
3.3 过滤对话框与视图刷新:抓包结果如何在MFC里显示
FileterDlg.h和FileterDlg.cpp是过滤条件对话框。所谓过滤,在Packet32这套体系里分两层:第一层是PacketSetFilter传给驱动的内核过滤,基于BPF指令;第二层是应用层自己过滤,只把符合条件的帧显示出来。这个工程的类名是FileterDlg,重心一般在应用层过滤,因为BPF指令在代码里肉眼不好调,界面上勾选条件更直观。
应用层过滤的典型写法,伪代码如下:
// 过滤条件示例:只看目标端口是80的TCP包 if (ntohs(pTcpHeader->DstPort) == 80) { AddPacketToList(pEthHeader, pIpHeader, pTcpHeader); }说明一下这段的位置:DispatchPacket解析完协议头之后,先走过滤条件,通过才进ListView,不通过直接释放内存。过滤界面上如果提供协议类型下拉框,选TCP/UDP/ICMP,这些条件会拼成一个结构体,主窗口在DispatchPacket里逐帧判断。比起BPF内核过滤,应用层过滤调试容易、改条件不用重装驱动,代价是CPU占用高——高流量下每帧都做字符串和端口比较,压力不小。
抓包结果往GetPacketListView里放,这里有一个非常典型的性能陷阱。如果每一帧都调InsertItem,界面消息处理会被刷爆:一秒钟进来几千个包时,窗口基本是白的,你以为是程序卡死,其实是UI线程被重绘占满了。常见做法是抓包进队列,定时器每50毫秒集中刷新一次ListView。我自己的习惯是:抓包线程只负责往队列里塞指针,UI定时器负责弹队列、批量插入、最后统一Invalidate。这样抓包线程不会被界面拖慢,界面也不会因为包太多而假死。要做到这一步,GetPacketView.cpp里的刷新逻辑就得从“逐包InsertItem”改成“批量InsertItem”,这是把抓包程序从玩具变成工具的分水岭。
4. 抓包程序避坑实录:驱动加载失败、丢包与过滤失灵的五个现场
4.1 现象:运行GetPacket.exe时提示找不到Packet32.dll
原因:Packet32.dll不在exe所在目录,也不在系统的PATH里。你改了源码重新编了DLL,但编译输出目录是Packet32工程的Debug目录,GetPacket.exe去自己目录里找不到,自然起不来。
解决:把Packet32工程和GetPacket工程放在同一个解决方案里,把DLL的输入输出目录都设成GetPacket.exe所在目录。最土但最有效的排查办法是打开Dependency Walker,把exe拖进去看缺哪个节点,红色高亮的地方就是问题所在。还有一种隐蔽情况:DLL文件存在,但导出的函数丢了。检查Packet32.def文件,它是模块定义文件,决定哪些函数能导出。改工程配置时不小心把.def从链接参数里去掉,链接出来的DLL就是个空壳,exe运行起来照样报加载失败。
4.2 现象:抓包循环一直有包,但界面列表不刷新或假死
原因:ListView每帧都执行InsertItem,高流量下UI线程卡在重绘里,窗口消息无法处理WM_PAINT,表现出来就是界面白了、拖不动了。
解决:批量插入前调用SetRedraw(FALSE),插入完再SetRedraw(TRUE)和Invalidate。把UI刷新控制在每秒20次左右,不要让每帧都直接触达控件。我一般会把到达的包先压进一个队列,UI定时器每50毫秒把队列里累积的帧一次性刷上去,大致逻辑如下:
// 定时器回调里做批量刷新,避免逐包InsertItem卡死UI m_uiLock.Enter(); while (!m_pendingPackets.empty()) { AddPacketToList(m_pendingPackets.front()); m_pendingPackets.pop_front(); } m_uiLock.Leave(); // 关闭重绘,批量更新后再统一打开 m_listView.SetRedraw(FALSE); m_listView.Invalidate(); m_listView.SetRedraw(TRUE);这段代码的意义是双重的:锁保证抓包线程和UI线程的队列访问安全;SetRedraw(FALSE)把界面刷新停住,等所有包都插完再一起重绘,防止插入过程中界面反复画半成品。其实很多“抓包卡死”并不是抓包逻辑挂了,就是界面刷新被拖死的,你把刷新频控住,问题就消掉一大半。
4.3 现象:过滤规则一设置,一个包都收不到
原因:BPF过滤掩码设错。PacketSetFilter的第二个参数是BPF程序,第三个参数是长度。如果你传入的是普通字符串,底层拿到的是非法指令,驱动直接把所有包丢掉。还有一种情况是端口字节序没转,过滤条件写的是80,底层拿到的却是0x5000反转后的值,永远匹配不上。
解决:先用NULL、0做一次收包验证,确认链路通。再加过滤时从最简单的条件开始,比如只过滤IP协议类型0x0800,能收到IP包后再往端口条件上加。不要一次把MAC、IP、端口全写上,因为查错的时候你完全不知道是哪一段坏。如果想做内核级BPF过滤,去查BPF指令格式的文档,自己拼字节流之前先拿现成工具生成过滤指令,确认能用再固化到代码里。
4.4 现象:Debug编译能跑,Release一启动就崩
原因:MFC工程里常见的未初始化变量,Debug配置下自动清零,Release配置下是栈里的随机值。这个时代的抓包代码喜欢在栈上定义大的字节数组,Release优化时栈帧布局变化,缓冲区越界问题一下就炸出来。
解决:逐帧检查Buffer的拷贝长度和ulUserBytes是否一致。解析IP头时,如果TotalLen小于实际收到的数据,按TotalLen解析,别按收包长度解析,否则越界读从偶发崩溃变成稳定崩溃。Release下排这种问题,我习惯在DispatchPacket入口加一条OutputDebugString,记录每一帧的地址、长度、协议类型,再配合WinDbg的!analyze看崩溃栈指向哪个偏移。凡是抓包程序,内存问题基本都是同一个病根:过度信任网卡给的长度,没验证就往下读。
4.5 现象:Win10/11 64位下VxD加载失败或Packet32.dll无法控制网卡
原因:zpacket.vxd是Windows 9x时代的虚拟设备驱动,Windows XP开始就不再支持。Packet32旧版DLL在64位系统上没有签名,内核驱动加载会被拒绝,这是驱动签名策略限制,不是代码逻辑的问题。
解决:现代系统上复现这套源码,只有两条路。第一条是保留上层MFC应用代码,把底层抓包驱动替换成WinPcap或Npcap的驱动,调用接口尽量对齐Packet32的命名,这样上层改动最小。第二条是纯学习,在虚拟机里装Windows XP镜像,用VC6编译之后直接实验,绕开驱动签名问题。我更推荐前者:驱动直接用成熟库,解析和显示用这套源码的逻辑,组合起来在Win11上也能跑。如果目标是学驱动本身,把NTDDNDIS.H和DEVIOCTL.H这两个头文件留着配合WDK读,比真去编译VxD更实际,这两个文件里的NDIS设备控制定义今天看依然有参考价值。
5. 进阶验证:用抓到的包反推TCP握手,并顺手扩展一列HTTP Host
5.1 用抓包结果验证TCP三次握手
代码跑通之后,最直观的验证不是看“能收到包”,而是看能不能基于原始帧反推出协议过程。拿两台机器或者一台机器配两个网卡做实验:一端用netstat起监听,另一端发起连接,抓包程序同时开着。你会看到三个关键帧:SYN包、SYN+ACK包、ACK包。判断依据是TCP头里六个标志位,按位与就能区分:
#define TCP_FLAG_FIN 0x01 #define TCP_FLAG_SYN 0x02 #define TCP_FLAG_RST 0x04 #define TCP_FLAG_PSH 0x08 #define TCP_FLAG_ACK 0x10 #define TCP_FLAG_URG 0x20 BYTE flag = pTcpHeader->Flags; if ((flag & TCP_FLAG_SYN) && (flag & TCP_FLAG_ACK)) { // 第二次握手:SYN+ACK } else if ((flag & TCP_FLAG_SYN) && !(flag & TCP_FLAG_ACK)) { // 第一次握手:纯SYN } else if ((flag & TCP_FLAG_ACK) && !(flag & TCP_FLAG_SYN)) { // 可能是第三次握手,也可能是普通数据包 }这里有个我踩过的坑:验证流程别用127.0.0.1回环,Windows的loopback流量不一定走网卡驱动,你在抓包里永远等不到SYN,会误以为程序坏了。用局域网IP或者虚拟机网卡,抓包逻辑才真正被触发。
5.2 扩展:给列表加一列HTTP Host
源码跑稳定之后,进阶用法很自然就出来了:在ListView里加一列HTTP Host,把80端口TCP包的应用层文本解析出来。常见做法是拿到TCP载荷指针后,做一次内存搜索,定位到“Host: ”字段,截到回车符为止。注意HTTP头是明文,但Host字段可能被拆到两个TCP段里,所以要先做TCP流重组再解析。完整实现需要维护一个按四元组索引的重组缓冲区,工作量不小,但这个功能做完,你的工具就从“能看包”进步到“能看业务了”。
写完这段,想起我第一次拿这类源码排查生产问题时的样子:开了半天抓包,一个包都收不到,后来发现是过滤条件里写死只放行UDP,TCP流量全被丢了。从那以后我所有抓包工具的验证流程都固定成三步:先不设过滤网卡全收,再按协议收,最后才上端口级过滤,一步不跳。希望帮到你。
本文还有配套的精品资源,点击获取