☰
C语言NIDS实战:从PCAP解析到告警的完整实现
2026/9/28 11:57:20 网站建设 项目流程

简介:这是一份面向高校计算机、网络安全相关专业学生的课程设计与期末大作业参考项目,主题为基于PCAP的网络入侵检测系统,采用C语言实现。项目已通过导师指导并获得97分高分评价,下载后无需修改即可直接运行,适合作为课程设计、期末大作业或网络编程与安全方向的实践素材。压缩包共17个文件,约891KB,包含6个C源文件、4个头文件、2个Makefile、1个Shell脚本、1份PDF报告、1个Markdown说明及Python辅助脚本等,覆盖抓包嗅探、线程池调度、流量分析与告警等核心模块,目录结构清晰,便于按模块阅读与二次开发。目前已有142人学习下载。读者可获得完整可运行的源码工程、配套使用说明与课程报告PDF,以及Makefile与测试脚本,帮助快速理解PCAP抓包、入侵检测流程与多线程分析设计,并对照报告梳理实现思路与排错方法。

1. 从一份 PCAP 到告警:这套 C 语言 NIDS 到底在做什么

手里攥着一份几百 MB 的 PCAP,Wireshark 里翻得眼花,却还是说不清刚才那波流量里到底有没有人扫端口、有没有人往内网打 webshell——这大概是很多做安全运维或课程设计的人共同的痛点。基于 PCAP 的网络入侵检测系统,本质就是把这件「人肉翻包」的活交给程序:读离线抓包文件或监听网卡,按规则匹配流量特征,命中就落一条告警。用 C 语言实现,是因为它贴着 libpcap 这层抓包库最近,零依赖、跑得快、内存自己管,特别适合嵌入式网关、教学演示和需要极致性能的检测节点。这套「源码+使用说明+报告」的组合,面向的是想真正搞懂 NIDS 内部机理的人:不是调个 Suricata 规则就完事,而是自己写解析器、自己定规则、自己算告警。读完你能拿到一条从 PCAP 解析到规则匹配再到告警输出的完整可复现路径,也能看清哪些地方最容易翻车。

2. 拆开一个包:libpcap 抓包与协议解析的落地骨架

2.1 为什么选 libpcap 而不是自己写 raw socket

很多人第一反应是用AF_PACKET或SOCK_RAW直接抓,觉得这样「更底层更可控」。但真写起来你会发现,链路层类型判断、混杂模式设置、抓包过滤表达式编译、跨 Linux/BSD/macOS 的兼容,全是体力活。libpcap 把这些都封装好了,pcap_open_offline读文件、pcap_open_live抓网卡、pcap_compile+pcap_setfilter下 BPF 过滤,一套 API 通吃。对 NIDS 来说,BPF 过滤尤其关键——你可以在内核层就把非目标流量丢掉,只把 TCP/UDP 送进用户态解析,性能差距是数量级的。常见做法是:离线分析用pcap_open_offline,实时检测用pcap_open_live配pcap_setnonblock避免阻塞主循环。

2.2 从以太网帧到 TCP 载荷的解析链路

一个包进来,解析顺序是固定的:以太网头(14 字节,注意可能有 VLAN 标签要偏移 4 字节)→ 判断 EtherType 是不是 0x0800(IPv4)→ IP 头(看 IHL 字段算实际长度,别写死 20)→ 判断协议号是不是 6(TCP)或 17(UDP)→ TCP 头(看 data offset 算载荷偏移)。每一步都要做长度校验,否则畸形包直接让你段错误。下面是最小可跑的解析骨架:

#include <pcap.h> #include <netinet/ip.h> #include <netinet/tcp.h> #include <netinet/ether.h> void packet_handler(u_char *args, const struct pcap_pkthdr *hdr, const u_char *pkt) { // 以太网头固定 14 字节 if (hdr->caplen < 14) return; const struct ether_header *eth = (struct ether_header *)pkt; uint16_t eth_type = ntohs(eth->ether_type); uint32_t offset = 14; // 处理 VLAN 标签(802.1Q),偏移再加 4 if (eth_type == 0x8100) { if (hdr->caplen < 18) return; eth_type = ntohs(*(uint16_t *)(pkt + 16)); offset += 4; } if (eth_type != 0x0800) return; // 只处理 IPv4 if (hdr->caplen < offset + 20) return; const struct ip *iph = (const struct ip *)(pkt + offset); uint32_t ip_hlen = iph->ip_hl * 4; // 头长度按 4 字节为单位 if (ip_hlen < 20) return; // 非法头长,丢弃 if (iph->ip_p != IPPROTO_TCP) return; if (hdr->caplen < offset + ip_hlen + 20) return; const struct tcphdr *tcph = (const struct tcphdr *)(pkt + offset + ip_hlen); uint32_t tcp_hlen = tcph->doff * 4; const u_char *payload = pkt + offset + ip_hlen + tcp_hlen; uint32_t payload_len = ntohs(iph->ip_len) - ip_hlen - tcp_hlen; // 到这里 payload/payload_len 就是 TCP 载荷,交给规则引擎 // 注意 payload_len 要用 caplen 再兜一次底,防止越界 }

逻辑说明:ip_hl和doff都是「以 4 字节为单位」的字段,忘了乘 4 是最经典的翻车点,解析出来的载荷指针会偏。参数说明:hdr->caplen是实际抓到的长度,hdr->len是原始包长,做载荷计算时两者都要考虑,抓包时 snaplen 设太小会截断载荷导致漏检。我一般把 snaplen 设成 65535,除非你明确只关心头部。

2.3 主循环与离线/在线两种模式的切换

主循环用pcap_loop或pcap_dispatch,前者一直抓到 EOF 或出错,后者抓一批就返回,适合需要定期做超时检测的场景。离线模式传文件句柄,在线模式传网卡句柄,回调函数完全复用。下面这段把两种模式统一起来:

int main(int argc, char *argv[]) { char errbuf[PCAP_ERRBUF_SIZE]; pcap_t *handle; // 参数是文件就走离线,是网卡名就走在线 if (argc > 1 && access(argv[1], F_OK) == 0) { handle = pcap_open_offline(argv[1], errbuf); } else { handle = pcap_open_live(argv[1], 65535, 1, 1000, errbuf); } if (!handle) { fprintf(stderr, "open failed: %s\n", errbuf); return 1; } // 只抓 TCP,减少用户态负担 struct bpf_program fp; if (pcap_compile(handle, &fp, "tcp", 0, PCAP_NETMASK_UNKNOWN) == 0) { pcap_setfilter(handle, &fp); } pcap_loop(handle, 0, packet_handler, NULL); pcap_close(handle); return 0; }

逻辑说明:pcap_open_live的第三个参数 1 表示混杂模式,第四个 1000 是超时毫秒。参数说明:BPF 表达式"tcp"可以换成"tcp port 80 or tcp port 443"之类,越精确内核丢得越多、用户态越轻松。注意pcap_compile的优化参数设 1 会生成更高效的过滤码,但调试规则时设 0 更容易看懂。

3. 规则引擎:把「什么算入侵」翻译成 C 代码能跑的逻辑

3.1 规则的数据结构设计:从字符串到匹配树

NIDS 的核心是规则。最简单的做法是每条规则一个结构体,字段包括协议、源/目的 IP、端口、载荷关键字、动作。但规则一多,逐条遍历就慢了。常见做法是先用协议和端口做一级索引,把规则分桶,再在桶内做字符串匹配。下面是一个够用的规则结构:

#define MAX_PAYLOAD_PATTERN 256 typedef struct { int proto; // IPPROTO_TCP / IPPROTO_UDP uint16_t dport; // 目的端口,0 表示任意 char pattern[MAX_PAYLOAD_PATTERN]; // 载荷关键字 int pattern_len; char msg[128]; // 告警描述 int severity; // 1-3 } rule_t; rule_t rules[] = { {IPPROTO_TCP, 80, "/etc/passwd", 11, "疑似路径穿越", 3}, {IPPROTO_TCP, 80, "union select", 12, "疑似 SQL 注入", 3}, {IPPROTO_TCP, 22, "SSH-", 4, "SSH 连接", 1}, }; int rule_count = sizeof(rules) / sizeof(rules[0]);

逻辑说明:pattern用定长数组是为了避免动态内存,嵌入式场景更稳。参数说明:dport设 0 表示不限制端口,适合做全局特征匹配;severity用来分级,报告里可以按级别统计。真实项目里规则通常从配置文件读,但教学和快速验证阶段硬编码数组最省事。

3.2 载荷匹配:memmem 与大小写不敏感的取舍

匹配载荷最直接的是memmem,它在二进制数据里找子串,比strstr安全(不依赖\0结尾)。但 HTTP 攻击特征经常大小写混写,UNION SELECT和union select都得命中。这时候要么统一转小写再匹配,要么用大小写不敏感的匹配函数。转小写会改动原始载荷,如果后面还要做别的检测就得先拷贝。我一般对载荷做一份小写副本专门用于匹配:

// 在 packet_handler 里,拿到 payload 之后 if (payload_len > 0 && payload_len < 8192) { char lower[8192]; for (uint32_t i = 0; i < payload_len; i++) lower[i] = tolower(payload[i]); for (int i = 0; i < rule_count; i++) { if (rules[i].proto != IPPROTO_TCP) continue; if (rules[i].dport != 0 && rules[i].dport != ntohs(tcph->th_dport)) continue; if (memmem(lower, payload_len, rules[i].pattern, rules[i].pattern_len)) { printf("[ALERT] %s | %s:%u -> %s:%u\n", rules[i].msg, inet_ntoa(iph->ip_src), ntohs(tcph->th_sport), inet_ntoa(iph->ip_dst), ntohs(tcph->th_dport)); } } }

逻辑说明:memmem是 GNU 扩展,Linux 下直接用,其他平台要自己实现一个。参数说明:8192这个上限是经验值,超过这个长度的载荷做全量小写转换性价比低,可以只转前 N 字节或直接跳过。注意inet_ntoa不是线程安全的,多线程场景要用inet_ntop。

3.3 端口扫描与 SYN Flood 的统计型检测

载荷匹配只能抓「内容里有特征」的攻击,端口扫描和 SYN Flood 这类没有明显载荷的,得靠统计。思路是维护一张源 IP 的计数表,单位时间内目的端口数超过阈值就判扫描,SYN 包数超过阈值就判 Flood。下面是一个极简的滑动窗口计数:

#define TABLE_SIZE 1024 typedef struct { uint32_t src_ip; uint16_t ports[64]; // 记录最近访问的端口 int port_cnt; int syn_cnt; time_t window_start; } stat_entry_t; stat_entry_t table[TABLE_SIZE]; void check_scan(uint32_t src_ip, uint16_t dport, int is_syn) { uint32_t idx = src_ip % TABLE_SIZE; stat_entry_t *e = &table[idx]; time_t now = time(NULL); if (now - e->window_start > 10) { // 10 秒一个窗口 e->src_ip = src_ip; e->port_cnt = 0; e->syn_cnt = 0; e->window_start = now; } if (e->src_ip != src_ip) return; // 哈希冲突,简单丢弃 // 端口去重后计数 int found = 0; for (int i = 0; i < e->port_cnt; i++) if (e->ports[i] == dport) { found = 1; break; } if (!found && e->port_cnt < 64) e->ports[e->port_cnt++] = dport; if (is_syn) e->syn_cnt++; if (e->port_cnt > 20) printf("[ALERT] 端口扫描 | 源 %u 在 10s 内访问 %d 个端口\n", src_ip, e->port_cnt); if (e->syn_cnt > 100) printf("[ALERT] SYN Flood | 源 %u 在 10s 内发送 %d 个 SYN\n", src_ip, e->syn_cnt); }

逻辑说明:哈希表用src_ip % TABLE_SIZE做索引,冲突直接丢弃是简化处理,真实场景要用链表或开放寻址。参数说明:窗口 10 秒、端口阈值 20、SYN 阈值 100 都是可调参数,报告里应该说明这些值的选取依据。注意time(NULL)精度是秒,高流量场景要用gettimeofday做毫秒级窗口。

4. 避坑与排查:那些让 NIDS 漏报误报的细节

4.1 现象:明明有攻击流量,程序一条告警都不出

原因通常是 BPF 过滤器把流量提前丢了。比如你写了"tcp port 80",但攻击走的是 8080,自然抓不到。另一个常见原因是 snaplen 设太小,载荷被截断,memmem匹配不到完整特征。解决:先用tcpdump -r xxx.pcap -nn确认目标流量确实在文件里,再把 BPF 表达式放宽到"tcp"甚至空,逐步收窄定位。

4.2 现象:程序跑几分钟就段错误

九成是解析时没做长度校验。畸形包、截断包、IP 头长度字段被恶意设成非法值,都会让指针飞到非法内存。解决:每一层解析前都检查caplen是否够,ip_hl和doff是否在合法范围(IP 头 20-60 字节,TCP 头 20-60 字节),载荷长度用caplen和ip_len取小值。血泪经验:加断言不如加if return,线上环境断言会直接崩。

4.3 现象:告警里 IP 和端口全是乱的

多半是字节序问题。网络字节序是大端,printf出来之前必须ntohs/ntohl。iph->ip_src是struct in_addr,直接当整数打印会得到反的地址。解决:IP 用inet_ntoa或inet_ntop,端口用ntohs,长度字段用ntohs。养成习惯:凡是协议头里的多字节字段,用之前先转。

4.4 现象:同一份 PCAP 跑两次,告警数量不一样

如果用了统计型检测,时间窗口依赖time(NULL),两次运行的起始时间不同,窗口边界就不同,计数自然有差异。解决:离线分析时不要用真实时间,改成用包的时间戳hdr->ts做窗口基准,这样同一份文件每次跑结果一致,报告才可复现。

4.5 现象:规则一多,处理速度断崖式下降

逐条遍历规则是 O(规则数) 的复杂度,规则上千条时每条流量都要跑上千次memmem。解决:先按端口分桶,只匹配该端口对应的规则;再按协议过滤;如果还慢,上 Aho-Corasick 多模式匹配,一次扫描命中所有关键字。教学项目里分桶通常就够了,AC 自动机是进阶优化。

5. 让检测结果可复现:离线回放、告警归并与报告生成

离线回放是验证 NIDS 最靠谱的手段。同一份 PCAP,固定规则、固定窗口参数,跑出来的告警应该完全一致。我一般会写一个回放脚本,把告警输出重定向到文件,再用sort | uniq -c做归并统计,看看哪些规则命中最多、哪些源 IP 最活跃。下面这段把告警按「源 IP + 规则」聚合:

# 假设程序输出格式为:[ALERT] msg | src:sport -> dst:dport ./nids test.pcap > alerts.log # 提取源 IP 和告警类型做聚合 awk -F'[|:]' '/ALERT/ {gsub(/ /,"",$2); print $2, $1}' alerts.log \ | sort | uniq -c | sort -rn | head -20

逻辑说明:awk按分隔符切出源 IP 和告警描述,uniq -c计数,sort -rn按次数倒序。参数说明:分隔符要根据你实际的输出格式调整,别照抄。这个统计结果直接可以放进报告,说明「本次检测共命中 N 类告警,其中端口扫描占比最高」。

报告里还应该有一张参数表,把关键阈值和选取理由写清楚,评审或答辩时这是加分项:

参数取值选取理由
snaplen65535避免载荷截断导致漏检
BPF 过滤tcp只处理 TCP,减少用户态负担
扫描窗口10 秒兼顾检测灵敏度和误报率
端口阈值20 个正常用户 10 秒内很少访问超 20 个端口
SYN 阈值100 个正常建连远低于此值

最后说个我自己的习惯:每次改完规则或阈值,一定拿同一份 PCAP 重跑一遍,对比告警差异。没有基线对比的调参就是玄学,今天调完觉得对了,明天换个流量又翻车。把回放和归并脚本固化成流程,比记住任何单个参数都管用。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询