ARTICLE DETAIL

资讯详情

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

Linux防火墙源代码阅读指南:从规则链到Netfilter钩子

Linux防火墙源代码阅读指南:从规则链到Netfilter钩子 简介一份防火墙软件源代码包面向网络安全方向的学生、C/C网络编程开发者及对系统驱动感兴趣的进阶学习者。资源以Visual C工程为主混合驱动层与用户态代码可用于研究Windows平台下防火墙的包捕获、协议过滤、钩子注入等核心实现机制。包内共236个文件压缩后仅1.23MB其中包含54个头文件、40个C源文件、9个C源文件以及DLL动态库、SYS驱动、EXE可执行程序等编译产出另有CHM帮助文档、TXT说明和REG注册表脚本便于梳理工程结构和快速部署调试。从内容预览可见具备构建脚本、收发模块与透传示例等关键模块适合对照源码理解网络数据包在驱动层的流转过程。目前已有398人学习下载对于希望从零剖析轻量级防火墙实现原理的读者是一份不错的学习样本。1. 防火墙软件源代码规则链是主线别从协议栈读起很多工程师第一次翻开防火墙软件源代码习惯从网络协议栈开始啃结果在sk_buff、路由查找里转了两周还没看到防火墙真正做事的地方。反过来的顺序更快先找规则链再找匹配函数最后回到钩子点看数据包从哪进来。原因在于无论叫 iptables、nftables 还是别的发行版防火墙核心都落在同一件事上用一套规则集合描述“什么样的包放行、什么样的包丢弃”。下面以 Linux 生态的防火墙软件源代码为主线带你走完从入口函数到规则匹配、再从并发优化到线上排查的完整路径。适合打算做二次开发、源代码审计或者只想搞清楚规则引擎到底怎么跑起来的开发者读。2. 防火墙软件源代码的入口与骨架Netfilter 钩子、表与链2.1 钩子点、表、链源码里三张必须画对的地图在 Linux 防火墙软件源代码里真正决定程序流程的不是“收到包之后怎么办”而是“在协议栈的哪几个位置插入了检查逻辑”。防火墙代码是通过 Netfilter 框架挂到协议栈上的。内核协议栈在五个固定位置调用钩子对应五个宏钩子宏方向触发时机NF_INET_PRE_ROUTING入站路由决策之前NF_INET_LOCAL_IN入站发给本机路由之后NF_INET_FORWARD转发本机只做路由转发NF_INET_LOCAL_OUT出站本机发出的包NF_INET_POST_ROUTING出站路由决策之后、出网卡前代码执行顺序有两层外层是钩子顺序内层是链内规则顺序。钩子顺序由注册时的优先级priority决定和 iptables 命令行的书写顺序无关。例如 LOCAL_IN 钩子在整个 IP 收包路径里排在 NF_INET_PRE_ROUTING 之后打开net/ipv4/ip_input.c能看到ip_local_deliver()里对NF_INET_LOCAL_IN的调用这就是 INPUT 链的执行起点。阅读防火墙源代码时把这五个点各自标注出上游调用处后续追踪逻辑会省很多时间。收包路径上 HOOK 调用的形态大致如下// net/ipv4/ip_input.c示意 int ip_local_deliver(struct sk_buff *skb) { ... return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, net, NULL, skb, NULL, NULL, ip_local_deliver_finish); }NF_HOOK是宏NF_INET_LOCAL_IN是钩子号最后一个参数ip_local_deliver_finish是检查通过之后继续执行的收尾函数。理解这个形态就理解了“包处理被切成一段一段”的本质钩子只负责插桩真正的检查逻辑都在钩子回调也就是表与链里。2.1.1 优先级相同怎么办静态注册顺序兜底Netfilter 允许模块把钩子注册到同一个优先级上此时按注册顺序执行。iptables 和 nftables 两套框架在发行版里默认配置下不同时生效钩子注册不叠加避免同一份流量被两套逻辑重复处理。排查“规则没生效”时先确认命令行操作的框架和内核实际挂载的是同一套再往下谈规则语法。2.2 从 iptables 到 nftables两代防火墙源码的差异读源码会同时遇到两个代码域老式net/ipv4/netfilter/iptable_filter.c与新式net/netfilter/nf_tables/。两者要解决的问题一样代码组织差别很大。iptables 的规则保存在xt_table结构里每个表是一块连续内存ipt_entry和ipt_entry_match按四字节对齐串联。匹配时由ipt_do_table()在表内做线性遍历规则增删要整表替换无法单条热插拔。nftables 则把规则拆成nft_rule与nft_expr两级对象表达式取代了 match/target 的二元组合。nft_do_chain()遍历表达式链跳转关系编码在 verdict 表达式里动态性更强。这里有一个容易忽略的分野iptables 的计量单位是“一条规则”nftables 的计量单位是“一组表达式”。代码审计时定位命中路径断点的落点也随之变化老实现在ipt_entry的 match 函数指针上断新实现在nft_expr_ops-eval上断。2.3 读这套源代码从哪里下手我常用的切入顺序是三步定位法先跑一次nft list ruleset把当前系统上实际生效的表和链列出来让配置与代码对得上。在代码树里搜索链名对应的结构体定义找到链的初始化位置和挂载的钩子。从nf_hook_slow()入手沿nft_do_chain()或ipt_do_table()向下读匹配主循环。# 查看当前规则集确认命名空间、表和链 nft list ruleset提示阅读顺序建议是“钩子 → 链 → 表达式/匹配 → verdict”而不是从网卡驱动读起。老实现的核心文件是net/ipv4/netfilter/ip_tables.c新实现的核心文件是net/netfilter/nf_tables_core.c两个执行主循环都不长适合作为起点。3. 把防火墙源代码摊开一个最小规则引擎的完整实现3.1 规则对象与匹配函数防火墙软件源代码里规则是最小的可执行单元。把它拆成三段看匹配条件、处理动作、统计计数。经常有人问为什么不用 JSON 描述规则答案在数据面热路径上每纳秒都很贵动态类型检查不适合出现在包处理路径里所以常见做法是编译期把文本配置解析成紧凑的二进制结构运行时只做掩码和比较。教学用的最小结构体如下enum fw_action { FW_ACCEPT, FW_DROP, }; struct fw_rule { uint16_t proto; /* 0 表示匹配任意协议 */ uint32_t src_ip; uint32_t src_mask; /* 网络掩码如 0xFFFFFF00 */ uint16_t src_port; uint16_t dst_port; enum fw_action action; uint64_t hits; /* 命中计数排障时直接读 */ }; struct fw_pkt { uint16_t proto; uint32_t src_ip; uint16_t src_port; uint16_t dst_port; }; static int match_rule(const struct fw_rule *r, const struct fw_pkt *p) { if (r-proto p-proto ! r-proto) return 0; if ((p-src_ip r-src_mask) ! (r-src_ip r-src_mask)) return 0; if (r-src_port p-src_port ! r-src_port) return 0; if (r-dst_port p-dst_port ! r-dst_port) return 0; return 1; }上面这段代码表达了三个要点。proto为 0 表示占位调用方不用为了“任意协议”额外造一个特殊值端口同理。src_mask把 IP 段匹配简化成两次按位与运算不用调子网计算函数。hits字段的作用容易被忽略线上排查“这条规则到底命中没有”时打印hits比打一条条日志便宜得多。match_rule()返回 0 表示未命中返回 1 表示命中动作由上层循环决定。3.2 解析器把文本配置变成内存里的规则任何防火墙软件的源代码都包含配置解析模块。命令行语法可以千变万化但解析的目标一致把字符串翻译成结构体数组。下面用 Python 演示解析层与执行层分离的思想def parse_rules(lines): rules [] for line in lines: line line.strip() if not line or line.startswith(#): continue parts line.split() rule {} rule[proto] 0 if parts[0] ! any: rule[proto] int(parts[0], 0) rule[src_ip], rule[src_mask] parse_cidr(parts[1]) rule[src_port] int(parts[2]) rule[dst_port] int(parts[3]) rule[action] drop if parts[4] drop else accept rules.append(rule) return rules配置行格式是协议 源地址/掩码 源端口 目的端口 动作。parse_cidr()把192.168.1.0/24拆成 IP 和掩码两个整数方便直接送进fw_rule。int(parts[0], 0)的第二个参数0表示允许十六进制协议号排障时少一次换算。真实产品不会用 Python 做数据面解析而是把解析放在用户态编码成 netlink 消息发给内核老实现看libiptc新实现看libnftnl内核侧nf_tables_api.c负责解码与挂链。3.3 包进来之后匹配与动作的执行路径规则对象就绪后执行路径可以概括为三层循环遍历链、遍历规则、遍历表达式。最小实现如下static int fw_chain_run(struct fw_chain *chain, struct fw_pkt *p) { for (size_t i 0; i chain-rule_count; i) { struct fw_rule *r chain-rules[i]; if (match_rule(r, p)) { r-hits; if (r-action FW_DROP) { return FW_VERDICT_DROP; } /* ACCEPT 不立即返回继续看后续规则 */ } } return FW_VERDICT_ACCEPT; }一个常被误解的点ACCEPT动作在 iptables 里意味着“本链检查结束并放行”后续同链规则不再执行。但如果你把ACCEPT的实现写成“立即返回放行”那它和末尾兜底ACCEPT就没有区别了。真实内核里ipt_do_table()用IPT_ACCEPT标记结束本表遍历nft_do_chain()用 verdict 表达式携带NF_ACCEPT。这里的原则是动作产生的结果要么是 verdict要么是继续执行不要混淆“默认策略”和“规则动作”。3.4 源码审计时容易踩的三个误区第一个误区是“有规则就有防护”。如果一条宽匹配ACCEPT排在前后面的DROP规则永远不会执行。审计时先看链尾策略再倒推哪条规则提前结束了遍历。第二个误区是“内存布局可以随意扩展”。ipt_entry的字节对齐、nft_rule末尾的柔性数组都会在规则动态扩容时露出问题。排查此类诡异现象优先看struct_size、offsetof相关宏的使用位置。第三个误区是“日志越多越安全”。热路径打printk会放大延迟正确做法是基于 tracepoint 或者按比例采样观测而不是每条匹配规则打一行。4. 防火墙软件源代码的性能边界锁、热更新与压测4.1 规则数量增长时遍历开销先顶不住规则条数增长会让防火墙软件源代码的执行时间线性上升。假设规则 200 条、每秒过包 50 万单包匹配的平均代价就值得认真对待。三种策略的取舍策略新增规则开销平均匹配延迟适用规则量线性遍历O(1)随条数线性上升百条以内哈希分桶O(1)需处理冲突近似 O(1)千条以上快速路径 兜底链两套表同步维护命中快速路径时极低混合场景常见的优化路径是哈希分桶按“协议 目的端口”散列到多个桶匹配时先查桶再遍历桶内短链表。代价是规则更新时要额外定位桶。还有一类实现会在主链之前挂一个短小的“快速路径”规则集命中直接放行避免每次查全量链。验证优化效果要用回放工具压测# 回放抓包文件循环 10 万次打到网卡极限 tcpreplay --intf1eth0 --topspeed --loop100000 fw_test.pcap # 压测期间观察热点函数 perf top -p $(pgrep -f your_fw_binary)不带--topspeed时回放速度受文件读取影响测不出真实 CPU 开销--loop必须够大否则程序刚热身就结束了。perf top里如果热点集中在match_rule这类函数说明规则匹配是主瓶颈。4.2 热更新换指针而不是改链表防火墙规则需要动态更新。如果在数据面直接改链表会撞上同步问题。常见做法是写时复制构建一份新规则数组整体换入。struct fw_chain { rwlock_t update_lock; struct fw_rule *rules; /* 指向当前生效的数组 */ unsigned int rule_count; }; void fw_chain_replace(struct fw_chain *chain, struct fw_rule *nrules, unsigned int ncount) { rwlock_write_lock(chain-update_lock); struct fw_rule *old chain-rules; chain-rules nrules; /* 原子指针替换 */ chain-rule_count ncount; rwlock_write_unlock(chain-update_lock); /* 延迟释放旧数组等待正在执行的读者结束 */ call_rcu(chain-rcu_head, fw_rule_free, old); }这段代码体现了两个原则。第一热路径读规则不需要加锁只做一次指针读取。第二旧数组不能立即free要用call_rcu推迟释放否则正在遍历旧数组的数据包会访问已释放内存。Linux 内核里rcu_assign_pointer()和rcu_dereference()是同一思想的正规封装阅读代码时看到这两个宏就可以快速定位规则的切换点。4.3 规则替换后的验证方法热更新代码写完不能只靠“看起来对”。验证分两步第一验证行为翻转。替换前构造 10000 个命中旧规则DROP的报文应全部丢弃替换后同一批报文应全部放行中间不允许出现同一条流 verdict 来回翻转的窗口。第二验证替换期间无内存错误。并发回放的同时反复加载、卸载规则集再配合内存检测工具跑 10 分钟# 构造源地址 192.168.1.50 到 22 端口的 SYN 包打 10000 个 hping3 -S -c 10000 -a 192.168.1.50 -p 22 192.168.1.10 # 同时循环加载、卸载规则集 for i in $(seq 1 1000); do fwctl load rules_v2.conf; fwctl unload; donehping3的-a是伪造源地址仅测试用。并发压测时回放网卡和 CPU 中断尽量分离否则 NUMA 争用会让数字失真。5. 用防火墙源代码做线上诊断丢包归属与日志埋点5.1 判断包在哪一级被丢表现为“端口不通”时先在源码标注的三个钩子点确认包走到了哪一级。步骤很简单nf_hook_slow()前打一个统计点ipt_do_table()/nft_do_chain()入口和出口各打一个统计点对比三个统计点的计数。计数差距出现在某一段问题就锁在某一段。用 nftables 时nft list chain inet filter input看规则nft reset counters inet filter input清零计数后反复打流量测一轮比刷新式观察更准。排查时先确认断点所在的路径真的被执行到了否则调试器会一直提示当前不会命中断点。5.2 失配与优先级陷阱规则顺序就是判断顺序不少线上事故的根因不是语法而是规则顺序。防火墙从链首往下执行遇到返回 verdict 的动作就停止宽匹配规则排在窄匹配规则前面会导致后面的规则永远见不到包。排查时给每条规则都开启计数用目标流量打一轮看计数从哪条开始跳动跳动点前面的规则就是真正的决策者。5.3 在匹配路径挂一个采样日志最后一个实用技巧不在每条规则里打日志而是做一个“每 N 个包记录一次”的环形采样。既不阻塞热路径又能保留丢包瞬间的规则编号和 verdict。环形缓冲只保留最近 64 条查询时间和流量无关。#define SAMPLE_EVERY 1000u #define RING_SIZE 64 static size_t sample_idx; static struct { uint64_t pkt_id; unsigned rule_idx; int verdict; } sample_ring[RING_SIZE]; void fw_path_trace(unsigned rule_idx, int verdict, uint64_t pkt_id) { if ((pkt_id % SAMPLE_EVERY) 0) { sample_ring[sample_idx % RING_SIZE] (typeof(*sample_ring)){ pkt_id, rule_idx, verdict }; sample_idx; } }采样条件用包 ID 取模周期固定不会随流量增长占用额外内存。查询时遍历sample_ring就能看到最近 64 个采样点的规则匹配轨迹配合命中计数能在不打断转发链路的前提下定位绝大多数丢包和误放行问题。这个做法的开销固定适合长期保留在生产防火墙里。本文还有配套的精品资源点击获取
返回列表