跳到主要内容

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() 方法中。
  • 流量来源:这里的情况稍微复杂一点。有两类车会汇入这里:
    1. 转发的车:它们刚通过了 FORWARD 检查站。
    2. 本地生成的车:它们刚通过了 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_rcvip_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_IPV4NFPROTO_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 参数还是会被传入。这允许你用一个函数处理多个钩子点(虽然通常为了清晰起见,我们会分开写)。

  • pfhooknum:这俩就是告诉内核「把上面那个函数挂到哪里」。比如你要监听 IPv4 的入站包,pf 就是 NFPROTO_IPV4hooknum 就是 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 是如何决定它们的执行顺序的。

现在,让我们把代码写起来,真正把我们的逻辑插入到网络数据流中。