这篇笔记记录了FTP协议是如何利用helper实现连接跟踪的。核心代码文件如下:
代码路径 | 说明 |
---|---|
net/netfilter/nf_conntrack_ftp.c | 实现文件 |
include/net/netfilter/nf_conntrack_ftp.h | 头文件 |
FTP协议的连接跟踪由开关CONFIG_NF_CONNTRACK_FTP控制。
1. 基本原理
向连接跟踪子系统注册一个helper来跟踪FTP控制连接上传输的报文,通过搜索这些报文中的PORT(主动模式)和PASV(被动模式)命令,进而获取到即将要建立的期望连接信息(端口和IP地址)。
2. 初始化
可以想的见,初始化时最关键的操作应该就是向连接跟踪子系统注册helper。初始化函数如下:
//该buffer用来拷贝skb中FTP控制连接报文内容,搜索关键字之前会先把报文拷贝到该
//buffer中,然后对buffer进行搜索,这样虽然会影响效率,但是实现简单
static char *ftp_buffer;
static DEFINE_SPINLOCK(nf_ftp_lock);
//虽然标准协议规定FTP的控制端口就是21,但是为了可扩展,内核在实现时,将可
//监听的控制端口做成了参数可配的,并且可以同时最多跟踪8个控制端口,下面分析
//代码时,为了理解方便,可以都视作只跟踪21号端口
#define MAX_PORTS 8
//要跟踪的控制端口号
static u_int16_t ports[MAX_PORTS];
//ports数组中当前指定了几个控制端口
static unsigned int ports_c;
module_param_array(ports, ushort, &ports_c, 0400);
//AF_INET和AF_INET6协议族各注册一个helper
static struct nf_conntrack_helper ftp[MAX_PORTS][2] __read_mostly;
//每个helper都有一个名字,这里保存名字,命名方法见下方init()
static char ftp_names[MAX_PORTS][2][sizeof("ftp-65535")] __read_mostly;
static int __init nf_conntrack_ftp_init(void)
{
int i, j = -1, ret = 0;
char *tmpname;
//ftp_buffer用于保存FTP控制连接的应用层数据,最长为65535,其具体用法见下方help()
ftp_buffer = kmalloc(65536, GFP_KERNEL);
if (!ftp_buffer)
return -ENOMEM;
//如果模块加载时没有指定控制端口信息,那么默认只监听21号端口
if (ports_c == 0)
ports[ports_c++] = FTP_PORT;
//初始化helper,然后将其向系统注册
for (i = 0; i < ports_c; i++) {
//初始化tuple(源和目的)
ftp[i][0].tuple.src.l3num = PF_INET;
ftp[i][1].tuple.src.l3num = PF_INET6;
for (j = 0; j < 2; j++) {
ftp[i][j].tuple.src.u.tcp.port = htons(ports[i]);
ftp[i][j].tuple.dst.protonum = IPPROTO_TCP;
//允许同时只存在1个未确认的期望连接
ftp[i][j].max_expected = 1;
//未确认的期望连接的超时时间为5分钟
ftp[i][j].timeout = 5 * 60; /* 5 Minutes */
ftp[i][j].me = THIS_MODULE;
//help()回调,非常重要,见下方
ftp[i][j].help = help;
//21号控制端口的helper名字为"ftp",其它端口的helper名字为"ftp-端口号"
tmpname = &ftp_names[i][j][0];
if (ports[i] == FTP_PORT)
sprintf(tmpname, "ftp");
else
sprintf(tmpname, "ftp-%d", ports[i]);
ftp[i][j].name = tmpname;
//向连接跟踪子系统注册helper
ret = nf_conntrack_helper_register(&ftp[i][j]);
if (ret) {
nf_conntrack_ftp_fini();
return ret;
}
}
}
return 0;
}
下面在看help()回调的实现之前,先来看看两个help()实现关键点:
- 搜索关键字定义;
- 连接序号缓存;
3. 搜索关键字
通过struct ftp_search定义了要搜索的一组关键字,以及一些搜索方法。
static struct ftp_search {
const char *pattern; //关键字
size_t plen; //关键字长度
char skip; //搜索过程中跳过什么字符
char term; //搜索遇到什么字符就停止搜索
enum nf_ct_ftp_type ftptype;//搜索的关键字类型
//搜索到关键字后,如何从关键字信息中提取IP地址和端口号
int (*getnum)(const char *, size_t, struct nf_conntrack_man *, char);
} search[IP_CT_DIR_MAX][2] = {
[IP_CT_DIR_ORIGINAL] = {
{
.pattern = "PORT",
.plen = sizeof("PORT") - 1,
.skip = ' ',
.term = '\r',
.ftptype = NF_CT_FTP_PORT,
.getnum = try_rfc959,
},
{
.pattern = "EPRT",
.plen = sizeof("EPRT") - 1,
.skip = ' ',
.term = '\r',
.ftptype = NF_CT_FTP_EPRT,
.getnum = try_eprt,
},
},
[IP_CT_DIR_REPLY] = {
{
.pattern = "227 ",
.plen = sizeof("227 ") - 1,
.skip = '(',
.term = ')',
.ftptype = NF_CT_FTP_PASV,
.getnum = try_rfc959,
},
{
.pattern = "229 ",
.plen = sizeof("229 ") - 1,
.skip = '(',
.term = ')',
.ftptype = NF_CT_FTP_EPSV,
.getnum = try_epsv_response,
},
},
};
这里需要多思考一下匹配方向,为什么PORT就一定是ORGINAL呢?这是因为PORT和控制连接的方向是一致的,如果还是不理解,列举下两种可能性就都清楚了。PASV在REPLY方向是一个道理。
help()中,检查到一个命令行后,会调用find_pattern()进行关键字匹配,传入该函数的参数就是上面的struct ftp_search中的字段。
@data:要搜索的报文内容
@dlen:data的长度
@numoff:输出参数,如果搜索命中,记录地址信息在data中的偏移
@numlen: 输出参数,从numoff开始的numlen个字节为地址信息
@cmd:搜索命中后,调用getnum()填充该参数(将其作为第二个参数传入getnum()中)
@ret:搜索命中返回1,不命中返回0,如果只命中了一部分,那么返回-1表示失败
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))
{
size_t i;
if (dlen == 0)
return 0;
if (dlen <= plen) {
//报文长度比关键字还短,但是可以匹配一部分关键字,返回-1
if (strnicmp(data, pattern, dlen) == 0)
return -1;
else return 0;
}
//不匹配返回0
if (strnicmp(data, pattern, plen) != 0)
return 0;
//命令已经匹配,下面尝试从命令中解析IP地址和端口号
//先跳过skip字符
for (i = plen; data[i] != skip; i++)
if (i == dlen - 1) return -1;
i++;
//记录地址信息偏移,调用getnum()解析地址信息并记录numlen
*numoff = i;
*numlen = getnum(data + i, dlen - i, cmd, term);
//命令匹配,但是没解析到任何地址参数,解析失败,返回-1
if (!*numlen)
return -1;
//一切正常并且搜索命中,返回1
return 1;
}
搜索过程非常直接,就是直接匹配FTP控制连接中的命令,不熟悉的可以查下FTP中的PORT和PASV命令格式。
4. 连接序号缓存
FTP控制连接上的命令都是以行为单位进行收发的,即它们是有边界的,但是TCP的传输是字节流,所有在匹配时,就非常有必要先识别报文中数据边界,然后才能对其进行搜索等后续处理。
在实现时,定义了一个结构专门缓存当前收到的一些行的tcp序号,通过这些序号进行定界。
#define NUM_SEQ_TO_REMEMBER 2
struct nf_ct_ftp_master {
//如果一个报文以新行结尾,则其末尾序号+1就是一个新行的起始序号,
//该序号就会被缓存到seq_aft_nl中(数组名是seq after newline的缩写)
u_int32_t seq_aft_nl[IP_CT_DIR_MAX][NUM_SEQ_TO_REMEMBER];
//记录了seq_aft_nl[]中当前缓存了几个序号
int seq_aft_nl_num[IP_CT_DIR_MAX];
};
helper在注册时,会将该结构作为扩展信息注册到FTP控制连接的连接跟踪信息块中。help()函数中,在收到报文时,如果该报文以\n结尾则尝试更新缓存信息。
4.1 序列号缓存项更新
static void update_nl_seq(u32 nl_seq, struct nf_ct_ftp_master *info, int dir,
struct sk_buff *skb)
{
unsigned int i, oldest = NUM_SEQ_TO_REMEMBER;
//从当前已缓存项中找一个最老的,即序号最小的
for (i = 0; i < info->seq_aft_nl_num[dir]; i++) {
//如果要缓存的seq已经在缓存数组中了,当然无需更新了
if (info->seq_aft_nl[dir][i] == nl_seq)
return;
//这个oldest的比较是个bug吧
if (oldest == info->seq_aft_nl_num[dir] ||
before(info->seq_aft_nl[dir][i], info->seq_aft_nl[dir][oldest]))
oldest = i;
}
if (info->seq_aft_nl_num[dir] < NUM_SEQ_TO_REMEMBER) {
//数组还没有满,放入下一个空闲位置即可,并且累加有效缓存项个数
info->seq_aft_nl[dir][info->seq_aft_nl_num[dir]++] = nl_seq;
nf_conntrack_event_cache(IPCT_HELPINFO_VOLATILE, skb);
} else if (oldest != NUM_SEQ_TO_REMEMBER &&
after(nl_seq, info->seq_aft_nl[dir][oldest])) {
//数组已满,并且要缓存的序号确实大于该最老的缓存项时进行更新
info->seq_aft_nl[dir][oldest] = nl_seq;
nf_conntrack_event_cache(IPCT_HELPINFO_VOLATILE, skb);
}
}
上面的更新函数实现这么怪,是因为seq_aft_nl[]数组并没有按照有序数组来维护。但是它的更新原则还是非常简单的:就是数组没满时,直接往后累加,如果满了,那么将tcp序列号最小的一个替换。
4.2 序列号缓存项查找
调用find_nl_seq()对缓存的序号进行查找。
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;
}
从下面的help()实现中可以看到,更新缓存项时传入的seq时下一个命令的起始序号,查找缓存项时传入的seq是当前报文的起始序号。仔细想,这是合理的,因为这样可以保证如果查找时序号如果命中,就可以说明当前报文就是一个命令行的开始,这样就可以进行前面的关键字搜索等后续处理了。
5. help()回调
实现FTP协议连接跟踪功能的核心是help(),从连接跟踪子系统之helper
中有看到,当非期望连接的skb在建立新的连接时,会根据Reply方向的tuple查找系统中已注册的helper,如果找到就会将该helper信息记录到连接跟踪信息块中,然后就会在help钩子处,调用helper中的help()回调。
static int help(struct sk_buff *skb, unsigned int protoff, struct nf_conn *ct,
enum ip_conntrack_info ctinfo)
{
unsigned int dataoff, datalen;
struct tcphdr _tcph, *th;
char *fb_ptr;
int ret;
u32 seq;
int dir = CTINFO2DIR(ctinfo);
unsigned int matchlen, matchoff;
//上面介绍的序号缓存信息
struct nf_ct_ftp_master *ct_ftp_info = &nfct_help(ct)->help.ct_ftp_info;
struct nf_conntrack_expect *exp;
union nf_inet_addr *daddr;
struct nf_conntrack_man cmd = {};
unsigned int i;
int found = 0, ends_in_nl;
typeof(nf_nat_ftp_hook) nf_nat_ftp;
//我们关注的控制命令只可能出现在ESTABLISHTED连接上
if (ctinfo != IP_CT_ESTABLISHED && ctinfo != IP_CT_ESTABLISHED+IP_CT_IS_REPLY)
return NF_ACCEPT;
//protoff是tcp协议距离skb->data的偏移,将TCP首部拷贝到_tcph中,
//并且th指向_tcph,既数据包中tcp的首部
th = skb_header_pointer(skb, protoff, sizeof(_tcph), &_tcph);
if (th == NULL)
return NF_ACCEPT;
//dataoff为应用层数据距离skb->data的偏移量
dataoff = protoff + th->doff * 4;
if (dataoff >= skb->len)
return NF_ACCEPT;
//datalen就是skb中剥除所有协议首部后剩余应用层数据的长度
datalen = skb->len - dataoff;
spin_lock_bh(&nf_ftp_lock);
//将FTP应用层数据拷贝到ftp_buffer中,ftp_ptr指向ftp_buffer
fb_ptr = skb_header_pointer(skb, dataoff, datalen, ftp_buffer);
BUG_ON(fb_ptr == NULL);
//ends_in_nl标志当前报文的最后一个字节是否以'\n'结尾,一般情况下都应如此
ends_in_nl = (fb_ptr[datalen - 1] == '\n');
//seq是报文的最后一个字节的序号+1
seq = ntohl(th->seq) + datalen;
//查找序号缓存项,如果没有找到,则尝试更新缓存信息后结束,
//因为这个数据包的第一个字节不是一个命令行的开头
if (!find_nl_seq(ntohl(th->seq), ct_ftp_info, dir)) {
ret = NF_ACCEPT;
goto out_update_nl;
}
//从连接跟踪信息块中找到skb传输方向上的地址信息
cmd.l3num = ct->tuplehash[dir].tuple.src.l3num;
memcpy(cmd.u3.all, &ct->tuplehash[dir].tuple.src.u3.all, sizeof(cmd.u3.all));
//搜索数据包的内容,从中寻找是否有特定的关键字(PORT和PASV信息)
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) {
ret = NF_DROP;
goto out;
} else if (found == 0) {
//没有包含关注的关键字,尝试更新序列号缓存信息后结束
ret = NF_ACCEPT;
goto out_update_nl;
}
//找到了我们关心的信息,说明即将要建立一个数据连接(即期望连接),
//所以分配一个期望并对其进行初始化,然后将其加入期望连接表中,这样
//等期望连接的数据包到达时就可以匹配到了
exp = nf_ct_expect_alloc(ct);
if (exp == NULL) {
ret = NF_DROP;
goto out;
}
//计算期望连接的tuple信息
daddr = &ct->tuplehash[!dir].tuple.dst.u3;
/* Update the ftp info */
if ((cmd.l3num == ct->tuplehash[dir].tuple.src.l3num) &&
memcmp(&cmd.u3.all, &ct->tuplehash[dir].tuple.src.u3.all, sizeof(cmd.u3.all))) {
/* Thanks to Cristiano Lincoln Mattos
<lincoln@cesar.org.br> 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;
}
//初始化期望连接结构
nf_ct_expect_init(exp, 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. */
//如果NAT会处理该数据包,则交给NAT处理,否则将其加入到期望连接跟踪表中
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, matchoff, matchlen, exp);
else {
/* Can't expect this? Best to drop packet now. */
if (nf_ct_expect_related(exp) != 0)
ret = NF_DROP;
else
ret = NF_ACCEPT;
}
out_put_expect:
nf_ct_expect_put(exp);
out_update_nl:
//只有当前数据包是以'\n'结尾时才更新序号缓存数组
if (ends_in_nl)
update_nl_seq(seq, ct_ftp_info, dir, skb);
out:
spin_unlock_bh(&nf_ftp_lock);
return ret;
}