跳到主要内容

9.5 连接跟踪助手与预期连接

到目前为止,我们谈论的连接跟踪都是「单线程」的——一条连接进来,一条记录出去。但是,现实世界的网络协议有时候比这要复杂得多。

你肯定遇到过这样的情况:某些协议不仅仅是「一个连接」那么简单。它们会把控制流和数据流分开——先在一条「控制通道」上打招呼,聊好了,再告诉你:「嘿,我马上要在另一个端口发数据了,你准备接一下」。

FTP 和 SIP(VoIP 协议)就是典型的例子。

这给 Netfilter 出了个难题。内核不是应用程序,它不懂 FTP 协议,它只看到两个孤立的数据包:一个在端口 21 聊天,另一个突然从某个随机高端口闯进来。如果没有额外的上下文,内核只能认为这两个包毫无关系,那个后面闯进来的数据包很有可能会被防火墙规则无情地 DROP 掉。

为了解决这个问题,Netfilter 引入了一个机制:连接跟踪助手

你可以把它想象成一个「懂行情的翻译官**——它潜入内核,专门盯着这些复杂协议的控制通道(比如 FTP 的 PORT 命令)。当它看到「我要开数据连接」的信号时,它会提前在内核的备忘录里记一笔:「一会儿如果有某个特定端口的连接过来,那是刚才那个老熟人介绍的,别拦着」。

这个机制叫做 Expectations(预期连接)

建立关联

当一个连接是作为另一个连接的「子连接」被预期进来时,我们就在两条连接之间建立了亲属关系。这在写防火墙规则时非常有用。

你不需要为每个随机的数据端口单独写规则,只需要写一条通用的:「接受任何与现有连接相关的包」。

iptables -A INPUT -m conntrack --ctstate RELATED -j ACCEPT

这条规则的意思是:只要你的状态是 RELATED(即你是某个现有连接的预期子连接),放行。

⚠️ 注意:别把亲戚关系搞窄了 RELATED 状态不仅仅是由这里的 Helper 创建的 Expectation 触发的。 比如,一个 ICMP 错误报文(像「Fragmentation needed」)发回来时,Netfilter 也会尝试在 ICMP 包里嵌入的原始包头中寻找对应的连接。如果能找到,这个 ICMP 包也会被标记为 RELATED。这属于协议栈层面的自动关联,不需要 Helper 插手。

助手的实现与注册

从代码上看,这些「翻译官」(Helpers)由 nf_conntrack_helper 结构体表示(定义在 include/net/netfilter/nf_conntrack_helper.h)。

注册和注销这两个动作非常直接,分别由 nf_conntrack_helper_register()nf_conntrack_helper_unregister() 完成。

以 FTP 为例。当你加载 nf_conntrack_ftp 模块时,它的初始化函数 nf_conntrack_ftp_init()(位于 net/netfilter/nf_conntrack_ftp.c)就会把 FTP 的 Helper 注册进内核。所有的 Helper 都被保存在一个哈希表 nf_ct_helper_hash 里,方便查找。

真正的「监听」发生在这里

注册只是第一步。Helper 什么时候开始工作?

这又回到了我们熟悉的 Hook 点。ipv4_helper() 这个回调函数被挂载在两个关键位置:

  • NF_INET_POST_ROUTING(数据包即将发出,ip_output
  • NF_INET_LOCAL_IN(数据包发给本地,ip_local_deliver

这意味着,当一个数据包(无论它是转发出去的还是发给本机的)到达这些节点时,ipv4_helper() 会被触发。它会去遍历刚才那个哈希表,看看有没有注册的 Helper 对这个连接感兴趣。

如果是 FTP 流量,help() 方法就会被调用。这就是具体干活的地方。

深入 FTP Helper:找模式

FTP Helper 的核心任务很枯燥但很关键:在数据流里找特定的「模式」。

它会在 FTP 控制通道的载荷里搜索特定的命令字符串。它最关心的是 PORT 命令——客户端用这个命令告诉服务器它开了哪个端口准备接收数据。

让我们看看源码(net/netfilter/nf_conntrack_ftp.c)里是怎么找的:

static int help(struct sk_buff *skb,
unsigned int protoff,
struct nf_conn *ct,
enum ip_conntrack_info ctinfo)
{
struct nf_conntrack_expect *exp;
// ...

for (i = 0; i < ARRAY_SIZE(search[dir]); i++) {
found = find_pattern(fb_ptr, datalen,
search[dir][i].pattern,
search[dir][i].plen,
search[dir][i].skip,
search[dir][i].term,
&matchoff, &matchlen,
&cmd,
search[dir][i].getnum);
if (found) break;
}

这里有一个循环,它在整个 FTP 载荷里扫描,试图匹配预定义的模式(比如 PORT 命令的 ASCII 码)。

⚠️ 特殊情况下的丢包 通常情况下,连接跟踪层是不应该丢包的——它只负责跟踪,过滤是防火墙层的事。 但这里是个例外。如果在寻找模式的过程中出现了异常(比如 find_pattern 返回了 -1,意味着只匹配到了一半,包可能被截断了),代码会直接:

if (found == -1) {
/* ... */
ret = NF_DROP;
goto out;
}

这是为了防止错误的跟踪信息污染状态表。与其记下一笔烂账,不如把这个可能有问题的包直接掐死。

如果成功找到了模式(比如完整的 PORT 命令),found 变量会被置位,我们拿到了里面的 IP 和端口信息。

下一步,就是真正创建 Expectation 了。

pr_debug("conntrack_ftp: match `%.*s' (%u bytes at %u)\n",
matchlen, fb_ptr + matchoff,
matchlen, ntohl(th->seq) + matchoff);

exp = nf_ct_expect_alloc(ct);
// ...
nf_ct_expect_init(exp, NF_CT_EXPECT_CLASS_DEFAULT, cmd.l3num,
&ct->tuplehash[!dir].tuple.src.u3, daddr,
IPPROTO_TCP, NULL, &cmd.u.tcp.port);
// ...
}

这里发生了几件事:

  1. 分配了一个 nf_conntrack_expect 对象。
  2. nf_ct_expect_init 填充细节:协议类型(TCP)、预期的源/目的地址、最重要的——刚才解析出来的端口。

这条记录被插入到系统的 Expectation 表中。内核现在知道了:「如果一会儿有 TCP 连连到这个端口,就把它和当前的 FTP 控制连接关联起来」。

子连接的「认祖归宗」

当那个预期的数据连接真的发起时,会发生什么?

这又回到了我们之前提过的 init_conntrack() 函数。当一个新的连接跟踪条目被创建时,内核会去查 Expectation 表:

static struct nf_conntrack_tuple_hash *
init_conntrack(struct net *net, struct nf_conn *tmpl,
const struct nf_conntrack_tuple *tuple,
// ... 参数省略 ...)
{
struct nf_conn *ct;
// ...
struct nf_conntrack_expect *exp;

// ...

exp = nf_ct_find_expectation(net, zone, tuple);
if (exp) {
pr_debug("conntrack: expectation arrives ct=%p exp=%p\n",
ct, exp);
/* Welcome, Mr. Bond. We've been expecting you... */
__set_bit(IPS_EXPECTED_BIT, &ct->status);
ct->master = exp->master;
if (exp->helper) {
help = nf_ct_helper_ext_add(ct, exp->helper,
GFP_ATOMIC);
if (help)
rcu_assign_pointer(help->helper, exp->helper);
}
// ...

这里有两个关键操作,标志着这个子连接的「认祖归宗」:

  1. __set_bit(IPS_EXPECTED_BIT, ...):给这个新连接打上标记,告诉全世界「我不是凭空出现的,我是被 EXPECTED 出来的」。这也是为什么 iptables -m conntrack --ctstate RELATED 能匹配到它的原因。
  2. ct->master = exp->master:把这个子连接的 master 指针指向它的父连接。这在做策略部署时非常有用——只要找到父亲,就能找到所有的儿子。

非标准端口:怎么教 Helper 识途?

默认情况下,FTP Helper 监听的是端口 21。这是写在定义里的(include/linux/netfilter/nf_conntrack_ftp.hFTP_PORT)。

但现实网络总是非标的。如果你的 FTP 服务器跑在 2121 端口上怎么办?

有两种办法教这个 Helper 识途。

方法一:加载参数

最简单的方法是在加载模块时直接告诉它:

modprobe nf_conntrack_ftp ports=2121

或者如果有好几个非标端口:

modprobe nf_conntrack_ftp ports=2022,2023,2024

这会把 Helper 绑定到这些新的端口上。

方法二:使用 CT target

如果在模块加载时不知道端口,或者想动态绑定,可以用 iptables 的 CT target。

这个 target 是在内核 2.6.34 加入的(位于 net/netfilter/xt_CT.c)。你可以写一条规则,专门捕捉特定端口的流量,然后告诉内核:「这个流量虽然看起来不像标准的 FTP,但请用 FTP Helper 来处理它」:

iptables -A PREROUTING -t raw -p tcp --dport 8888 -j CT --helper ftp

这里我们在 raw 表的 PREROUTING 链里操作,确保在连接跟踪建立的最早阶段就把 Helper 绑定上去。

深入一点:Target 和 Match 是怎么挂进内核的? 你可能会好奇,像 CT 这样的 target,或者 conntrack 这样的 match,是怎么被内核识别的?

它们实际上是 Xtables 的扩展。

  • Target 扩展(比如 CT, ACCEPT, DROP)由 xt_target 结构体表示。它们通过 xt_register_target()(单个)或 xt_register_targets()(批量)注册到内核。
  • Match 扩展(比如 state, conntrack, length)由 xt_match 结构体表示,通过 xt_register_match()xt_register_matches() 注册。

当 iptables 规则被解析时,内核就是靠这些注册进来的结构体来知道「遇到 -j CT 该调用哪个函数」,或者遇到 -m length 该怎么去检查包的长度。


到目前为止,我们拆解了连接跟踪的初始化过程,以及它如何处理像 FTP 这样复杂的协议。连接跟踪是 Netfilter 的基石,但它是藏在幕后的。下一章,我们要走到台前,聊聊那个你可能最熟悉的伙伴——iptables,看看这些底层的机制是如何通过规则表露出来的。