誰動了你的五元組-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都必須被調用的原因。

這種問題在很多人看來根本就不是什麼問題,當我因為去剖析某種細節而感到愉快的時候,在他們看來卻是不過如此,然而,這種不過如此的問題卻偶爾真的成了那些解決常規問題不屑於這種細枝末節的人們路上的障礙,他們不知道問題發生的根因,也不屑於去深究這種實現上細節,於是很多人還是最終找到了我,同時我也感受到了愉快。

對,這是手藝人的自嗨,和工程無關。


浙江温州皮鞋濕,下雨進水不會胖。