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);
// ...
}
这里发生了几件事:
- 分配了一个
nf_conntrack_expect对象。 - 用
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);
}
// ...
这里有两个关键操作,标志着这个子连接的「认祖归宗」:
__set_bit(IPS_EXPECTED_BIT, ...):给这个新连接打上标记,告诉全世界「我不是凭空出现的,我是被 EXPECTED 出来的」。这也是为什么iptables -m conntrack --ctstate RELATED能匹配到它的原因。ct->master = exp->master:把这个子连接的master指针指向它的父连接。这在做策略部署时非常有用——只要找到父亲,就能找到所有的儿子。
非标准端口:怎么教 Helper 识途?
默认情况下,FTP Helper 监听的是端口 21。这是写在定义里的(include/linux/netfilter/nf_conntrack_ftp.h 的 FTP_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,看看这些底层的机制是如何通过规则表露出来的。