防火墙软件源代码二次开发实战:从架构定位到eBPF升级
简介这是一份面向网络安全初学者与防火墙开发爱好者的源代码学习资源围绕防火墙核心机制展开适合具备一定C/C基础、希望理解包过滤与网络钩子实现原理的开发者参考。压缩包共236个文件约1.23MB以54个.h头文件与40个.cpp源文件为主体辅以9个.c文件、4个makefile与4个sources构建脚本另有23个ico图标、10个htm页面、7个dll动态库、5个exe可执行文件及若干dsp、dsw工程文件整体构成一套可编译的Windows平台防火墙工程。内容预览显示涉及Build.bat构建脚本、MINIHOOK.C与PROTHOOK.C钩子模块、Packet.c数据包处理、RECV.C与SEND.C收发逻辑以及xpassthru.c透传实现覆盖从驱动层拦截到应用层收发的完整链路。目前已有399人学习下载读者可借此梳理防火墙数据包捕获、过滤规则与钩子注入的代码组织方式理解工程目录结构与模块划分为二次开发或课程设计提供可运行的参考样本。1. 防火墙软件源代码从“能编译”到“敢改一行”之间隔着什么很多人第一次拿到一份防火墙软件的源代码第一反应是找main()在哪第二反应是能不能直接make一把跑起来。我当年也是这么干的结果在一台测试机上编译通过、进程也起来了但规则一条都没生效抓包发现流量根本没进到自己的 hook 点。问题不在代码而在于我没搞清楚这份源代码到底工作在哪个层次——是用户态的包过滤还是内核态的 netfilter 钩子还是干脆只是个 iptables 的封装壳子。防火墙软件源代码这个方向真正难的不是读懂某一行 C 代码而是搞清楚它的数据包在协议栈里从哪进、从哪出、规则匹配发生在第几层。这篇笔记面向的是手里已经有一份防火墙源码、想把它跑起来并做二次开发的工程师我会按“先定位架构、再跑通最小闭环、然后改规则、最后排错”的顺序把这条路上我踩过的坑和验证方法讲清楚。适合有 Linux 网络编程基础、能看懂 socket 和 netfilter 基本概念的人跟做。2. 先给源代码做一次“架构体检”判断它属于哪一类防火墙拿到一份防火墙源代码别急着编译。先花二十分钟做架构体检能省掉后面几天的瞎折腾。防火墙按工作位置大致分三类用户态代理型比如基于 TUN/TAP 或 raw socket 抓包再转发、内核态过滤型netfilter/iptables 钩子、eBPF、以及控制面封装型本质是调用系统防火墙命令或库。这三类的源代码结构、编译方式、调试手段完全不同。判断方法很直接全局搜关键字。2.1 用关键字定位数据包处理路径在源码根目录跑几条 grep基本就能定性。下面这几条命令是我每次拿到新源码必跑的按顺序执行输出结果直接决定后面怎么编译和调试。# 1. 找内核态钩子netfilter / nf_hook / eBPF grep -rn nf_register_hook\|nf_register_net_hook\|NF_INET_PRE_ROUTING\|bpf_prog_load --include*.c --include*.h . # 2. 找用户态抓包raw socket / pcap / TUN grep -rn AF_PACKET\|SOCK_RAW\|pcap_open_live\|/dev/net/tun\|TUNSETIFF --include*.c . # 3. 找控制面封装system() 调 iptables / nft / 调用 libiptc grep -rn iptables\|nftables\|libiptc\|system(\ip --include*.c --include*.sh . # 4. 找规则匹配核心看有没有自研的匹配引擎 grep -rn match_rule\|rule_match\|acl_check\|policy_lookup --include*.c .逻辑说明第 1 条命中说明是内核模块编译产物是.ko调试要用 dmesg 和 ftrace第 2 条命中说明是用户态抓包编译产物是普通可执行文件调试用 gdb 加抓包对比第 3 条命中说明它自己不做包处理只是规则下发器那你要关注的是它怎么和系统防火墙交互而不是包路径。第 4 条是判断它有没有自研匹配逻辑如果只有前三条没有第四条那它大概率是个“壳”。参数说明--include*.c限定只搜 C 源码避免被文档和二进制干扰-rn中-r递归、-n显示行号方便直接跳转。如果你的源码是 C 或 Rust把 include 换成对应后缀即可。这一步不要偷懒用 IDE 全局搜索命令行 grep 的输出更容易贴进笔记做对比。2.2 看构建系统判断依赖边界架构定性之后看构建系统。Makefile、CMakeLists.txt、Cargo.toml、go.mod各自暴露的依赖信息量很大。重点看三样内核头文件路径、链接了哪些库、有没有条件编译开关。# 看 Makefile 里的内核路径和编译目标 grep -n KERNELDIR\|KDIR\|/lib/modules\|obj-m\|EXTRA_CFLAGS Makefile # 看链接了哪些库用户态项目 grep -n \-l Makefile CMakeLists.txt 2/dev/null | grep -v ^Binary # 看条件编译开关决定功能裁剪 grep -rn #ifdef\|#if defined --include*.c . | head -30逻辑说明obj-m出现说明是内核模块必须用对应内核版本的 headers 编译版本不匹配会直接编译失败或加载后 panic。-l那行能看出它依赖 libpcap、libnetfilter_queue 还是 libmnl这些库的版本直接影响 API 调用方式。条件编译开关决定了很多功能默认是关的你编译出来“功能不全”往往就是开关没开。参数说明KERNELDIR指向内核源码或 headers 路径常见值是/lib/modules/$(shell uname -r)/build。如果你的目标内核和当前运行内核不一致这个路径必须手动改不能靠默认值。head -30是防止条件编译太多刷屏先看前 30 条找规律。提示如果 grep 出来既有内核钩子又有用户态抓包说明这是个混合架构控制面和数据面分离。这种项目编译要分两步先编内核模块再编用户态程序顺序反了会找不到符号。3. 跑通最小闭环让第一条规则真正拦住一个包架构清楚了接下来目标只有一个让这份源代码编译出来的东西能拦住一个具体的包。不要一上来就配复杂规则先用最小闭环验证数据路径是通的。我一般用“本机 ping 自己”或者“两台虚拟机对 ping”来测因为 ICMP 好构造、好观察、好抓包。3.1 编译与加载内核模块和用户态程序分开处理如果是内核模块编译加载的步骤和普通程序不一样顺序错了会浪费很多时间。下面以最常见的 netfilter 模块为例。# 内核模块编译在源码根目录 make -C /lib/modules/$(uname -r)/build M$(pwd) modules # 加载模块先看依赖 sudo insmod firewall_core.ko # 如果报 Unknown symbol先加载依赖模块 sudo modprobe nf_conntrack # 确认加载成功 lsmod | grep firewall dmesg | tail -20 # 用户态程序编译如果有 make sudo ./firewallctl --load-rules rules.conf逻辑说明make -C ... M$(pwd) modules是内核模块的标准外部编译方式-C切到内核构建目录M指回你的源码目录。insmod失败最常见的原因是依赖模块没加载dmesg里会明确写Unknown symbol照着补modprobe就行。用户态程序负责下发规则加载完模块后必须跑一次否则内核里规则表是空的什么也拦不住。参数说明$(uname -r)必须和你的目标内核完全一致差一个小版本都可能加载失败。rules.conf是规则文件格式取决于项目定义常见是每行一条“源 IP 目的 IP 端口 动作”。第一次测试建议只写一条deny icmp规则方便验证。3.2 用一条 ICMP 规则验证拦截生效规则下发之后怎么确认它真的生效了不能只看程序输出“success”要用抓包和计数器双重验证。# 终端 A抓包看 ICMP 有没有被丢 sudo tcpdump -i any -n icmp -c 10 # 终端 B发起 ping ping -c 5 192.168.1.100 # 查看内核模块的规则命中计数如果项目支持 cat /proc/net/firewall_stats # 或者用项目自带工具 sudo ./firewallctl --stats逻辑说明如果规则生效tcpdump 在发包端能看到 request但在被拦方向看不到 reply或者两边都看不到。更可靠的是看模块自己的计数器命中计数增加说明包确实进了你的匹配逻辑。如果 tcpdump 能看到完整往返但计数器不动说明包根本没进你的 hook问题出在注册钩子那一步不是规则匹配。参数说明-i any抓所有网卡避免抓错接口-c 10抓 10 个包自动停防止刷屏。/proc/net/firewall_stats是常见做法但具体路径看项目实现有的用 debugfs有的用 netlink 上报。如果没有统计接口就在匹配函数入口加printk用dmesg -w实时看。注意测试拦截规则时如果你是通过 SSH 连的测试机千万别一上来就 deny 所有 tcp会把自己关在门外。先放行你的管理端口或者用本地终端操作。4. 改规则匹配逻辑从“能跑”到“按我的需求跑”最小闭环跑通之后真正的二次开发才开始。大部分需求集中在两处改匹配条件比如加一个自定义字段和改动作比如从 drop 改成 redirect 到用户态队列。这一章讲怎么安全地改以及改完怎么验证没改坏。4.1 定位规则匹配的核心函数不同架构的匹配入口不一样但套路相似。内核态找 hook 函数里的匹配循环用户态找收到包之后的 policy 查找。下面是一个典型的内核态匹配函数骨架你要找的就是类似结构。/* 典型 netfilter hook 匹配骨架函数名各项目不同 */ static unsigned int fw_hook_fn(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) { struct fw_rule *rule; struct fw_hdr *hdr; /* 1. 解析包头拿到五元组 */ hdr fw_parse_header(skb); if (!hdr) return NF_ACCEPT; /* 解析失败放行避免断网 */ /* 2. 遍历规则表做匹配 */ list_for_each_entry(rule, fw_rule_list, list) { if (fw_match(rule, hdr)) { FW_STAT_INC(rule-hit_count); /* 命中计数 */ return rule-action; /* NF_DROP / NF_ACCEPT / NF_QUEUE */ } } /* 3. 没有命中任何规则走默认策略 */ return fw_default_policy; }逻辑说明这段骨架里fw_parse_header负责从 skb 里提取五元组fw_match是单条规则的匹配函数rule-action决定放行还是丢弃。你要加自定义匹配条件就改fw_match要改动作就改rule-action的取值逻辑。注意NF_ACCEPT和NF_DROP是 netfilter 的标准返回值不要自己造返回值。参数说明skb是内核里的包结构state包含 hook 点和网卡信息。list_for_each_entry是内核链表遍历宏规则多的时候这里是性能瓶颈可以考虑改成哈希或 Trie。FW_STAT_INC是自定义计数宏改逻辑时别把它删了否则后面没法验证命中。4.2 加一个自定义匹配字段并验证假设需求是“按包长度拦截”加一个min_len字段。改动分三步结构体加字段、解析配置时读入、匹配函数里比较。/* 1. 规则结构体加字段 */ struct fw_rule { __be32 saddr, daddr; __be16 sport, dport; u16 min_len; /* 新增最小包长0 表示不限制 */ u8 action; u64 hit_count; }; /* 2. 匹配函数里加判断 */ static bool fw_match(struct fw_rule *rule, struct fw_hdr *hdr) { if (rule-min_len hdr-pkt_len rule-min_len) return false; /* 原有五元组匹配 */ if (rule-saddr rule-saddr ! hdr-saddr) return false; /* ... 其余匹配 ... */ return true; }逻辑说明min_len为 0 时跳过判断保证老规则不受影响这是加字段的基本原则——新字段必须有“不生效”的默认值。匹配函数里先判断新条件再判断老条件顺序不影响结果但先判断开销小的能省一点 CPU。改完结构体记得同步改配置解析代码否则配置文件里写了也不生效。参数说明u16够表示 65535 的包长不用u32。hdr-pkt_len来自skb-len注意是网络字节序还是主机字节序比较前要统一。验证时构造一个长度小于min_len的包看是否被正确放行同时确认命中计数没有增加。提示改完内核模块一定要先rmmod再insmod直接覆盖.ko文件再加载会报“模块已存在”。rmmod前确认没有规则正在被使用否则可能卸载失败。5. 避坑与排查防火墙源码调试里最容易翻车的五件事这一章是我这些年攒下来的血泪经验每一条都对应一个真实翻车现场。防火墙代码的特殊性在于它直接处理网络流量改错一行可能把自己网络搞断所以排查思路和普通程序不一样。5.1 编译通过但加载后内核 panic现象insmod之后屏幕直接卡死或重启dmesg里能看到Unable to handle kernel NULL pointer dereference。原因最常见的是在模块初始化函数里访问了还没初始化的全局指针或者 hook 注册之后立刻有包进来但规则表还是空的匹配函数解引用了空指针。解决初始化顺序必须是“先初始化规则表和锁再注册 hook”。注册 hook 之前确保所有全局结构体已经分配。调试时可以在 hook 函数入口先判断if (!fw_rule_list_ready) return NF_ACCEPT;加一个就绪标志位兜底。5.2 规则明明配了却拦不住包现象配置文件里写了 denyfirewallctl也提示加载成功但 ping 照样通计数器不动。原因三种可能——hook 注册的优先级不对被系统其他规则先放行了或者 hook 点选错了比如该在NF_INET_FORWARD却注册到了NF_INET_LOCAL_IN或者包根本没走你预期的路径比如走了 loopback。解决先确认 hook 点和优先级NF_IP_PRI_FIRST到NF_IP_PRI_LAST之间选数值越小越先执行。用tcpdump -i any确认包的实际路径如果是本机到本机走的是 loopback要在LOCAL_OUT和LOCAL_IN都注册。优先级和 hook 点这两个参数在nf_register_net_hook的nf_hook_ops结构体里注册前打印出来核对。5.3 用户态和内核态规则不同步现象用户态--stats显示规则已下发但内核dmesg里看不到任何匹配日志两边数据对不上。原因用户态写规则用的是 netlink内核态读的时候结构体对齐不一致或者字节序没转换导致内核解析出来的规则是乱的。解决netlink 通信的结构体必须用__attribute__((packed))或者显式填充对齐所有多字节字段统一用网络字节序收发两端都做htonl/ntohl。调试时在 netlink 接收函数里把原始 buffer 按字节打印出来和发送端对比一眼就能看出对齐问题。5.4 性能断崖式下跌现象规则加到几百条之后吞吐从千兆掉到几十兆CPU 软中断跑满。原因匹配函数用的是线性遍历每条规则都全字段比较规则越多越慢。另外如果在 hook 里做了printk调试输出日志本身就会拖垮性能。解决把线性匹配换成哈希查找按五元组做 key。调试用的printk上线前必须删掉或改成pr_debug并关闭动态调试。规则数量超过 100 条就应该考虑优化数据结构这是硬指标。5.5 卸载模块后网络异常现象rmmod之后网络时通时断或者 conntrack 表残留导致新连接建不起来。原因模块卸载时没有清理自己注册的 hook 和 conntrack 条目或者清理顺序不对先删了规则表再注销 hook中间有包进来就出问题。解决卸载顺序和加载顺序严格相反——先注销 hook确保没有新包进来再清空规则表最后释放内存。conntrack 条目如果自己管理卸载前要主动删除。在module_exit里加synchronize_net()等待正在处理的包结束这个调用能避免大部分卸载时的竞态问题。6. 进阶用 eBPF 给老防火墙源码做一次“无痛升级”如果你手里的防火墙源码是传统的 netfilter 模块改起来要重新编译、加载、重启迭代一次几分钟。我现在的习惯是把新功能用 eBPF 写挂到XDP或tc钩子上和老的 netfilter 模块共存验证稳定后再逐步迁移。这样改一行逻辑不用重编内核模块bpftool加载就能生效回滚也快。具体做法是用tc在网卡 ingress 挂一个 eBPF 程序做快速过滤把明显该丢的包在进入协议栈之前就丢掉减轻 netfilter 的压力。下面是一个最小可用的 eBPF 过滤骨架按源 IP 丢弃。/* xdp_drop_by_ip.c —— 按源 IP 丢弃编译用 clang -target bpf */ #include linux/bpf.h #include bpf/bpf_helpers.h #include linux/if_ether.h #include linux/ip.h struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); __type(key, __u32); /* 源 IP */ __type(value, __u8); /* 1 表示丢弃 */ } blocklist SEC(.maps); SEC(xdp) int xdp_filter(struct xdp_md *ctx) { void *data (void *)(long)ctx-data; void *data_end (void *)(long)ctx-data_end; struct ethhdr *eth data; struct iphdr *iph; __u32 src; __u8 *blocked; if ((void *)(eth 1) data_end) return XDP_PASS; if (eth-h_proto ! __constant_htons(ETH_P_IP)) return XDP_PASS; iph (void *)(eth 1); if ((void *)(iph 1) data_end) return XDP_PASS; src iph-saddr; blocked bpf_map_lookup_elem(blocklist, src); if (blocked *blocked) return XDP_DROP; return XDP_PASS; } char _license[] SEC(license) GPL;逻辑说明程序挂在 XDP 钩子上在网卡驱动层就拿到包比 netfilter 更早。blocklist是一个哈希 map用户态用bpftool或 libbpf 往里写要封的 IP。匹配到就返回XDP_DROP包直接丢不进协议栈。没匹配到返回XDP_PASS交给后面的 netfilter 模块继续处理两者不冲突。参数说明max_entries按你实际要封的 IP 数量设1024 够大多数场景。__constant_htons是编译期字节序转换比运行时htons高效。编译命令是clang -O2 -target bpf -c xdp_drop_by_ip.c -o xdp_drop_by_ip.o加载用ip link set dev eth0 xdp obj xdp_drop_by_ip.o sec xdp。验证时先往 map 里加一个测试 IPping 它看是否被丢然后删掉恢复。我现在的习惯是任何对老防火墙源码的改动先在 eBPF 里验证逻辑确认没问题再决定要不要落到内核模块里。这样翻车成本低后悔药随时能吃。这套方法不一定适合所有场景但对迭代频繁的规则类需求能省下大量编译等待时间。希望帮到你。本文还有配套的精品资源点击获取