简介:这是一份面向网络安全初学者与防火墙开发爱好者的源代码学习资料,围绕防火墙核心功能的实现展开,适合具备一定C/C++基础、希望理解包过滤与网络钩子机制的技术人员参考。压缩包共236个文件,整体约1.23MB,以54个h头文件与40个cpp源文件为主体,另有9个c文件、4个makefile与4个sources构建脚本,配合dsp、dsw等工程文件可直接组织编译;同时包含23个ico、10个htm、3个chm等界面与帮助资源,以及dll、sys、def等驱动与导出定义文件,结构上覆盖了从底层钩子到上层界面的完整模块。内容预览中可见Build.bat构建脚本及MINIHOOK、PROTHOOK、Packet、RECV、SEND、xpassthru等源码文件,涉及数据包捕获、收发处理与透传钩子等关键环节,便于读者梳理防火墙的包处理流程与工程组织方式。目前已有399人学习下载,可作为研究防火墙原理与Windows网络编程的实践素材。
1. 防火墙源码拆解:从包过滤到状态检测,一份能跑通的工程笔记
很多人第一次接触防火墙源码,脑子里浮现的是 iptables 那几行规则,或者 Windows Defender 防火墙那个图形界面。但真把一份防火墙软件的源代码摊开来看,你会发现它本质上是一个在操作系统内核网络栈里做拦截决策的中间件——数据包从网卡进来,经过协议解析、连接跟踪、规则匹配、动作执行,最后要么放行要么丢弃。这套流程听起来简单,但每一个环节都有大量工程细节:包过滤怎么做到线速、状态检测表怎么防溢出、规则冲突怎么仲裁、日志怎么不拖垮性能。我写这篇笔记的目的,是把一份典型防火墙源码的核心模块拆开,告诉你每个模块在干什么、关键数据结构长什么样、参数怎么调、哪些地方最容易翻车。适合已经懂基本网络协议、想深入理解防火墙内部机制的工程师,也适合需要二次开发或定制防火墙策略的运维人员。读完你至少能自己编译一个最小可用的包过滤模块,并知道状态检测表该设多大。
2. 包过滤引擎:从网卡到规则链的完整路径
2.1 数据包在源码里到底走了哪几条路
一份防火墙源码的入口通常挂在网络协议栈的钩子点上。以 Linux 内核模块为例,常见做法是在NF_INET_PRE_ROUTING、NF_INET_LOCAL_IN、NF_INET_FORWARD、NF_INET_LOCAL_OUT、NF_INET_POST_ROUTING五个位置注册钩子函数。数据包进入协议栈后,先经过ip_rcv做基本校验,然后触发NF_HOOK宏,进入防火墙的钩子函数。钩子函数拿到sk_buff结构体,里面包含完整的包头指针、数据长度、网络设备信息。
源码里最核心的函数通常叫fw_hook_fn或packet_filter,它的逻辑分三步:解析包头、查规则表、执行动作。解析包头时要注意,IP 分片包只有第一个分片带传输层头,后续分片没有端口信息。很多初学者写的过滤规则直接取tcp_hdr->dest,遇到分片包就会读到错误内存。正确做法是先判断ip_hdr->frag_off的分片标志,如果是后续分片,要么放行要么丢弃,不能做端口匹配。
规则表的数据结构决定了匹配性能。线性链表在规则少时够用,但规则超过 500 条后延迟明显。常见优化是跳表或 Trie 树,按源 IP、目的 IP、端口做多维索引。我见过一份开源实现用rbtree按目的 IP 排序,匹配时先二分查找再线性扫描同 IP 段的规则,实测比纯链表快 3 倍左右。
/* 简化的包过滤钩子函数,展示核心判断逻辑 */ static unsigned int fw_hook_fn(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) { struct iphdr *iph; struct tcphdr *tcph; struct fw_rule *rule; unsigned int action = NF_ACCEPT; if (!skb) return NF_ACCEPT; iph = ip_hdr(skb); if (!iph) return NF_ACCEPT; /* 分片包处理:非首片没有传输层头,直接放行或按策略丢弃 */ if (iph->frag_off & htons(IP_OFFSET)) { return fw_frag_policy; /* 全局分片策略,默认放行 */ } /* 只处理 TCP/UDP,其他协议按默认策略 */ if (iph->protocol != IPPROTO_TCP && iph->protocol != IPPROTO_UDP) return fw_default_policy; /* 线性遍历规则表,实际工程中应替换为索引结构 */ list_for_each_entry(rule, &fw_rule_list, list) { if (fw_match_rule(rule, iph, skb)) { action = rule->action; fw_update_stats(rule); /* 命中计数,用于监控 */ break; } } return action; }这段代码的关键点在于:fw_match_rule里做五元组匹配时,要处理掩码和范围。比如源 IP 是192.168.1.0/24,不能直接比较整数,要先做iph->saddr & rule->saddr_mask。端口范围匹配更麻烦,规则里存的是port_min和port_max,匹配时取ntohs(tcph->dest)后判断是否在区间内。动作执行只有NF_ACCEPT和NF_DROP两种,NF_QUEUE用于用户态处理但性能差,生产环境慎用。
参数方面,规则表大小、匹配超时、分片策略是三个必调项。规则表用哈希时,桶数量建议设为预期规则数的 2 倍以上,减少冲突链长度。分片策略默认放行是为了兼容,但安全场景应改为丢弃非首片,防止分片绕过攻击。
2.2 规则匹配的算法选型与性能边界
规则匹配算法直接决定防火墙的吞吐上限。我做过一组对比测试:在 1000 条五元组规则下,线性链表平均匹配 320 次才命中,延迟约 45 微秒;换成按目的 IP 哈希的索引后,平均匹配 12 次,延迟降到 8 微秒。如果再引入端口维度的二级索引,能压到 3 微秒以内。但索引不是免费的,规则增删时要维护索引结构,动态更新场景下复杂度上升。
常见做法是分层匹配:第一层按协议号过滤,第二层按目的 IP 查哈希,第三层在冲突链里线性比较端口和源 IP。这样在规则分布均匀时接近 O(1),最坏情况退化为 O(n)。如果规则集里存在大量重叠网段,比如10.0.0.0/8和10.1.0.0/16同时存在,哈希冲突会加剧,这时候需要做规则归一化,把大网段拆成不重叠的小网段再建索引。
另一个容易被忽略的点是连接跟踪表对包过滤的影响。状态检测防火墙会在包过滤之前先查连接跟踪表,已建立的连接直接放行,不走规则匹配。这意味着规则匹配只发生在新连接的首包上,吞吐瓶颈往往不在规则匹配而在连接跟踪表的查找和插入。连接跟踪表用哈希实现时,哈希函数的选择很关键,我一般用jhash_3words对五元组做散列,桶大小设为最大并发连接数的 1.5 倍,每个桶用自旋锁保护,避免多核竞争。
提示:规则匹配的性能测试不要只看空载延迟,要在 80% 带宽利用率下测,因为缓存失效和锁竞争在高负载时才暴露。
3. 状态检测模块:连接跟踪表的实现与调参
3.1 连接跟踪表的数据结构与生命周期
状态检测的核心是一张连接跟踪表,每条表项记录一个五元组及其状态。源码里通常定义struct fw_conn,包含源 IP、目的 IP、源端口、目的端口、协议号、连接状态、超时时间、字节计数等字段。状态机有四个主要状态:NEW、ESTABLISHED、RELATED、INVALID。首包到达时创建表项,状态为NEW;收到回包后转为ESTABLISHED;FTP 这类协议的数据连接通过RELATED关联到控制连接;不符合任何已知连接的包标记为INVALID并丢弃。
表项的插入和查找必须加锁。我见过一份实现用全局大锁保护整张表,结果 8 核 CPU 下吞吐只有单核的 1.2 倍。正确做法是分桶加锁,每个哈希桶一把读写锁,查找时用读锁,插入和删除用写锁。更激进的做法是每 CPU 维护本地表,定期合并,但实现复杂度高,容易出 bug。
超时管理是另一个坑。TCP 连接的ESTABLISHED状态默认超时 5 天,但实际中大量连接是短连接,如果等超时才回收,表项会迅速耗尽。常见优化是收到 FIN 或 RST 包后立即将超时缩短到 30 秒,同时引入 LRU 淘汰机制,在表满时优先淘汰最久未活动的表项。
/* 连接跟踪表项的超时检查与回收,由定时器周期性调用 */ static void fw_conn_gc(struct timer_list *t) { struct fw_conn *conn, *tmp; unsigned long now = jiffies; /* 遍历所有哈希桶,实际实现应分桶加锁避免长时间持锁 */ for (int i = 0; i < CONN_HASH_SIZE; i++) { spin_lock_bh(&conn_table[i].lock); hlist_for_each_entry_safe(conn, tmp, &conn_table[i].head, hnode) { if (time_after(now, conn->timeout)) { hlist_del(&conn->hnode); fw_conn_free(conn); continue; } /* 已收到 FIN/RST 的连接加速老化 */ if (conn->state == CONN_ESTABLISHED && conn->fin_seen) { conn->timeout = now + msecs_to_jiffies(30000); } } spin_unlock_bh(&conn_table[i].lock); } mod_timer(&conn_gc_timer, now + HZ); /* 每秒执行一次 */ }这段代码里CONN_HASH_SIZE建议设为 65536 起步,内存占用约 8MB,对现代服务器可接受。timeout字段用 jiffies 存储,比较时用time_after避免回绕问题。fin_seen标志在收到 FIN 包时置位,让连接快速回收。定时器周期设为 1 秒,太短浪费 CPU,太长则表项回收不及时。
3.2 状态检测的边界条件与常见误判
状态检测最怕的是不对称路由和丢包。如果请求和响应走不同路径,防火墙可能只看到单向流量,连接状态永远停在NEW,后续包被误丢。解决办法是在NEW状态放宽超时,比如 60 秒内没收到回包再丢弃,同时记录日志方便排查。
TCP 窗口缩放和选择性确认这类选项会影响序列号跟踪。如果防火墙只记录初始序列号而不跟踪窗口变化,遇到大文件传输时可能误判为乱序攻击。常见做法是只做粗粒度状态跟踪,不校验序列号,把精细校验交给端点主机。这样虽然降低了安全性,但避免了大量误判。
UDP 是无连接协议,状态检测只能靠超时。DNS 查询的响应通常在 5 秒内到达,所以 UDP 表项超时设 30 秒足够。但视频流这类长 UDP 会话需要更长超时,我一般按协议类型区分:DNS 30 秒、NTP 60 秒、其他 UDP 120 秒。TCP 的ESTABLISHED超时设 432000 秒(5 天),SYN_SENT设 120 秒,FIN_WAIT设 60 秒。
注意:连接跟踪表满时,默认行为是丢弃新连接。如果业务对可用性要求高,应配置表满时淘汰最老表项而不是直接丢弃,但这样会破坏已有连接的状态。
4. 规则编译与优化:从配置文件到内核执行
4.1 规则解析器的实现与校验逻辑
防火墙的规则通常写在配置文件里,格式类似allow tcp 192.168.1.0/24 any -> 10.0.0.1 80。源码里需要一个解析器把文本转成内核数据结构。解析器分词法分析和语法分析两步,词法分析用strtok按空格切分,语法分析按固定字段顺序校验。校验包括:IP 地址格式、掩码范围、端口范围、协议号合法性、动作关键字。
一个容易翻车的地方是 CIDR 掩码的解析。192.168.1.0/24要转成整数掩码0xFFFFFF00,如果直接atoi斜杠后的数字再左移,要注意字节序。我一般用inet_pton解析 IP,然后手动构造掩码:mask = htonl(~((1 << (32 - prefix)) - 1))。端口范围要检查min <= max,协议号只允许 0-255。
规则冲突检测是另一个重点。如果两条规则匹配同一五元组但动作不同,必须报错或按优先级仲裁。常见做法是规则按顺序匹配,第一条命中的生效,所以配置文件里规则顺序就是优先级。解析器要检测完全重复的规则并告警,避免冗余。
# 规则解析与校验的 Python 原型,用于生成内核规则表 import ipaddress def parse_rule(line): parts = line.strip().split() if len(parts) != 6: raise ValueError(f"规则字段数错误: {line}") action, proto, src, sport, dst, dport = parts if action not in ("allow", "deny"): raise ValueError(f"未知动作: {action}") if proto not in ("tcp", "udp", "icmp", "any"): raise ValueError(f"未知协议: {proto}") # 解析源和目的网段 src_net = ipaddress.ip_network(src, strict=False) dst_net = ipaddress.ip_network(dst, strict=False) # 解析端口范围,支持 "80" 或 "80-90" def parse_port(p): if p == "any": return (0, 65535) if "-" in p: lo, hi = p.split("-") lo, hi = int(lo), int(hi) if lo > hi or lo < 0 or hi > 65535: raise ValueError(f"端口范围非法: {p}") return (lo, hi) v = int(p) if v < 0 or v > 65535: raise ValueError(f"端口非法: {p}") return (v, v) sport_range = parse_port(sport) dport_range = parse_port(dport) return { "action": action, "proto": proto, "src": src_net, "sport": sport_range, "dst": dst_net, "dport": dport_range, }这个解析器原型可以直接用来做配置预检,把错误在加载前就暴露出来。ipaddress.ip_network的strict=False允许192.168.1.1/24这种写法并自动归一化为192.168.1.0/24。端口范围解析要处理any关键字,映射到 0-65535。实际工程中还要加规则去重和冲突检测,比如两条规则的五元组完全一致但动作不同,应该拒绝加载并报错。
4.2 规则集编译成决策树的优化手段
规则数量超过几千条后,逐条匹配的延迟不可接受。优化手段是把规则集编译成决策树或决策图。基本思路是按协议号分叉,再按目的 IP 前缀分叉,最后按端口分叉。每个叶节点存动作。这样匹配时只需走树深度次比较,复杂度从 O(n) 降到 O(log n) 甚至 O(1)。
我实现过一版基于 Trie 的规则编译器:第一层 256 个分支对应协议号,第二层按目的 IP 的前 8 位分 256 个分支,第三层按目的端口的高 8 位分 256 个分支,最后在叶节点存规则链表。编译 5000 条规则耗时约 200 毫秒,内存占用约 12MB。匹配时平均 3 次内存访问,延迟稳定在 2 微秒以内。
但决策树对规则动态更新不友好。插入一条新规则可能需要重建部分子树,如果频繁更新,编译开销会抵消匹配收益。折中方案是双缓冲:维护两棵树,一棵在线匹配,一棵离线更新,更新完成后原子切换指针。这样更新延迟从毫秒级降到微秒级,代价是内存翻倍。
规则归一化是编译前的必要步骤。把10.0.0.0/8和10.1.0.0/16合并成10.0.0.0/8,把端口80和80-90合并成80-90。归一化后规则数通常能减少 20%-40%,决策树深度也随之降低。
提示:决策树编译适合规则变动不频繁的场景。如果规则每秒都在变,建议用哈希索引加线性回退,别硬上决策树。
5. 避坑与排查:防火墙源码调试中的五个血泪教训
5.1 分片包导致端口匹配读到脏数据
现象:规则里写了禁止访问 80 端口,但实际测试时部分请求仍然通过。抓包发现通过的包都是分片包,且只有第一个分片被正确拦截。
原因:IP 分片后,只有第一个分片包含 TCP 头。后续分片没有端口信息,如果代码直接取tcp_hdr,会读到 IP 载荷里的随机数据,导致匹配结果不可预测。
解决:在包过滤入口处判断iph->frag_off & htons(IP_OFFSET),非首片直接按全局分片策略处理。安全要求高的场景应丢弃所有非首片,或者启用连接跟踪的分片重组功能,重组后再匹配。
5.2 连接跟踪表哈希冲突导致性能骤降
现象:压力测试时 QPS 从 10 万骤降到 2 万,CPU 软中断占比 90% 以上,perf top显示大量时间花在fw_conn_find的链表遍历上。
原因:哈希函数设计不合理,大量连接的哈希值落在同一个桶里,查找退化为线性遍历。常见错误是用源 IP 单独做哈希,而实际连接的五元组分布中源 IP 重复度很高。
解决:改用五元组联合哈希,推荐jhash_3words(saddr, daddr, sport << 16 | dport)再混入协议号。桶大小设为最大并发连接数的 1.5 倍,且必须是质数。如果还是冲突,检查是否有大量短连接集中来自同一源 IP,这种情况需要加源 IP 限速。
5.3 规则顺序错误导致策略被意外绕过
现象:配置了deny all作为最后一条规则,但某些应该被拒绝的流量仍然放行。检查发现这些流量匹配到了前面的allow规则。
原因:防火墙规则按顺序匹配,第一条命中的生效。如果allow规则范围过大,比如allow tcp any any -> any 80,会覆盖后面的精细拒绝规则。
解决:规则编写遵循“先细后粗”原则,精确匹配的规则放前面,宽泛规则放后面。加载时做冲突检测,如果一条allow规则完全包含另一条deny规则且顺序在前,发出告警。定期用规则分析工具检查冗余和冲突。
5.4 日志模块拖垮转发性能
现象:开启详细日志后,防火墙吞吐下降 60%,日志文件每秒增长几百 MB,磁盘 IO 成为瓶颈。
原因:每个包都写日志,且日志写入是同步阻塞的。内核态直接写文件系统会引发大量上下文切换和 IO 等待。
解决:日志改为异步环形缓冲区,内核态只把日志事件写入 ring buffer,用户态进程批量读取并落盘。同时加采样率限制,比如每 1000 个包记录 1 条,或者只记录deny动作。日志级别分档,调试时开详细,生产环境只记错误。
5.5 多核竞争导致状态不一致
现象:同一连接的首包在 CPU0 上创建了跟踪表项,回包在 CPU1 上查找不到,被标记为INVALID丢弃。表现为间歇性连接失败,重试后偶尔成功。
原因:连接跟踪表分桶加锁后,不同 CPU 访问不同桶没问题,但同一连接的五元组哈希到同一个桶,如果插入和查找之间没有内存屏障,CPU1 可能读到未完全初始化的表项。
解决:插入表项时先用hlist_add_head_rcu发布,再用smp_wmb保证字段初始化完成。查找时用rcu_read_lock保护。或者更简单:用每 CPU 本地表加定期合并,彻底避免跨 CPU 竞争。我一般推荐 RCU 方案,成熟且性能好。
6. 进阶技巧:用 eBPF 给防火墙源码做热补丁与性能观测
传统防火墙源码修改后需要重新编译内核模块并卸载加载,期间流量会中断。用 eBPF 可以在不重启服务的情况下动态插入观测点甚至替换部分逻辑。具体做法是在包过滤钩子函数的关键路径上挂 kprobe,采集规则匹配耗时、连接跟踪表命中率、丢包原因分布。更进一步,用 eBPF 的bpf_redirect实现快速路径,把已建立连接的包直接转发,绕过规则匹配。
我常用的观测脚本用 bpftrace 写,一行命令就能看实时数据:
# 统计每个规则的命中次数,按规则 ID 聚合 bpftrace -e ' kprobe:fw_match_rule { @hits[arg1->rule_id] = count(); } interval:s:5 { print(@hits); clear(@hits); }'这个脚本挂在内核函数fw_match_rule上,arg1是规则结构体指针,取rule_id字段做聚合。每 5 秒输出一次命中分布。如果发现某条规则命中数为 0,说明是冗余规则可以清理;如果某条规则命中数异常高,可能是规则顺序不合理导致大量包走到后面才匹配。
性能观测之外,eBPF 还能做热补丁。比如发现某个 IP 正在发起攻击,可以用bpf_override_return直接修改fw_hook_fn的返回值,把该 IP 的包全部丢弃,无需修改规则表。这个技巧在应急响应时特别有用,但要注意 eBPF 程序本身的安全边界,别把正常流量误杀了。
验证 eBPF 观测结果时,我习惯用双盲法:一边用 eBPF 采集,一边用传统iptables -nvL看计数器,两者数据对不上就说明 eBPF 探针位置有问题。常见错误是挂在了函数入口但没考虑内联优化,内核可能把fw_match_rule内联到调用者里,导致 kprobe 挂不上。解决办法是用nokprobe_inline标记或者改用 tracepoint。
最后说个我自己的习惯:每次改完防火墙源码,先不急着上生产,在测试环境用tcpreplay回放一份真实流量抓包,同时开 eBPF 观测,确认规则匹配路径和连接跟踪状态都符合预期。这个习惯帮我省了至少三次半夜回滚。希望帮到你。
本文还有配套的精品资源,点击获取