简介:这是一套基于C/C++与VB混合开发的防火墙软件源代码,面向网络协议分析、安全编程学习者以及课程设计开发者,重点展示Windows平台下流量捕获、包过滤与钩子拦截的实现思路。压缩包共236个文件,约1.23MB,核心代码包括54个.h头文件和40个.cpp源文件,同时附有驱动/动态库(dll、sys)、可执行文件、VB窗体(frm)及Visual C++工程文件(dsp/dsw),便于直接打开编译学习;txt说明与chm帮助文档可辅助梳理架构。包内从进程钩子挂接到底层数据包收发、直通转发等关键模块均有完整实现,配合构建脚本和工程配置可快速还原项目骨架。已有398人学习,对想理解防火墙内部模块划分、驱动与用户态交互的读者具有实际参考价值。
1. 接手防火墙源代码,先别急着翻规则引擎
接手一个防火墙软件的源代码,大多数人第一个动作是搜规则引擎,因为文档里都写着强大的规则过滤,想当然认为复杂度全在规则解析里。实际翻过几套开源防火墙就会发现,规则解析只在配置变更时执行一次,而收包、查表、命中、放行这条数据路径,每个包都要走一遍。真正决定性能上限和系统可靠性的,是这条路径上的分支和锁。源代码里最值钱的不是那几段正则,而是数据路径上的顺序与边界条件。这篇顺着读防火墙源码的常见路径走一遍:先定位数据路径,再用最小 C 模型还原判断逻辑,最后落到编译、调试和二次开发。
2. 数据路径优先:防火墙源码分析的三个骨架点
2.1 规则引擎不忙,忙的是数据路径
防火墙源码里通常有两条明显不同的执行路径:一条是控制路径,用户敲入一条命令,工具程序把规则翻译成二进制结构,再经由 netlink 送进内核;另一条是数据路径,每个到达的包都会穿过的代码段。控制路径上的解析、编译、校验,只在规则变更时执行,一天下来可能几千次;数据路径则是每秒成千上万次地往返。如果一上来就读规则解析器,很容易陷入正则库和语法分析树里出不来。
先看数据路径,能确认几件事:包在几个位置被拦截,每处拦截做几次遍历,命中和未命中的分支分别落在哪一行。这些信息直接决定了它的横向扩展能力和攻击面,比任何一行花哨的规则都要命。读数据路径之前,建议先用工具画出函数调用链。常见做法是先跑一次流量压测,同时抓 perf top 或者 bpftrace 的 stack 输出,看热点集中在内核哪些符号上。若热点不在防火墙模块里,而是落在协议栈常规处理上,说明问题可能在别处;若热点集中在规则遍历的那几个函数上,就该顺着那条线往下深挖。拿到热点后,再去源码里找对应的注册入口,往往比全文搜索 filter 要快得多。
2.2 从 Netfilter 钩子表定位 hook 注册点
Linux 里绝大多数开源防火墙都不会真正改协议栈主线,而是以模块方式挂在 Netfilter 框架上。Netfilter 在协议栈的关键节点预留了五个钩子点,防火墙就是往这些点注册回调,按优先级依次执行。先背下这五个位置,再去源码里搜nf_register_net_hook或者类似的注册函数,就能快速找到模块挂载点。
| 钩子点 | 触发时机 | 防火墙用途 |
|---|---|---|
| NF_INET_PRE_ROUTING | 入包,路由决策前 | DNAT、预过滤、防扫描 |
| NF_INET_LOCAL_IN | 目的为本机的包 | 入站访问控制 |
| NF_INET_FORWARD | 需要转发的包 | 网关转发控制 |
| NF_INET_LOCAL_OUT | 本机发出的包 | 出站访问控制 |
| NF_INET_POST_ROUTING | 出包,选路后离开前 | SNAT、出站限速 |
有人会问,FORWARD 和 LOCAL_IN 不都是进来的包吗?差别在目的地址:目的 MAC 是网卡自身则走 LOCAL_IN,否则按路由结果决定是否走 FORWARD。这个区分几乎决定了一个防火墙软件该注册在哪个钩子上。误把转发的包当成入站包去过滤,源地址、目标端口全对不上,规则看起来写了却始终不生效。
定位注册点时不一定要看运行中的进程。静态阅读源码时,搜索nf_hook_ops结构体的赋值和注册调用,比找具体协议处理函数更直接。常见的开源防火墙会在初始化阶段把一组nf_hook_ops注册到 Netfilter,里面带钩子点常量、优先级和回调函数名。看到回调函数的签名里出现skb、协议头和入出接口,再跳进函数体,数据路径的入口就找到了。另一种辅助路径是看/proc/net/netfilter/下暴露的注册信息,通过它确认当前系统实际装载了哪些表和钩子。
提示:内核源码阅读时注意版本差异,老版本用
nf_register_hook,新内核统一走nf_register_net_hook,函数前缀不同,语义基本一致。
2.3 规则链与表的映射:先找到被遍历的数据结构
防火墙源码里规则在用户态和内核态各有一份表达。用户态那份负责给人看,内核态那份直接参与报文逐条比对。iptables 是过去十几年最常见的接口,它把规则按表和链组织:表决定这条规则属于哪个处理阶段,链决定这个阶段里包流向的哪个部位会被过滤。照这个映射关系去查源码,能很快弄清楚一条规则最终走进了哪份数组或链表。
表和链的对应关系如下:
| 表 | 涉及链 | 典型职责 |
|---|---|---|
| filter | INPUT、FORWARD、OUTPUT | 防火墙访问控制 |
| nat | PREROUTING、INPUT、OUTPUT、POSTROUTING | 地址转换 |
| mangle | 五条链均涉及 | 修改报文、设置标记 |
| raw | PREROUTING、OUTPUT | 跳过状态跟踪 |
看内核源码时,ip_tables.c实际是把规则拆成一个二维结构:外层按规则编号排成数组,每一条规则里包含多个 match 段和一个 target 段。数据包抵达后从头开始逐条扫过,match 逐项比对,全部通过则执行 target,动作通常是 ACCEPT、DROP 或跳到下一条链。理解了这套结构,反过来再看用户态的 iptables 或者 nft 工具,传递规则其实就是把这些字段逐条序列化,再经由 netlink 送进内核同一个结构里。观察这条传递链路的具体方法,第 4 章会给命令。
3. 用最小 C 代码复刻防火墙的命中判断
3.1 规则结构体与匹配语义:顺序命中优先
先把真实防火墙的数据模型缩小到最低限度。规则只保留六个字段:协议类型、源地址、目的地址、源端口、目的端口、动作。真实产品里还有网卡接口、时间、连接状态、标记、限速等字段,但核心匹配流程完全一样:拿包头字段和规则逐项比较,只要有一项不相等就跳下一条;全部相等则按动作放行或丢弃。这也带来一个实战经验:规则顺序很重要,热门的、频率最高的规则尽量往前提,否则每个包都得把前面一大堆不相关的规则扫一遍。
下面这条结构体定义,是很多防火墙源码里规则项的缩小版:
/* fw_rule.h - 最小防火墙规则结构 */ #define FW_ACCEPT 1 #define FW_DROP 0 struct fw_rule { uint8_t proto; /* 0=任意协议,否则用 IPPROTO_TCP / IPPROTO_UDP */ uint32_t src_ip; /* 源地址,网络字节序,0 表示不限制 */ uint32_t dst_ip; /* 目的地址,网络字节序,0 表示不限制 */ uint16_t src_port; /* 源端口,网络字节序 */ uint16_t dst_port; /* 目的端口,网络字节序 */ uint8_t action; /* 命中后的动作:FW_ACCEPT / FW_DROP */ uint64_t hits; /* 命中计数器,排查规则有没有被触发时用 */ };这里有三个容易错的地方:地址用网络字节序存储,直接和包头字段做整数比较,省去每个包一次的字节序转换;端口同理,赋值时要用htons()而不是按主机字节序硬塞;hits字段放在结构体最后,不影响遍历速度,但对应真实防火墙里iptables -nvL那列计数器,排障时价值极高。
3.2 逐字段匹配的 C 实现
接着写匹配函数。真实防火墙代码在判断协议和端口之前还要做报文长度校验,防止越界读取,这里先把校验位置留出来,后面再展开。
int fw_match_rule(const struct fw_rule *rule, const struct iphdr *iph, const struct tcphdr *tcph) { /* 协议不匹配直接跳过 */ if (rule->proto && rule->proto != iph->protocol) return 0; /* 地址匹配:源和目的都必须符合,0 代表任意 */ if (rule->src_ip && rule->src_ip != iph->saddr) return 0; if (rule->dst_ip && rule->dst_ip != iph->daddr) return 0; /* 端口匹配只在协议为 TCP/UDP 时进行 */ if (rule->src_port && rule->src_port != tcph->source) return 0; if (rule->dst_port && rule->dst_port != tcph->dest) return 0; return 1; }逻辑说明:函数返回 1 表示当前这条规则命中,返回 0 表示跳过,顺序遍历的事交给调用方。把匹配和遍历拆成两个函数,是源码里常见的分层方式,外层只管拿下一条规则,内层只回答这一条合不合。各参数的含义和注意点如下表:
| 参数 | 含义 | 坑点 |
|---|---|---|
| rule | 当前要比较的单条规则 | 指针不能为 NULL,遍历前先判空 |
| iph | 已解析的 IP 头指针 | 调用前必须确认报文长度足够,否则指针踩空 |
| tcph | TCP 头指针,UDP 时对应 udphdr | 非 TCP/UDP 协议时传 NULL,函数内要靠 proto 提前拦截 |
这个函数的开销决定了一个简单防火墙的转发性能:每增加一条规则,最坏情况多一次完整比较。真实项目会做规则聚合、哈希索引把 O(n) 变成接近 O(1),但逐条比较的语义必须保留,因为防火墙规则本身有顺序语义,不能随便重排。
3.3 带主函数的可运行测试模型
把结构体和函数放一起,再塞一个最小的主函数,就能编译验证:
#include <stdio.h> #include <stdint.h> #include <arpa/inet.h> #include "fw_rule.h" int main(void) { struct fw_rule rules[2] = { { IPPROTO_TCP, 0, htonl(0x0A000001), 0, htons(22), FW_DROP, 0 }, { IPPROTO_TCP, 0, htonl(0x0A000001), 0, htons(80), FW_ACCEPT, 0 } }; /* 构造模拟包:源 10.0.0.2,目的 10.0.0.1,目的端口 80 */ struct iphdr iph = { .protocol = IPPROTO_TCP }; struct tcphdr tcph = { .dest = htons(80), .source = htons(50000) }; iph.saddr = inet_addr("10.0.0.2"); iph.daddr = inet_addr("10.0.0.1"); for (int i = 0; i < 2; i++) { if (fw_match_rule(&rules[i], &iph, &tcph)) { printf("命中规则 %d,动作=%s\n", i, rules[i].action == FW_ACCEPT ? "ACCEPT" : "DROP"); break; } } return 0; }编译验证:
gcc -Wall -O2 -o fw_rule_test fw_rule_test.c ./fw_rule_test这个模型虽小,完整还原了真实防火墙的逐条命中即停语义。第一条规则是 DROP 22 端口,第二条是 ACCEPT 80 端口,模拟包访问的是 80 端口,会先跳过第一条、命中第二条,打印 ACCEPT。把端口改成 22,就会命中第一条打印 DROP,和真实 iptables 行为一致。拿这套最小模型做单测,比直接改内核模块上机验证快得多。真实源代码里的规则匹配多半就是这样一层套一层:外层是链的遍历,内层是 match 的逐项比较,测试时把包头字段按真实报文结构填充,就能覆盖绝大多数分支。
4. 编译、加载与调试:让源代码在本地跑起来
4.1 从 Linux 内核源码分析到生成防火墙模块
读懂和修改真实防火墙源码,最稳的路径是在本地编出一套可直接替换的系统模块。很多读者只看不跑,改完一个判断分支,既不知道对性能的影响,也不清楚会不会破坏 hook 注册。把源码落实到可执行文件,才是源代码分析的正确收尾。准备工作分两段:内核模块和用户态工具。内核模块优先,因为它承载全部数据路径逻辑。
常见做法是取一份与当前系统匹配的内核源码包,在make menuconfig里确认开启CONFIG_NETFILTER、CONFIG_NF_TABLES、CONFIG_IP_NF_IPTABLES以及对应协议模块。然后把自研防火墙模块放进内核源码树的net/ipv4/netfilter/目录下,用内核构建机制单独编译:
make -C /lib/modules/$(uname -r)/build M=$(pwd)/fwmodule modules sudo insmod fw_kernel.ko-C参数指向当前内核的构建目录,没有这个目录就先去装内核开发包。modules目标把当前目录下的源码编成.ko,insmod加载后可以立刻在/proc/net/netfilter/里确认注册结果。注意:模块编译用的内核头文件必须和运行内核版本完全一致,否则insmod会直接报版本魔术错误,这个错误查看dmesg尾部即可,通常能给出版本字符串做对照。
4.2 用 strace 跟踪规则下发,验证源码改动
编译通过不等于逻辑正确。修改源码后,最有效的验证不是抓包,而是看一条规则从命令行进到内核走了什么路径。strace 可以完整看到用户态工具是如何组装系统调用的:
strace -f -e trace=setsockopt,sendmsg,recvmsg \ iptables -A INPUT -p tcp --dport 22 -j ACCEPT输出里能看到socket(AF_INET, SOCK_RAW)建立套接字、setsockopt设置选项,以及一大段 netlink 消息通过sendmsg发出。如果改过规则编码逻辑,对比修改前后这段 netlink 消息的差异,基本就是问题所在。这个思路对 iptables 和 nft 两种接口都成立,只是消息格式不同。
源码级调试时容易遇到"当前不会命中断点"的问题。常见原因有三个:一是内核模块用旧头文件编译,代码行号和实际加载的不一致;二是调试的是用户态工具,但 gdb 加载的符号路径指向系统自带的那个 iptables 二进制;三是在容器里调试,宿主机和容器内核模块共享,用户态路径却相互隔离。遇到这种情况,先把可执行文件路径指向自己编出来的那个二进制,再确认模块符号地址:cat /proc/modules拿模块基址,对照nm输出的函数偏移,断点就一定能落上去。
4.3 三条排错命令定位"规则没生效"
规则写了、模块挂了、工具也跑通了,流量还是没按预期放行。这种情况在二次开发阶段尤其常见,大概率是逻辑分支没走到。先别急着改代码,按下面这张表的顺序跑一遍,能省下大半天的排查时间。
| 排查点 | 命令 | 重点关注 |
|---|---|---|
| 计数器有没有增长 | iptables -nvL -t filter --line-numbers | 命中次数停在 0,说明数据路径没走到这条规则 |
| 内核日志有无报错 | dmesg -T | grep -i -E 'netfilter|nf_tables' | 看有没有 dropped、invalid 之类的关键字 |
| hook 是否注册成功 | lsmod | grep -E 'iptable_|nf_' | 模块加载了但 hook 没注册,多半是初始化条件不满足 |
计数器是第一观察点。如果一条放行规则的计数器在压测后纹丝不动,要么包根本没走到这个 hook,要么走到了但字节序比较失败。字节序比较失败是自写规则匹配时最容易踩的坑:端口和地址在抓包输出里看起来一模一样,但一边是主机字节序、一边是网络字节序,整数比较永远不相等。把两边的值同时按十进制打印,一秒见分晓。
5. 二次开发绕不开的五个源码级细节
优先级别乱动。Netfilter 的同一钩子点上可以注册多个回调,按优先级从小到大执行。自研模块如果照抄上层框架的优先级,很可能在别人处理之前就把包消耗掉,后面的状态跟踪、审计全部失效。新模块一律从NF_IP_PRI_FILTER附近开始,确实需要先处理,再单独注册一个更小的优先级号,并用注释标明依赖关系。
报文长度校验放在第一位。真实流量里会有大量长度极短的畸形包,访问 TCP 头之前不做长度判断,一读源端口就越界。常见做法是在函数开头调用pskb_may_pull或等价接口,确认整个协议头都在线性区里。这个检查放得越早,后续代码越安全。
conntrack 状态要统筹设计。连接跟踪表会在 PRE_ROUTING 之后维护连接状态,回包如果被判定为 INVALID,即使入站规则允许也无法命中。改源码时如果跳过 raw 表直接进 filter,要清楚自己丢掉了 conntrack 这个前置信息。
计数器先取再删。线上调规则时,习惯是先把可疑规则停用观察计数器,再决定删除。但很多改法直接调替换接口,把整条规则连同统计一起清零。想保留现场,就先执行iptables -L -v -Z记录当前值再删规则。源码里的 counters 字段一定要透传到用户态,否则排障全靠猜。
规则文件要加校验。产品化时规则以下发文件形式落地,这个文件一旦被篡改,所有防护形同虚设。常见做法是给规则文件加 HMAC 签名,加载时校验签名和数据完整性,校验逻辑放在模块初始化里而不是用户态工具里,避免被绕过。有人会问自研模块的源码怎么加密,内核模块编译后本身就是机器码,源码级保护意义有限,真正要护住的是规则文件和下发通道的完整性。
验证这些细节是否漏水,最快的方式是给数据路径打点,量一次规则遍历的平均耗时:
perf stat -e cycles,instructions -p $(cat /var/run/firewalld.pid) sleep 5如果 cycles 和 instructions 的比例偏高,把压测缩到单个匹配函数上继续抓,交替用 bpftrace 在fw_match_rule的入口和出口各放一个探针,两个时间戳相减就是单条规则匹配的开销。翻源码时把这些探针预留好,下一轮迭代直接复用。
本文还有配套的精品资源,点击获取