谁动了你的五元组-Linux Netfilter NAT之nf_nat_alloc_null_binding

语言: CN / TW / HK

Linux的Netfilter NAT实现中,为什么会有一个nf_nat_alloc_null_binding(在低版本内核比如2.6,它叫alloc_null_binding)调用?

该函数是在一条流没有命中任何NAT规则的时候调用了,其内部实现和对待命中了NAT规则的流的方式几乎一样,唯一的约束是,它只能修改一条流的源端口。

问题是,既然没有命中任何规则,为什么要修改流的源端口呢?

我早就想好好解释一下这个问题了,但我一直觉得有人已经解释过了,所以就没有写任何东西。周二下午家里有事正好休假,等待期间我用手机搜了一下这个话题,几乎没有发现正确的答案,很令人失望。正好最近也碰到一个问题,那我就写点东西吧。

碰到一个问题,一条流没有命中任何NAT规则,结果它的源端口却被改变了,这是谁做的呢?答案当然是, 这是nf_nat_alloc_null_binding做的! 细节就是:

  • 如果有一条流已经占据了未命中流的tuple,那么未命中流的tuple就会被修改,具体来讲就是修改它的源端口。

如果读过nf_conntrack的实现,可能有人不信,怼曰:

  • 如果要修改一条流的源端口,那必然是和既有流的tuple冲突了啊。
  • 如果和既有的tuple冲突,那必然在该流进入conntrack的时候就会匹配到啊。
  • 匹配到的tuple肯定已经被confirm了啊。
  • confirm的流肯定已经经过了NAT HOOK了啊。
  • 经过了NAT HOOK就不会再check tuple了啊。

很有道理,然而,事实上数据包是无锁经过conntrack逻辑的,期间任何事情都会发生。

多说无益,设计一个实验是必要的,如果我能展示出这个场景,非要抬杠还有什么意义呢。

我在实验中构造两条流,其中一条命中NAT规则,然后占据一个tuple,然后第二条流虽然未命中任何NAT规则,却不得不修改其源端口以适应tuple的唯一性。两条流被打上了不同的mark,因此很容易识别。

首先,我们看发起这两条流的代码:

#!/usr/bin/python3

# client.py 正常的流,它绑定192.168.56.101:12345去连接192.168.56.102:80

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 标识正常流
sock.setsockopt(socket.SOL_SOCKET, socket.SO_MARK, 200)
sock.bind(('192.168.56.101', 12345))

server_address = ('192.168.56.102', 80)
sock.connect(server_address)
sock.close();

我们来看一下抢占client.py生成的流tuple的小偷流:

#!/usr/bin/python3

# thief.py 该流发起后通过NAT规则抢占了client.py发起的正常流的tuple

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 用于NAT规则识别该流
sock.setsockopt(socket.SOL_SOCKET, socket.SO_MARK, 100)
sock.bind(('192.168.56.101', 54321))

server_address = ('192.168.56.102', 80)
sock.connect(server_address)
sock.close();

为了抢占tuple得手,我们配置下面的NAT规则:

iptables -t nat -A POSTROUTING -p tcp -m mark --mark 0x64 -j SNAT --to-source 192.168.56.101:12345

并且save mark以区别不同的流:

iptables -t mangle -A POSTROUTING -j CONNMARK --save-mark

好了,现在,先运行thief.py再运行client.py,你就会发现client.py发起的流的源端口已经被改变:

[email protected]:~/test# ./thief.py && ./client.py && conntrack -L
tcp      6 119 TIME_WAIT src=192.168.56.101 dst=192.168.56.102 sport=54321 dport=80 src=192.168.56.102 dst=192.168.56.101 sport=80 dport=12345 [ASSURED] mark=100 use=1
tcp      6 119 TIME_WAIT src=192.168.56.101 dst=192.168.56.102 sport=12345 dport=80 src=192.168.56.102 dst=192.168.56.101 sport=80 dport=50487 [ASSURED] mark=200 use=1
conntrack v1.4.5 (conntrack-tools): 2 flow entries have been shown.

可以看到,mark 200的流源端口已经被修改为50487了。

不过遗憾的是,这并不能让人满意,毕竟你先运行了thief.py,根据NAT规则,thief.py发起的流已经根据规则被强行转换了源端口,后来的client.py发起的流不得不重新选择一个端口,不然五元组就不唯一了,这并没有问题,在默默修改源端口和直接不通之间,显然NAT的实现选择了前者,无论如何,这都是nf_nat_alloc_null_binding的 功劳。

如果先运行client.py后运行thief.py,将会造成后者不通,因为当它的源端口被NAT规则强制修改成了12345时,将会和已经创建的client.py流tuple冲突。

我们需要另一种场景,先让client.py发包创建conntrack,但在其conntrack尚未confirm的时候,让thief.py发包通过NAT规则,抢占client.py流即将使用的tuple,迫使它不得不重新选择一个:

  • 我们就让client.py流进入nf_nat_alloc_null_binding的时候让thief.py流抢占其tuple。

client.py流显然不会匹配到NAT规则,它显然会执行nf_nat_alloc_null_binding,我们就在这个函数将要执行的时候做点动作,下面是stap脚本:

#!/usr/bin/stap -g

global launch

probe begin
{
   
   
	launch = 1
}

probe module("nf_nat").function("__nf_nat_alloc_null_binding")
{
   
   
	if (launch == 1 && $manip == 1) {
   
   
		launch = 0;
		system("/root/test/thief.py");
		mdelay(50);
	}
}

probe module("nf_conntrack").function("nf_conntrack_tuple_taken").return
{
   
   
	printf("nf_conntrack_tuple_taken   ret:%d\n", $return);
}

将这个thief.stp脚本跑起来,然后执行client.py:

[email protected]:~/test# ./client.py && conntrack -L
tcp      6 119 TIME_WAIT src=192.168.56.101 dst=192.168.56.102 sport=54321 dport=80 src=192.168.56.102 dst=192.168.56.101 sport=80 dport=12345 [ASSURED] mark=100 use=1
tcp      6 119 TIME_WAIT src=192.168.56.101 dst=192.168.56.102 sport=12345 dport=80 src=192.168.56.102 dst=192.168.56.101 sport=80 dport=30049 [ASSURED] mark=200 use=1
conntrack v1.4.5 (conntrack-tools): 2 flow entries have been shown.

一样的结果。

我想说的是,在一个流的conntrack被创建,经过nf_nat_alloc_null_binding,最终被confirm,这条路径非常长,其中很容易发生任意conntrack的插入和删除,如下的脚本展示了这一切,即便我们撤掉thief.py脚本,在clilent.py流到达nf_nat_alloc_null_binding的时候,插入一条抢占其tuple的conntrack项,也可以完成这个实验:

#!/usr/bin/stap -g

%{
   
   
#include <net/netfilter/nf_conntrack.h>
%}

global launch

probe begin
{
   
   
	launch = 1
}

probe module("nf_nat").function("__nf_nat_alloc_null_binding")
{
   
   
	if (launch == 1 && $manip == 1) {
   
   
		launch = 0;
		// 直接插入一条reply tuple和client.py流冲突的conntrack项,天知道这是怎么来的。
		system("conntrack -I --protonum 6 --timeout 100 --reply-src 192.168.56.102 --reply-dst 192.168.56.101 --state SYN_SENT --reply-port-dst 12345 --reply-port-src 80 --src 1.1.1.1 --dst 192.168.56.102");
		mdelay(50);
	}
}

probe module("nf_conntrack").function("nf_conntrack_tuple_taken").return
{
   
   
	printf("nf_conntrack_tuple_taken   ret:%d\n", $return);
}

%{
   
   
struct nf_conn *thief = NULL;
%}

function alertit(stp_ct:long)
%{
   
   
	struct nf_conn *ct = (struct nf_conn *)STAP_ARG_stp_ct;
	struct nf_conntrack_tuple *tuple;
	unsigned short port;

	tuple = &ct->tuplehash[IP_CT_DIR_REPLY].tuple;
	port = ntohs((unsigned short)tuple->dst.u.all);
	if (port == 80 && thief == NULL) {
   
   
		STAP_PRINTF("The thief coming!\n");
		thief = ct;
	}
%}

probe module("nf_conntrack").function("nf_conntrack_hash_check_insert")
{
   
   
	alertit($ct);
}

function run_away(stp_tuple:long, stp_ct:long)
%{
   
   
	if (thief) {
   
   
		thief = NULL;
		STAP_PRINTF("The thief ran away...\n");
	}
%}

probe module("nf_conntrack").function("nf_conntrack_alter_reply")
{
   
   
	run_away($newreply, $ct);
}

执行该脚本,然后只需要执行client.py即可:

[email protected]:~/test# ./client.py && conntrack -L
tcp      6 119 TIME_WAIT src=192.168.56.101 dst=192.168.56.102 sport=12345 dport=80 src=192.168.56.102 dst=192.168.56.101 sport=80 dport=55424 [ASSURED] mark=200 use=1
tcp      6 99 SYN_SENT src=1.1.1.1 dst=192.168.56.102 sport=0 dport=0 [UNREPLIED] src=192.168.56.102 dst=192.168.56.101 sport=80 dport=12345 mark=0 use=1
conntrack v1.4.5 (conntrack-tools): 2 flow entries have been shown.

顺便放一个服务端的抓包:

17:28:15.522879 IP 192.168.56.101.55424 > 192.168.56.102.80: Flags [S], seq 1953656515, win 64240, options [mss 1460,sackOK,TS val 846034088 ecr 0,nop,wscale 7], length 0

显然,nf_nat_alloc_null_binding更改了client.py流的源端口。

要表达的观点是, NAT逻辑在主机粒度(or 容器?netns?)是全局管理的,即便没有命中NAT规则的流,为了确保tuple唯一性,该流的tuple也可能会被改变,这就是为什么对每一条流,nf_nat_alloc_null_binding都必须被调用的原因。

这种问题在很多人看来根本就不是什么问题,当我因为去剖析某种细节而感到愉快的时候,在他们看来却是不过如此,然而,这种不过如此的问题却偶尔真的成了那些解决常规问题不屑于这种细枝末节的人们路上的障碍,他们不知道问题发生的根因,也不屑于去深究这种实现上细节,于是很多人还是最终找到了我,同时我也感受到了愉快。

对,这是手艺人的自嗨,和工程无关。


浙江温州皮鞋湿,下雨进水不会胖。