ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

防火墙源码分析实战:数据路径与Netfilter钩子解析

防火墙源码分析实战:数据路径与Netfilter钩子解析 简介这是一套基于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 是过去十几年最常见的接口它把规则按表和链组织表决定这条规则属于哪个处理阶段链决定这个阶段里包流向的哪个部位会被过滤。照这个映射关系去查源码能很快弄清楚一条规则最终走进了哪份数组或链表。表和链的对应关系如下表涉及链典型职责filterINPUT、FORWARD、OUTPUT防火墙访问控制natPREROUTING、INPUT、OUTPUT、POSTROUTING地址转换mangle五条链均涉及修改报文、设置标记rawPREROUTING、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 头指针调用前必须确认报文长度足够否则指针踩空tcphTCP 头指针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目标把当前目录下的源码编成.koinsmod加载后可以立刻在/proc/net/netfilter/里确认注册结果。注意模块编译用的内核头文件必须和运行内核版本完全一致否则insmod会直接报版本魔术错误这个错误查看dmesg尾部即可通常能给出版本字符串做对照。4.2 用 strace 跟踪规则下发验证源码改动编译通过不等于逻辑正确。修改源码后最有效的验证不是抓包而是看一条规则从命令行进到内核走了什么路径。strace 可以完整看到用户态工具是如何组装系统调用的strace -f -e tracesetsockopt,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的入口和出口各放一个探针两个时间戳相减就是单条规则匹配的开销。翻源码时把这些探针预留好下一轮迭代直接复用。本文还有配套的精品资源点击获取
返回列表