skaiuijing

引言

Netfilter的作用就是让用户空间程序能够注册对应的网络协议栈处理规则,从而实现高效的网络转发和过滤。许多常见的主机防火墙应用程序就是使用netfilter实现的。

iptables 是 Linux 系统中用于配置防火墙规则的命令行工具,它是 Netfilter 框架的用户空间接口。

netfilter工作在ip层。

概述

Netfilter:提供钩子点和框架(内核机制)。

iptables:提供规则管理和配置接口(用户工具 + 内核模块)。

协作关系:用户通过 iptables 定义规则 → 内核模块存储并执行规则 → 数据包在经过钩子点时被规则处理。

数据包的处理

在前面我们已经知道了数据包是如何进入内核的,通过DMA写入sk_buff对应的内存区域,然后一步步被拆解包头,分析字段,例如,如果我们确认一个数据包是在ip层后,它就会调用对应的函数进行处理。

输入:

1
2
3
4
5
6
graph LR
A[Network Driver] --> B[ip_rcv]
B --> C[NF_INET_PRE_ROUTING]
C --> D[ipv4_conntrack_in]
D --> E[ip_rcv_finish]
E --> F[Routing Subsystem]

路由这里继续:

1
2
3
4
5
6
7
8
9
10
11
12
13
graph LR
F[Routing Subsystem]
F --> G[ip_local_deliver]
G --> H[NF_INET_LOCAL_IN]
H --> I[ipv4_helper]
I --> J[ipv4_confirm]
J --> K[ip_local_deliver_finish]
K --> L[transport layer]

F --> M[Forwarding ip_forward]
M --> N[NF_INET_FORWARD]
N --> O[Forwarding ip_forward_finsh]
O-->e[ip_output]

输出:

1
2
3
4
5
graph LR
L[transport]-->aa[routing lookup]
aa-->ab[ip_local_out]
ab-->ac[NF_INET_LOCAL_OUT]
ac-->ad[ipv4_conntrack_local]-->ae[nf_conntrack_in]

继续划分:

1
2
3
graph LR

ae[nf_conntrack_in]-->ba[ip_output]-->bb[NF_INET_POST_ROUTING]-->bc[ipv4_helper]-->bd[ipv4_confirm]-->de[ip_finish_output]

hook

在netfilter这里,共有这么几个hook点:

1
2
3
4
5
6
7
8
enum nf_inet_hooks {
NF_INET_PRE_ROUTING,
NF_INET_LOCAL_IN,
NF_INET_FORWARD,
NF_INET_LOCAL_OUT,
NF_INET_POST_ROUTING,
NF_INET_NUMHOOKS
};

每个钩子对应于内核网络堆栈中的特定触发点位置:

Hook 点 位置函数 触发时机 常见用途
PRE_ROUTING ip_rcv() 收包后、路由前 DNAT、早期丢弃
LOCAL_IN ip_local_deliver() 路由后、交付前 INPUT 链,本机防火墙
FORWARD ip_forward() 路由后、转发时 FORWARD 链,路由器防火墙
LOCAL_OUT __ip_local_out() 本机发包、路由后 OUTPUT 链,限制本机流量
POST_ROUTING ip_output() 发包前、出接口时 SNAT、TTL 修改
NUMHOOKS - - 计数器

iptables

基于内核 Netfilter 提供的钩子回调机制,Netfilter 的作者进一步开发了 iptables,作为用户空间与内核空间之间的桥梁,用于管理和应用自定义的数据包处理规则。通过 iptables,用户无需直接编写内核代码,就能灵活地控制数据包的过滤、转发和地址转换。

iptables 的体系结构可以分为两部分:

用户空间(user-space)

1.提供 iptables 命令行工具,作为管理接口。

2.用户通过该工具定义规则(如允许、拒绝、重定向数据包),并将规则下发给内核。

3.本质上是一个配置层,负责与内核通信,而不直接处理数据包。

内核空间(kernel-space)

1.内核中的 iptables 模块依托 Netfilter 框架,在内存中维护规则表(tables)、链(chains)和规则(rules)。

2.不同的表对应不同的功能:

  • filter:数据包过滤(ACCEPT、DROP 等)
  • nat:网络地址转换(SNAT、DNAT、MASQUERADE)
  • mangle:修改数据包头部字段(如 TTL、TOS)
  • raw:控制是否启用连接跟踪

3.当数据包经过协议栈中的钩子点(如 PRE_ROUTING、LOCAL_IN、FORWARD、LOCAL_OUT、POST_ROUTING)时,内核会调用相应链上的规则进行匹配和处理。

eBPF

我们关注的是netfilter对应的这几个hook点,通过eBPF技术,我们可以将编写的模块注入到内核中。

挂载

我们需要指定hook的函数、优先级、挂载类型这些,然后加载到内核中,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

auto prog = bpf_object__find_program_by_name(obj, "netfilter_hook");
if (!prog) {
std::cerr << "[netfilter] 找不到 netfilter_hook 程序\n";
goto error;
}
int prog_fd = bpf_program__fd(prog);
union bpf_attr attr = {};
attr.link_create.prog_fd = prog_fd;
attr.link_create.attach_type = BPF_NETFILTER;
attr.link_create.netfilter.pf = NFPROTO_IPV4;
attr.link_create.netfilter.hooknum = NF_INET_LOCAL_IN;
attr.link_create.netfilter.priority = -128;
nf_fd_ingress = syscall(__NR_bpf, BPF_LINK_CREATE, &attr, sizeof(attr));
if (nf_fd_ingress < 0) {
std::cerr << "[netfilter] attach ingress 失败: " << strerror(errno) << "\n";
goto error;
}
attr.link_create.netfilter.hooknum = NF_INET_LOCAL_OUT;
nf_fd_egress = syscall(__NR_bpf, BPF_LINK_CREATE, &attr, sizeof(attr));
if (nf_fd_egress < 0) {
std::cerr << "[netfilter] attach egress 失败: " << strerror(errno) << "\n";
goto error;
}
}

netfilter层难道只能获取到ip网际层的信息吗?

获取上层信息

在每个eBPF的hook点,获取到的上下文都是不同的,不同层访问的信息都是有限制的。

在内核层,可以对数据包执行许多操作,例如丢弃、修改、重定向等等,但是在应用层,当数据包要被递交给具体进程时,就无法做到这些了,但是内核层能获取到的信息又非常有限,这该怎么办呢?

我们可以想到,tcp在传输层实际上已经与具体进程绑定了,那么在传输层进行具体操作不就可以了吗?

但是,越往上层,我们使用eBPF程序修改数据包的权限就小,性能也越差,但是底层又无法直接与进程相关联,这该怎么办?

例如,我们知道五元组):源ip,目的ip,源端口,目的端口,协议.可以代表一个链接,也可以直接代表一个进程。

但是在传输层虽然可以解析到协议及端口,但是ip层又相对变得透明了,难道我们还要进行逆向解析数据包吗?

在netfilter这里其实有一个很有意思的机制,也就是early demux机制。

early demux机制

设计哲学

我们都知道如果一件事情经常发生,那么它大概率在任何时间尺度下都经常发生。

所以,如果数据包经常交付给某个进程,那么接下来到达网络协议栈的数据包,也大概率是交付给这个进程。

如果我们能准确判断出一类数据包,那么对于这所有的一类数据包,我们只需要进行同一类的处理即可。

例如,如果我们在数据包刚到达网卡时就知道这个数据包是给某个进程的,而且协议栈只需要对这一类数据包进行简单的检查处理,并没有严格的约束。那么我们完全可以绕过协议栈,直接把包的内容丢给该进程。

从时间局部性我们可以得知,如果某个数据在某时刻被访问,那么它很可能在不久后再次被访问。这体现在:

  • TCP 连接是持久的:一旦建立连接,后续的数据包会频繁使用同一个 socket和路由。
  • socket 和 dst 缓存在内核中:这些结构在连接期间保持不变,适合被提前复用。

所以:既然你刚刚用过这个 socket,那我就假设你还会继续用它。

内核基于这个原理,实现了early demux机制。

内核实现

在传统路径中,IP 层处理完数据包后,才进入传输层查找 socket(如 TCP 的连接四元组匹配)并设置 skb->skskb->dst。这一步通常涉及:

  • 哈希查找 socket(可能是红黑树或哈希表)
  • 查找路由缓存或重新计算路由

而 early demux 的优化在于:

如果数据包属于一个已建立连接(如 TCP ESTABLISHED),那么我们可以在 IP 层就直接绑定 socket 和路由信息,跳过后续查找。

下面是Linux6.6内核源码,传输层的early demux相关代码,我们可以看见,其实在ip层,内核就已经获取了传输层相关信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int tcp_v4_early_demux(struct sk_buff *skb);
int udp_v4_early_demux(struct sk_buff *skb);
static int ip_rcv_finish_core(struct net *net, struct sock *sk,
struct sk_buff *skb, struct net_device *dev,
const struct sk_buff *hint)
{
const struct iphdr *iph = ip_hdr(skb);
int err, drop_reason;
struct rtable *rt;

drop_reason = SKB_DROP_REASON_NOT_SPECIFIED;

if (ip_can_use_hint(skb, iph, hint)) {
err = ip_route_use_hint(skb, iph->daddr, iph->saddr, iph->tos,
dev, hint);
if (unlikely(err))
goto drop_error;
}

if (READ_ONCE(net->ipv4.sysctl_ip_early_demux) &&
!skb_dst(skb) &&
!skb->sk &&
!ip_is_fragment(iph)) {
switch (iph->protocol) {
case IPPROTO_TCP:
if (READ_ONCE(net->ipv4.sysctl_tcp_early_demux)) {
//通过ealry demux机制,获取对应的sock指针
tcp_v4_early_demux(skb);

/* must reload iph, skb->head might have changed */
iph = ip_hdr(skb);
}
break;
case IPPROTO_UDP:
if (READ_ONCE(net->ipv4.sysctl_udp_early_demux)) {
//对UDP也同样这么处理,但是往往无效,毕竟UDP是无连接的
err = udp_v4_early_demux(skb);
if (unlikely(err))
goto drop_error;

/* must reload iph, skb->head might have changed */
iph = ip_hdr(skb);
}
break;
}
}
省略后面的代码
}

eBPF程序编写

既然知道了这一点,那么我们完全可以建立数据包与进程信息的映射:

1
2
3
4
5
6
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__type(key, struct sock *);
__type(value, struct ProcInfo);
__uint(max_entries, 1 >> 16);
} sock_map SEC(".maps");

通过map,在应用层,我们建立sock与进程信息的map结构,在ip层,就能直接通过sock查找到数据包关联的进程信息。

总结

对netfilter进行了一个简单的介绍,具体用法留给感兴趣的读者自行探索。

值得一提的是,如果传输层发送端的目标地址是本地地址时,数据包会绕过netfilter,我们的hook程序无法捕捉到这些数据包,这是为什么呢?

这个等以后有机会再写吧。