ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC+WinPcap/Npcap手写网络嗅探器与协议解析实战

MFC+WinPcap/Npcap手写网络嗅探器与协议解析实战

简介:这是一套基于 Visual Studio、MFC 与 WinPcap 编写的网络嗅探器完整工程,面向正在学习网络协议分析、抓包编程和 Windows 桌面开发的初、中级读者,也可供安全测试与网络排障人员参考。项目提供较友好的图形界面,支持 IPv4、IPv6、ARP、ICMP、TCP、UDP、HTTP 等常见协议的捕获与解析,适合用来理解从网卡抓包、协议识别到界面展示的完整链路。压缩包为 rar 格式,共 22 个文件,约 1.92MB,包含 7 个头文件、4 个 cpp 源文件,以及解决方案、工程配置、图标和界面资源等,结构紧凑,便于在 VS 中打开并对照学习。已有 5406 人学习或下载。读者可获得可运行的 MFC 工程源码,研究 WinPcap 调用、抓包流程、协议字段解析和界面交互实现,并在此基础上做二次开发或课程设计。

1. 手写嗅探器的真正价值不在抓包那一行

手写一个 MFC + WinPcap 网络嗅探器(sniffer),最值钱的地方不是那行抓包调用,而是你被迫在字节层面搞懂了以太网帧、IP 头、TCP 握手到底长什么样。我第一次把自制的 sniffer 跑在实验室一台机器的虚拟网卡上,抓到 ARP 广播的那一下,才彻底明白界面上那些漂亮表格全是人一行行解析出来的,没有任何魔法。这篇写给两种人:一种是想用 MFC 做带界面的抓包工具、又不想被现成组件糊住的开发者;另一种是刚啃完协议教材、需要把课本图和真实字节对上号的学习者。下面按选型、骨架、解析、避坑、收尾五步,把真机上踩过的东西摊开讲,能直接抄的部分我都标了。所有演示只在自己有权限的本机或实验网段里跑,这条底线先立住。

2. 选型这一步别省:MFC + WinPcap 还值不值得当底座

2.1 界面层先定死:MFC 的适用边界在哪

我一般会先问一句:这个工具最终交付形态是什么。如果答案是「一个 exe,双击就能跑,不需要用户装运行时,界面就是列表加详情两栏」,MFC 依然是个省事的选择。静态链接 CRT 之后单文件几十兆,启动快,和 Win32 API 贴得近,抓包线程、消息循环、窗口句柄这些概念天然对得上。反过来,如果你要做树形协议栈、要深浅色主题、要高分屏下 200% 缩放不糊,MFC 就得自己补一堆自绘逻辑,代码量会追上用 Qt 的收益。

新手常误判的一点是以为 MFC 老就等于难维护。实际上 MFC 的坑集中在两类:一是字符集,二是消息机制。字符集在 VS 工程属性里选「使用 Unicode 字符集」之后,CString 和 std::string 之间的转换要显式走 CT2A / CA2T,很多人图省事直接强转,中文路径和中文协议名立刻变问号。消息机制则是抓包线程不能碰控件,必须 PostMessage,这一点后面单独讲。

熟手看的是另一层:MFC 的消息泵和 WinPcap 的回调天然是两个执行上下文,pcap_loop 跑在哪个线程、回调里能不能 Sleep、界面重绘会不会把抓包拖慢,这些决定了架构。我见过不少 Demo 把 pcap_loop 直接写在按钮响应函数里,点一下开始,整个窗口就白了,这不是 MFC 的问题,是把阻塞调用放在 UI 线程的经典翻车。

所以界面层我给个判断:工具型、单窗口、团队 C++ 背景、交付要轻,选 MFC;要跨平台、要复杂控件、要快速迭代 UI,选 Qt,别硬扛。这个结论不看情怀,只看你后续三个月要不要改界面。

2.2 抓包层是重头戏:WinPcap 和 Npcap 的四个实测差异

项目名叫 WinPcap,但真要在新机器上跑起来,绕不开 Npcap。两者 API 层面高度兼容,源码迁移量通常只有工程配置那几行,但行为差异会直接决定你的工具能不能用。

第一是系统兼容。WinPcap 最后一个版本停在很早期,驱动签名和 NDIS 版本都跟不上现在的系统,在较新的系统上经常是安装程序跑完了、设备管理器里也有虚拟网卡,但 pcap_findalldevs_ex 返回空列表,或者打开时直接报错。这不是代码写错,是驱动没加载。Npcap 在这一层的支持是一直在维护的,新系统上装完就能枚举到设备。

第二是回环抓包。WinPcap 抓不到 127.0.0.1 上的流量,这对调试本地服务是硬伤。你想验证自己写的客户端和服务端在本机通信时的报文格式,结果抓不到包,只能两台机器对着测。Npcap 在安装时勾选回环支持,就能枚举出一个回环设备,抓本机流量。这一条对学习协议的人价值极大,因为不用凑两台机器。

第三是链路层类型。这个坑最隐蔽:不同网卡返回的 pcap_datalink 不一样,以太网是 DLT_EN10MB,回环设备在部分实现里是别的类型,无线网卡也可能有差异。你按以太网固定 14 字节去解析,一旦换了设备,整个偏移全错,抓出来的端口号是乱码。正确做法是打开适配器之后立刻判断链路层类型,不是 DLT_EN10MB 就在界面上明确提示「当前设备非以太网链路,仅显示原始字节」,而不是硬解。

第四是分发形态。Npcap 有自己的安装器和 redist 版本,如果你的工具要给别人用,得考虑对方机器上有没有装驱动、装的是哪个版本、要不要静默安装。WinPcap 时代这个问题反而简单,因为大家装的都是同一个老包。这一点不是技术问题,是交付问题,但会在最后一刻咬你。

提示:不要在同一台机器上同时装 WinPcap 和 Npcap,驱动层会打架,表现是枚举到重复设备或者打开句柄失败。切换前先卸干净再重启。

2.3 三套组合放一起比:一张表看完取舍

维度MFC + WinPcapMFC + NpcapQt + Npcap
新系统可用性差,驱动常装不上好好
源码迁移成本基线低,改库路径即可中,界面全重写
回环抓包不支持支持(安装时勾选)支持
交付体积最小小,需带驱动安装器大,带 Qt 运行库
界面扩展性一般,需自绘一般强
适合人群老项目维护、教学新项目首选要长期迭代的团队

表格看完结论很清楚:如果你是照着这个项目学原理,MFC + WinPcap 的代码结构完全够用,把库换成 Npcap 照样编译;如果你是要交付一个能长期用的工具,直接按 MFC + Npcap 起步,省掉后面一堆兼容性扯皮。我自己的做法是保留 WinPcap 的 API 调用习惯,工程里只换链接的库和头文件路径,这样教学和实用两头都不耽误。

3. 骨架先跑通:环境配置、网卡枚举与抓包线程

3.1 VS 工程里最容易配错的三处

第一处是头文件顺序。WinPcap 的扩展接口(比如按字符串枚举设备)需要两个宏在包含头文件之前定义,漏了就会提示函数未声明,新手很容易以为是库没链上,跑去反复检查附加依赖项。

// stdafx.h / pch.h 中,必须写在 #include <pcap.h> 之前 #define WPCAP // 启用 WinPcap 扩展 API #define HAVE_REMOTE // 启用 pcap_findalldevs_ex 等远程/字符串源接口 #include <pcap.h> #pragma comment(lib, "wpcap.lib") // 抓包主库 #pragma comment(lib, "Packet.lib") // 驱动侧辅助库,Npcap 下路径不同

这段的关键在宏的位置,不在 lib。WPCAP 和 HAVE_REMOTE 决定了 pcap.h 里展开哪些声明,写反了编译器就找不到符号。Packet.lib 是老版本驱动访问用的,换成 Npcap 之后库目录变了,需要在项目属性里把附加库目录指向 Npcap 的 SDK 目录,否则链接期报 LNK2019。

第二处是平台位数。抓包驱动是分位数的,x86 的 exe 配 x64 的库,编译能过,运行直接崩在 pcap_open 上。工程属性里的平台和实际链接的 lib 目录必须一致,这一点在从别人那儿拷工程时特别容易错。

第三处是字符集。前面提过,选 Unicode 之后所有 pcap 返回的 char* 错误信息都要转换才能显示。错误缓冲区 errbuf 是 PCAP_ERRBUF_SIZE 大小,别自己拍脑袋定 256。

3.2 枚举网卡并打开适配器:把失败原因写进日志

枚举这步的价值不只是拿设备列表,而是把所有失败原因暴露出来。很多人写 Demo 只看函数返回值,失败了就一个弹窗「打开失败」,然后没下文,排查全靠猜。

pcap_if_t* alldevs = nullptr; char errbuf[PCAP_ERRBUF_SIZE] = { 0 }; // 第二个参数是远程认证信息,本机抓包传 nullptr if (pcap_findalldevs_ex(PCAP_SRC_IF_STRING, nullptr, &alldevs, errbuf) == -1) { // errbuf 里是具体原因:驱动没装、权限不足、服务未启动 CString msg; msg.Format(_T("枚举网卡失败: %s"), CA2T(errbuf)); AfxMessageBox(msg); return; } // 遍历时优先取带地址的设备,没有地址的通常是驱动虚拟出来的空壳 for (pcap_if_t* d = alldevs; d != nullptr; d = d->next) { CString name(d->name); CString desc = d->description ? CString(d->description) : _T("(无描述)"); m_listDev.AddString(desc + _T(" | ") + name); }

这段里有两个细节值得说。一是 d->description 可能为空,老驱动不填这个字段,不判空直接转 CString 会崩。二是设备名(d->name)才是后面打开用的标识,描述只是给人看的,别把描述传进 pcap_open。枚举完之后记得在合适的位置 pcap_freealldevs,否则反复点「刷新」会泄漏。

打开适配器用 pcap_open,参数分别是设备名、抓包长度(snaplen)、混杂模式标志、读超时毫秒、认证信息、错误缓冲。

// snaplen 设 65535 表示抓完整帧;只想看头部可以设 96,能显著降低拷贝开销 // 混杂模式传 1 只对共享网络段有效,交换网络和无线网卡上基本抓不到别人的流量 pcap_t* adhandle = pcap_open(devName, 65535, PCAP_OPENFLAG_PROMISCUOUS, 1000, nullptr, errbuf); if (adhandle == nullptr) { // 常见原因:驱动未加载、被安全软件拦截、设备已被其他程序独占 return FALSE; }

snaplen 这个参数新手最容易随手写个 65535 然后不管。它在高流量下是真会掉包的,因为每个包都要完整拷到用户态。只做协议分析的话 96 字节够看到四五层头,抓包效率能差出好几倍。读超时 1000 毫秒的意思是驱动最多攒 1 秒就返回一批,这个值影响的是响应延迟,不是丢包。

3.3 抓包线程与界面线程:用环形缓冲加自定义消息解开死锁

pcap_loop 是阻塞的,放在 UI 线程里点一下就假死。标准做法是开一个工作线程跑循环,回调里不碰任何控件,把数据投给界面线程。

投递方式有两种。简单的是 PostMessage 带上堆上的数据指针,界面线程收到后负责 delete,好处是解耦彻底,坏处是高流量下消息队列会爆,界面处理不过来。稳一点的是回调里把数据压进一个固定容量的环形缓冲,界面用定时器按 100 毫秒批量取,取的时候加个临界区。后者在千兆环境下明显更稳,因为消息数量被定时器限流了。

// 工作线程入口,pParam 传对话框 this 指针 UINT CMySnifferDlg::SniffThread(LPVOID pParam) { CMySnifferDlg* pThis = (CMySnifferDlg*)pParam; // cnt 传 -1 表示一直抓到 pcap_breakloop 被调用为止 pcap_loop(pThis->m_adhandle, -1, packet_handler, (u_char*)pThis); return 0; } // 回调运行在抓包线程,这里只做解析入队,绝不直接操作控件 void packet_handler(u_char* param, const struct pcap_pkthdr* header, const u_char* pkt_data) { CMySnifferDlg* pThis = (CMySnifferDlg*)param; if (header->caplen < 14) return; // 截断帧直接丢,别硬解 pThis->PushToRingBuffer(pkt_data, header); // 内部加锁 // 通知界面有新数据,消息只带一个计数,不带指针,避免生命周期问题 ::PostMessage(pThis->m_hWnd, WM_PACKET_ARRIVED, 0, 0); }

这里的 msg 参数传 0,界面线程收到后自己去环形缓冲取,指针不跨线程,谁分配谁释放的问题就不存在了。停止抓包调 pcap_breakloop,它会让 pcap_loop 尽快返回,然后在线程结束时 pcap_close。顺序反了会崩,因为回调还在跑就关了句柄。

注意:pcap_breakloop 不保证立即返回,它只是设置一个标志,循环会在下一次超时或收包后退出。所以你在停止时如果马上 pcap_close,大概率踩到野指针。等线程 WaitForSingleObject 返回之后再关。

4. 解析层怎么写:从以太网帧还原出五元组

4.1 逐层剥头:14 字节起步的偏移算法

解析的核心就一件事:算出每层的起始偏移和头部长度,然后用 ntohs / ntohl 把网络字节序转回主机序。写死偏移是最常见的错误来源,因为 IP 有选项时头部不是 20 字节,TCP 有选项时也不是 20 字节,VLAN 帧还会整体后移 4 字节。

void ParsePacket(const u_char* pkt, u_int caplen, u_int wirelen) { if (caplen < 14) return; // 以太网头:目的 MAC 6 + 源 MAC 6 + 类型 2 u_short ethType = ntohs(*(const u_short*)(pkt + 12)); u_int off = 14; // 802.1Q VLAN:外层类型 0x8100,内层真实类型在 +16,偏移整体后移 4 if (ethType == 0x8100 && caplen >= 18) { ethType = ntohs(*(const u_short*)(pkt + 16)); off += 4; } if (ethType != 0x0800) return; // 只解 IPv4,ARP 和 IPv6 另走分支 if (caplen < off + 20) return; const u_char* ip = pkt + off; u_int ihl = (ip[0] & 0x0F) * 4; // 首部长度以 4 字节为单位,最小值 20 if (ihl < 20 || caplen < off + ihl) return; u_short ipTotalLen = ntohs(*(const u_short*)(ip + 2)); u_char proto = ip[9]; // IP 总长减去 IP 头,得到载荷长度;用它而不是 caplen,因为 caplen 可能被 snaplen 截断 u_int payload = (ipTotalLen > ihl) ? (ipTotalLen - ihl) : 0; if (payload == 0) return; if (proto != 6 || caplen < off + ihl + 20) return; // 只看 TCP,且要够最小头 const u_char* tcp = ip + ihl; u_short sport = ntohs(*(const u_short*)(tcp)); u_short dport = ntohs(*(const u_short*)(tcp + 2)); u_int seq = ntohl(*(const u_int*)(tcp + 4)); u_char flags = tcp[13]; u_int tcpHl = (tcp[12] >> 4) * 4; // TCP 头长度同样以 4 字节为单位 if (tcpHl < 20) return; u_int dataLen = (payload > tcpHl) ? (payload - tcpHl) : 0; // 到这里五元组齐了:sport / dport / proto,加上 ip 头里的源目地址 }

这段最值得盯的是三个地方。一是 caplen 和 wirelen 的区别:header 里 caplen 是实际抓到的字节数,wirelen 是线上真实长度,snaplen 设小了 caplen 会小于 wirelen,这时候你按 wirelen 去算载荷长度没问题,但绝不能用 wirelen 去访问 pkt 数组,越界读就崩了。二是 ihl 和 tcpHl 都可能是 0 或超过实际长度,必须校验,我见过构造的畸形包直接把 tcpHl 算成 60,一读就越界。三是所有多字节字段都要 ntohs / ntohl,漏一个端口号就变成 12345 这种莫名其妙的数字。

4.2 过滤表达式与内核态过滤:把无用流量挡在回调之外

抓到全部流量再在回调里 if 判断协议,是很浪费的做法。WinPcap 支持 BPF 过滤表达式,编译后塞进内核,不匹配的包根本不会拷到用户态。这在千兆网卡上差别非常明显。

struct bpf_program fcode; // 只关心本机 80 端口相关的 TCP,外加所有 ARP 方便观察地址解析 const char* filter = "tcp port 80 or arp"; // 第三个参数 1 表示编译后立即生效;第四个参数是子网掩码,可为 0 if (pcap_compile(adhandle, &fcode, filter, 1, 0) < 0) { // 表达式语法错会返回 -1,错误信息用 pcap_geterr 取 return FALSE; } if (pcap_setfilter(adhandle, &fcode) < 0) return FALSE; pcap_freecode(&fcode); // 设置成功后立刻释放,否则重复设置会泄漏

过滤表达式有几个新手常踩的语法坑:端口写 port 80 而不是 port = 80;抓某个网段用 net 192.168.1.0/24;方向和逻辑组合要用 and / or / not,不是 && 和 ||。写错了编译期就报错,好在 pcap_geterr 会给出具体位置。

另一个容易忽略的点是过滤粒度。如果你要做的是会话统计,过滤掉 ARP 和 ICMP 会让统计看起来「少了点什么」,因为地址解析失败往往就是 ARP 的问题。我一般先不过滤跑一遍看全貌,再根据分析目标收窄表达式。

4.3 界面呈现:列表虚拟化与详情十六进制视图

解析结果最终要落在控件上。MFC 的 CListCtrl 在几千行时还行,上到几万行就开始拖。解决办法是虚拟列表:控件的 LVN_GETDISPINFO 里按需返回某一行的文本,数据本身存在自己的 vector 里,控件只负责显示可见的那几十行。这么改完之后,十万条记录的滚动是流畅的。

详情区我一般用两个视图并排:上面是协议树(文本层级),下面是十六进制加 ASCII 的对照。十六进制视图不复杂,每行 16 字节,左边偏移,中间 hex,右边可打印字符,其余用点号占位。这个视图是排查错位的利器——当解析出来的端口明显不对时,直接看原始字节,对着 RFC 的字段布局数一遍,问题基本就定位了。

还有一点是时间戳。header 里的 ts 是 timeval,秒加微秒,别只显示秒,抓包分析里毫秒级差异很关键,比如判断重传和乱序。我习惯显示成时分秒加六位小数,列表里对齐排好,看起来清爽也好比对。

5. 避坑与排查:五个真机翻车现场

5.1 抓不到包:三类现象的排查顺序

现象一:界面上一个包都不来,但网卡列表正常。原因通常是过滤表达式写得太窄或者干脆编译失败被忽略了返回值。解决是先清空过滤器跑一遍,确认有流量之后再逐步加条件;同时检查 pcap_compile 的返回值,别把 -1 当成功。

现象二:能抓到广播和 ARP,但抓不到本机访问外网的 TCP。原因是混杂模式在交换网络下不管用,你只可能看到本机收发的流量;如果连本机的都没有,多半是选错了适配器(选中了虚拟网卡或已断开的无线)。解决是把网卡描述和地址都列出来,带 IP 的那个才是你要的。

现象三:装完驱动重启后依然枚举为空。原因是驱动没真正加载,设备管理器里能看到叹号,或者被安全软件拦了。解决是把安全软件临时放行,卸载后重装,装完必须重启,别跳过这一步。

5.2 界面卡死与崩溃:线程边界上的坑

现象:点「开始抓包」窗口立刻无响应,拖都拖不动。原因很明确,pcap_loop 跑在 UI 线程。解决是移到工作线程,回调里只入队。这个坑几乎每个手写抓包工具的人都踩过一次,包括我。

现象:抓包跑一会儿偶发崩溃,dump 里指向 CString 构造。原因是回调里直接拼 CString 更新控件,跨线程访问了 MFC 对象。MFC 的大多数对象有线程亲和性,必须回到创建它的线程去用。解决办法就是前面说的 PostMessage 加自己取数据,绝不在工作线程里构造界面对象。

现象:点「停止」后概率性崩溃。原因是停止时 pcap_close 在线程还没退出时就执行了。解决是 breakloop 之后等线程句柄,超时设个两三秒,没退就再等,确认退出再 close。

5.3 解析错位与乱码:字节序、截断长度和字符集

现象:端口号显示成 80 但源地址变成 0.0.0.0,或者端口是 20480 这种明显不对的数。原因是没有做字节序转换,或者用 caplen 当总长去算偏移。解决是把所有 ntohs / ntohl 补全,并且统一用 IP 头里的总长字段做边界判断。

现象:抓到的载荷里有中文显示成方块或问号。原因是 char 数组直接转 CString 走了错误的代码页。解决是明确源数据编码(HTTP 头里的 charset 或按 UTF-8 尝试),用 MultiByteToWideChar 显式指定代码页转换,失败就回退成十六进制显示,别猜。

现象:换了台机器或换了无线网卡,所有字段全乱。原因是链路层类型不是以太网,你还在按 14 字节偏移解。解决是打开后检查 pcap_datalink,不是 DLT_EN10MB 就只显示原始字节并在状态栏提示。

注意:调试解析逻辑时,把 pcap 存成文件再离线读,比反复在线抓效率高得多。用 pcap_dump 落盘,改完代码再 pcap_open_offline 重放,同一份数据可以反复验证,不用每次重新复现现场。

6. 最后一公里:让它从玩具变成能用的判断依据

跑通抓包和解析只是及格线,真正决定这个工具好不好用的是三件事:性能、留存、结论。

性能上,把 snaplen 从 65535 降到 96 到 128,环形缓冲容量按每秒包数估,界面刷新用 100 毫秒定时器而不是收到就刷,这三条改完,千兆环境下基本不丢包也不卡顿。要更稳还可以调 pcap_setbuff 加大内核缓冲,代价是内存占用,按机器配。

留存上,一定要有导出 pcap 的能力。自己写的解析再全,也比不上成熟分析工具的深度,遇到疑难问题时把原始包存下来,换个工具二次分析,是最省时间的路径。加上时间戳和抓包时的过滤器条件一起记下来,回看的时候才知道上下文。

结论上,列表里别只堆原始包,按五元组聚合出一个会话视图,显示每个会话的包数、字节数、起止时间、TCP 标志位分布。判断「这个连接是不是有问题」靠的是会话级的统计,不是单包。我自己最常用的一列是标志位序列,SYN 之后连续多个 SYN 就是没连上,出现了 RST 就是被拒,这些一眼就能看出来,比逐包翻快得多。

回过头看,我在这个项目上最大的教训不是哪个 API 用错,而是早期图快把所有逻辑塞进一个文件、所有调用放在 UI 线程,等到要加会话统计和过滤功能时,改动成本翻了三倍。从那以后我每次起手写这类工具,都强制先把线程边界、数据结构和停止流程这三件事定下来再写第一行解析代码,哪怕多花半天,后面省的是好几天。这套骨架本身不难,难的是别在细节上想当然,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进