FTP 协议 基于 Netfilter Conntrack 的 动态端口 开放

由于 防火墙的默认策略 应该是DROP,对于像 FTP 这种在被动模式下,需要新开端口建立数据通道的协议而言,防火墙不能够主动的开辟相关的端口,也就在默认DROP情况下,FTP的数据端口不能通信,就会出现能登录成功,但获取不到目录的情况。关于 FTP 的介绍,可参考这篇帖子

在 Linux 中,Netfilter 自带一个 conntrack 模块,于是就先以 ftp 协议为例研究了一下。其主要原理是,监控相应端口的流量,尝试获取到含有动态端口的数据包,然后,将相应端口的数据包标记成RELATED的数据(猜测是这样),因而,使其实现通信。

实现 ftp 动态端口的模块 文件是 net/netfilter/nf_conntrack_ftp.c,include/linux/netfilter/nf_conntrack_ftp.h,include/uapi/linux/netfilter/nf_conntrack_ftp.h,net/netfilter/nf_nat_ftp.c,还有一个 net/netfilter/ip_vs_ftp.c 文件暂时没看懂干啥的。这些文件均来自于 linux 内核文件。在 Ubuntu 18.04 系统中,以 apt-get install linux-source-5.0.0 的方式获取。

测试系统 Ubuntu 18.04,内核版本:5.0.0-31-generic,iptables 版本:1.6.1

加载 nf_conntrack_ftp 模块

首先,了解如何开启linux系统自带的 ftp 协议的动态端口模块。

  1. 准备ftp客户端和服务器,linux下可使用vsftpd作为ftp服务器,相关安装方法,可参考这篇帖子。windows下的ftp客户端,可使用WinScp这个软件。
  2. 设置系统对conntrack模块的支持
    vim /etc/sysctl.conf
    添加一行:
    net.netfilter.nf_conntrack_helper=1
    然后使用sysctl -p使其生效。
    主要是新版系统需要加这个一句,旧版系统不需要,旧版似乎是默认开启的,此部分可参考这个文章
  3. 加载 nf_conntrack_ftp 模块
    modprobe nf_conntrack
    modprobe nf_conntrack_ftp
    
  4. iptables 测试规则(此处仅是ipv4的测试规则,ipv6需要用到ip6tables)
    iptables -F # 清空规则
    iptables -P INPUT ACCEPT
    iptables -P OUTPUT ACCEPT
    iptables -P FORWARD DROP # FORWARD链规则默认DROP
    iptables -A FORWARD -p tcp -m state --state NEW --dport 21 -j ACCEPT
    iptables -A FORWARD -p tcp -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
    # 按照对上面那篇文章的理解,应该是用下面这个规则的,
    # 但是,测试发现,只用下面这个反而通信不了,用上面那句规则,模块是会工作的。有点迷,可能没理解好。
    # iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -m helper --helper opc -p tcp -j ACCEPT
    
    此处在 FORWARD 链上测试,也可自行改成INPUT和OUTPUT链,Ubuntu 18.04 使用 bridge_utils 配置网桥后,需要额外加载模块,FOWWARD 链规则才生效,相关操作可见这篇帖子
    5. 开启 ftp 服务器后,使用 winscp 客户端对其进行访问测试。
    默认情况下,winscp 是用 被动模式通信的,被动模式的确需要动态端口开放这这一策略。
    不过首先要明确一点,防火墙上做动态端口开放,显然要求协议是明文的,加密是不行的,加密就没法从数据包里获得地址和动态端口信息了(除非知道秘钥,并且重写模块)。所以,连接设置如下:
    在这里插入图片描述
    如果选用主动模式通信,可点击上面界面中的“高级”,然后进入“连接”,取消“被动模式”的选中即可。主动模式,似乎只需要再开放20端口即可,不用conntrack,当然这和防火墙策略有关,也许不想实时开着20端口呢。
    在这里插入图片描述
    然后,连接就能读取成功,去除模块,或者减少iptables的放行规则,将无法通信。

nf_conntrack_ftp 模块粗读

今天太晚了,先不写了。

include/uapi/linux/netfilter/nf_conntrack_ftp.h

这个文件主要是定义了FTP几种模式:

/* This enum is exposed to userspace */
enum nf_ct_ftp_type {
	/* PORT command from client */
	NF_CT_FTP_PORT,
	/* PASV response from server */
	NF_CT_FTP_PASV,
	/* EPRT command from client */
	NF_CT_FTP_EPRT,
	/* EPSV response from server */
	NF_CT_FTP_EPSV,
};

上面两个是ipv4下面的主动和被动模式,下面两个则是ipv6下面的主动和被动模式

include/linux/netfilter/nf_conntrack_ftp.h

#define FTP_PORT	21

#define NF_CT_FTP_SEQ_PICKUP	(1 << 0)

#define NUM_SEQ_TO_REMEMBER 2
/* This structure exists only once per master */
struct nf_ct_ftp_master {
	/* Valid seq positions for cmd matching after newline */
	u_int32_t seq_aft_nl[IP_CT_DIR_MAX][NUM_SEQ_TO_REMEMBER];
	/* 0 means seq_match_aft_nl not set */
	u_int16_t seq_aft_nl_num[IP_CT_DIR_MAX];
	/* pickup sequence tracking, useful for conntrackd */
	u_int16_t flags[IP_CT_DIR_MAX];
};

没太懂这里的,但是能看出来时用来存储序列号的,就是conntrack时,要校验并更新数据流的序列号。如果序列号对不上,并不会放行这个包。

net/netfilter/nf_conntrack_ftp.c

#define MAX_PORTS 8
static u_int16_t ports[MAX_PORTS];
static unsigned int ports_c;
module_param_array(ports, ushort, &ports_c, 0400);

static bool loose;
module_param(loose, bool, 0600);

首先限制了最大监听的端口数量,不超过8个ftp服务器的端口。
module_param 这个貌似是可以在加载模块是传递参数,0400这个表明权限,但是不太懂这个loose变量到底是什么啥意思,修复的那个bug代表啥含义。

static int try_rfc959(const char *, size_t, struct nf_conntrack_man *,
		      char, unsigned int *);
static int try_rfc1123(const char *, size_t, struct nf_conntrack_man *,
		       char, unsigned int *);
static int try_eprt(const char *, size_t, struct nf_conntrack_man *,
		    char, unsigned int *);
static int try_epsv_response(const char *, size_t, struct nf_conntrack_man *,
			     char, unsigned int *);
			     
static struct ftp_search {
	const char *pattern;
	size_t plen;
	char skip;
	char term;
	enum nf_ct_ftp_type ftptype;
	int (*getnum)(const char *, size_t, struct nf_conntrack_man *, char, unsigned int *);
} search[IP_CT_DIR_MAX][2];

这四个函数,就是尝试解析 ftp 数据包的,分别以那四种情况,去尝试解析出 动态端口。
然后含有个search结构体数据,里面包含了四种情况对应的解析方法,getnum指针就指向这几个函数。

/* Return 1 for match, 0 for accept, -1 for partial. */
static int find_pattern(const char *data, size_t dlen,
			const char *pattern, size_t plen,
			char skip, char term,
			unsigned int *numoff,
			unsigned int *numlen,
			struct nf_conntrack_man *cmd,
			int (*getnum)(const char *, size_t,
				      struct nf_conntrack_man *, char,
				      unsigned int *))

这个函数也很好懂,传递参数数就是search结构体中的数据,如果满足某一个种情况,就由参数指针返回解析到的动态端口(可能还有ip地址)。

/* Look up to see if we're just after a \n. */
static int find_nl_seq(u32 seq, const struct nf_ct_ftp_master *info, int dir)
{
	unsigned int i;

	for (i = 0; i < info->seq_aft_nl_num[dir]; i++)
		if (info->seq_aft_nl[dir][i] == seq)
			return 1;
	return 0;
}

这个应该是验证数据包的序列号和保存下来的是否一致。

/* We don't update if it's older than what we have. */
static void update_nl_seq(struct nf_conn *ct, u32 nl_seq,
			  struct nf_ct_ftp_master *info, int dir,
			  struct sk_buff *skb)
{
	unsigned int i, oldest;

	/* Look for oldest: if we find exact match, we're done. */
	for (i = 0; i < info->seq_aft_nl_num[dir]; i++) {
		if (info->seq_aft_nl[dir][i] == nl_seq)
			return;
	}

	if (info->seq_aft_nl_num[dir] < NUM_SEQ_TO_REMEMBER) {
		info->seq_aft_nl[dir][info->seq_aft_nl_num[dir]++] = nl_seq;
	} else {
		if (before(info->seq_aft_nl[dir][0], info->seq_aft_nl[dir][1]))
			oldest = 0;
		else
			oldest = 1;

		if (after(nl_seq, info->seq_aft_nl[dir][oldest]))
			info->seq_aft_nl[dir][oldest] = nl_seq;
	}
}

这个则是更新存储的序列号。
然后,接下来,就是最关键的help函数了

static int help(struct sk_buff *skb,
		unsigned int protoff,
		struct nf_conn *ct,
		enum ip_conntrack_info ctinfo)

help函数中的部分内容如下

	/* Until there's been traffic both ways, don't look in packets. */
	if (ctinfo != IP_CT_ESTABLISHED &&
	    ctinfo != IP_CT_ESTABLISHED_REPLY) {
		pr_debug("ftp: Conntrackinfo = %u\n", ctinfo);
		return NF_ACCEPT;
	}

对于SYN包,这里就直接返回了。所以,iptables里面,需要一条21端口的NEW包规则。
所以,这里是要求数据包一定是在有双方数据流的情况才执行后面的代码。
从这个角度看,这个返回值NF_ACCEPT似乎并不会放行数据包。

	th = skb_header_pointer(skb, protoff, sizeof(_tcph), &_tcph); // tcp 头部
	if (th == NULL)
		return NF_ACCEPT;
	dataoff = protoff + th->doff * 4;
	/* No data? */
	if (dataoff >= skb->len) {
		pr_debug("ftp: dataoff(%u) >= skblen(%u)\n", dataoff,
			 skb->len);
		return NF_ACCEPT;
	}
	datalen = skb->len - dataoff;

这个dataoff是偏移量,指向了tcp的payload开始的地方,这个datalen则是这个payload的长度。

	spin_lock_bh(&nf_ftp_lock); // 似乎是上锁
	fb_ptr = skb_header_pointer(skb, dataoff, datalen, ftp_buffer);
	BUG_ON(fb_ptr == NULL);

	ends_in_nl = (fb_ptr[datalen - 1] == '\n');
	seq = ntohl(th->seq) + datalen;

fb_ptr 则是拿到了 payload的起始地址。
ends_in_nl 是ftp协议会以换行符结尾。
seq则是计算下一条数据包的序列号。

	/* Look up to see if we're just after a \n. */
	if (!find_nl_seq(ntohl(th->seq), ct_ftp_info, dir)) {
		/* We're picking up this, clear flags and let it continue */
		if (unlikely(ct_ftp_info->flags[dir] & NF_CT_FTP_SEQ_PICKUP)) {
			ct_ftp_info->flags[dir] ^= NF_CT_FTP_SEQ_PICKUP;
			goto skip_nl_seq;
		}

		/* Now if this ends in \n, update ftp info. */
		pr_debug("nf_conntrack_ftp: wrong seq pos %s(%u) or %s(%u)\n",
			 ct_ftp_info->seq_aft_nl_num[dir] > 0 ? "" : "(UNSET)",
			 ct_ftp_info->seq_aft_nl[dir][0],
			 ct_ftp_info->seq_aft_nl_num[dir] > 1 ? "" : "(UNSET)",
			 ct_ftp_info->seq_aft_nl[dir][1]);
		ret = NF_ACCEPT;
		goto out_update_nl;
	}

这一步似乎在验证序列号。

	cmd.l3num = nf_ct_l3num(ct);
	memcpy(cmd.u3.all, &ct->tuplehash[dir].tuple.src.u3.all,
	       sizeof(cmd.u3.all));

这个l3num其实是指ipv4还是ipv6,如果是ipv4,其值是2,如果是ipv6,其值是10,详见 include/linux/socket.h,里面 160 行的 supportd address families 部分。
u3.all则是指ip地址,第二步是在拷贝地址,因为ipv4和ipv6并不一样,用联合体将两种类型封装到一块去了。

	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;
	}
	if (found == -1) {
		/* We don't usually drop packets.  After all, this is
		   connection tracking, not packet filtering.
		   However, it is necessary for accurate tracking in
		   this case. */
		nf_ct_helper_log(skb, ct, "partial matching of `%s'",
			         search[dir][i].pattern);
		ret = NF_DROP;
		goto out;
	} else if (found == 0) { /* No match */
		ret = NF_ACCEPT;
		goto out_update_nl;
	}

将find_pattern的四种情况都查询一遍,如果匹配到四种模式数据包的任何一种,就返回1,都没有匹配就返回0,如果只部分匹配(例如,匹配到字母,但没有找到端口),返回-1,似乎是将部分匹配的包当前异常包丢弃了。

	exp = nf_ct_expect_alloc(ct);
	if (exp == NULL) {
		nf_ct_helper_log(skb, ct, "cannot alloc expectation");
		ret = NF_DROP;
		goto out;
	}

如果匹配到了,就为该数据流,分配一个exp(不知道为什么取名为expect,期望,期待?)

	daddr = &ct->tuplehash[!dir].tuple.dst.u3; // 指向该数据包的目的地址(可能是ipv4,也可能是ipv6)
	/* Update the ftp info */
	if ((cmd.l3num == nf_ct_l3num(ct)) &&
	    memcmp(&cmd.u3.all, &ct->tuplehash[dir].tuple.src.u3.all,
		     sizeof(cmd.u3.all))) {
		/* Enrico Scholz's passive FTP to partially RNAT'd ftp
		   server: it really wants us to connect to a
		   different IP address.  Simply don't record it for
		   NAT. */
		if (cmd.l3num == PF_INET) {
			pr_debug("NOT RECORDING: %pI4 != %pI4\n",
				 &cmd.u3.ip,
				 &ct->tuplehash[dir].tuple.src.u3.ip);
		} else {
			pr_debug("NOT RECORDING: %pI6 != %pI6\n",
				 cmd.u3.ip6,
				 ct->tuplehash[dir].tuple.src.u3.ip6);
		}

		/* Thanks to Cristiano Lincoln Mattos
		   <[email protected]> for reporting this potential
		   problem (DMZ machines opening holes to internal
		   networks, or the packet filter itself). */
		if (!loose) {
			ret = NF_ACCEPT;
			goto out_put_expect;
		}
		daddr = &cmd.u3;
	}

如果是NAT模式,那数据包里服务器给的连接的动态端口和IP地址和这个数据包的IP地址可能不一样,因此单独做了判断。

	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);
		/* Now, NAT might want to mangle the packet, and register the
	 * (possibly changed) expectation itself. */
	nf_nat_ftp = rcu_dereference(nf_nat_ftp_hook);
	if (nf_nat_ftp && ct->status & IPS_NAT_MASK)
		ret = nf_nat_ftp(skb, ctinfo, search[dir][i].ftptype,
				 protoff, matchoff, matchlen, exp);
	else {
		/* Can't expect this?  Best to drop packet now. */
		if (nf_ct_expect_related(exp) != 0) {
			nf_ct_helper_log(skb, ct, "cannot add expectation");
			ret = NF_DROP;
		} else
			ret = NF_ACCEPT;
	}

这个nf_ct_expect_init函数,则是给netfilter某种指示,在这两个IP地址上,要放行目的端口是cmd.u.tcp.port的包,这个端口就是解析到的动态端口了。

static int nf_ct_ftp_from_nlattr(struct nlattr *attr, struct nf_conn *ct)
{
	struct nf_ct_ftp_master *ftp = nfct_help_data(ct);

	/* This conntrack has been injected from user-space, always pick up
	 * sequence tracking. Otherwise, the first FTP command after the
	 * failover breaks.
	 */
	ftp->flags[IP_CT_DIR_ORIGINAL] |= NF_CT_FTP_SEQ_PICKUP;
	ftp->flags[IP_CT_DIR_REPLY] |= NF_CT_FTP_SEQ_PICKUP;
	return 0;
}

这个不太懂是干什么的

static struct nf_conntrack_helper ftp[MAX_PORTS * 2] __read_mostly;

似乎是限定了最多的帮助函数,每个端口会分配一个helper,最多有8个端口,每个端口有ipv4和ipv6两种情况,因此是16个。

static const struct nf_conntrack_expect_policy ftp_exp_policy = {
	.max_expected	= 1,
	.timeout	= 5 * 60,
};

这个似乎是expect的策略,涉及到到底什么时候关掉开启的动态端口等策略。

static int __init nf_conntrack_ftp_init(void)
{
	int i, ret = 0;

	NF_CT_HELPER_BUILD_BUG_ON(sizeof(struct nf_ct_ftp_master));

	ftp_buffer = kmalloc(65536, GFP_KERNEL);
	if (!ftp_buffer)
		return -ENOMEM;

	if (ports_c == 0)
		ports[ports_c++] = FTP_PORT;

	/* FIXME should be configurable whether IPv4 and IPv6 FTP connections
		 are tracked or not - YK */
	for (i = 0; i < ports_c; i++) {
		nf_ct_helper_init(&ftp[2 * i], AF_INET, IPPROTO_TCP, "ftp",
				  FTP_PORT, ports[i], ports[i], &ftp_exp_policy,
				  0, help, nf_ct_ftp_from_nlattr, THIS_MODULE);
		nf_ct_helper_init(&ftp[2 * i + 1], AF_INET6, IPPROTO_TCP, "ftp",
				  FTP_PORT, ports[i], ports[i], &ftp_exp_policy,
				  0, help, nf_ct_ftp_from_nlattr, THIS_MODULE);
	}

	ret = nf_conntrack_helpers_register(ftp, ports_c * 2);
	if (ret < 0) {
		pr_err("failed to register helpers\n");
		kfree(ftp_buffer);
		return ret;
	}

	return 0;
}

这个则是分别在各个端口还有两种ip协议下,注册help函数。

net/nefilter/nf_nat_ftp.c

从名字可以看出,这个文件应该是ftp的NAT有关系。在不考虑NAT的情况下,并不需要加载这个模块以实现动态端口开放。

/* FIXME: Time out? --RR */

static int nf_nat_ftp_fmt_cmd(struct nf_conn *ct, enum nf_ct_ftp_type type,
			      char *buffer, size_t buflen,
			      union nf_inet_addr *addr, u16 port)
{
	switch (type) {
	case NF_CT_FTP_PORT:
	case NF_CT_FTP_PASV:
		return snprintf(buffer, buflen, "%u,%u,%u,%u,%u,%u",
				((unsigned char *)&addr->ip)[0],
				((unsigned char *)&addr->ip)[1],
				((unsigned char *)&addr->ip)[2],
				((unsigned char *)&addr->ip)[3],
				port >> 8,
				port & 0xFF);
	case NF_CT_FTP_EPRT:
		if (nf_ct_l3num(ct) == NFPROTO_IPV4)
			return snprintf(buffer, buflen, "|1|%pI4|%u|",
					&addr->ip, port);
		else
			return snprintf(buffer, buflen, "|2|%pI6|%u|",
					&addr->ip6, port);
	case NF_CT_FTP_EPSV:
		return snprintf(buffer, buflen, "|||%u|", port);
	}

	return 0;
}

/* So, this packet has hit the connection tracking matching code.
   Mangle it, and change the expectation to match the new version. */
static unsigned int nf_nat_ftp(struct sk_buff *skb,
			       enum ip_conntrack_info ctinfo,
			       enum nf_ct_ftp_type type,
			       unsigned int protoff,
			       unsigned int matchoff,
			       unsigned int matchlen,
			       struct nf_conntrack_expect *exp)
{
	union nf_inet_addr newaddr;
	u_int16_t port;
	int dir = CTINFO2DIR(ctinfo);
	struct nf_conn *ct = exp->master;
	char buffer[sizeof("|1||65535|") + INET6_ADDRSTRLEN];
	unsigned int buflen;

	pr_debug("type %i, off %u len %u\n", type, matchoff, matchlen);

	/* Connection will come from wherever this packet goes, hence !dir */
	newaddr = ct->tuplehash[!dir].tuple.dst.u3;
	exp->saved_proto.tcp.port = exp->tuple.dst.u.tcp.port;
	exp->dir = !dir;

	/* When you see the packet, we need to NAT it the same as the
	 * this one. */
	exp->expectfn = nf_nat_follow_master;

	/* Try to get same port: if not, try to change it. */
	for (port = ntohs(exp->saved_proto.tcp.port); port != 0; port++) {
		int ret;

		exp->tuple.dst.u.tcp.port = htons(port);
		ret = nf_ct_expect_related(exp);
		if (ret == 0)
			break;
		else if (ret != -EBUSY) {
			port = 0;
			break;
		}
	}

	if (port == 0) {
		nf_ct_helper_log(skb, ct, "all ports in use");
		return NF_DROP;
	}

	buflen = nf_nat_ftp_fmt_cmd(ct, type, buffer, sizeof(buffer),
				    &newaddr, port);
	if (!buflen)
		goto out;

	pr_debug("calling nf_nat_mangle_tcp_packet\n");

	if (!nf_nat_mangle_tcp_packet(skb, ct, ctinfo, protoff, matchoff,
				      matchlen, buffer, buflen))
		goto out;

	return NF_ACCEPT;

out:
	nf_ct_helper_log(skb, ct, "cannot mangle packet");
	nf_ct_unexpect_related(exp);
	return NF_DROP;
}

这个文件虽然简单(相比什么sip和h323而言),但目前并没有看懂它怎么做的,为什么这么做。

总结

本文对 FTP 协议的动态端口开放的实现源码进行了初探,感觉Linux内核果然是博大精深啊,本人水平还差的远,非常惭愧。

OPC DA 协议 动态端口开放

参照 这个 FTP 模块,做了一个 OPC DA 协议的动态端口开放。代码在这里

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章