ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C语言实现PCAP离线网络入侵检测系统:协议解析与规则匹配

C语言实现PCAP离线网络入侵检测系统:协议解析与规则匹配 简介这是一套面向高校网络与信息安全课程设计的完整项目资料围绕基于PCAP的网络入侵检测系统展开适合正在完成期末大作业或希望深入理解TCP/IP协议与多线程编程的学生。项目使用libpcap库在指定接口嗅探数据包通过分析流量特征识别SYN泛洪等拒绝服务攻击涉及套接字监听、线程池调度与协议解析等核心知识点。压缩包共17个文件约891KB包含6个C源文件与4个头文件构成的主体实现、Makefile与shell脚本负责构建和测试、Python脚本用于ARP欺骗模拟另附课程报告PDF与README说明文档结构清晰便于按模块阅读。目前已有217人学习下载。读者可获得可直接编译运行的源码、完整的项目说明与实验报告以及针对高吞吐量网络恶意流量检测的排错思路适合作为网络编程与安全方向的进阶实践参考。1. 从一份 PCAP 到告警这套 C 语言 NIDS 到底在做什么手里攥着一份几百 MB 的 PCAPWireshark 能打开、能过滤可真要判断「这里面有没有扫描、有没有爆破、有没有可疑的 DNS 隧道」靠人眼一条条翻是不现实的。基于 PCAP 的网络入侵检测系统本质就是把这件苦力活自动化离线读包、按规则匹配、命中就落告警。用 C 语言实现这件事是很多高校课设和入门安全工程训练里的经典题目也是少数能同时练到「协议解析 内存管理 规则引擎」三个硬技能的方向。它适合谁一是正在做课设、需要一份能跑通、能讲清楚原理、还能写进报告的学生二是想从「会调 Wireshark」进阶到「自己写解析器」的工程师。整套系统的输入是 PCAP 文件输出是告警列表中间要过四道关链路层解封装、IP/TCP/UDP 重组、规则匹配、告警输出。下面按这个顺序把每一步的选型理由、可抄的代码和参数设置讲透最后再聊怎么验证它真的有效而不是只会打印几行日志。2. 离线解析 PCAP从文件头到第一个以太网帧2.1 为什么不用 libpcap 的在线抓包而选离线文件很多人第一反应是pcap_open_live抓实时流量但课设和复现场景里离线 PCAP 才是主力。原因很实际实时抓包依赖网卡权限、依赖流量持续产生你没法保证每次跑出来的结果一致而 PCAP 文件是静态的同一份输入跑一百遍结果都一样方便调试规则、方便写报告、方便对比不同参数下的检出率。libpcap 提供了pcap_open_offline专门读文件底层还是同一套pcap_next_ex接口学一套 API 两处都能用。常见做法是先用pcap_open_offline打开文件拿到pcap_t句柄再循环调用pcap_next_ex取包。这里有个容易被忽略的点PCAP 文件有全局头magic number、版本、时区、快照长度、链路类型每个包前面还有包头时间戳、抓包长度、原始长度。libpcap 帮你把这两层都剥掉了你拿到的是链路层帧的裸指针和长度。但如果你要自己解析文件格式比如课设要求不能依赖 libpcap那就得手动读这 24 字节全局头和每包 16 字节包头注意字节序——magic number 是0xa1b2c3d4还是0xd4c3b2a1决定了后面所有多字节字段要不要翻转。2.2 用 libpcap 读包的最小可运行代码#include pcap.h #include stdio.h #include arpa/inet.h int main(int argc, char *argv[]) { if (argc ! 2) { fprintf(stderr, usage: %s file.pcap\n, argv[0]); return 1; } char errbuf[PCAP_ERRBUF_SIZE]; // 离线打开不涉及网卡权限 pcap_t *handle pcap_open_offline(argv[1], errbuf); if (!handle) { fprintf(stderr, open failed: %s\n, errbuf); return 1; } struct pcap_pkthdr *header; const u_char *packet; int ret; long count 0; // pcap_next_ex 返回 1 表示读到包0 表示超时离线不会-1 出错-2 到文件尾 while ((ret pcap_next_ex(handle, header, packet)) 0) { if (ret 0) continue; count; // header-caplen 是实际抓到的长度header-len 是原始长度 printf(packet %ld: caplen%u len%u\n, count, header-caplen, header-len); } printf(total packets: %ld\n, count); pcap_close(handle); return 0; }编译命令是gcc -o reader reader.c -lpcap。这段代码的逻辑很直白打开文件、循环取包、打印长度。参数上要盯住两个字段——caplen和len。caplen是实际存进文件的字节数受快照长度snaplen限制len是包在链路上的原始长度。如果caplen len说明包被截断了后面解析 IP 层时可能读不到完整 payload规则匹配会漏。我一般会在解析前加一句判断if (header-caplen header-len) { /* 标记截断谨慎匹配 */ }。2.3 链路层类型判断别默认就是以太网pcap_datalink(handle)返回链路层类型常见值有DLT_EN10MB以太网值 1、DLT_LINUX_SLLLinux cooked值 113、DLT_RAW原始 IP值 12。课设里给的 PCAP 大多是以太网但如果你从某些环境导出的文件是DLT_LINUX_SLL直接按以太网解析就会把 16 字节的 SLL 头当成 MAC 地址后面全错。处理方式是先判断类型再决定跳过多少字节以太网跳 14 字节含 VLAN 的话还要加 4SLL 跳 16 字节RAW 不跳。这个判断放在解析函数入口比事后 debug 省事得多。3. 协议解析与流重组把裸字节变成五元组3.1 以太网、IP、TCP/UDP 头的逐层剥离拿到链路层帧后解析顺序是固定的先看以太网头的ether_type字段偏移 12 字节2 字节0x0800是 IPv40x0806是 ARP0x86dd是 IPv6。课设一般只处理 IPv4。跳到 IP 头后看protocol字段偏移 9 字节1 字节6 是 TCP17 是 UDP1 是 ICMP。IP 头长度由ihl字段决定低 4 位乘以 4通常是 20 字节但有选项时会更长不能写死。TCP 头里要关注source port、dest port各 2 字节、flags偏移 13 字节1 字节其中 SYN0x02、ACK0x10、FIN0x01、RST0x04、window size。UDP 头简单8 字节源端口、目的端口、长度、校验和。解析时所有多字节字段都要用ntohs/ntohl转成主机字节序这是新手最容易翻车的地方——忘了转端口号会变成 0x5000 这种诡异值。3.2 用结构体映射还是手动偏移两种写法各有场景。用struct iphdr、struct tcphdrLinux 头文件里自带代码短但依赖平台且结构体对齐可能和协议实际布局不一致需要__attribute__((packed))。手动偏移可移植性好适合课设要求「不依赖特定平台头文件」的情况。我一般用混合方式定义自己的 packed 结构体字段顺序严格按 RFC 来。#include stdint.h // 以太网头14 字节 struct eth_hdr { uint8_t dst[6]; uint8_t src[6]; uint16_t ether_type; // 网络字节序 } __attribute__((packed)); // IPv4 头固定部分 20 字节 struct ip_hdr { uint8_t ihl_version; // 高4位版本低4位头长度 uint8_t tos; uint16_t total_len; uint16_t id; uint16_t frag_off; uint8_t ttl; uint8_t protocol; uint16_t checksum; uint32_t src_ip; uint32_t dst_ip; } __attribute__((packed)); // TCP 头固定部分 20 字节 struct tcp_hdr { uint16_t src_port; uint16_t dst_port; uint32_t seq; uint32_t ack; uint8_t data_off; // 高4位头长度 uint8_t flags; uint16_t window; uint16_t checksum; uint16_t urg_ptr; } __attribute__((packed));__attribute__((packed))告诉编译器不要插入填充字节保证结构体大小和协议头一致。用的时候先const struct eth_hdr *eth (const struct eth_hdr *)packet;判断ntohs(eth-ether_type) 0x0800后再const struct ip_hdr *ip (const struct ip_hdr *)(packet 14);。注意ihl_version要拆int ip_hlen (ip-ihl_version 0x0f) * 4;TCP 头长度同理(tcp-data_off 4) * 4。写死 20 字节在带选项的包上会错位。3.3 流重组为什么单包匹配会漏掉大部分攻击只按单包匹配能抓到 SYN 扫描、单包 ICMP 洪水这类但抓不到「三次握手后发 payload 的爆破」「分片传输的 webshell 上传」。要检测这些必须把同一个 TCP 流的包按序拼起来。流表用五元组做 key源 IP、目的 IP、源端口、目的端口、协议。每个流维护一个缓冲区、期望的下一个 seq、以及状态SYN_SENT、ESTABLISHED、CLOSED。实现上收到包后先查流表没有就新建有就按 seq 排序插入缓冲区。这里有个坑seq 是 32 位无符号数会回绕直接比大小会出错要用(int32_t)(seq - expected)的方式判断先后。缓冲区不能无限增长我一般设 64KB 上限超了就丢弃最旧的或直接标记流异常。课设规模下用简单的线性链表或固定大小哈希表就够不必上红黑树。3.4 规则匹配引擎从字符串匹配到状态机规则格式可以自己定常见的是类似 Snort 的alert tcp any any - any 80 (content:/etc/passwd; msg:path traversal;)。解析规则时把协议、源/目的、端口、content 关键字提取出来。匹配时先做五元组过滤再做 payload 的字符串查找。memmem比strstr安全因为它能处理含\0的二进制 payload。#define _GNU_SOURCE #include string.h // 在 payload 中查找 pattern返回偏移或 -1 long match_content(const uint8_t *payload, size_t plen, const uint8_t *pattern, size_t patlen) { if (patlen 0 || plen patlen) return -1; const uint8_t *p memmem(payload, plen, pattern, patlen); return p ? (long)(p - payload) : -1; }memmem是 GNU 扩展Linux 下可用Windows 需要自己实现或换memchr循环。参数上payload是 TCP/UDP 载荷起始指针plen是载荷长度总长度减去 IP 头和 TCP 头。如果规则里有多条 content要按顺序匹配且支持distance、within这类相对偏移约束否则误报会很高。课设级别可以先只做单 content 精确匹配把误报率压下来再扩展。4. 告警输出与性能别让日志拖垮检测4.1 告警格式与落盘策略告警至少包含时间戳、源 IP、目的 IP、源端口、目的端口、协议、命中规则 ID、规则描述。输出格式用 CSV 或 JSON 都行CSV 更省事JSON 方便后续用 Python 分析。落盘时不要每条告警都fopen/fclose那样 IO 开销巨大。正确做法是程序启动时打开一个文件句柄循环里fprintf结束时fclose。如果告警量特别大可以加一个内存队列攒够 100 条或每 1 秒刷一次盘。FILE *alert_fp fopen(alerts.csv, w); if (!alert_fp) { perror(fopen); return 1; } fprintf(alert_fp, timestamp,src_ip,src_port,dst_ip,dst_port,proto,rule_id,msg\n); // 循环中命中规则时 fprintf(alert_fp, %ld,%s,%u,%s,%u,%s,%d,%s\n, header-ts.tv_sec, src_ip_str, ntohs(tcp-src_port), dst_ip_str, ntohs(tcp-dst_port), TCP, rule_id, rule_msg); fflush(alert_fp); // 需要实时看结果时加否则靠 fclose 刷fflush每包都调会慢但课设场景下包量不大加了能保证程序崩溃时日志不丢。生产环境会改成批量刷。IP 转字符串用inet_ntop比inet_ntoa线程安全虽然单线程用哪个都行但养成习惯没坏处。4.2 性能瓶颈在哪实测数据说话一份 100MB 的 PCAP大约 15 万到 20 万个包。纯解析不匹配规则在普通笔记本上跑完约 0.3 到 0.5 秒。加上规则匹配如果规则只有几十条总时间在 1 秒以内。瓶颈通常不在解析而在两处一是流重组的链表查找流数量上万时线性查找会明显变慢二是规则里的字符串匹配如果规则上百条且 payload 很大memmem调用次数会爆炸。优化手段按性价比排序先把流表换成哈希表五元组算个简单哈希查找从 O(n) 降到 O(1)再把规则按协议和端口分组只对相关流跑相关规则最后才考虑用 Aho-Corasick 多模式匹配替换单条memmem。课设报告里如果能给出「优化前 X 秒优化后 Y 秒」的对比数据说服力会强很多。4.3 内存管理C 语言课设的经典翻车点每个包解析时如果malloc了缓冲区一定要在循环末尾free否则跑几万个包内存就爆了。流表里的缓冲区在流关闭时要释放程序退出前要遍历流表全部释放。用 Valgrind 跑一遍valgrind --leak-checkfull ./nids test.pcap把 definitely lost 清零这是课设拿高分的基本功。另一个坑是pcap_next_ex返回的packet指针指向 libpcap 内部缓冲区下一次调用就失效如果要跨包保存 payload必须自己memcpy一份。5. 避坑与排查那些让课设从「能跑」变成「跑对」的细节5.1 告警数量异常多或异常少现象跑完 PCAP告警几千条或者一条都没有。原因前者通常是规则里的 content 太短比如只匹配GET或者没做端口过滤把正常流量全命中了后者常见于字节序没转端口比较永远不相等或者caplen截断导致 payload 为空。解决先用tcpdump -r test.pcap -c 10 -XX看几个包的实际字节确认解析偏移对不对再把规则 content 加长到至少 6 字节并加上目的端口约束。5.2 解析到某个包就段错误现象程序跑到第 N 个包崩溃gdb 显示在解析 IP 头时访问越界。原因caplen小于 IP 头最小长度20 字节或者链路层类型判断错了跳过的字节数不对。解决在每层解析前加长度检查if (caplen 14 20) continue;TCP 层再加if (caplen ip_offset ip_hlen 20) continue;。链路层类型用pcap_datalink判断别默认以太网。5.3 流重组后 payload 顺序错乱现象明明 PCAP 里有攻击特征重组后却匹配不到。原因seq 回绕处理错误或者重传包、乱序包没去重导致缓冲区里数据错位。解决用(int32_t)(seq - expected_seq)判断先后大于 0 说明是未来包先缓存等于 0 才追加小于 0 是重传直接丢弃。缓冲区按 seq 排序插入不要简单追加。5.4 规则文件读进来全是乱码现象规则文件里写了中文描述读出来是乱码。原因文件编码是 GBK程序按 UTF-8 读或者反过来。解决统一用 UTF-8 保存规则文件读取时按字节处理不要用fscanf(%s)读带空格的描述改用fgets读整行再解析。fgets会保留换行符记得strcspn(buf, \r\n) 0去掉。5.5 同一份 PCAP 两次跑结果不一样现象告警数量有波动。原因流表用了哈希遍历顺序不确定导致某些依赖顺序的规则命中情况不同或者用了多线程但没加锁。解决单线程跑流表遍历用稳定顺序比如按创建时间排序确保确定性。课设场景下确定性比性能重要得多。6. 验证与进阶怎么证明你的 NIDS 不是摆设写完能跑只是起点能证明它「检测得对」才是课设拿高分的关键。我一般用三层验证。第一层构造已知攻击的 PCAP用hping3生成 SYN 扫描、用curl发带../的请求、用dig发超长 DNS 查询分别抓包存成小文件跑 NIDS 看是否命中对应规则。第二层用公开数据集DARPA 1999 或 CIC-IDS2017 里的 PCAP 片段这些有标注可以算检出率和误报率。第三层对比 Wireshark 的过滤结果用tcp.port80 http.request.uri contains passwd过滤出的包数和你 NIDS 的告警数对比差异大就说明规则或解析有问题。进阶方向有两个值得投入。一是把规则匹配换成 Aho-Corasick 自动机支持几百条规则同时匹配性能提升明显代码量也可控适合写进课设的「优化」章节。二是加一个简单的会话重建和协议识别比如识别 HTTP 请求行、DNS 查询名这样规则可以写成「DNS 查询名长度大于 50 且包含 base64 字符」这种更贴近真实检测的逻辑而不是死板的字符串匹配。// Aho-Corasick 的 goto 表简化示意 #define ALPHABET 256 #define MAX_STATES 1024 int goto_table[MAX_STATES][ALPHABET]; int fail_link[MAX_STATES]; int output[MAX_STATES]; // 命中规则 ID-1 表示无 // 构建阶段把所有规则的 pattern 插入 trie // 匹配阶段对 payload 逐字节走 goto失配走 fail输出命中这段只是骨架完整实现需要 BFS 构建 fail 指针代码量约 200 行。课设里如果时间紧可以先不做但报告里提一句「后续可引入 AC 自动机优化多规则匹配」能体现你知道边界在哪。最后说个我自己的习惯每次改完解析代码先拿一个只有 10 个包的小 PCAP 跑用printf把每层的偏移和长度打出来确认无误再上大文件。这个习惯帮我省了无数次 gdb 调试的时间。C 语言写网络解析指针偏移错一位后面全错而小文件能让错误暴露得足够快。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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