简介:这是一份面向网络编程初学者与安全技术爱好者的VC++网络抓包程序源代码,基于WinPcap底层驱动实现网卡数据包截取与协议解析,可用于学习网络嗅探原理、以太网帧结构及TCP/IP协议栈的实践分析。资源包共44个文件,以16个h头文件与8个cpp源文件为核心,涵盖抓包主程序、过滤对话框、IP地址与协议解析等模块,另含Packet32驱动相关文件、vxd虚拟设备驱动、dll动态库及bmp、ico等界面资源,压缩包整体约78KB,结构紧凑便于阅读调试。目前已有780人学习下载,适合作为网络协议分析、抓包工具开发的入门参考。读者可从中获取完整的工程目录组织方式、网卡数据捕获与过滤的实现思路,以及驱动层与应用层交互的代码范例,对理解Windows平台下的原始套接字与NDIS中间层机制有实际帮助。
1. 从一份 VC++ 抓包源码说起:网卡数据包截取到底怎么落地
手上这份vc++ sniff截取网卡数据包,网络抓包程序,源代码.zip是一个典型的 Windows 平台底层抓包工程,核心思路是通过 Packet32 驱动接口直接绑定网卡,绕过系统协议栈拿到原始以太网帧。它解决的不是"发个 HTTP 请求看看返回"这种应用层问题,而是让你在链路层看到每一个进出网卡的字节——包括 ARP、TCP 握手、UDP 广播,甚至那些没有进程认领的杂包。适合谁?做协议逆向的、排查局域网异常流量的、想搞明白 WinPcap 底层怎么调 NDIS 的,以及需要在一个不依赖第三方运行库的纯 VC++ 工程里嵌入抓包能力的开发者。整包源码里Packet32.c、Packet32.h、Ntddpack.h是驱动通信层,GetPacket.cpp和FileterDlg.cpp是业务层,结构清晰,拿来改比从零写省至少两周。
2. 拆开 Packet32 驱动层:网卡绑定与原始帧读取的实现逻辑
2.1 Packet32 与 NDIS 的交互路径
Windows 上想在链路层收包,绕不开 NDIS 中间层驱动。这份源码没有自己写.sys,而是直接调用Packet32.dll导出的函数,底层对应zpacket.vxd(VxD 是 9x 时代的,NT 内核走的是Packet32.sys那套,源码里Packet32.mak和Packet32.dsp就是驱动工程的构建文件)。关键调用链是:PacketOpenAdapter拿到网卡句柄 →PacketSetHwFilter设混杂模式 →PacketSetBuff分配内核缓冲区 →PacketReceivePacket阻塞或非阻塞读。Ntddpack.h里定义的是IOCTL控制码,比如IOCTL_PACKET_SET_MODE对应设置混杂,IOCTL_PACKET_READ对应读包。这些控制码通过DeviceIoControl下发到驱动,驱动再从 NDIS 拿到NDIS_PACKET结构,拷贝到用户态缓冲区。
常见做法是先用PacketGetAdapterNames枚举本机所有网卡,返回一个双空字符结尾的字符串列表,格式是"描述\0名称\0\0"。很多人第一次调这个函数会翻车,因为缓冲区大小没给够,函数返回FALSE但GetLastError是ERROR_INSUFFICIENT_BUFFER,得先调一次拿长度再分配。
2.2 打开网卡并设置混杂模式
下面这段代码是从GetPacket.cpp里提炼的打开流程,我补了错误处理和参数说明:
// 打开第一块可用网卡并设为混杂模式 #include "Packet32.h" #include "Ntddpack.h" LPADAPTER OpenSnifferAdapter() { char adapterList[8192]; ULONG listLen = sizeof(adapterList); // 第一次调用拿网卡名列表,缓冲区给足 if (!PacketGetAdapterNames(adapterList, &listLen)) { DWORD err = GetLastError(); // ERROR_INSUFFICIENT_BUFFER 说明缓冲区小了,按 listLen 重新分配 if (err == ERROR_INSUFFICIENT_BUFFER) { // 实际工程里这里要动态分配,示例简化 return NULL; } return NULL; } // adapterList 格式:描述\0名称\0\0,名称形如 \Device\NPF_{GUID} char* adapterName = adapterList + strlen(adapterList) + 1; LPADAPTER lpAdapter = PacketOpenAdapter(adapterName); if (lpAdapter == NULL || lpAdapter->hFile == INVALID_HANDLE_VALUE) { return NULL; } // 设置混杂模式,接收所有经过网卡的帧 if (!PacketSetHwFilter(lpAdapter, NDIS_PACKET_TYPE_PROMISCUOUS)) { PacketCloseAdapter(lpAdapter); return NULL; } // 分配 1MB 内核缓冲,驱动侧环形队列 if (!PacketSetBuff(lpAdapter, 1024 * 1024)) { PacketCloseAdapter(lpAdapter); return NULL; } return lpAdapter; }逻辑说明:PacketGetAdapterNames返回的列表里,描述和名称是连续存放的,中间用\0分隔,所以取名称要跳过描述字符串的长度再加一。PacketSetHwFilter的参数NDIS_PACKET_TYPE_PROMISCUOUS是让网卡不按 MAC 地址过滤,收到所有帧都往上传。PacketSetBuff设的是驱动内部环形缓冲区大小,太小会丢包,太大占非分页内存,1MB 到 4MB 是常见区间。参数怎么改:如果只抓本机流量,把PROMISCUOUS换成NDIS_PACKET_TYPE_DIRECTED | NDIS_PACKET_TYPE_BROADCAST能减少无关包;如果抓 VLAN 标签包,还得加NDIS_PACKET_TYPE_ALL_LOCAL。
2.3 收包循环与时间戳处理
收包用PacketReceivePacket,它返回一个LPBYTE缓冲区,里面是bpf_hdr头加原始帧数据。bpf_hdr里bh_caplen是实际抓到的长度,bh_datalen是帧原始长度,bh_tstamp是驱动打的时间戳。注意这个时间戳精度取决于驱动,常见做法是收到包后在用户态再打一个GetTickCount做相对时间参考。
// 收包循环,每次读一批 void CaptureLoop(LPADAPTER lpAdapter) { PACKET packet; memset(&packet, 0, sizeof(packet)); packet.Length = 65536; // 单次最大读 64KB packet.Buffer = new BYTE[packet.Length]; while (g_bRunning) { if (PacketReceivePacket(lpAdapter, &packet, TRUE)) { // packet.Buffer 里是 bpf_hdr + 帧数据 struct bpf_hdr* pHdr = (struct bpf_hdr*)packet.Buffer; BYTE* pFrame = packet.Buffer + pHdr->bh_hdrlen; ULONG frameLen = pHdr->bh_caplen; // 这里把 pFrame 交给解析层,比如以太网头解析 ParseEthernetFrame(pFrame, frameLen); } else { // 超时或驱动错误,检查 GetLastError DWORD err = GetLastError(); if (err != ERROR_TIMEOUT) { break; // 真错误,退出 } } } delete[] packet.Buffer; }参数说明:PacketReceivePacket第三个参数TRUE表示阻塞等待,设FALSE就是非阻塞轮询,CPU 占用高但延迟低。packet.Length是单次读取上限,设 65536 能覆盖一个完整 jumbo frame。bh_hdrlen通常是 20 字节左右,取决于驱动对齐。失败时看GetLastError,ERROR_TIMEOUT是正常的读超时,其他错误码比如ERROR_DEVICE_NOT_CONNECTED说明网卡被拔了或者驱动被卸载。
3. 业务层解析:从以太网帧到 IP 包头的过滤与展示
3.1 协议头逐层剥离
拿到原始帧后,protocol.h里定义了各层结构体。以太网头 14 字节,类型字段0x0800是 IPv4,0x0806是 ARP,0x86DD是 IPv6。IP 头最小 20 字节,ipaddr.h和ipaddr.cpp封装了 IP 地址的解析和格式化。下面是一个典型的解析入口:
// 解析以太网帧,返回上层协议类型 int ParseEthernetFrame(BYTE* pFrame, ULONG len) { if (len < 14) return -1; // 太短,丢弃 ETHERNET_HEADER* pEth = (ETHERNET_HEADER*)pFrame; USHORT ethType = ntohs(pEth->type); switch (ethType) { case 0x0800: // IPv4 return ParseIPv4(pFrame + 14, len - 14); case 0x0806: // ARP return ParseARP(pFrame + 14, len - 14); case 0x86DD: // IPv6 return ParseIPv6(pFrame + 14, len - 14); default: return -1; // 不关心的类型 } }逻辑说明:ntohs把网络字节序转成主机序,Windows 上要#include <winsock2.h>。pFrame + 14跳过以太网头,len - 14是剩余长度。这里没有做 VLAN 处理,如果帧带 802.1Q 标签,类型字段在偏移 12 字节处是0x8100,真正的类型在偏移 16 字节,需要额外判断。常见做法是先检查0x8100,是的话偏移加 4 再解析。
3.2 过滤对话框的参数配置
FileterDlg.cpp和FileterDlg.h实现了一个过滤设置对话框,让用户输入 IP、端口、协议号来筛选包。过滤逻辑在用户态做,收到包后逐层比对。参数配置上,IP 过滤支持单地址和网段(比如192.168.1.0/24),端口过滤支持范围和列表。下面是一个过滤判断的简化实现:
// 判断一个 IPv4 包是否通过过滤条件 BOOL PassFilter(IPV4_HEADER* pIp, ULONG len, FILTER_CONFIG* cfg) { // 协议过滤 if (cfg->protocol != 0 && pIp->protocol != cfg->protocol) { return FALSE; } // 源 IP 过滤 if (cfg->srcIp != 0 && pIp->srcAddr != cfg->srcIp) { return FALSE; } // 目的 IP 过滤 if (cfg->dstIp != 0 && pIp->dstAddr != cfg->dstIp) { return FALSE; } // 端口过滤,只对 TCP/UDP 有效 if (cfg->port != 0 && (pIp->protocol == 6 || pIp->protocol == 17)) { USHORT srcPort = 0, dstPort = 0; // IP 头长度可变,IHL * 4 才是真实头长 ULONG ipHdrLen = (pIp->verIhl & 0x0F) * 4; if (pIp->protocol == 6) { TCP_HEADER* pTcp = (TCP_HEADER*)((BYTE*)pIp + ipHdrLen); srcPort = ntohs(pTcp->srcPort); dstPort = ntohs(pTcp->dstPort); } else { UDP_HEADER* pUdp = (UDP_HEADER*)((BYTE*)pIp + ipHdrLen); srcPort = ntohs(pUdp->srcPort); dstPort = ntohs(pUdp->dstPort); } if (srcPort != cfg->port && dstPort != cfg->port) { return FALSE; } } return TRUE; }参数说明:pIp->verIhl低四位是 IP 头长度,单位是 4 字节,所以乘 4 才是字节数。TCP 头里源端口和目的端口各占 2 字节,ntohs转换后比对。FILTER_CONFIG结构里protocol为 0 表示不限制,srcIp和dstIp为 0 表示任意。这个过滤是"与"关系,所有条件都满足才通过。如果要做"或"逻辑,得在调用处改判断顺序。
3.3 列表展示与性能取舍
GetPacketListView.cpp负责把解析后的包显示到 ListCtrl 里。这里有个性能坑:每收一个包就InsertItem一次,包速率高的时候 UI 线程直接卡死。常见做法是攒一批(比如 100 个)再批量插入,或者用虚拟列表(LVS_OWNERDATA)。源码里用的是普通 ListCtrl,实测在千兆网满速下丢包严重,因为 UI 刷新跟不上。如果只是做协议分析,建议把展示和收包分到两个线程,中间用环形队列缓冲,队列满了就丢最老的包,保证收包线程不阻塞。
4. 编译与运行:VC++ 6.0 到 VS2022 的工程迁移避坑
4.1 工程文件版本差异
源码里GetPacket.dsw和GetPacket.dsp是 VC++ 6.0 的工程格式,Packet32.dsw和Packet32.dsp是驱动工程的。用 VS2022 打开会提示升级,升级后dsw变成sln,dsp变成vcxproj。但Packet32.mak是 nmake 用的,VS 不认,得手动把Packet32.c加进工程或者直接用现成的Packet32.dll。注意zpacket.vxd是给 Windows 9x 的,NT 内核系统上不需要,但源码包里带着,编译时别把它当依赖。
4.2 字符集与链接错误
VC++ 6.0 默认多字节字符集,VS2022 默认 Unicode。直接编译会报一堆const char*转LPCWSTR的错误。解决办法是在项目属性里把字符集改成"使用多字节字符集",或者给所有字符串加_T()宏。另一个常见错误是Packet32.lib没链接,报unresolved external symbol _PacketOpenAdapter。在项目属性 → 链接器 → 输入 → 附加依赖项里加上Packet32.lib,并把Packet32.dll放到输出目录。
4.3 管理员权限与驱动签名
PacketOpenAdapter需要管理员权限,否则返回NULL且GetLastError是ERROR_ACCESS_DENIED。在 VS 里调试时,把 VS 以管理员身份运行,或者在项目属性 → 链接器 → 清单文件 → UAC 执行级别里设requireAdministrator。另外Packet32.sys在 64 位系统上需要签名,如果用的是老版本驱动,可能被 Windows 拒绝加载,报ERROR_INVALID_IMAGE_HASH。常见做法是开测试签名模式(bcdedit /set testsigning on),但生产环境别这么干。
5. 避坑与排查:抓不到包、丢包、蓝屏的常见原因
5.1 现象:PacketGetAdapterNames返回空列表
原因:网卡驱动没装好,或者当前用户没有权限读注册表里的网卡信息。PacketGetAdapterNames底层是读HKLM\SYSTEM\CurrentControlSet\Control\Class\{4D36E972-...}下的网卡实例。解决:用管理员权限运行,检查设备管理器里网卡有没有黄色感叹号,必要时重装网卡驱动。
5.2 现象:能打开网卡但收不到任何包
原因:混杂模式没设成功,或者网卡被防火墙/安全软件拦截了。有些安全软件会 hookDeviceIoControl,把IOCTL_PACKET_SET_MODE拦掉。解决:先关掉安全软件测试,用PacketSetHwFilter的返回值判断是否成功,失败时打印GetLastError。另外检查网卡是不是虚拟网卡(比如 VMware 的),虚拟网卡的混杂模式行为跟物理网卡不一样。
5.3 现象:高速率下大量丢包
原因:用户态缓冲区太小,或者收包线程被 UI 阻塞。PacketSetBuff设的 1MB 在千兆满速下只能撑几十毫秒。解决:把缓冲区加到 4MB 以上,收包线程优先级提到THREAD_PRIORITY_TIME_CRITICAL,UI 展示用虚拟列表或者直接写文件。如果还丢,考虑用PacketSetMinToCopy设一个最小拷贝阈值,减少驱动到用户态的切换次数。
5.4 现象:编译报Ntddpack.h找不到NDIS_PACKET定义
原因:Ntddpack.h依赖ntddndis.h,而ntddndis.h又依赖ndis.h,这些头文件在 DDK/WDK 里,不在标准 VC++ 安装包里。解决:装 WDK(Windows Driver Kit),把inc目录加到项目附加包含目录。或者直接用Packet32.h里封装好的结构,别直接引Ntddpack.h。
5.5 现象:程序运行一段时间后蓝屏
原因:驱动层缓冲区溢出,或者用户态传了非法指针给PacketReceivePacket。packet.Buffer必须是NonPagedPool或者锁定内存,普通new出来的内存可能被换页,驱动直接访问会出问题。解决:用VirtualAlloc加MEM_COMMIT | MEM_RESERVE分配,或者调PacketSetBuff让驱动自己管理缓冲,用户态只读不写。
6. 进阶技巧:把抓包数据落成 pcap 文件并用 Wireshark 验证
源码本身只做了展示,没存文件。实际排查问题时,把原始帧存成 pcap 格式,用 Wireshark 打开看协议树,比在 ListCtrl 里翻效率高得多。pcap 文件头 24 字节,每个包前面加 16 字节的pcap_pkthdr,字段依次是时间戳秒、微秒、抓到的长度、原始长度。下面是一个写入函数:
// 写 pcap 全局头 void WritePcapHeader(FILE* fp) { struct pcap_file_header { DWORD magic; // 0xa1b2c3d4 WORD version_major; // 2 WORD version_minor; // 4 DWORD thiszone; // 0 DWORD sigfigs; // 0 DWORD snaplen; // 65535 DWORD linktype; // 1 = Ethernet } hdr = { 0xa1b2c3d4, 2, 4, 0, 0, 65535, 1 }; fwrite(&hdr, sizeof(hdr), 1, fp); } // 每收到一个包写一次 void WritePcapPacket(FILE* fp, BYTE* pFrame, ULONG len, struct timeval* tv) { struct pcap_pkthdr { DWORD ts_sec; DWORD ts_usec; DWORD caplen; DWORD len; } ph = { tv->tv_sec, tv->tv_usec, len, len }; fwrite(&ph, sizeof(ph), 1, fp); fwrite(pFrame, len, 1, fp); }参数说明:magic用0xa1b2c3d4表示大端字节序,如果用0xd4c3b2a1就是小端。linktype设 1 对应以太网,Wireshark 按这个值选解析器。caplen是实际写入的长度,len是帧原始长度,如果做了截断(比如只抓前 128 字节),caplen小于len。写完用 Wireshark 打开,如果提示"文件格式错误",八成是magic写反了或者linktype不对。
我自己的习惯是:每次改完抓包逻辑,先跑一个ping生成已知流量,存成 pcap 后用 Wireshark 看 ICMP 包能不能正常解析。如果 Wireshark 里显示"Malformed Packet",说明帧头解析有偏移错误,回头查bh_hdrlen和 VLAN 标签。从那以后我每次动 Packet32 的缓冲区参数,都强制走一遍"ping → 存 pcap → Wireshark 验证"这个流程,比盯着 ListCtrl 猜靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取