简介:面向网络协议学习与 Windows 抓包实践的原创 MFC+WinPcap 嗅探器工程,适合已掌握 C++ 基础、希望深入理解从链路层到应用层数据包结构的高校学生与网络开发人员。项目基于 VS 的 MFC 对话框框架,调用 WinPcap 驱动完成网卡监听与数据帧捕获,可解析 IPv4、IPv6、ARP、ICMP、TCP、UDP 及 HTTP 等协议,并配有较友好的图形界面,便于在实验环境中直观对照协议字段的收发过程。压缩包共 22 个文件,约 1.92MB,包含 7 个 h 头文件与 4 个 cpp 源文件,用于承载协议解析与界面逻辑,配套 sln 解决方案、vcproj 工程文件及 ico、png 图标资源,另有 rc、rc2 资源脚本与 txt 说明,工程结构完整,可直接用 Visual Studio 打开编译调试。上线以来已有 5410 人学习下载,可作为课程设计、网络协议分析入门或抓包工具二次开发的参考样例,帮助读者快速理清 WinPcap 调用流程、协议分层解析思路与 MFC 界面组织方式。
1. 深夜排障时,一套能自己改的抓包工具比什么都顺手
机房角落一台工控设备每隔几分钟就掉线重连,交换机端口镜像申请不下来,手上只有一台装了 Windows 的笔记本。这时候从源码包编译出来的原创MFC+WinPcap网络嗅探器(sniffer)就派上用场了:枚举本机网卡、按 BPF 表达式过滤、把以太网/IP/TCP/UDP/ICMP/ARP 逐层拆开,左边包列表、右边协议树、底下十六进制,抓到的包还能存成 pcap 拿回工位慢慢看。它适合两类人:一类是想弄懂 WinPcap 抓包链路怎么和 MFC 界面搭起来的学生和转岗开发;另一类是手里有一堆私有协议,需要一个能随手改解析字段的小工具的老手。整套东西不依赖数据库,编译出来就是独立 exe,改起来比通用抓包软件轻得多。
2. 技术选型与工程骨架:MFC 对话框 + WinPcap 的分层怎么搭
2.1 为什么是 MFC + WinPcap 这套老组合
看这份源码之前,先把“为什么不用 Qt”“为什么不用原始 socket”这两个问题想明白,后面读代码会顺很多。MFC 在这类工具里的优势不是界面多漂亮,而是资源编辑器、消息映射、控件封装全都长在 Visual Studio 里,一个对话框资源加一个CDialogEx派生类就能把包列表、协议树、十六进制视图摆出来,编译产物是单个 exe,拷到没有运行库的机器上补个 VC 运行库就能跑。对于嗅探器这种“界面只是壳、核心在抓包线程”的程序,MFC 的消息循环反而比很多框架更直接:工作线程PostMessage,UI 线程收消息刷新控件,不需要额外信号槽或事件总线。
WinPcap 提供的是内核态 NPF 驱动加用户态wpcap.dll的整套能力,网卡处于混杂模式时能把经过的完整以太网帧递上来,还能在驱动层挂 BPF 过滤器,避免把所有流量都拷到用户态再丢弃。原始 socket 的SOCK_RAW也能收包,但通常只能拿到本机协议栈处理过的数据,混杂模式支持依赖驱动,过滤器要自己写,跨网卡枚举也麻烦。这个项目的定位就是“教学可读 + 能改能用”,所以选 MFC 做壳、WinPcap 做抓包引擎,协议解析全部放在自己的 C++ 代码里,不依赖第三方解析库,改字段时不用翻别人的文档。
常见做法是把抓包和解析拆成两层:PacketCapture只负责设备、过滤器、回调和原始字节缓冲;ProtocolParser只接收一段const u_char*和长度,返回解析结果结构体。界面层既不直接调pcap_*,也不碰字节偏移,这样换网卡、换过滤规则、加新协议都不会牵一发动全身。源码包里的类名可能略有差异,但按这个分层去读,基本能对上。
2.2 工程目录与模块职责
这套源码的目录通常不会太深,核心文件集中在工程根目录和几个按职责拆分的文件夹里。下面这张表是按常见工程结构整理的模块清单,拿到源码后可以对照着找文件,找不到同名文件就按职责找对应类:
| 文件/目录 | 职责 | 关键类或函数 |
|---|---|---|
SnifferApp.cpp/.h | 应用入口,注册对话框 | CSnifferApp::InitInstance |
SnifferDlg.cpp/.h | 主界面,控件初始化与消息响应 | CSnifferDlg::OnInitDialog、OnBnClickedStart |
PacketCapture.cpp/.h | 网卡枚举、打开设备、抓包线程、停止释放 | CPacketCapture::EnumDevices、StartCapture、StopCapture |
ProtocolParser.cpp/.h | 以太网/IP/TCP/UDP/ICMP/ARP 解析 | CProtocolParser::ParseFrame |
PacketListCtrl.cpp/.h | 包列表虚拟列表控件 | CPacketListCtrl::OnGetDispInfo |
HexView.cpp/.h | 十六进制与 ASCII 对照显示 | CHexView::SetData |
PcapFile.cpp/.h | 抓包结果保存为 pcap 文件 | CPcapFile::Save |
res/ | 图标、对话框资源、版本信息 | IDD_SNIFFER_DIALOG |
真正需要先看的是PacketCapture和ProtocolParser这两个类,界面类可以放到后面。抓包线程的生命周期、缓冲区的所有权、过滤器编译失败后的回退,这些容易出问题的地方都在这两个类里。PacketListCtrl用虚拟列表是为了撑住几万条包不卡,HexView一般就是一个只读CEdit或者自绘控件,按 16 字节一行拼字符串。
读目录时注意一个细节:如果源码里同时存在pcap.h和项目自己的pcap_include.h,后者通常是为了在包含系统头之前定义WPCAP、HAVE_REMOTE这些宏。顺序错了会报大量类型重定义或函数未声明,后面避坑章节会展开。
2.3 VS 工程配置:包含目录、库目录、预处理器和运行库
WinPcap 开发包不是装完驱动就能编译,必须在 VS 工程里把头文件和库路径指对。常见做法是把 WinPcap Developer's Pack 解压到某个固定目录,然后在项目属性里配置:C/C++ 的附加包含目录指向Include,链接器的附加库目录指向Lib(32 位工程用Lib,64 位工程用Lib/x64),附加依赖项填wpcap.lib和Packet.lib。位数必须和工程一致,32 位工程链 64 位库会直接报LNK2019或LNK1112。
// pcap_include.h // 必须放在所有 pcap 头文件之前,顺序错了宏不生效 #define WPCAP // 启用 WinPcap 的扩展 API #define HAVE_REMOTE // 启用 pcap_findalldevs_ex 等远程抓包接口 #include <pcap.h> // 让链接器自动找库,工程里就不用再手填附加依赖项 #pragma comment(lib, "wpcap.lib") #pragma comment(lib, "Packet.lib")这段头文件的重点在宏的顺序:WPCAP和HAVE_REMOTE必须在#include <pcap.h>之前定义,否则pcap_findalldevs_ex、pcap_open这些声明不会展开,编译期就会报“未声明的标识符”。#pragma comment(lib, ...)是 MSVC 特有的写法,省去在属性页里手填库名,但库目录仍然要配,否则链接器找不到wpcap.lib。如果工程使用预编译头,把pcap_include.h放进stdafx.h或pch.h里更稳妥,避免每个 cpp 重复包含顺序不一致。
字符集也要留意。MFC 工程默认可能是 Unicode,而 WinPcap 的设备名、描述都是char*,直接传给CString会编译失败。常见做法是统一在抓包层用std::string,到界面层再CA2T转换;或者工程属性里把字符集改成“使用多字节字符集”,省掉转换但不利于后续国际化。我一般保留 Unicode,只在PacketCapture内部用char缓冲,边界清晰,后面加日志也方便。
2.4 启动时序:对话框初始化和设备枚举谁先谁后
主对话框的OnInitDialog里不要直接开始抓包,先把控件、列宽、定时器准备好,再枚举网卡填到下拉框,等用户点“开始”才打开设备。这样即使 WinPcap 驱动没装好,界面也能正常弹出来,只是下拉框为空,用户能看懂是环境问题而不是程序崩溃。枚举函数返回的设备链表用完后必须pcap_freealldevs,否则每刷新一次就漏一块内存。
BOOL CSnifferDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 初始化包列表列头,后面只更新行数据 m_packetList.InsertColumn(0, _T("序号"), LVCFMT_RIGHT, 60); m_packetList.InsertColumn(1, _T("时间"), LVCFMT_LEFT, 140); m_packetList.InsertColumn(2, _T("源地址"), LVCFMT_LEFT, 160); m_packetList.InsertColumn(3, _T("目的地址"), LVCFMT_LEFT, 160); m_packetList.InsertColumn(4, _T("协议"), LVCFMT_LEFT, 80); m_packetList.InsertColumn(5, _T("长度"), LVCFMT_RIGHT, 70); // 枚举网卡,失败时只提示,不阻塞界面 if (!m_capture.EnumDevices()) { SetDlgItemText(IDC_STATIC_STATUS, _T("未找到可用网卡,请检查 WinPcap 驱动")); GetDlgItem(IDC_BTN_START)->EnableWindow(FALSE); } return TRUE; }这段代码把“界面可用”和“抓包可用”解耦了:EnumDevices内部调用pcap_findalldevs_ex,把设备名和描述存到m_capture的成员列表里,同时填充下拉框。返回false时只禁用开始按钮,不弹模态框,排查时用户能看到状态栏提示。列头用InsertColumn一次建好,后面抓包回调只做InsertItem/SetItemText,避免每次刷新重建列导致闪烁。
3. 抓包核心链路:从 pcap_findalldevs 到 pcap_loop 的完整落地
3.1 网卡枚举:设备名、描述和回环接口
pcap_findalldevs_ex返回的是pcap_if_t链表,每个节点里有name(给pcap_open用的设备名)、description(给人看的描述)、addresses(IP 地址链表)和flags。界面下拉框显示description,实际打开设备时用name,这两个不能混。如果description为空,退化成显示name。回环接口在 Windows 上通常叫rpcap://...或者NPF_Loopback,普通抓包看不到本机127.0.0.1的流量,需要单独选回环设备或者装 Npcap 的回环支持。
bool CPacketCapture::EnumDevices() { pcap_if_t* alldevs = nullptr; char errbuf[PCAP_ERRBUF_SIZE] = { 0 }; // 第一个参数为 nullptr 表示枚举本机所有网卡 if (pcap_findalldevs_ex(PCAP_SRC_IF_STRING, nullptr, &alldevs, errbuf) == -1) { m_lastError = errbuf; return false; } m_devices.clear(); for (pcap_if_t* d = alldevs; d != nullptr; d = d->next) { DeviceInfo info; info.name = d->name ? d->name : ""; info.desc = d->description ? d->description : info.name; m_devices.push_back(info); } // 链表用完必须释放,否则每次枚举都泄漏 pcap_freealldevs(alldevs); return !m_devices.empty(); }PCAP_SRC_IF_STRING是rpcap://,表示从本机抓包源枚举;errbuf由调用方提供,失败时写入错误文本,长度固定PCAP_ERRBUF_SIZE。m_devices存的是拷贝后的std::string,因为pcap_freealldevs之后原链表就失效了,不能把d->name指针直接存下来。枚举失败最常见的原因是 NPF 驱动没启动,errbuf里会写“Error opening adapter”之类,先看服务列表里npf是否在运行。
3.2 打开设备与过滤器:snaplen、promisc、timeout 三个参数
pcap_open的参数决定抓包行为:snaplen是每个包最多截取多少字节,嗅探器要解析完整帧,一般给 65536;promisc为 1 时进入混杂模式,交换机镜像口或集线器环境下能看到非本机流量,普通交换口下只能看到广播和自己;timeout是内核缓冲区的等待毫秒数,给 1000 表示最多等 1 秒就把一批包递上来,太小会增加系统调用次数,太大界面刷新会有延迟。过滤器用pcap_compile编译成 BPF 程序,再用pcap_setfilter挂到设备上,编译失败要回退到“不过滤”,不能直接崩。
bool CPacketCapture::OpenDevice(int index, const std::string& filter, std::string& err) { if (index < 0 || index >= (int)m_devices.size()) { err = "设备索引越界"; return false; } char errbuf[PCAP_ERRBUF_SIZE] = { 0 }; // 65536 保证整帧可读,1 开启混杂,1000ms 缓冲超时 m_handle = pcap_open(m_devices[index].name.c_str(), 65536, 1, 1000, nullptr, errbuf); if (m_handle == nullptr) { err = errbuf; return false; } if (!filter.empty()) { bpf_program prog; // 1 表示编译后优化,0 表示按本机子网掩码推导广播地址 if (pcap_compile(m_handle, &prog, filter.c_str(), 1, 0) == -1) { err = pcap_geterr(m_handle); pcap_close(m_handle); m_handle = nullptr; return false; } if (pcap_setfilter(m_handle, &prog) == -1) { err = pcap_geterr(m_handle); pcap_freecode(&prog); pcap_close(m_handle); m_handle = nullptr; return false; } pcap_freecode(&prog); // 过滤器挂上后即可释放编译结果 } return true; }pcap_compile的第四个参数optimize给 1 会让 BPF 做指令优化,抓大流量时能少跑一些无用分支;第五个参数netmask给 0 表示由库自己根据设备地址推导,在过滤表达式里用到broadcast或multicast时更准确。pcap_freecode释放的是编译出来的 BPF 指令内存,不是释放过滤器本身,pcap_setfilter之后就可以调,漏掉会泄漏。过滤表达式写错时pcap_compile返回 -1,pcap_geterr能拿到具体原因,比如syntax error,直接显示到状态栏比弹窗更容易排查。
3.3 抓包线程与 UI 投递:不要让回调直接碰控件
pcap_loop是阻塞的,放在 UI 线程里界面立刻卡死,所以必须开工作线程。线程函数里循环调用pcap_loop或pcap_next_ex,每收到一个包就拷贝一份到堆上,通过PostMessage把指针传给主窗口,UI 线程收到消息后解析、插入列表、更新树,最后delete掉这份数据。这里的关键是所有权:谁new谁负责释放,跨线程只传指针不传栈对象,避免回调返回后数据失效。
// 自定义消息,LPARAM 存放堆上的 PacketData* #define WM_PACKET_ARRIVED (WM_USER + 100) struct PacketData { struct timeval ts; u_int caplen; // 实际截获长度 u_int len; // 原始帧长度 std::vector<u_char> bytes; }; void CPacketCapture::OnPacket(u_char* user, const struct pcap_pkthdr* h, const u_char* bytes) { CPacketCapture* self = reinterpret_cast<CPacketCapture*>(user); if (self->m_stopping) return; PacketData* data = new PacketData(); >void CPacketCapture::StopCapture() { if (m_handle == nullptr) return; m_stopping = true; pcap_breakloop(m_handle); // 让 pcap_loop 尽快返回 if (m_thread != nullptr) { // 最多等 3 秒,避免界面卡死 WaitForSingleObject(m_thread, 3000); CloseHandle(m_thread); m_thread = nullptr; } pcap_close(m_handle); m_handle = nullptr; m_stopping = false; }WaitForSingleObject的超时给 3 秒是折中:正常情况几十毫秒就退出,驱动异常时等太久界面会假死。超时后仍然要CloseHandle,但此时不能再pcap_close,否则工作线程可能还在用句柄,常见做法是标记泄漏并提示重启程序,而不是强行关闭。m_stopping在回调里也要判断,breakloop之后可能还会进来一两个包,直接丢弃,不再PostMessage。
4. 协议解析与界面呈现:一包数据怎么变成树形结构
4.1 以太网帧与 VLAN 偏移:14 字节不是固定的
解析入口拿到的是从以太网头开始的原始字节。前 6 字节目的 MAC、6 字节源 MAC、2 字节类型/长度,一共 14 字节。类型为0x0800是 IPv4,0x0806是 ARP,0x86DD是 IPv6。如果类型是0x8100,说明带 VLAN 标签,后面还有 2 字节 TCI 和 2 字节内层类型,IP 头从第 18 字节开始,不是第 14 字节。很多教学代码默认没有 VLAN,拿到真实镜像流量就解析错位,这是第一个要防的坑。
struct EthernetHeader { u_char dst[6]; u_char src[6]; u_short type; }; bool CProtocolParser::ParseFrame(const u_char* data, u_int caplen, ParsedPacket& out) { if (caplen < sizeof(EthernetHeader)) return false; const EthernetHeader* eth = reinterpret_cast<const EthernetHeader*>(data); u_short type = ntohs(eth->type); u_int offset = sizeof(EthernetHeader); // 802.1Q VLAN 标签:类型 0x8100,后面 4 字节,内层类型在偏移 16 if (type == 0x8100 && caplen >= offset + 4) { type = ntohs(*reinterpret_cast<const u_short*>(data + offset + 2)); offset += 4; } out.srcMac = MacToString(eth->src); out.dstMac = MacToString(eth->dst); out.etherType = type; if (type == 0x0800) return ParseIPv4(data + offset, caplen - offset, out); if (type == 0x0806) return ParseARP(data + offset, caplen - offset, out); return true; // 其他类型只显示以太网头,不继续解析 }ntohs把网络字节序转成主机字节序,类型字段必须转,MAC 地址不用。VLAN 判断放在最前面,是因为带标签的帧如果按 14 字节直接读 IP 头,版本号会读成垃圾值。caplen - offset传下去之前要保证caplen >= offset,否则指针越界;上面每个分支都先检查长度,这是解析器不崩的基本功。MacToString把 6 字节格式化成aa:bb:cc:dd:ee:ff,放界面树的第一层。
4.2 IP/TCP/UDP/ICMP 头部解析:边界检查和字节序一个都不能少
IPv4 头固定 20 字节,但IHL字段可能大于 5,表示有选项,实际头长是IHL * 4。总长度字段是 16 位,包含 IP 头和数据;分片标志里MF或片偏移非零表示不是完整包,TCP/UDP 头可能不在这个帧里,解析时要么跳过传输层,要么标记“分片”。协议号 6 是 TCP、17 是 UDP、1 是 ICMP。TCP 头至少 20 字节,data offset高 4 位表示头长;UDP 头固定 8 字节;ICMP 头 8 字节,类型和代码决定后续含义。
bool CProtocolParser::ParseIPv4(const u_char* data, u_int caplen, ParsedPacket& out) { if (caplen < 20) return false; const IPv4Header* ip = reinterpret_cast<const IPv4Header*>(data); u_int ihl = (ip->verIhl & 0x0F) * 4; // 头长,单位 4 字节 u_short totalLen = ntohs(ip->totalLen); if (ihl < 20 || caplen < ihl) return false; out.srcIp = IpToString(ip->src); out.dstIp = IpToString(ip->dst); out.ttl = ip->ttl; out.ipProtocol = ip->protocol; // 分片包不解析传输层,避免把载荷当端口读 u_short frag = ntohs(ip->fragOff); if ((frag & 0x1FFF) != 0) return true; const u_char* payload = data + ihl; u_int payloadLen = caplen - ihl; if (totalLen >= ihl && totalLen - ihl < payloadLen) payloadLen = totalLen - ihl; // 去掉以太网填充字节 if (ip->protocol == 6) return ParseTCP(payload, payloadLen, out); if (ip->protocol == 17) return ParseUDP(payload, payloadLen, out); if (ip->protocol == 1) return ParseICMP(payload, payloadLen, out); return true; }verIhl & 0x0F取低 4 位得到IHL,乘 4 才是字节数;高 4 位是版本号,IPv4 应该是 4,可以顺手校验。totalLen可能小于caplen - ihl,因为以太网最小帧有 60 字节,IP 包不够长时后面会填充,按totalLen截断才不会把填充字节当载荷。分片判断用fragOff & 0x1FFF,非零表示这是后续分片,第一个分片偏移为 0 但MF置位,严格来说也要标记,教学代码里可以先按偏移非零跳过。
4.3 树形控件与十六进制视图:先算偏移,再插节点
协议树用CTreeCtrl,根节点是“帧”,下面挂“以太网”“IPv4”“TCP”等子节点,每个字段一行。十六进制视图按 16 字节一行,左边偏移、中间 hex、右边 ASCII。填充树之前先把解析结果结构体准备好,界面层只负责把字段转成字符串,不重新算偏移。包列表用虚拟列表LVS_OWNERDATA,只存索引和元数据,GetDispInfo时再格式化,几万条也不会卡。
void CSnifferDlg::AppendPacketToList(const PacketData* data, const ParsedPacket& parsed) { int row = m_packetList.GetItemCount(); m_packetList.InsertItem(row, _T("")); // 虚拟列表只占位 // 实际文本在 OnGetDispInfo 里按 row 取,避免一次性建 CString m_packets.push_back(PacketRow{>struct FlowKey { u_int srcIp, dstIp; u_short srcPort, dstPort; u_char proto; bool operator==(const FlowKey& o) const { return srcIp == o.srcIp && dstIp == o.dstIp && srcPort == o.srcPort && dstPort == o.dstPort && proto == o.proto; } }; struct FlowStat { timeval firstSeen, lastSeen; u_long long upBytes = 0, downBytes = 0; u_long upPackets = 0, downPackets = 0; };上面这个结构只做了单向统计,实际用的时候要按方向拆,或者用有序的std::map把正反两个方向合并成一条会话。哈希表用std::unordered_map<FlowKey, FlowStat>,自定义哈希函数把五个字段混一下,避免大量冲突。会话超时清理可以放在 UI 定时器里,比如 5 分钟没有新包的会话标灰,不立刻删除,方便回看。
第二个方向是导出 pcap。WinPcap 自带pcap_dump_open、pcap_dump、pcap_dump_close,在抓包回调里同时写文件即可,注意pcap_dump不是线程安全的,只能在工作线程里调用。导出后用通用抓包软件打开对比,如果时间戳错乱,检查pcap_pkthdr是不是原样传进去的,不要自己改ts。第三个方向是按进程归属,这个在 Windows 上要靠GetExtendedTcpTable拿端口到 PID 的映射,再反查进程名,只能做近似,因为端口会复用,抓包时间和查表时间差几毫秒就可能对不上。
我一般改这类工具的顺序是:先加日志,把每个包的caplen、len、解析结果写到文件,确认解析层没问题;再加会话表,因为它是纯内存结构,不影响抓包稳定性;最后才动界面和导出。每次改完用一段固定的测试流量回放,对比包数、会话数、导出文件大小,三项都对得上才继续。从那以后我每次接手抓包类项目,都强制先把“停止顺序”和“缓冲区所有权”这两条走一遍,否则后面加多少功能都是建在沙子上。希望帮到你。
本文还有配套的精品资源,点击获取