9.2 Netfilter Hooks
让我们把目光收回到内核网络协议栈的内部。
上一节我们提到了,Netfilter 的核心是一张遍布网络栈的「大网」。现在我们要看看这张网究竟挂在哪些关键的桩子上。
我们在讨论 IPv4 和 IPv6 的收发路径时,其实已经多次路过这些桩子了,只是当时我们全速赶路,没有停下来细看。Netfilter 在网络栈中定义了五个关键的挂载点,无论是 IPv4 还是 IPv6,这些挂载点的名称和含义都是统一的。它们构成了内核拦截和修改流量的通用骨架。
以下是这五个钩子的精确落点,每一个都对应着数据包生命中的一个特定时刻。
数据包的五个检查站
想象一下,数据包是一列在铁轨上飞驰的火车。Netfilter 的钩子就是铁轨上的五个检查站。
NF_INET_PRE_ROUTING:这是所有入站火车的第一站。
- 位置:位于 IPv4 的
ip_rcv()和 IPv6 的ipv6_rcv()方法中。 - 时机:这是数据包进入网络栈后的第一个钩子。此时,内核刚刚拿到数据包,甚至还没有去查路由表。
- 意义:在这里拦截,意味着你能在内核决定把包扔给谁之前,最先看到它。如果你想做一个「通用」的捕包器,这里是最佳位置——因为不管这包是发给本机还是转发,它都得先过这一关。
NF_INET_LOCAL_IN:这是目的地是本机的火车才会进的站。
- 位置:位于 IPv4 的
ip_local_deliver()和 IPv6 的ip6_input()方法中。 - 前置条件:数据包必须先通过
PRE_ROUTING,并且通过了路由子系统的查找(确定目的地是本地)。 - 意义:只有真正要发给本机上层应用的包,才会走到这里。如果你只想关心「外界发给我的东西」,应该在这里拦截。
NF_INET_FORWARD:这是过路车走的专用线。
- 位置:位于 IPv4 的
ip_forward()和 IPv6 的ip6_forward()方法中。 - 前置条件:同样通过了
PRE_ROUTING和路由查找,但结果是「这包要转发给别人」。 - 意义:这是 Linux 作为路由器的核心路径。如果你想控制机器的转发行为(比如做防火墙拒绝转发),这里是必经之路。
NF_INET_POST_ROUTING:这是所有出站火车的最后一站。
- 位置:位于 IPv4 的
ip_output()和 IPv6 的ip6_finish_output2()方法中。 - 流量来源:这里的情况稍微复杂一点。有两类车会汇入这里:
- 转发的车:它们刚通过了
FORWARD检查站。 - 本地生成的车:它们刚通过了
LOCAL_OUT检查站。
- 转发的车:它们刚通过了
- 意义:无论包从哪里来,只要它要离开这台机器,最后一次被拦截修改的机会就在这里。通常做源地址伪装(SNAT)都发生在这里。
NF_INET_LOCAL_OUT:这是本机生成的火车的始发站。
- 位置:位于 IPv4 的
__ip_local_out()和 IPv6 的__ip6_local_out()方法中。 - 时机:本地进程发出的数据包,在经过路由查找决定走哪个出口之后,真正进入发送队列之前。
- 意义:这是本地应用发出的包的第一道关卡。如果你想限制本机某个进程的对外访问,或者给发出的包打标记,这里是起点。
回到「火车」这个类比: 这五个检查站的顺序不是随意摆放的,而是严格取决于铁轨的物理连接。
- 本地发出的包,只需要路过
LOCAL_OUT->POST_ROUTING。 - 发给本机的包,只走
PRE_ROUTING->LOCAL_IN。 - 转发的包,路途最远:
PRE_ROUTING->FORWARD->POST_ROUTING。 如果你在LOCAL_IN里等一个转发的包,那你永远等不到——这在物理上是不通的。
内核是如何在这些点上「叫停」的
光有概念上的检查站还不够,内核得有一套机制能在这些点真正把代码挂上去。这个机制就是我们在前面章节提到过的 NF_HOOK 宏。
这个宏被硬编码在网络协议栈的关键路径上(也就是上面提到的那些 ip_rcv、ip_forward 等函数里)。它的定义位于 include/linux/netfilter.h:
static inline int NF_HOOK(uint8_t pf, unsigned int hook, struct sk_buff *skb,
struct net_device *in, struct net_device *out,
int (*okfn)(struct sk_buff *))
{
return NF_HOOK_THRESH(pf, hook, skb, in, out, okfn, INT_MIN);
}
这短短几行代码,实际上是连接网络协议栈和 Netfilter 子系统的桥梁。让我们看看它的参数,每一个都承载了关键信息:
- pf:这是在问你「是哪个协议族?」。通常是
NFPROTO_IPV4或NFPROTO_IPV6。Netfilter 需要知道这个,因为不同协议的处理逻辑虽然有共性,但细节不同。 - hook:这是我们在上面讨论的那五个点之一(比如
NF_INET_PRE_ROUTING)。它告诉内核「现在要把代码挂在哪个检查站」。 - skb:不用多说,这是那个被带枪押送的「嫌疑人」(数据包)。
- in:入站网卡设备。如果包是从外面进来的,这里就是那张网卡对应的
net_device结构。 - out:出站网卡设备。注意这里有一个容易踩的坑:在某些阶段(比如
PRE_ROUTING),内核还没做路由决策,根本不知道包要从哪个网卡出去,所以这个参数会是NULL。千万别在这个时候试图解引用它,否则直接 panic。 - okfn:这是一个「如果一切顺利,接下来去哪」的回调函数指针。如果所有的钩子都对这个包说「放行(NF_ACCEPT)」,内核就会调用这个函数,让数据包继续它在网络栈里的原本旅程。
钩子函数的「裁决权」
当你的钩子函数被调用时,你手里握着数据包的命运。你必须返回一个「裁决值」,告诉内核下一步该怎么办。这些返回值定义在 include/uapi/linux/netfilter.h 中:
- NF_DROP (0):死刑。直接丢弃数据包,不留下任何痕迹(当然,除了你自己的日志)。对方不会收到任何 ICMP 错误,就像包掉进了黑洞。
- NF_ACCEPT (1):放行。这包没问题,或者我已经处理完了,继续让它走原来的路。
- NF_STOLEN (2):劫持。这是一个有趣的裁决。它告诉内核「别管这个包了,我已经把它偷走了」。后续的网络栈代码不会再看到这个包。这意味着你的模块现在全权负责处理这个包——要么你自己把它发出去,要么把它释放掉。如果你偷了包却不释放,那就会导致内存泄漏。
- NF_QUEUE (3):转交第三方。把这个包塞到一个队列里,传给用户空间的进程去处理。这就是
nfqueue机制的实现基础,允许用户空间程序(比如libnetfilter_queue)来决定包的命运。 - NF_REPEAT (4):再审一次。请求内核再次调用当前的钩子函数。这是一个少见但强大的选项,通常用于需要多次处理同一数据包的复杂场景。
注册:如何把你的钩子挂上去
知道了检查站的位置,也知道了如何裁决,剩下的就是怎么把你的警察派到那个岗位上。
要注册一个钩子回调,你需要准备一个 nf_hook_ops 结构体(或者一组结构体数组)。这个结构体就是你的「派工单」。它的定义在 include/linux/netfilter.h:
struct nf_hook_ops {
struct list_head list;
/* 用户填充以下字段 */
nf_hookfn *hook; /* 钩子函数指针 */
struct module *owner; /* 拥有此模块的指针(通常是 THIS_MODULE) */
u_int8_t pf; /* 协议族 */
unsigned int hooknum; /* 钩子编号 */
/* 钩子按优先级升序调用 */
int priority; /* 优先级 */
};
这里有几个字段值得细说,因为它们直接决定了你的代码能不能正确工作:
-
hook:这是指向你实际编写的钩子函数的指针。它的原型必须严格遵循
nf_hookfn定义:unsigned int nf_hookfn(unsigned int hooknum,struct sk_buff *skb,const struct net_device *in,const struct net_device *out,int (*okfn)(struct sk_buff *));注意,虽然你注册时可能只关心一个钩子点,但回调函数的
hooknum参数还是会被传入。这允许你用一个函数处理多个钩子点(虽然通常为了清晰起见,我们会分开写)。 -
pf 和 hooknum:这俩就是告诉内核「把上面那个函数挂到哪里」。比如你要监听 IPv4 的入站包,
pf就是NFPROTO_IPV4,hooknum就是NF_INET_PRE_ROUTING。 -
priority(优先级):这是一个非常关键的机制。
回到那张「火车检查站」的类比:一个检查站上可能同时有多个部门的警察在执勤。有的部门查毒品,有的查关税,有的查违禁品。谁先查? 这就是
priority决定的。数值越小,优先级越高,越先被调用。内核提供了一组标准的优先级常量(比如
NF_IP_PRI_FIRST,NF_IP_PRI_CONNTRACK,NF_IP_PRI_NAT_SRC等),定义在include/uapi/linux/netfilter_ipv4.h中。⚠️ 千万别手滑把优先级搞错了。如果你的防火墙规则优先级比连接跟踪还高,你可能会看到连接跟踪完全失效——因为包还没被记录就被你 Drop 或者修改了。
准备好结构体后,就可以向系统注册了。有两个 API 可以用:
nf_register_hook(struct nf_hook_ops *reg):注册单个钩子。适合简单的模块。nf_register_hooks(struct nf_hook_ops *reg, unsigned int n):注册一组钩子。第二个参数是数组长度。当你需要在多个协议族或多个钩子点挂载函数时,这个函数能帮你省去不少循环调用的麻烦,且保证了原子性——要么全成功,要么全失败。
在接下来的两节里,我们会看到两个实际的注册示例。而在下一节的图示中,我们会直观地看到当多个钩子挂在同一个检查站时,priority 是如何决定它们的执行顺序的。
现在,让我们把代码写起来,真正把我们的逻辑插入到网络数据流中。