netfilter内核子系统和conntrack 经过作者的各种尝试,要实现一个高效的原生Full Cone NAT,在应用层是无法实现的了(就算通过程序抓包后实时添加iptables规则,或从用户态刷新conntrack,也非常低效),因此,我们只能深入linux内核来进一步研究。 HACK THE KERNEL! 上文已有提到,在linux中负责处理nat的内核子系统是netfilter,而netfilter对应的前端有iptables和nftables。 为对nat的状态进行跟踪,netfilter引入了conntrack。conntrack用来记录每一个连接(TCP/UDP/ICMP/DCCP等会话)的双向地址和端口(或id, code等会话标识符)信息。netfilter对每一个conntrack定义了一个 struct nf_conn 结构:
1
2
3
4
5
6
7
| // include/net/netfilter/nf_conntrack.h
struct nf_conn {
struct nf_conntrack ct_general;
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
unsigned long status;
// ...
};
|
其中,tuplehash 数组是我们需要关心的。该数组通常有2个成员:tuplehash[IP_CT_DIR_ORIGINAL] 和 tuplehash[IP_CT_DIR_REPLY]。要了解它们分别代表什么,先来看一下 struct nf_conntrack_tuple_hash 里的 struct nf_conntrack_tuple tuple 成员。这个 tuple 结构定义如下(为便于理解,一些不在同一源文件定义的struct和union被整合进来):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| // include/net/netfilter/nf_conntrack_tuple.h
struct nf_conntrack_tuple {
struct nf_conntrack_man { //tuple.src 指示了源地址和源会话标识符
union nf_inet_addr u3; //源IP地址(ipv4或ipv6)
union nf_conntrack_man_proto {
// 这个union指示了源会话标识符,可以是一个TCP/UDP端口,或ICMP id,dccp port等。
// 此处省略除TCP、UDP外的其它协议
__be16 all;
struct {
__be16 port;
} tcp;
struct {
__be16 port;
} udp;
// ...
} u;
u_int16_t l3num;
} src;
struct { //tuple.dst 指示了目的地址和目的会话标识符
union nf_inet_addr u3; //目的IP地址
union { //和tuple.src一样,这个union指示了目的会话标识符
__be16 all;
struct {
__be16 port;
} tcp;
struct {
__be16 port;
} udp;
// ...
} u;
u_int8_t protonum; //协议号
u_int8_t dir;
} dst;
};
|
不难看出,struct nf_conntrack_tuple 结构是一个五元组,其包括了:源地址/源端口/目的地址/目的端口/协议号。而在刚才的 tuplehash 数组中,tuplehash[IP_CT_DIR_ORIGINAL] 和 tuplehash[IP_CT_DIR_REPLY] 分别代表 “源五元组” 和 “期望收到的应答五元组”。在这里,你可以分别把它们暂时简单的理解为出站和入站的五元组,一个conntrack由一对五元组组成。 在 tuplehash[IP_CT_DIR_ORIGINAL] 中,src 指的是内网主机(或本机)的源地址,dst 指的是TCP/UDP流量的远端目的地址;而在 tuplehash[IP_CT_DIR_REPLY] 中,src 是TCP/UDP的远端主机的地址,dst 是本机的地址。 在内网主机经过NAT网关一次普通的SNAT后,一个conntrack存放的一对五元组tuple应包含如下信息: - tuplehash[IP_CT_DIR_ORIGINAL].tuple : 相对于内网主机到远端主机的方向,如 src192.168.1.3:5000 -> dst114.114.114.114:53
- tuplehash[IP_CT_DIR_REPLY].tuple :相对于远端主机到本机的方向,如 src114.114.114.114:53 -> dst120.239.65.166:38720 (其中120.239.65.166是本机即NAT网关的外网IP,38720是SNAT映射后得到的外网端口)
参考nf_nat,我们可以概括出一次SNAT由如下步骤完成: - 来自内网主机的数据包流经nat表的POSTROUTING链并触发SNAT/MASQUERADE hook,nf_nat 进行源IP转换和端口映射,并调用 nf_nat_setup_info() 对当前conntrack(我们假设这个conntrack命名为conn1)的tuplehash信息进行修改,将转换后的源外网IP和映射后的外网端口写到 tuplehash[IP_CT_DIR_REPLY].tuple.dst 中。
- 当有新的数据包流入时,nf_conntrack_core 通过在 resolve_normal_ct() 中根据流入的数据包得到对应的一个tuple,因为这个tuple的信息与 conn1 的 tuplehash[IP_CT_DIR_REPLY].tuple 一致,调用 nf_conntrack_find_get() 即获取到先前的 conn1。
- 再根据 conn1 的 tuplehash[IP_CT_DIR_ORIGINAL].tuple 信息,将流入的数据包的目的IP和端口还原成内网主机的IP的端口。
* 以上过程仅从代码层面推测,未经过严格debug验证,如实际过程有误欢迎提出。 编写一个xt_FULLCONENAT内核模块要实现一个原生的Full Cone NAT功能,至少需要编写一个netfilter内核模块,外加一个或多个前端模块(前端模块是在用户态的一个.so文件,不涉及内核态API)。 注意,我们现在只关注RFC3489,即只实现UDP的Full Cone。其它协议的内网穿透相对而言不太常用,我们暂且搁置。 在此列出以下两种实现方案: - 实现应用于mangle表的hook,对每个流出流入的包进行地址和端口信息修改。相当于手动实现一遍DNAT和SNAT。这种方案可以绕过conntrack对五元组的严格验证,但实现复杂,而且性能较差。
- 参考现有的NAT模块(如NETMAP),实现应用于nat表的hook。这种方案采用netfilter现有的nat方法来实现地址转换。但是受限于conntrack的五元组约束,除了需要依赖conntrack模块内置的映射规则来进行标准的nat,还需要另行在我们的模块中维护一张映射表。
作者采用了第二种方案。大体的设想是:在nat表的POSTROUTING链和PREROUTING链各添加一个FULLCONENAT规则,对于POSTROUTING的操作,FULLCONENAT与MASQUERADE表现无异,但需要将“端口映射记录”暂存到本模块维护的映射表中;而在PREROUTING链,FULLCONENAT对于每一个未被conntrack记录的入站连接,根据本模块维护的端口映射表,按需DNAT至相应的内网主机。
|