资讯动态

基于WinPcap的数据包嗅探器:协议解析、会话重组与验收实践

发布时间:2026/10/26 17:05:09 来源:尧图企业网站定制
简介面向高校计算机网络安全课程学习者与实验实践者这份华中科技大学2018年网络安全实验源码围绕基于Winpcap的数据包抓取与分析器展开并给出ARP攻击与中间人攻击的实验实现可用于课程实验、协议分析练习和攻防原理验证。压缩包共27个文件、约293KB以7个cpp和8个头文件承载核心逻辑配合2个ui界面文件及pro、qrc等工程配置另有png、jpg图示资源便于在Qt环境中查看界面、理解模块划分并复现实验流程。已有2815人学习具备一定参考热度。读者可据此掌握抓包线程、协议解析、发包与ARP欺骗等模块的组织方式结合工程结构梳理嗅探器与中间人攻击实验的实现脉络并辅助排查环境配置和代码运行中的常见问题。1. 抓包程序跑通了却讲不清这份网络安全实验要交付什么A同学的程序已经能抓到包dump 出来一屏十六进制可 TCP 端口读出来是 65535HTTP 请求头全是乱码。问题并不在 WinPcap他把 IP 头长度写死成 20 字节一遇到带选项的包后面整条解析链就错位了。这类情况在网络安全实验里很常见——基于 WinPcap 的数据包分析器嗅探器看着只是调几个 API真正拉开差距的是偏移计算、字节序处理、会话重组和长时间运行的稳定性。这套方案围绕一个完整的数据包嗅探与分析器展开按抓包层、协议解析层、会话层、展示层四层拆开给出可编译的源码片段、参数说明、会话表设计和一份能现场演示的验收清单。适合三类读者赶网络安全实验和课程设计的同学、想拿一份结构清晰、能直接改的抓包分析源码的开发者以及把流量分析做成毕设题目的读者。2. 需求拆解与技术选型四层能力边界与 WinPcap 的位置动手写代码之前先把边界划清楚否则很容易做成“能抓包但不能分析”的半成品。实验室里常见的问题是一上来就调pcap_loop回调里直接 printf 二进制写完发现无法回答“这条连接聊了什么”“哪个 IP 流量最大”这类必须回答的问题。所以先定能力点再定技术栈。2.1 四个可验收的能力点第一是抓包能力能枚举本机网卡、指定任意一块采集、支持实时过滤、能保存成 pcap 文件供离线回放。第二是解析能力把链路层、网络层、传输层、应用层的字段拆出来以太网类型、IP 源目的地址、TCP 端口与标志位、应用层首部行一个都不能少。第三是会话能力按五元组把散落的包聚成流能按流导出负载、能算每条流的字节数和持续时间。第四是统计与展示协议分布、Top N 会话、异常端口或明文凭据的标记。这四点对应四个模块也正好是答辩时的四个演示环节。很多同学只做了第一点就以为做完了结果被问“这条 TCP 流重传了几次”直接卡住。2.2 抓包引擎的取舍WinPcap、Npcap 与原始套接字方案依赖抓包范围过滤能力适用场景WinPcap独立内核驱动混杂模式下可收本广播域帧内核态 BPF课程实验、教材配套Npcap兼容 WinPcap 接口同上另支持回环采集内核态 BPF新系统上的首选原始套接字 SOCK_RAW系统自带无额外安装仅本机进出流量只能用户态自行筛轻量工具、不想装驱动SharpPcap 等托管封装跟随底层驱动跟随底层跟随底层C# 做界面选型结论很直接实验要求“基于 WinPcap”代码就按 WinPcap 的 API 写编译时链接wpcap.lib和Packet.lib实际在自己机器上跑用 Npcap 的兼容模式安装即可头文件和函数签名不变一份源码两边都能编译。原始套接字的优势是零依赖缺点是拿不到链路层帧头、也无法使用内核过滤做协议分析器会缺一层数据不建议作为主路线。提示所有抓包行为只在本人设备、本人有权分析的实验环境中进行不要对他人网络做采集。2.3 源码目录与模块职责划分目录按层切不按文件类型切这样改一层不会牵连另一层。下面是我一般会用的结构sniffer/ ├── src/ │ ├── capture/ # 抓包层设备枚举、打开、过滤、循环 │ │ ├── device.c │ │ └── capture.c │ ├── decode/ # 解析层逐层偏移计算每个协议一个文件 │ │ ├── eth.c │ │ ├── ipv4.c │ │ ├── tcpudp.c │ │ └── app.c │ ├── session/ # 会话层五元组聚合、流重组、超时淘汰 │ │ └── flow_table.c │ └── output/ # 展示层控制台表格、pcap 落盘、统计导出 ├── include/ └── tests/ # 离线 pcap 样本与回归脚本会话层的核心键值定义成定长结构体方便做哈希和比较/* 五元组双向流的键方向在查找时归一化处理 */ typedef struct { uint32_t src_ip; /* 网络字节序 */ uint32_t dst_ip; uint16_t src_port; /* 主机字节序解析时已 ntohs */ uint16_t dst_port; uint8_t proto; /* 6TCP, 17UDP, 1ICMP */ } flow_key_t;这里有个容易忽略的设计点同一个连接的两个方向源和目的在结构体里是反的如果直接拿它当 key一条流会被拆成两条。通常的做法是在插入前做一次归一化——比较 (ip, port) 二元组的字典序小的那端固定放在src字段同时在流结构里记一个direction标记重组时按标记决定拼接顺序。会话表字段类型作用keyflow_key_t哈希索引first_seen / last_seen时间戳计算持续时长、超时淘汰packets / bytes计数统计与 Top N 排序isnuint32起始序列号重组基准segs链表乱序区间缓存state枚举SYN / ESTABLISHED / CLOSED有了这张表后面所有“按流分析”的需求都能挂上去包括导出某个流的上传文件、统计重传次数、标记长时间空闲连接。3. 抓包层实现网卡枚举、混杂模式与 BPF 内核过滤的落地细节抓包层是整个程序的地基它决定了上限。这一层写不好后面解析再漂亮也没用——抓不全、抓不全还丢包统计出来的数据全是错的。3.1 网卡枚举与设备句柄打开先拿到设备列表再让用户或配置文件指定一块而不是依赖pcap_lookupdev返回的默认值那个函数在新系统上经常返回一个不可用的名字。#include pcap.h pcap_if_t *alldevs NULL, *d NULL; char errbuf[PCAP_ERRBUF_SIZE] {0}; /* 1. 枚举全部网卡含未启用的虚拟网卡 */ if (pcap_findalldevs(alldevs, errbuf) -1) { fprintf(stderr, enum devices failed: %s\n, errbuf); return -1; } /* 2. 逐块尝试打开跳过非以太网链路类型 */ pcap_t *handle NULL; for (d alldevs; d ! NULL; d d-next) { handle pcap_open_live(d-name, 65535, /* snaplen抓完整帧避免截断 */ 1, /* promisc混杂模式 */ 1000, /* to_ms读超时单位毫秒 */ errbuf); if (handle pcap_datalink(handle) DLT_EN10MB) break; if (handle) { pcap_close(handle); handle NULL; } } pcap_freealldevs(alldevs);三个参数值得逐一说清。snaplen设成 65535 是为了拿到完整帧如果设成教材里常见的 96 或 1514超长帧和带 VLAN 标签的帧会被截断caplen小于len重组时缺字节导出的文件和原始文件校验对不上。promisc决定是否进入混杂模式下文单独讲。to_ms是读超时它决定pcap_next_ex多久返回一次设成 0 会一直阻塞直到有包设得太小则空转耗 CPU1000 毫秒在实验场景下比较稳。pcap_datalink的检查别省。有些虚拟网卡返回的是DLT_NULL或DLT_RAW帧头长度不是 14 字节如果按以太网帧解析从第一个字节就错位现象就是“能抓到包但所有字段都是乱的”——这个坑后面还会再提一次。3.2 混杂模式到底开不开promisc 参数与实验环境promisc 1时网卡会把收到的所有帧都上交包括目的 MAC 不是自己的promisc 0时只收广播、组播和发给自己 MAC 的帧。在交换式网络里交换机会按 MAC 表转发本机端口上本来就看不到别人的单播流量开不开混杂区别不大。所以实验里能看到多少流量取决于接入方式而不是这个参数本机自身的进出流量永远可见同一广播域内的广播帧可见剩下的要看实验环境是怎么搭的——多数课程实验会安排在本机回环、镜像端口或集线器环境下完成这也是能演示出效果的前提。注意不要为了“多抓点东西”把程序部署到不属于自己的网络位置实验的边界就是自己的设备和自己有权分析的链路。无线网卡是另一个变量。部分无线驱动在混杂模式下的行为与有线不同抓到的帧可能带 802.11 头而不是以太网头此时pcap_datalink返回的不是DLT_EN10MB程序应该给出一句明确的提示而不是默默解析出错。我一般会在打开设备后打印一行datalink %d出问题时第一眼就能确认。3.3 BPF 过滤把过滤放到内核里过滤不是“抓完再筛”而是编译成 BPF 指令交给驱动不匹配的包在内核态就被丢掉用户态根本没机会看到。这一个设计直接决定高流量下会不会丢包。struct bpf_program fp; const char *filter_exp tcp port 80 or udp port 53; /* optimize1 让编译器做指令优化netmask 用于 broadcast 之类的关键字 */ if (pcap_compile(handle, fp, filter_exp, 1, PCAP_NETMASK_UNKNOWN) 0) { fprintf(stderr, compile filter failed: %s\n, pcap_geterr(handle)); return -1; } if (pcap_setfilter(handle, fp) 0) { fprintf(stderr, set filter failed: %s\n, pcap_geterr(handle)); return -1; } pcap_freecode(fp); /* 编译产物用完即释放否则每次改规则都泄漏 */常用的表达式整理成一张表改规则时直接取用表达式含义tcp只要 TCP 报文udp port 53DNS 查询与响应host 192.0.2.10与该地址的双向流量文档保留地址实际换成自己的not arp and not icmp排除 ARP 与 ICMP减少干扰tcp[tcpflags] tcp-syn ! 0只抓 SYN看连接发起ip[2:2] 500按 IP 总长度筛大包pcap_compile的第三个参数是优化开关第四个是网络掩码。掩码只在表达式里用到broadcast、multicast这类关键字时才起作用不清楚就传PCAP_NETMASK_UNKNOWN别乱填一个值导致broadcast匹配错。一个实操习惯程序启动时先不带过滤跑十几秒确认能看到包再加上过滤表达式。反过来做出问题时你分不清是过滤写错了还是根本没抓到——这是排查阶段最浪费时间的一种情况。3.4 抓包循环、超时与丢包统计pcap_loop和pcap_next_ex的差别在于前者带回调、后者是拉取式。做分析器我倾向用pcap_next_ex配一个独立线程因为回调函数里做任何耗时操作都会拖慢驱动的缓冲回收进而丢包。/* 采集线程主循环只做拷贝入队不做解析 */ while (g_running) { struct pcap_pkthdr *hdr NULL; const u_char *data NULL; int rc pcap_next_ex(handle, hdr, data); if (rc 0) continue; /* 读超时正常现象 */ if (rc -1) { /* 读错误记录并退出 */ break; } if (rc -2) break; /* 离线文件读完 */ /* hdr-caplen 是实际抓到的字节数hdr-len 是原始帧长度 两者不等说明被 snaplen 截断重组时必须处理 */ ring_push(g_ring, hdr, data); } /* 定期打印统计ps_drop 才是真实丢包数 */ struct pcap_stat st; if (pcap_stats(handle, st) 0) { printf(recv%u drop%u ifdrop%u\n, st.ps_recv, st.ps_drop, st.ps_ifdrop); }ps_drop是驱动层因为缓冲区满而丢弃的包数ps_recv是交给应用层的包数。如果ps_drop一直涨说明消费速度跟不上生产速度要么把解析挪到工作线程要么调大驱动的缓冲区要么把过滤规则收窄。这个指标建议常驻在界面上它是判断程序健康度的最直接信号比任何日志都直观。环形队列的容量按“峰值速率 × 消费耗时”估。千万级 pps 的场景轮不到课程实验但实验室里跑一个打流工具也很容易把队列打满容量给到几万条、单条按snaplen预留几百兆内存就够用了。4. 协议解析层实现从以太网帧到 TCP 流的逐层偏移计算解析层的本质是一串减法每一层告诉下一层“我从哪里开始、有多长”。写错任何一层的长度后面的字段全部错位。这部分代码不难难在没有一行是能凭直觉写的。4.1 以太网帧头与 VLAN 标签的偏移最小实现是读 14 字节取第 13、14 字节的 EtherType 判断上层协议。但真实环境里 VLAN 标签会让偏移浮动一层 802.1Q 加 4 字节QinQ 双层加 8 字节。/* 返回上层协议号*offset 输出三层头的起始位置 */ uint16_t decode_eth(const uint8_t *frame, int caplen, int *offset) { if (caplen 14) return 0; uint16_t type (frame[12] 8) | frame[13]; int off 14; /* 逐层剥 VLAN 标签最多剥两层防死循环 */ int guard 0; while ((type 0x8100 || type 0x88A8) guard 2) { if (caplen off 4) return 0; type (frame[off 2] 8) | frame[off 3]; off 4; } *offset off; return type; /* 0x0800IPv4, 0x0806ARP, 0x86DDIPv6 */ }这里没有用结构体强转而是手工按字节取值。原因是结构体对齐struct ethhdr在某些编译器下会被填充到 16 字节sizeof出来不是 14一乘一加就偏了。要么#pragma pack(1)要么就像上面这样逐字节组。后者更啰嗦但不会翻车我一般选后者。guard那个计数器不是多余的。如果帧本身损坏标签位一直是 0x8100没有上限的 while 会一直读越界——这是缓冲区溢出最常见的入口之一。4.2 IPv4 头部IHL、总长度与分片以太网返回 0x0800 之后进入 IP 层。这里是错位问题的高发区核心就两个字段首部长度和总长度。typedef struct { uint8_t ver_ihl; /* 高 4 位版本低 4 位首部长度单位 4 字节 */ uint8_t tos; uint16_t total_len; /* 网络字节序含首部与负载 */ uint16_t id; uint16_t flags_frag; uint8_t ttl; uint8_t proto; uint16_t checksum; uint32_t src, dst; } ipv4_hdr_t; int decode_ipv4(const uint8_t *p, int caplen, ipv4_info_t *out) { if (caplen 20) return -1; int ihl (p-ver_ihl 0x0F) * 4; /* 关键不是固定 20 */ if (ihl 20 || ihl caplen) return -1; out-hdr_len ihl; out-total_len (p[2] 8) | p[3]; /* 网络字节序手工转换 */ out-proto p[9]; out-src read_u32_be(p 12); out-dst read_u32_be(p 16); uint16_t ff (p[6] 8) | p[7]; out-mf (ff 0x2000) ! 0; /* 还有后续分片 */ out-frag_off (ff 0x1FFF) * 8; /* 单位是 8 字节要乘回去 */ /* 用 total_len 而不是 caplen 作为负载边界caplen 可能被 snaplen 截断 */ out-payload_len out-total_len - ihl; return ihl; }三行注释对应三个真实问题。把ihl写死成 20遇到带选项的包比如带时间戳选项的探测包就整体错位端口读出来是随机数这正是第 1 章里 A同学遇到的现象。用caplen当负载长度遇到截断帧会把不属于本包的内存算进负载重组出来是脏数据。frag_off忘了乘 8分片顺序就全乱了。分片的处理策略要提前定。课程实验里通常做两级单包解析时标记mf和frag_off把分片按(src, dst, id, proto)缓存起来等偏移连续了再拼成一个完整的 IP 载荷交给上层如果只做统计不做重组至少要在界面上标一句“该包为分片负载未解析”别默默当成正常包处理。4.3 TCP/UDP 解析data offset 与端口判断TCP 头也是变长的data offset字段的含义和 IP 的 IHL 一样只是位置藏在第 13 个字节的高 4 位。int decode_tcp(const uint8_t *p, int caplen, tcp_info_t *t) { if (caplen 20) return -1; t-sport (p[0] 8) | p[1]; t-dport (p[2] 8) | p[3]; t-seq read_u32_be(p 4); t-ack read_u32_be(p 8); int doff (p[12] 4) * 4; /* 首部长度单位 4 字节 */ if (doff 20 || doff caplen) return -1; t-flags p[13]; /* FIN/SYN/RST/PSH/ACK/URG */ t-win (p[14] 8) | p[15]; t-payload_off doff; /* 负载相对 TCP 头起点的偏移 */ return doff; }UDP 简单得多固定 8 字节头但有个陷阱UDP 的length字段理论上应该等于头加负载实际抓到的包里偶尔不一致尤其是某些实现产生的畸形包。稳妥的做法是取min(length - 8, caplen - 8)作为负载长度宁可少算也不要越界读。端口到应用的映射我一般用一张静态表加一层启发式53 走 DNS、80/8080 走 HTTP、443 走 TLS、21 走 FTP 控制连接。但端口只是线索不是结论——实验里把服务跑在非标准端口是常态所以还要加首包特征判断负载前几个字节是可打印 ASCII 且以GET /、POST /、HTTP/1.开头就按 HTTP 解析二进制且第一个字节是 0x16、第二字节是 0x03就按 TLS 记录层处理。两条规则同时命中时以特征判断为准。4.4 会话表与流重组序列号区间合并单个包解析完只是原料要回答“这条连接传了什么”得把同一条流里的负载按序列号拼起来。真正的难点是乱序和重传网络上到达顺序和发送顺序不一致是常态。我一般用区间合并来处理每条流维护一个已写入区间列表新段到来时先算它相对 ISN 的偏移再和已有区间求并集只有落在空洞里的部分才真正追加到流缓冲区。/* 简化版把新段并入流缓冲区返回实际写入的字节数 */ int flow_insert(flow_t *f, uint32_t seq, const uint8_t *data, int len) { if (!f-has_isn) { f-isn seq; f-has_isn 1; } /* 首个 SYN 定基准 */ uint32_t rel seq - f-isn; /* 无符号减法天然处理回绕 */ /* 已完全覆盖重传→ 丢弃 */ if (range_contains(f-ranges, rel, rel len)) return 0; int written 0; for (int i 0; i len; i) { uint32_t pos rel i; if (!range_contains(f-ranges, pos, pos 1)) { f-buf[pos] data[i]; /* 空洞才写 */ written; } } range_add(f-ranges, rel, rel len); /* 只记区间不搬数据 */ return written; }seq - isn用的是无符号减法序列号回绕时结果依然正确这是刻意的写法不要改成有符号相减。f-buf按流的最大偏移动态扩容扩容策略用倍增避免每来一个段就 realloc 一次。range_contains用简单的区间链表或有序数组都行流数量在实验规模下几万条链表的 O(n) 完全够用。会话淘汰也在这层last_seen超过阈值TCP 给 300 秒UDP 给 60 秒就从哈希表摘掉缓冲区一起释放。少了这一步跑一下午内存就能吃掉几个 G这也是下一章要展开的问题之一。5. 避坑与排查抓不到包、字段错位、跑久了崩的 5 个高频问题这一章是我做完几轮实验后整理的“返工清单”每条都按现象、原因、解决三段写。真正调程序的时候八成时间耗在这五类问题上。5.1 现象程序启动成功包计数一直是 0原因三种可能按概率排序——设备选错了pcap_findalldevs返回的列表里混着若干未启用的虚拟网卡名字还都很像驱动没装或版本不匹配pcap_open_live其实返回了 NULL 但没检查只在回环上通信的流量跑在另一块逻辑网卡上而你打开的是物理网卡。解决先把所有设备名打印出来逐个打开并打印pcap_datalink的返回值给pcap_open_live的返回值和errbuf做双重判空第一轮调试先不设过滤表达式确认能抓到再逐步加规则。还有一个笨办法但非常有效手动 ping 一下外部地址如果计数器纹丝不动说明设备或权限有问题与解析代码无关。5.2 现象包抓到了但端口、长度字段全是奇怪的数字原因两个经典原因。一是字节序没转网络上是大端主机是小端ntohs/ntohl漏一处数值就完全对不上二是用结构体指针强转整帧编译器按 4 字节对齐填充结构体sizeof(struct iphdr)不等于 20指针一加就偏了。解决全部改成手工按字节取值再移位组合也就是 4.2 节那种写法或者用#pragma pack(1)并配合static_assert校验结构体大小。第二种写起来清爽但依赖编译器行为移植到别的工具链要重新验证稳妥起见我选第一种。另外建议把第一个抓到的包的十六进制 dump 出来人工对着协议格式数一遍偏移比断点调试十次都管用。5.3 现象流量一大就丢包界面卡住不动原因在pcap_loop的回调里做了解析、格式化、写日志甚至刷新界面。回调运行在采集线程上它一慢驱动缓冲区回收就慢ps_drop蹭蹭往上涨。这是最典型的架构问题不是代码 bug。解决回调或pcap_next_ex循环里只做一件事——把pcap_pkthdr和帧数据拷贝进环形队列立刻返回解析、格式化、落盘统统交给工作线程。队列要有满队列策略丢弃最旧的还是丢弃最新的做流量分析通常丢最新并计数保证展示出来的时序是连续的。改完再看ps_drop通常会掉到 0 或个位数。5.4 现象跑两三个小时内存从几十兆涨到几个 G原因会话表只增不减每条流都挂着缓冲区每次抓包malloc不freepcap_compile出来的bpf_program没调pcap_freecode离线文件回放时反复打开关闭句柄但没pcap_close。解决给会话表加定时清理线程按last_seen淘汰并按 LRU 兜底小对象用对象池复用别频繁走系统分配器bpf_program用完立刻释放写一个每 60 秒打印一次“当前流数量 / 缓冲总字节”的监控行肉眼就能看出增长趋势。真要定位泄漏用 ASan 或 Valgrind 跑一遍离线回放比盯代码快得多。5.5 现象重组出来的文件和原始文件大小对不上校验值不符原因caplen小于len的截断包没处理缺的字节被当成结束重传段重复写入分片包被当成完整包直接交给上层total_len大于实际抓到的长度时继续按total_len读读到了队尾之外的内存。解决所有按长度取数据的地方边界统一写成min(协议声明长度, caplen - 已消费偏移)重组时用 4.4 节的区间合并天然去重分片必须有重组逻辑或明确的“跳过并标记”分支把snaplen拉到最大从源头减少截断。最后做一个端到端的回归先用离线工具导出一段流再用程序重组同一段两个文件做哈希比对一致才算通过。6. 验收与答辩用三条证据链证明你的嗅探器真的能跑通答辩现场最怕的不是被问倒而是被问“你怎么知道它抓的是对的”。所以验收要准备成可复现的演示而不是一句“我测过了”。验收项演示动作通过标准抓包能力打开指定网卡发起一次网页访问3 秒内出现对应连接ps_drop保持个位数过滤能力切换udp port 53与tcp两条规则前后包类型完全隔离无残留旧规则解析能力定位一个带选项的 IP 包IHL 显示大于 20各层字段与十六进制 dump 对得上会话能力选一条 TCP 流导出负载与离线工具导出结果哈希一致稳定性连续跑 30 分钟内存曲线平稳流数量随超时淘汰回落第二条证据链是“讲一条流的故事”。挑一个完整的三次握手加一次请求响应从 SYN 包的序列号讲到负载的序列号偏移再讲为什么最终缓冲区里只有一份数据而没有重传副本。这一段讲得出来说明你是真懂数据讲不出来代码基本是抄的。第三条是自我攻击测试故意构造畸形长度字段的包、故意制造乱序和重传、故意把snaplen调到很小看程序是崩掉还是给出明确提示。能做到后者的方案才算过关。我有一个改不掉的习惯每次提交前先拿一段固定的离线 pcap 回放一遍把五个验收项跑满再对着统计数字看一遍有没有异常跳变。这套动作前后不过五分钟但省下来的返工时间远超投入——毕竟解析错位的 bug靠肉眼盯代码是找不出来的。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑