简介:这份华中科技大学2018年计算机网络安全实验源码,面向学习网络协议分析、抓包嗅探与ARP欺骗原理的高校学生及安全初学者,围绕基于Winpcap的数据分析器实现展开,可用于课程实验复现与Qt网络编程参考。压缩包内共27个文件,以8个C++头文件和7个源文件为核心,配合Qt界面文件、界面截图与图片素材,以及qmake工程配置和资源文件,整体约293KB,结构紧凑,便于按模块阅读。内容覆盖数据包捕获线程、数据包发送、协议解析、ARP报文构造及ARP欺骗对话框等环节,既呈现从链路层抓包到中间人攻击演示的完整流程,也提供可编译运行的Qt工程骨架,便于对照实验要求梳理模块划分、线程协作与界面交互。目前已有2815人学习下载,适合需要复盘实验思路、补充抓包与ARP攻击实现细节的读者参考。
1. 从一次「只打印长度」的抓包实验说起:这套嗅探器要解决什么
某高校网络安全实验课里,数据包分析器(嗅探器)几乎是必做题目:要求跑起来能抓到包、能把以太网帧一路剥到应用层、能在界面上说清「这个包里是谁在跟谁说话」。很多人第一版只写了pcap_loop加一句printf("len=%d"),答辩时被追问「这个 1460 字节的载荷属于哪条 TCP 连接、它的序号是多少」就哑了。这套基于 WinPcap 的实验源码要解决的就是三件事:抓得全(不因为界面刷新丢包)、解得开(链路层到应用层逐层有明确偏移)、说得清(会话、流量、Top Talker 能当场演示)。它适合正在做网络安全实验、计算机网络课程设计,需要一份能跑、能讲、能改的实现参考的人。所有抓取动作都限定在自己和团队授权的实验网段内,不碰生产环境。
2. 需求与技术选型:为什么是 WinPcap 加双缓冲队列
动手写代码之前,先要把「实验要交什么」翻译成「程序要有什么」。这一步偷懒,后面会反复返工——我见过不少同学先写了三天界面,最后发现抓包线程和界面线程抢同一块缓冲区,一开混杂模式就假死。
2.1 实验要交付的三张成绩单:抓得到、看得懂、说得清
把验收标准反过来当需求写,是最省事的做法。抓得到,指的是网卡枚举、过滤器、混杂模式、抓包文件落盘四件事都要通;看得懂,指的是至少能解析以太网、ARP、IPv4、TCP、UDP、ICMP,再挑 DNS 和 HTTP 做应用层展示;说得清,指的是能给出会话维度的统计,而不是一堆孤立的数据包。
功能清单通常长这样:
| 模块 | 必须实现 | 加分项 |
|---|---|---|
| 采集 | 网卡列表、BPF 过滤、开始/停止 | 抓包文件保存与回放 |
| 解析 | Eth/ARP/IP/TCP/UDP/ICMP | DNS、HTTP 首行、TLS 握手类型 |
| 统计 | 包数、字节数、协议分布 | 五元组会话表、Top Talker |
| 展示 | 列表 + 详情字段树 | 十六进制视图、时间轴 |
非功能需求容易被忽略,但恰恰是答辩时区分「能跑」和「做完了」的地方:连续抓十分钟不崩、界面不卡、内存不持续上涨、过滤表达式改完立即生效。这三条对应的就是线程模型、缓冲区上限和资源释放,后面几章会逐条落地。
2.2 WinPcap 的能力边界与四种抓包方案对比
在 Windows 上做抓包,可选的路子不止一条,选型理由要能说出口,不能只说「老师给的」。
| 方案 | 拿到什么 | 优点 | 限制 |
|---|---|---|---|
| WinPcap / Npcap | 链路层原始帧 | API 成熟,过滤器强,能存 pcap | 需装驱动,权限要求高 |
| 原始套接字 SOCK_RAW | IP 层及以上 | 系统自带,无需驱动 | 拿不到以太网头,过滤器要自己写 |
| 系统自带抓包工具 | 离线文件 | 零开发成本 | 无法做二次解析与界面集成 |
| 自己写 NDIS 过滤驱动 | 全帧 | 性能最好 | 实验周期内做不完 |
结论很清楚:实验要的是链路层帧 + 灵活过滤 + 可回放,WinPcap 的pcap_*接口是成本最低的选择。需要注意的现实问题是驱动兼容性,新版本 Windows 上一般装 Npcap 并勾选兼容模式,代码层的pcap_open_live、pcap_compile这些调用完全不用改,这也是选它的一个隐性好处——换机器不用重写。
提示:链接库时把
wpcap.lib和Packet.lib都带上,只带前者会在调用pcap_findalldevs时报一堆未解析符号,这是最常见的环境问题。
2.3 线程模型:采集线程只做搬运,解析和刷新各管一段
最省事也最容易翻车的写法是在pcap_loop的回调里直接解析 + 刷界面。回调是驱动回调上下文,在里面做耗时操作会拖慢内核缓冲区取包,表现出来就是「明明网卡在跑,程序却少包」。我的做法是把链路切成三段:
网卡 → NPF 驱动缓冲 → 采集线程(回调仅入队) → 环形队列 → 解析线程 → 会话表/统计 → UI 定时拉取采集线程的回调只做三件事:拷贝帧、记时间戳和原始长度、入队。解析线程批量出队,一次处理 64 个包再更新统计,减少锁竞争。界面用 200ms 定时器主动拉快照,而不是让解析线程去碰控件。
队列的容量和丢弃策略要显式设计,不能默认「不会满」:
// 固定容量环形队列:满了丢新包并计数,绝不阻塞采集线程 class PacketQueue { public: explicit PacketQueue(size_t cap) : cap_(cap) {} bool push(Packet&& p) { std::unique_lock<std::mutex> lk(m_); if (q_.size() >= cap_) { // 容量 8192 是实测比较稳的值 dropped_.fetch_add(1); // 丢弃要计数,界面上必须能看见 return false; } q_.push_back(std::move(p)); cv_.notify_one(); return true; } // 批量出队:一次最多取 maxN 个,减少锁的获取次数 size_t popBatch(std::vector<Packet>& out, size_t maxN, int waitMs) { std::unique_lock<std::mutex> lk(m_); if (q_.empty()) { cv_.wait_for(lk, std::chrono::milliseconds(waitMs), [this] { return !q_.empty() || stop_; }); } size_t n = 0; while (!q_.empty() && n < maxN) { out.push_back(std::move(q_.front())); q_.pop_front(); ++n; } return n; } private: std::deque<Packet> q_; std::mutex m_; std::condition_variable cv_; size_t cap_; std::atomic<uint64_t> dropped_{0}; bool stop_ = false; };cap_取 8192 是个经验值:太小在突发流量下丢包明显,太大则内存占用和「停止响应」的延迟都上去了。dropped_这个计数器看着不起眼,但它是排查「为什么抓到的包比 Wireshark 少」的唯一线索,界面上一定要留一格显示它。waitMs给 100 到 200 之间,既能让解析线程及时退出,也不会空转烧 CPU。
3. 总体架构设计:四层职责与关键数据结构
架构图不用画得多漂亮,但每一层的输入输出必须能一句话说清。说不清就说明模块边界没划对,写到一半一定会互相打洞。
3.1 四层架构与数据流走向
采集层管网卡和过滤器,输出原始帧;解析层管偏移和协议,输出结构化字段;存储层管会话表和统计聚合;展示层只管渲染,不参与任何计算。数据流是单向的,展示层不能反向调用解析层,这条约束能挡住绝大多数耦合问题。
| 层 | 主要类/文件 | 输入 | 输出 |
|---|---|---|---|
| 采集层 | CaptureWorker | 网卡名、过滤表达式 | Packet 队列 |
| 解析层 | ProtocolParser | 一帧字节流 | ParsedPacket |
| 存储层 | SessionTable / Statistics | ParsedPacket | 会话记录、聚合值 |
| 展示层 | MainWindow / DetailView | 快照 | 表格与字段树 |
单向数据流带来一个必须接受的代价:界面永远是「稍微滞后」的。这个滞后在 200ms 级别时人眼完全无感,但换来的是采集线程永远不会被界面拖慢。
3.2 关键数据结构:Packet 与五元组会话表
Packet里必须同时保留「原始长度」和「抓取长度」两个字段,很多人只存一个,后面统计流量就会算错。
struct Packet { std::chrono::system_clock::time_point ts; uint32_t caplen; // 实际抓到的字节数,受 snaplen 限制 uint32_t len; // 帧在链路上的真实长度 std::vector<uint8_t> data; // caplen 长度的字节流 }; // 五元组作为会话键;双向流量合并到同一条会话 struct FlowKey { uint32_t ip_a, ip_b; // 已按大小归一化,保证 A→B 和 B→A 命中同一条 uint16_t port_a, port_b; uint8_t proto; bool operator==(const FlowKey& o) const { return ip_a == o.ip_a && ip_b == o.ip_b && port_a == o.port_a && port_b == o.port_b && proto == o.proto; } };归一化的做法是:比较(src_ip, src_port)与(dst_ip, dst_port)的字典序,小的放 a 侧。这样同一条连接的两个方向只会产生一个 key,会话表规模直接减半,统计里的「上行/下行」也能靠一个dir标记区分。
会话表用std::unordered_map<FlowKey, Session, FlowHash>,Session里放包数、字节数、首包时间、末包时间、TCP 状态标记。要注意的是必须设淘汰策略——按末包时间做定时清理,超过 5 分钟无新包的会话移出,否则长时间抓包内存曲线会一路向上,这是实验现场最容易暴露的问题之一。
3.3 BPF 过滤表达式:把「抓什么」前移到驱动层
过滤器不是可有可无的优化,它是让实验能讲出重点的关键。抓全量包然后再在代码里筛选,等于把驱动缓冲当垃圾桶,丢包率会明显上升。
| 目的 | 表达式 |
|---|---|
| 只看某台主机 | host 192.0.2.10 |
| 只看 Web 流量 | tcp port 80 or tcp port 8080 |
| 排除广播干扰 | not broadcast and not multicast |
| 只看 DNS 查询 | udp port 53 |
| 只看 TCP SYN | tcp[tcpflags] & tcp-syn != 0 |
| 组合条件 | host 192.0.2.10 and tcp and not port 22 |
编译和安装过滤器的代码不长,但两个返回值都要检查,失败时把pcap_geterr打出来:
bool applyFilter(pcap_t* ad, const char* expr) { struct bpf_program prog; // optimize=1 开启 BPF 指令优化;PCAP_NETMASK_UNKNOWN 表示不依赖子网掩码 if (pcap_compile(ad, &prog, expr, 1, PCAP_NETMASK_UNKNOWN) != 0) { fprintf(stderr, "compile failed: %s\n", pcap_geterr(ad)); // 表达式语法错误在这里暴露 return false; } if (pcap_setfilter(ad, &prog) != 0) { pcap_freecode(&prog); return false; } pcap_freecode(&prog); // setfilter 内部已拷贝,code 必须由调用方释放 return true; }snaplen也要一起定:全帧抓取用 65535,只看头部做统计用 96 就够,后者能显著降低拷贝开销。但在详情面板要展示载荷的场景下必须用大值,否则caplen < len,用户点开一个包发现载荷是空的,会以为是解析写错了。
4. 协议解析实现:从以太网帧头逐层剥到应用层
解析这一章是整个实验的技术核心,答辩时八成的问题集中在这里。写的时候守住一个原则:任何一层的偏移,都必须先用长度校验确认能读,再读。
4.1 链路层与网络层:用偏移量读,别硬套结构体
直接用struct强转字节流看起来很优雅,实际上是个深坑:编译器对齐、#pragma pack的平台差异、VLAN 标签导致的偏移变化,任意一条都能让解析结果错位。用显式偏移 + 大端读取函数最稳。
static inline uint16_t be16(const uint8_t* p) { return (uint16_t(p[0]) << 8) | p[1]; } static inline uint32_t be32(const uint8_t* p) { return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | p[3]; } // 返回三层起始偏移;遇到 802.1Q 标签要连续跳过 bool parseEthernet(const uint8_t* d, uint32_t caplen, ParseCtx& ctx) { if (caplen < 14) return false; // 以太网头固定 14 字节 uint16_t type = be16(d + 12); uint32_t off = 14; while (type == 0x8100) { // VLAN 标签:2 字节 TCI + 2 字节内层类型 if (caplen < off + 4) return false; type = be16(d + off + 2); off += 4; } ctx.eth_type = type; // 0x0800 IPv4 / 0x0806 ARP / 0x86DD IPv6 ctx.l3_off = off; return true; }IPv4 头部是变长的,IHL字段以 4 字节为单位,合法范围 5 到 15。分片要单独处理:只有分片偏移为 0 的首片才含有传输层头,非首片强行解析 TCP 会读出乱码,这是很多人第一次跑就「端口号莫名其妙」的原因。
bool parseIPv4(const uint8_t* d, uint32_t caplen, ParseCtx& ctx) { const uint8_t* ip = d + ctx.l3_off; if (caplen < ctx.l3_off + 20) return false; if ((ip[0] >> 4) != 4) return false; // 版本号必须是 4 uint32_t ihl = (ip[0] & 0x0F) * 4u; // 20 ~ 60 if (ihl < 20 || caplen < ctx.l3_off + ihl) return false; ctx.src_ip = be32(ip + 12); ctx.dst_ip = be32(ip + 16); ctx.proto = ip[9]; // 6=TCP 17=UDP 1=ICMP uint16_t frag = be16(ip + 6); ctx.is_first_frag = ((frag & 0x1FFF) == 0); // 偏移非 0 表示不是首片 ctx.l4_off = ctx.l3_off + ihl; return true; }4.2 传输层:TCP 头长度、标志位与载荷偏移
TCP 头部的data offset同样以 4 字节为单位,且可能带选项(时间戳、SACK 等),所以载荷位置不能写死成 54。标志位单独抽成结构,后面做会话状态判断和过滤器演示都要用。
bool parseTCP(const uint8_t* d, uint32_t caplen, ParseCtx& ctx) { if (!ctx.is_first_frag) return false; // 非首片不解析传输层 if (caplen < ctx.l4_off + 20) return false; const uint8_t* tcp = d + ctx.l4_off; ctx.sport = be16(tcp); ctx.dport = be16(tcp + 2); ctx.seq = be32(tcp + 4); uint8_t dataOff = (tcp[12] >> 4) * 4; // 20 ~ 60 uint8_t flags = tcp[13]; if (dataOff < 20 || caplen < ctx.l4_off + dataOff) return false; ctx.syn = (flags & 0x02) != 0; ctx.ack = (flags & 0x10) != 0; ctx.fin = (flags & 0x01) != 0; ctx.rst = (flags & 0x04) != 0; ctx.payload_off = ctx.l4_off + dataOff; ctx.payload_len = (caplen > ctx.payload_off) ? (caplen - ctx.payload_off) : 0; return true; }UDP 简单得多,固定 8 字节头,payload_off = l4_off + 8。ICMP 只需读类型和代码两个字节,类型 8 是请求、0 是应答,做演示时用ping打两个包就能验证。
4.3 应用层识别:DNS 与 HTTP 各取一段
应用层识别要克制。端口只是猜测,80 端口跑的不一定是 HTTP,非 80 端口也可能是。我的处理是「端口优先、内容校验兜底」:DNS 看 53 端口且报文结构合法,HTTP 看 80/8080 且载荷前几个字节是可打印 ASCII。
DNS 的问题名是分段存储的,要循环拼接,并且必须处理越界和压缩指针:
// 从 Question 段还原域名,p 指向 QNAME 首字节,len 为剩余可用长度 std::string readQName(const uint8_t* p, uint32_t len) { std::string name; uint32_t i = 0; while (i < len) { uint8_t l = p[i]; if (l == 0) break; // 0 表示名字结束 if ((l & 0xC0) == 0xC0) break; // 压缩指针:单包展示不追偏移,直接截断 if (i + 1 + l > len) break; // 越界保护,恶意报文会踩这里 name.append(reinterpret_cast<const char*>(p) + i + 1, l); name.push_back('.'); i += 1 + l; } if (!name.empty() && name.back() == '.') name.pop_back(); return name; }HTTP 只需要首行和Host,用\r\n找边界,找得到就取,找不到就原样展示前 64 字节。别在这一步上较劲——实验展示的是「能识别出这是一条 HTTP 请求」,不是做完整的解析器。
注意:解析层任何一处读取前都要有
caplen校验。少了这句,一个畸形报文就能让程序直接崩掉,而现场的演示流量里恰好最容易出现畸形包。
4.4 统计模块:协议分布、会话排行与滑动窗口速率
统计要的是「一眼能讲」,不是把所有数字堆上去。我一般做四块:协议分布饼图(按 eth_type 或 proto 计数)、会话 Top 10(按总字节数排序)、总包数与总字节数、每秒包数曲线。
协议统计就是个计数数组,把解析结果里的 proto 查表累加即可。速率用滑动窗口,比「总数除以运行时长」更能反映突发:
// 1 秒滑动窗口,每 100ms 一格,共 10 格 class RateWindow { public: void tick(uint64_t nowMs) { size_t slot = (nowMs / 100) % 10; if (slot != cur_) { buckets_[slot] = 0; cur_ = slot; } } void add(uint64_t bytes) { buckets_[cur_] += bytes; } uint64_t bps() const { uint64_t s = 0; for (auto v : buckets_) s += v; return s; } private: std::array<uint64_t, 10> buckets_{}; size_t cur_ = 0; };会话排行每次刷新重排 Top 10 就够了,全量排序在会话数上万时会明显卡顿,用一个固定大小的最小堆维护更稳。界面刷新频率定在 5 帧每秒,人眼看着是实时的,CPU 占用又低。
5. 避坑与常见问题排查:五个最容易被现场打回的坑
下面五条,基本覆盖了这套实验从编译到演示 80% 的意外。每条都按「现象 → 原因 → 解决」写,方便直接对照。
5.1 采集侧:抓不到包,或者抓到的全是别人的
现象一:点了开始,列表一直是空的。原因通常有三种:网卡选成了虚拟网卡或回环、程序没有以管理员身份运行、过滤器表达式把流量全过滤掉了。排查顺序我固定为:先把过滤器清空成空字符串重跑一次,如果还是零包,看pcap_open_live返回的设备是不是列表里那块物理网卡,最后确认提权。解决方式是在界面启动时调用pcap_findalldevs把设备名和描述都列出来,让用户主动选,而不是默认取第一个。第一个经常是「本地连接* 12」这类虚拟设备,抓一天也是零。
现象二:过滤表达式改了不生效,还是抓到一堆无关包。原因多半是pcap_compile失败但返回值没检查,或者过滤器装在了已经停止的句柄上。BPF 语法错误时pcap_compile返回 -1,pcap_setfilter自然也没执行,程序照常跑原来的(无)过滤条件。解决就是把第 3.3 节那段返回值检查补上,并且把编译错误字符串直接显示在过滤器输入框旁边,用户当场就知道tcp port abc写错了,不用去猜。
5.2 解析侧:端口颠倒、乱码与偶发崩溃
现象三:解析出来的源端口是 80,目的端口是个大随机数。这是字节序问题。网络字节序是大端,x86 是小端,直接(uint16_t*)p取值会把 0x0050 读成 0x5000,也就是 20480。凡是多字节字段,一律走be16/be32转换,别图省事。同理,IP 地址用ntohl之后的整数做 key 更稳,直接比较点分字符串会出现「同一个地址两种写法」的问题。
现象四:抓到某些包程序直接退出,或者详情面板显示一堆乱码。退出基本是越界读:帧被snaplen截断后caplen小于各层头部之积,代码却照常按偏移取字段。乱码则常见于带 VLAN 标签或 IP 选项的包,偏移算错了一路错到底。解决是两道防线:一是每层解析前用caplen做前置校验(第 4 章的代码里每条都有);二是把「三层偏移」和「四层偏移」作为解析上下文显式传递,不允许多处硬编码14和34。VLAN 那条循环必须在,实验网段里只要有台交换机的端口打了 tag,这条就会派上用场。
5.3 运行侧:界面假死与内存持续上涨
现象五:抓包跑了几分钟,界面点不动,任务管理器里内存一路涨。假死的原因是回调里更新控件。pcap_loop的回调运行在采集线程的上下文中,SetWindowText、InsertItem这类操作跨线程调用会让消息队列堵死,而队列满了采集就停。解决办法在 2.3 节已经定了:回调只入队,界面用定时器拉快照。
内存上涨有两个常见来源:一是会话表只增不删,二是每次pcap_next_ex拿到的pcap_pkthdr结构用完没释放。前者的处理是按末包时间做定时淘汰,后者在 WinPcap 的手册里写得很清楚——pcap_next_ex返回的 header 需要free,很多人用pcap_loop写惯了,换成手动循环就忘了这一句。这两处修完,连续跑两小时内存曲线基本是平的。
6. 验收与答辩:把一次抓包演示讲成完整闭环
答辩现场的时间通常只有五到八分钟,最忌讳的是「打开了程序,然后一边点一边想下一步干什么」。我的做法是提前写一条固定的演示主线,串起采集、解析、统计、存储四层,让评委看到的是一次完整闭环,而不是若干孤立功能。
主线用三个动作,总时长控制在三分钟内:第一步,用udp port 53过滤,在另一台机器上执行一次域名查询,现场展示 DNS 查询名被正确还原,这一步证明过滤器生效 + 应用层解析成立;第二步,清空过滤器,访问一个实验内网页面,抓三次握手,点开 SYN 包展示序号、标志位和载荷长度,这一步证明传输层偏移算对;第三步,切到会话视图,展示 Top Talker,再点保存把这一段写成 pcap 文件,用系统抓包工具重新打开同一个文件,证明存下来的东西是标准格式、能被第三方工具认可。整条主线不依赖网络是否通畅,失败了也能当场降级到回放已有文件。
验收清单我一般提前对一遍,避免现场才发现某一项其实没通:
| 检查项 | 合格标准 | 常见不达标表现 |
|---|---|---|
| 网卡枚举 | 列出物理网卡且有描述 | 只有一块默认设备 |
| 过滤器 | 改完立即生效,错误有提示 | 静默失败 |
| 协议解析 | Eth/ARP/IP/TCP/UDP/ICMP 全覆盖 | 只解析到 IP |
| 应用层 | DNS 与 HTTP 至少一个可演示 | 只显示原始十六进制 |
| 统计 | 会话表可排序、可清理 | 只显示总包数 |
| 存储 | 导出 pcap 能被第三方打开 | 自己定义的文本格式 |
| 稳定性 | 连续 10 分钟不崩、不涨内存 | 五分钟假死 |
答辩被追问最多的四个点,提前想好答案:一是为什么要用队列,答丢包与线程解耦;二是caplen和len的区别,答截断与真实长度;三是非首分片为什么不能解析传输层,答载荷里没有 TCP 头;四是抓到的东西合不合规,答只在自有实验网段内、只做协议字段统计、不还原用户内容。最后这个问题的回答态度,往往比技术细节更能决定印象分。
我吃过一次亏:第一次演示时没做过滤,满屏广播包把列表刷得飞快,评委让我「找出刚才那次 DNS 查询」,我翻了半分钟没翻到。从那以后我每次演示前都强制走一遍「先设过滤器、再触发流量、再点开单包」的三步,并且提前把 pcap 文件存好当保底。这套流程写进源码的 README 里,换个人照着做也不会乱。希望帮到你。
本文还有配套的精品资源,点击获取