TCP一定要是TCP嗎? 它可能是個trick!
豪雨滂沱,作文一篇,當笑話看看就好。
前幾天寫了兩篇與本文相關的隨筆:
https://blog.csdn.net/dog250/article/details/106881244
https://blog.csdn.net/dog250/article/details/106955747
想讓數據包按照TCP的樣子被傳輸和被處理,非常複雜。
然而,TCP是一個端到端有狀態協議,這意味着中間轉發設備沒有能力處理TCP的細節,如果在端系統也不需要處理TCP細節的時候, 一個流只需要讓中間轉發設備看起來像TCP就行了!
什麼時候端系統不需要處理TCP細節,以及爲什麼要讓一個本不是TCP的流看起來像TCP呢?
我們要明白中間轉發設備的一些行爲。中間轉發設備會針對TCP做出一些動作,比如:
- 流量高峯期對除TCP之外的其它協議進行丟包限速(過度對TCP丟包會引發端系統盲CC算法的過激反應,加劇網絡擁塞)。
- 按照TCP四元組對單流進行限速。
- 按照TCP四元組對單流進行整形。
- …
這些動作基於以下的事實:
- 行爲最終會作用於端到端系統,端到端系統會正確處理TCP細節,使網絡收斂。
而我們可以通過忽略端到端處理,從這些動作中得到收益,而不是限制:
- 給UDP封裝一個TCP頭使其在高峯期免於被限速。
- 爲同一個流封裝不同四元組的TCP頭而免於被限速。
- 爲同一個流封裝不同四元組的TCP頭而免於被整形。
本文給出一個 將任意流量僞裝成TCP流量 的POC。我稱爲 dummy TCP隧道。
本來我是想用Netfilter做的,但是我厭倦了寫內核模塊,本來我是想用eBPF做的,但是我覺得有點譁衆取寵,於是,我用scapy+tun來做,因爲簡單!
下面是dummy TCP隧道python代碼(僅側重於數據面):
#!/usr/bin/python
# dtun.py
from scapy.all import *
import socket
import fcntl
IFF_TUN = 0x0001
IFF_NO_PI = 0x1000
TUNSETIFF = 0x400454ca
src = '0.0.0.0'
peer = '0.0.0.0'
sport = 0
dport = 0
seq = 12345 # 本應random,但爲了簡單,算了
ack_seq = 12345 # 本應random,但爲了簡單,算了
# 從網絡接收dummy TCP隧道封裝好的報文,直接解除TCP頭,送到tun網卡
def net2tun(packet):
global ack_seq, src, peer, sport, dport
flags = packet[TCP].flags
# 處理模擬SYNACK的if分支
if flags & 0x02 != 0 and flags & 0x10 == 0:
ip = IP(src = src, dst = peer)
# 收到SYN包,獲取ack,直接回復SYNACK
tcp = ip/TCP(sport = sport, dport = dport, flags = "SA", seq = seq, ack = ack_seq)
ack_seq = packet[TCP].seq
send(tcp)
# 處理正常通信的if分支
elif flags & 0x02 == 0:
os.write(tun.fileno(), str(packet[TCP].payload))
ack_seq = packet[TCP].seq + len(packet[TCP].payload)
def recv():
filter = "src " + peer + " and tcp and tcp port 12345"
sniff(filter = filter,iface="enp0s8", prn = net2tun)
# 模擬TCP握手,其作用主要是協商序列號以及讓中間狀態設備初始化流表。
def handshake(src, dst):
global seq, ack_seq, sport, dport
ip = IP(src = src, dst = dst)
tcp = ip/TCP(sport = sport, dport = dport, flags = "S", seq = seq)
pkt = sr1(tcp, iface = "enp0s8")
ack_seq = pkt[TCP].ack
print pkt[TCP].seq
print pkt[TCP].ack
# 直接封裝TCP頭後,發到網絡
def tun2net(src, dst, payload):
global seq, ack_seq
ip = IP(src = src, dst = dst)
tcp = ip/TCP(sport = sport, dport = dport, flags = "A", seq = seq, ack = ack_seq)/payload
seq += len(payload)
send(tcp)
if __name__ == "__main__":
global peer
tunip = sys.argv[1]
peer = sys.argv[2]
src = sys.argv[3]
syn = sys.argv[4]
dport = int("12345")
sport = int("12345")
tun = open('/dev/net/tun', 'r+w')
iface = "tun0"
# 打開並設置tun設備
ifr = struct.pack('16sH', iface, IFF_TUN|IFF_NO_PI)
fcntl.ioctl(tun, TUNSETIFF, ifr)
ip = socket.inet_aton(tunip)
sockfd = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0);
ifr = struct.pack('16sH2s4s8s', iface, socket.AF_INET, '\x00'*2, ip, '\x00'*8)
fcntl.ioctl(sockfd, 0x8916, ifr)
ifr = struct.pack('16sH2s4s8s', iface, socket.AF_INET, '\x00'*2, socket.inet_aton("255.255.255.0"), '\x00' * 8)
fcntl.ioctl(sockfd, 0x891C, ifr)
ifr = struct.pack('16sH', iface, IFF_UP)
fcntl.ioctl(sockfd, SIOCSIFFLAGS, ifr)
try:
threading.Thread(target = recv).start()
if syn == "1":
handshake(src, peer)
# 從tun設備接收裸數據包,封裝TCP頭,直接發到dummy TCP隧道
while True:
packet = os.read(tun.fileno(), 2048)
tun2net(src, peer, packet)
except KeyboardInterrupt:
os._exit(0)
來看下效果。
準備兩臺虛擬機hostA和hostB,直連,配置如下:
# hostA
enp0s8:192.168.56.110/24
# hostB
enp0s8:192.168.56.101/24
下面在hostA和hostB上分別啓動上述腳本:
# hostB (先啓動B,因爲它等待接收SYN)
root@zhaoya-VirtualBox:/home/zhaoya/tun# ./dtun.py 1.1.1.2 192.168.56.110 192.168.56.101 0
# hostA
[root@localhost tun]# ./dtun.py 1.1.1.1 192.168.56.101 192.168.56.110 1
此時兩臺機器的隧道就已經建立好了,由於並沒有真正的TCP連接,爲了避免端到端的RST,需要在hostA和hostB上額外添加兩條過濾規則:
iptables -t mangle -A POSTROUTING -p tcp -m tcp --tcp-flags RST RST -j DROP
OK,現在可以實驗了。
在hostA上ping hostB的tun網卡的地址:
[root@localhost ~]# ping 1.1.1.2 -c 2
PING 1.1.1.2 (1.1.1.2) 56(84) bytes of data.
64 bytes from 1.1.1.2: icmp_seq=1 ttl=64 time=42.1 ms
64 bytes from 1.1.1.2: icmp_seq=2 ttl=64 time=50.2 ms
--- 1.1.1.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 42.196/46.243/50.290/4.047 ms
tcpdump抓包如下:
[root@localhost tun]# tcpdump -i enp0s8 tcp port 12345 -n
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on enp0s8, link-type EN10MB (Ethernet), capture size 262144 bytes
# 看起來像是TCP的握手包
06:24:40.483759 IP 192.168.56.110.12345 > 192.168.56.101.12345: Flags [S], seq 12345, win 8192, length 0
06:24:40.522107 IP 192.168.56.101.12345 > 192.168.56.110.12345: Flags [S.], seq 12889, ack 12345, win 8192, length 0
# 哦,缺失了3rd ACK,嗯,有待改進...
06:24:46.525279 IP 192.168.56.101.12345 > 192.168.56.110.12345: Flags [.], seq 0:48, ack 1, win 8192, length 48
06:24:46.989491 IP 192.168.56.110.12345 > 192.168.56.101.12345: Flags [.], seq 1:85, ack 48, win 8192, length 84
06:24:47.027375 IP 192.168.56.101.12345 > 192.168.56.110.12345: Flags [.], seq 48:132, ack 85, win 8192, length 84
06:24:47.989780 IP 192.168.56.110.12345 > 192.168.56.101.12345: Flags [.], seq 85:169, ack 132, win 8192, length 84
06:24:48.034897 IP 192.168.56.101.12345 > 192.168.56.110.12345: Flags [.], seq 132:216, ack 169, win 8192, length 84
TCP流,搞得跟真事兒一樣…
其實我們沒有建立任何TCP連接,都是假的。
中間設備看到這種包之後,會認爲這來自於一個真實的TCP連接,當它想對這個流進行限制的時候,它會實施排隊,丟包等動作, 以期待端到端的TCP cc算法可以通過測量RTT,探測丟包等感知到這種抑制動作,從而作出紳士般的避讓行爲。 然而,根本就沒有端到端的TCP處理,都是裝出來的。
現在看看可以進一步做點什麼。
如果我用10個不同的源地址建立10條這樣的dummy TCP隧道,那麼隧道的過境包每次只需要從這10條隧道里隨便挑選一條就可以了。理論上,如果中間設備對TCP單流進行限速的話,這種方案可以將吞吐提高10倍!
好吧,你可能會說中間設備可以對一個IP段進行限速,那也不是沒辦法,只要隧道兩端有一端是喇叭大張口,就可以用那一端來模擬SYN來構建多條隧道:
多年以前,我那個時候玩OpenU++Q–N,我一直嚷嚷着TCP隧道會造成連接崩潰,我一直嚷嚷着一定要用UDP隧道,嗯,那時其實就應該像本文這麼玩,之所以沒有想到這等技巧,或許是因爲金融網是一個準內網,QoS各方面都有所保證,而且又不會過多對UDP有所限制,如果當時的生產環境換到公共Internet上,此等trick估計早就被我偷偷摸摸上線了。
左右手互搏,道高一尺,魔高一丈,自己打臉,然後抓着經理的領帶把經理推進溝渠。
浙江溫州皮鞋溼,下雨進水不會胖。