Netfilter内核 API 解析
Netfilter Hook 管理(最基础)
1. 注册 / 注销钩子
#include <linux/netfilter.h> #include <linux/netfilter_ipv4.h> // 注册钩子 int nf_register_net_hook(struct net *net, const struct nf_hook_ops *ops); // 注销钩子 void nf_unregister_net_hook(struct net *net, const struct nf_hook_ops *ops);2. 钩子结构体
struct nf_hook_ops { nf_hookfn *hook; // 钩子处理函数 struct module *owner; // THIS_MODULE int pf; // 协议 NFPROTO_IPV4 int hooknum; // 钩子点 int priority; // 优先级 };3. 钩子函数原型
typedef unsigned int nf_hookfn(void *priv, struct sk_buff *skb, const struct nf_hook_state *state);NFQUEUE 核心 API
NFQUEUE 由两部分组成:
- Netfilter 框架:抓包、钩子拦截、转交队列子系统
- nfnetlink_queue 子系统:
- 维护队列 + 用户进程绑定关系
- 封装 Netlink 消息
- 内核→用户态发包
- 接收用户裁决、重新注入协议栈
依赖模块:
nfnetlink:底层 Netfilter 专用 Netlink 总线nfnetlink_queue:NFQUEUE 队列管理核心
关键核心数据结构
1.struct nf_queue_handler
每个队列号对应一个队列处理器:
- 挂在全局队列哈希表
- 记录绑定的用户态 portid
- 队列最大长度、超时、回调
2.struct nf_queue_entry
每个被放入队列的skb 封装项:
- 持有
struct sk_buff *skb - 钩子状态
nf_hook_state - 唯一 packet id
- 所属网络命名空间
net - 超时定时器
3. Netlink 侧
复用标准struct sock+ nfnetlink 消息头:nlmsghdr→nfgenmsg→nfqnl_msg_packet_hdr→ 原始报文 payload
1.nf_queue— 把数据包发送到用户态(内核→用户)
#include <net/netfilter/nf_queue.h> unsigned int nf_queue(void *priv, struct sk_buff *skb, const struct nf_hook_state *state, unsigned int queuenum);作用:将数据包入队,内核主动发给用户态。
2.nf_reinject— 用户裁决后,把数据包放回内核
void nf_reinject(struct sk_buff *skb, const struct nf_hook_state *state, unsigned int verdict);3. 队列 entry 管理
struct nf_queue_entry *nf_queue_entry_get(u32 id, struct net *net); void nf_queue_entry_free(struct nf_queue_entry *entry);完整内核流转原理
步骤 1:数据包经过 Netfilter 钩子
数据包走到挂载点:NF_INET_PRE_ROUTING / LOCAL_IN / FORWARD / LOCAL_OUT / POST_ROUTING
你在 hook 里调用:
return nf_queue(priv, skb, state, queue_num);步骤 2:nf_queue()内部做了什么
- 校验队列号合法性
- 查找
nf_queue_handler(按 queue_num) - 分配
nf_queue_entry,封装 skb + 钩子现场 - 挂入队列等待队列调度
- 返回
NF_QUEUE告知 netfilter:包已转交队列,暂停协议栈流转
步骤 3:封装 nfnetlink 消息
队列子系统:
- 分配 Netlink skb
- 填充:
nlmsghdrnfgenmsgnfqnl_msg_packet_hdr(packet_id、hook、协议族)- 拷贝原始 IP 报文作为 payload
- 根据队列绑定的用户态 portid
- 调用netlink_unicast发给用户进程
👉 这就是内核主动发数据给用户态的底层真相。
步骤 4:用户态接收、解析、裁决
用户通过 nfnetlink socketrecvmsg拿到消息:
- 解析 nlmsghdr → nfgenmsg → nfqnl 包头
- 取出 packet_id、原始报文
- 规则判断后,构造VERDICT 裁决消息
- 通过同一个 socket 发回内核
步骤 5:内核接收用户裁决
nfnetlink 收到底层消息:
- 解析 verdict、packet_id、裁决动作(ACCEPT/DROP)
- 根据 packet_id 查到
nf_queue_entry - 调用
nf_reinject()
步骤 6:nf_reinject重新注入协议栈
nf_reinject(skb, state, verdict);- NF_ACCEPT:继续后续 netfilter 钩子 & 协议栈流转
- NF_DROP:直接释放 skb,丢弃
- NF_STOLEN:内核接管,不继续流转
队列绑定原理(用户进程怎么和队列挂钩)
- 用户态创建
NETLINK_NETFILTERsocket - 执行
bind,指定自身nl_pid = getpid() - 发送
NFQNL_MSG_CONFIG消息绑定队列号 - 内核记录:
queue_num <--> 用户 portid
关键点
- 同队列号只能绑定一个用户进程
- 同一进程可以绑定多个不同队列号
- 靠
queue_num + portid二元组精准路由
超时机制
每个nf_queue_entry带定时器:
- 用户迟迟不返回裁决
- 超时后内核自动DROP防止包滞留内存
nf_queue 阻塞当前数据包流转
数据包被挂起、暂停内核网络协议栈后续流程,等待用户态裁决,超时才自动放行 / 丢弃。
- 数据包走到 Netfilter Hook,你调用
return nf_queue(...) - 内核不会继续走后续协议栈
- 把 skb 封装成
nf_queue_entry放入队列挂起 - 暂停该包的转发 / 上层递送流程
- 等待用户态通过 NFQUEUE 返回裁决(ACCEPT/DROP)
- 收到裁决后调用
nf_reinject,才继续流转或丢弃
👉单个数据包是同步阻塞等待裁决的
只阻塞当前这一个数据包:
- 其他数据包正常进栈、正常过钩子
- 不会阻塞整个网卡、不会阻塞所有流量
- 只是被放入 NFQUEUE 的那个包暂停
是异步挂起,不是内核线程死等:
不是忙等、不是阻塞内核进程;是skb 暂时脱离协议栈,挂在队列中休眠等待,不占 CPU。
NFQUEUE 内置超时定时器:
- 默认超时一般3~10s(内核可配置)
- 用户态迟迟不发裁决 → 内核自动超时处理
- 超时默认行为:自动 DROP 或 ACCEPT(内核参数可调)
作用:防止用户态进程崩溃、卡死,导致内核数据包永久悬挂、内存泄漏
NFQUEUE 阻塞的粒度
NFQUEUE 阻塞是per-packet 单包挂起:
- 只把当前这一个 skb从协议栈摘出、挂进队列
- 同连接后续数据包照常进栈、照常转发
- 不是阻塞整个连接、不是阻塞网卡队列
队列长度限制(洪水防护)
内核有NFQUEUE 队列最大缓存数:
- 队列满了之后,新来的包直接DROP,不再入队
- 避免大量包挂在内核占满内存
极端场景:大量包同时进 NFQUEUE
如果整条流量全都被扔进 NFQUEUE,每个包都要等用户态裁决:
- 不是时序乱了
- 是整条流时延叠加、吞吐暴跌、排队积压
- 队列满后开始丢包,触发 TCP 拥塞控制降速
这是流量限速 / 排队问题,不是时序错乱问题。
会不会影响整机网络?
- 不会卡死整机网络
- 只阻塞被匹配进入 NFQUEUE 的流量
- 其他不进队列的流量完全正常转发 / 上网
举例:你只把TCP 80丢进 NFQUEUE
- 80 流量会被阻塞等裁决
- SSH、ping、其他端口完全不受影响
对 TCP 连接时序的影响
1. TCP 自身有超时重传、滑动窗口、乱序缓存
TCP 天然设计就是容忍:
- 单包延迟
- 中间包慢、前后包先到
- 轻微乱序
2. NFQUEUE 慢包带来的现象
- 被阻塞的那个包延迟变大
- 后面的包先到达对端,进入 TCP乱序队列(out-of-order queue)
- 等你 NFQUEUE 裁决放行、慢包终于到达
- TCP 按原始序列号重新排序,向上层交付
✅整条连接时序逻辑不变、业务无感知、不会协议错乱
3. 不会发生:
- 不会 TCP 时序漂移
- 不会序号错乱
- 不会连接断开
- 不会流崩塌
4. 会发生:
- 单包时延增高
- 轻微乱序重排开销
- 吞吐被拉低(尤其小包密集场景)
对 IP / 二层时序的影响
- IP 是无连接不可靠,本身不保证时序
- 前后包先走、慢包后到是常态
- NFQUEUE 只是人为增加了某一个包的延时,不改变 IP 固有机制
对网卡 & 内核收包队列的影响
- NFQUEUE 挂起一个包不占网卡 ring buffer
- 网卡继续收后续包、内核继续调度软中断
- 不会卡住网卡收包时序
- 不会导致整网卡吞吐卡死
和普通 Netlink 区别
- 普通 Netlink:纯消息传递,不阻塞数据包
- NFQUEUE:绑定网络报文生命周期,天然阻塞数据流
怎么规避时延影响
- 用户态epoll 非阻塞极速裁决
- 配置 NFQUEUE超时阈值调低
- 业务分层:只拦截控制包,不拦截大数据流
- 多队列分散流量,避免单队列积压
nf_queue 核心调用链路简图
skb 到达 Netfilter Hook ↓ return nf_queue() ↓ 分配 nf_queue_entry 封装 skb ↓ 封装 nfnetlink 消息 ↓ netlink_unicast 发给用户 portid ↓ 用户 recv → 规则判断 → 发回 VERDICT ↓ 内核解析裁决 → 查到 entry ↓ nf_reinject 重新注入协议栈 ↓ ACCEPT 继续流转 / DROP 丢弃极简总结
- NFQUEUE 只阻塞单个数据包,不阻塞整条连接。
- 单个包延迟,不会打乱 TCP 序列号时序。
- 后续包正常先行到达,TCP 乱序缓存自动重排,上层无感知。
- 不影响网卡收包时序、不卡死整机网络。
- 副作用:时延增大、轻微乱序开销、吞吐下降,但协议时序完全正常。
数据包(skb)操作常用 API
1. 取 IP 头
struct iphdr *ip_hdr(const struct sk_buff *skb);2. 取 TCP 头
struct tcphdr *tcp_hdr(const struct sk_buff *skb);3. 取 UDP 头
struct udphdr *udp_hdr(const struct sk_buff *skb);访问数据包数据
网络包在内核用struct sk_buff *skb管理;
直接强转访问不安全,必须用内核标准工具函数 + 处理分片 / 非线性 skb。
skb_linearize
skb_linearize = 把分散在多个内存页的数据包,合并成一段连续的内核内存
- 不调用它 → 直接访问
skb->data越界 → 内核直接崩溃(panic /oops) - 调用它 → 数据变连续 → 安全访问
skb->data + offset
skb_linearize做了什么?
它重新拷贝、合并所有分片,变成一段连续的内核缓冲区:
skb->data -> 连续大内存块(完整的 IP头 + TCP头 + 全部数据)合并后:
skb->data指向连续内存起始skb->len= 总长度- 可以安全用指针偏移访问任何位置
- 不会越界、不会崩溃
skb_is_linear
skb_is_linear (skb) → 判断这个数据包是否是连续内存 **
- 返回1:数据连续(线性),可以直接安全访问
- 返回0:数据不连续(非线性、分片存储),直接访问会内核崩溃
它只是判断,不修改任何数据,超级轻量!
- 非线性 skb数据分散在多个内存页
- 直接访问
skb->data + offset会越界、panic、宕机 - 所以必须先判断:
- 连续 → 直接用
- 不连续 → 必须
skb_linearize合并
skb_linearize 会修改 skb!
- 会重新分配内存
- 会拷贝数据
- 原来的分片会被释放
- 所以它不是只读操作
不能在中断上下文随意用
- 可能会睡眠(分配内存时)
- HOOK 里可以用,因为 HOOK 属于软中断,允许睡眠
有性能开销
- 合并、拷贝需要时间
- 如果你不需要访问数据,就不要调用
- 只在必须读取 payload时调用
NFQUEUE 场景必须调用
因为:
- 你要在内核态解析数据
- 或者要把数据发给用户态解析→ 都要求数据连续
skb_pull和skb_push
void *skb_pull(struct sk_buff *skb, unsigned int len); void *skb_push(struct sk_buff *skb, unsigned int len);skb_pull(skb, len):向后移数据指针,去掉头部(剥头)skb_push(skb, len):向前移数据指针,腾出头部空间(加头)- 共同点:只改指针,不改数据,超快!
形象图解
假设数据包初始状态:
[data] ↑ skb->data (指针在这里)1.skb_pull(skb, 20)向后移 20 字节(剥掉 20 字节)
[ 20字节 | 数据 ] ↑ skb->data 移到这里!- 作用:跳过 IP 头、TCP 头,直接看 Payload。
- 结果:
skb->data += 20skb->len -= 20
2.skb_push(skb, 20)向前移 20 字节(预留 20 字节空间)
[ 新空间 | 原数据 ] ↑ skb->data 移到这里!- 作用:要在前面插入新的协议头(比如加个 UDP 头)。
- 结果:
skb->data -= 20skb->len += 20
最经典实战场景
场景 1:skb_pull剥掉头部,读取 Payload
前提:必须先skb_linearize
// 确保数据连续 skb_linearize(skb); struct iphdr *iph = ip_hdr(skb); // 1. 剥掉 IP 头 skb_pull(skb, iph->ihl * 4); // 2. 剥掉 TCP 头 struct tcphdr *th = (struct tcphdr *)skb->data; skb_pull(skb, th->doff * 4); // ✌️ 现在 skb->data 就是纯应用层数据! printk("Payload: %s", skb->data);场景 2:skb_push构造数据包(发送时用)
如果你要修改并发送数据包,需要加头:
// 腾出 TCP 头的空间 struct tcphdr *new_tcp = skb_push(skb, sizeof(struct tcphdr)); // 填充 TCP 头部数据 new_tcp->source = htons(80);关键注意事项
1. 必须配合skb_linearize
如果数据包是非线性(分散存储),直接调用skb_pull会内核崩溃!安全模板:
// 先检查是否连续 if (!skb_is_linear(skb)) { // 不连续就合并,失败则丢包 if (skb_linearize(skb)) return NF_DROP; } // 现在才能安全 pull/push! skb_pull(skb, len);2. 空间够不够?
skb_push向前推,不能超过skb_headroom(头部预留空间),否则报错。skb_pull向后拉,不能超过skb->len,否则返回NULL。
3. 与nf_queue的关系
- 如果你只是把包丢给用户态,不修改、不看内容,不需要pull/push/linearize。
- 如果你在内核态读取 Payload,必须走流程:
linearize→pull→ 读数据
关键坑点
坑 1:skb 是非线性、有分片
skb->data只存线性部分,后面数据在 frag 页里,直接越界宕机。
解决方案:遍历前先线性化
if (skb_linearize(skb) < 0) return NF_DROP;坑 2:不判断协议直接强转
先判iph->protocol再拿 TCP/UDP 头,不然非法包直接 oops。
坑 3:不校验长度就访问
必须保证skb->len >= iphdr_len + trans_len,防止越界。
坑 4:网络序大小端
端口、IP、序号都是网络字节序,要用ntohs/ntohl转主机序。
Netfilter 判定(verdict)
NF_ACCEPT // 允许 NF_DROP // 丢弃 NF_STOLEN // 内核接管 NF_QUEUE // 进入队列(由nf_queue返回) NF_REPEAT // 重新调用钩子连接跟踪(conntrack)
Linux 连接跟踪(conntrack)是 Netfilter 内核框架里的状态跟踪子系统,核心是用五元组识别流、维护连接状态机、支撑有状态防火墙 / NAT / 负载均衡等功能。
连接跟踪(Connection Tracking,简称 ct):内核把每个双向数据流当作一个 “连接”,记录其状态、五元组、超时、统计、NAT 映射等,后续同流包直接匹配状态,不用重复解析规则。
五元组(Flow Key)
- 源 IP(src_ip)
- 目的 IP(dst_ip)
- 源端口(src_port)
- 目的端口(dst_port)
- 四层协议(proto:TCP/UDP/ICMP)
双向 Tuple
一个连接存两个方向:
- Original(orig):发起方向(如客户端→服务器)
- Reply(reply):响应方向(服务器→客户端)NAT 就是修改 reply 方向的 tuple。
核心用途
- ✅ 有状态防火墙:
iptables -m state --state NEW/ESTABLISHED - ✅ NAT:SNAT/DNAT/masquerade(依赖 conntrack 保存映射)
- ✅ 负载均衡:LVS/IPVS 识别同一连接
- ✅ 容器 / 网络虚拟化:Docker/OVS 的网络隔离与转发
Netfilter 钩子与处理流程
conntrack 由nf_conntrack内核模块实现,在 Netfilter 关键钩子点介入,优先级极高(-200)。
1)关键钩子点(IPv4)
- PRE_ROUTING:入站包第一步,nf_conntrack_in查找 / 创建连接
- LOCAL_OUT:本机发出包,同上
- FORWARD:转发包,匹配已建连接
- POST_ROUTING:出站包最后,nf_conntrack_confirm把连接正式加入哈希表
2)数据包处理四步曲
- 解析 tuple:从 IP+TCP/UDP 头提取五元组
- 哈希查找:查全局 conntrack 哈希表,命中→更新状态;未命中→新建
nf_conn - 状态机更新:按协议(TCP/UDP/ICMP)更新状态、重置超时
- Confirm:包正常转发 / 路由后,把
nf_conn存入哈希表;中途丢包则放弃保存
核心数据结构(内核 5.10+)
1)连接项:struct nf_conn(一个连接对应一个)
struct nf_conn { struct nf_conntrack ct_general; // 通用头(引用计数、销毁函数) struct nf_conntrack_tuple tuple[IP_CT_DIR_MAX]; // orig + reply 五元组 enum ip_conntrack_state state; // 连接状态(NEW/ESTABLISHED 等) unsigned long timeout; // 超时时间 struct timer_list timer; // 超时定时器 struct nf_conn_nat *nat; // NAT 扩展(映射信息) // 统计、协议私有数据... };2)哈希表:conntrack_table
- 全局哈希表,桶数可配置(默认 65536)
- 每个桶挂链表,存
nf_conn - 查找:五元组哈希→桶→遍历链表匹配 tuple
#include <net/netfilter/nf_conntrack.h> // 获取连接跟踪 struct nf_conn *nf_ct_get(const struct sk_buff *skb, enum ip_conntrack_info *ctinfo); // 查找连接 struct nf_conn *nf_ct_find_existing(struct net *net, const struct nf_conntrack_zone *zone, u_int8_t family, const nf_conntrack_tuple *tuple);协议状态机(TCP 最复杂,UDP 极简)
1)TCP 状态(核心 6 种)
- NEW:收到 SYN,新建连接
- SYN_SENT:发 SYN 后等待 SYN+ACK
- SYN_RECV:收 SYN+ACK 后等待 ACK
- ESTABLISHED:三次握手完成(默认超时 5 天)
- FIN_WAIT:收到 FIN,半关闭
- CLOSED:连接关闭(超时或 RST)
2)UDP 状态(无连接,仅 2 态)
- UNREPLIED:单向流(仅一个方向有包),默认超时 30 秒
- ASSURED:双向流(两个方向都有包),默认超时 180 秒
3)ICMP 状态
基于 Echo Request/Reply 匹配,超时 30 秒。
常见问题与调优
1)哈希表满:nf_conntrack: table full, dropping packet
- 原因:并发连接超
nf_conntrack_max - 解决:
# 临时生效 echo 262144 > /proc/sys/net/nf_conntrack_max # 永久生效(/etc/sysctl.conf) net.nf_conntrack_max=262144 sysctl -p
2)性能损耗
- conntrack 对每个包做哈希查找与状态更新,高并发(10 万 + PPS)会占 CPU
- 调优:
- 关闭不必要的协议跟踪(如仅跟踪 TCP)
- 增大哈希桶数(
nf_conntrack_buckets) - 对无状态流量(如 DNS)用
iptables -j NOTRACK跳过 conntrack
3)超时导致连接被误删
- 长连接(如 SSH、数据库)被超时断开→调大
tcp_timeout_established - 短连接(如 HTTP)→保持默认或调小
netfilter 日志
nf_log_packet(net, state->hook, skb, state->in, state->out, loginfo, "自定义日志: ");