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 協議的動態端口開放。代碼在這裏

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