作者:0x7F@知道創宇404實驗室
時間:2021年4月12日
0x00 前言
一直對 P2P 和 NAT 穿透的知識比較感興趣,正巧最近看到一篇不需要第三方服務器實現 NAT 穿透的項目(https://github.com/samyk/pwnat),經過學習研究后發現這個項目也有很多局限性;借此機會,學習了下 NAT 原理和 UDP 穿透的實現。
本文詳細介紹了 NAT 的原理,并以此作為基礎介紹了 UDP 穿透的原理和實現。
0x01 NAT基礎和分類
NAT(Network Address Translation)全稱為「網絡地址轉換」,用于為了解決 IPv4 地址短缺的問題。NAT 可以將私有地址轉換為公有 IP 地址,以便多臺內網主機只需要一個公有 IP 地址,也可以正常與互聯網進行通信。
NAT 可以分為兩大類:
- 基礎NAT:網絡地址轉換(Network Address Translation)
- NAPT:網絡地址端口轉換(Network Address Port Translation)
1.基礎NAT
基礎NAT 僅對網絡地址進行轉換,要求對每一個當前連接都要對應一個公網IP地址,所以需要有一個公網 ip 池;基礎NAT 內部有一張 NAT 表以記錄對應關系,如下
| 內網ip | 外網ip |
|---|---|
| 192.168.1.1 | 1.2.3.4 |
| 192.168.1.12 | 1.2.3.5 |
| 192.168.1.123 | 1.2.3.6 |
基礎NAT又分為:靜態NAT 和 動態NAT,其區別在于:靜態要求內網ip和外網ip存在固定的一一對應關系,而動態不存在這種固定的對應關系。
2.NAPT
NAPT 需要對網絡地址和端口進行轉換,這種類型允許多臺主機共用一個公網 ip 地址,NAPT 內部同樣有一張 NAT 表,并標注了端口,以記錄對應關系,如下:
| 內網ip | 外網ip |
|---|---|
| 192.168.1.1:1025 | 1.2.3.4:1025 |
| 192.168.1.1:3333 | 1.2.3.5:10000 |
| 192.168.1.12:7788 | 1.2.3.6:32556 |
NAPT又分為:錐型NAT 和 對稱型NAT,其對于映射關系有不同的權限限制,錐型NAT 在網絡拓撲圖上像圓錐,我們在下文進行深入了解。
0x02 NAPT
目前常見的都是 NAPT 類型,我們常說的 NAT 也是特指 NAPT(我們下文也遵循這個)。如圖1所示,NAPT 可分為四種類型:1.完全錐型,2.受限錐型,3.端口受限錐型,4.對稱型。
1.完全錐型
從同一個內網地址端口(192.168.1.1:7777)發起的請求都由 NAT 轉換成公網地址端口(1.2.3.4:10000),192.168.1.1:7777 可以收到任意外部主機發到 1.2.3.4:10000 的數據報。
2.受限錐型
受限錐型也稱地址受限錐型,在完全錐型的基礎上,對 ip 地址進行了限制。
從同一個內網地址端口(192.168.1.1:7777)發起的請求都由 NAT 轉換成公網地址端口(1.2.3.4:10000),其訪問的服務器為 8.8.8.8:123,只有當 192.168.1.1:7777 向 8.8.8.8:123 發送一個報文后,192.168.1.1:7777 才可以收到 8.8.8.8 發往 1.2.3.4:10000 的報文。
3.端口受限錐型
在受限錐型的基礎上,對端口也進行了限制。
從同一個內網地址端口(192.168.1.1:7777)發起的請求都由 NAT 轉換成公網地址端口(1.2.3.4:10000),其訪問的服務器為 8.8.8.8:123,只有當 192.168.1.1:7777 向 8.8.8.8:123 發送一個報文后,192.168.1.1:7777 才可以收到 8.8.8.8:123 發往 1.2.3.4:10000 的報文。
4.對稱型
在 對稱型NAT 中,只有來自于同一個內網地址端口 、且針對同一目標地址端口的請求才被 NAT 轉換至同一個公網地址端口,否則的話,NAT 將為之分配一個新的公網地址端口。
如:內網地址端口(192.168.1.1:7777)發起請求到 8.8.8.8:123,由 NAT 轉換成公網地址端口(1.2.3.4:10000),隨后內網地址端口(192.168.1.1:7777)又發起請求到 9.9.9.9:456,NAT 將分配新的公網地址端口(1.2.3.4:20000)
可以這么來理解,在 錐型NAT 中:映射關系和目標地址端口無關,而在 對稱型NAT 中則有關。錐型NAT 正因為其于目標地址端口無關,所以網絡拓撲是圓錐型的。
補充下 錐型NAT 的網絡拓撲圖,和對稱型進行比較
0x03 NAT的工作流程
按照上文描述,我們可以很好的理解 NAT 對傳輸層協議(TCP/UDP)的處理,這里舉例來更加深入的理解 NAT 的原理。
1.發送數據
當一個 TCP/UDP 的請求(192.168.1.1:7777 => 8.8.8.8:123)到達 NAT 網關時(1.2.3.4),由 NAT 修改報文的源地址和源端口以及相應的校驗碼,隨后再發往目標:
192.168.1.1:7777 => 1.2.3.4:10000 => 8.8.8.8:123
2.接收數據
隨后 8.8.8.8:123 返回響應數據到 1.2.3.4:10000,NAT 查詢映射表,修改目的地址和目的端口以及相應的校驗碼,再將數據返回給真實的請求方:
8.8.8.8:123 => 1.2.3.4:10000 => 192.168.1.1:7777
3.其他協議
不同協議的工作特性不同,其和 TCP/UDP 協議的處理方式不同;比如 ICMP 協議工作在 IP 層,沒有端口信息,NAT 以 ICMP 報文中的 identifier 作為標記,以此來判斷這個報文是內網哪臺主機發出的。
下圖為 Cisco Packet Tracer 下,在客戶端發起 TCP/UDP/ICMP 請求后的 NAT translations:
當然還有一些特殊的協議,比如 FTP 協議,當請求一個文件傳輸時,主機在發送請求的同時也通知對方自己想要在哪個端口接受數據,NAT 必須進行特殊處理才能支持這種通信機制。
在 NAT 中有一個應用網關層(Application Layer Gateway, ALG),以此來統一處理這些協議問題。
4.映射老化時間
建立了 NAT 映射關系后,這些映射什么時候失效呢?
不同協議有不同的失效機制,比如 TCP 的通信在收到 RST 過后就會刪除映射關系,或 TCP 在某個超時時間后也會自動失效,而 ICMP 在收到 ICMP 響應后就會刪除映射關系,當然超時后也會自動失效。具體的實現還和各個廠商有關系。
0x04 NAT類型探測
探測 NAT 的類型是 NAT 穿透中的第一步,我們可以通過客戶端和兩個服務器端的交互來探測 NAT 的工作類型,以下是來源于 STUN 協議(https://tools.ietf.org/html/rfc3489) 的探測流程圖,在其上添加了一些標注:
如圖所示,我們可以整理出:
- 客戶端使用同一個內網地址端口分別向主服務器和協助服務器(不同IP)發起 UDP 請求,主服務器獲取到客戶端出口地址端口后,返回給客戶端,客戶端對比自己本地地址和出口地址是否一致,如果是則表示處于
Open Internet中。 - 協助服務器同樣也獲取到了客戶端出口地址端口,將該信息轉發給主服務器,同樣將該信息返回給客戶端,客戶端對比兩個出口地址端口(1.主服務器返回的,2.協助服務器返回的)是否一致,如果是則表示處于
Symmetric NAT中。 - 客戶端再使用不同的內網地址端口分別向主服務器和協助服務器(不同IP)發起 UDP 請求,主服務器和協助服務器都可以獲得一個新的客戶端出口地址端口,協助服務器將客戶端出口地址端口轉發給主服務器。
- 主服務器向協助服務器獲取到的客戶端出口地址端口發送 UDP 數據,客戶端如果可以收到數據,則表示處于
Full-Cone NAT中。 - 主服務器使用另一個端口,向主服務器獲取到的客戶端出口地址端口發送 UDP 數據,如果客戶端收到數據,則表示處于
Restricted NAT中,否則處于Restricted-Port NAT中。
按照該步驟,我們編寫了 NAT 類型探測的示例腳本 nat_check.py。
#!/usr/bin/python3
#coding=utf-8
import socket
import sys
def server(addr):
print("[NAT CHECK launch as server on %s]" % str(addr))
# listen UDP service
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(addr)
# [1. check "Open Internet" and "Symmetric NAT"]
# recevie client request and return export ip
data, cconn = sock.recvfrom(1024)
print("server get client info: %s" % str(cconn))
data = "%s:%d" % (cconn[0], cconn[1])
sock.sendto(data.encode("utf-8"), cconn)
# receive assist data about client another export ip
data, aconn = sock.recvfrom(1024)
print("server get client info (from assist): %s" % data.decode("utf-8"))
sock.sendto(data, cconn)
# [2. check "Full-Cone NAT", "Restricted NAT" and "Restricted-Port NAT"]
# recevie client request
data, cconn = sock.recvfrom(1024)
print("server get client info: %s" % str(cconn))
# receive assist data about client another export ip
data, aconn = sock.recvfrom(1024)
print("server get client info (from assist): %s" % data.decode("utf-8"))
# send data to client through (assist get) export ip
print("send packet for testing Full-Cone NAT")
array = data.decode("utf-8").split(":")
caconn = (array[0], int(array[1]))
sock.sendto("TEST FOR FULL-CONE NAT".encode("utf-8"), caconn)
# send data to client through (server get) export ip and with different port
sock.recvfrom(1024) # NEXT flag
print("send packet for testing Restricted NAT")
cdconn = (cconn[0], cconn[1] - 1)
sock.sendto("TEST FOR Restricted NAT".encode("utf-8"), cdconn)
# send data to client through (server get) export ip
sock.recvfrom(1024) # NEXT flag
print("send packet for testing Restricted-Port NAT")
sock.sendto("TEST FOR Restricted-Port NAT".encode("utf-8"), cconn)
# server()
def assist(addr, serv):
print("[NAT CHECK launch as assist on %s && server=%s]" %
(str(addr), str(serv)))
# listen UDP service
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(addr)
# [1. check "Open Internet" and "Symmetric NAT"]
# recevie client request and forward to server
data, conn = sock.recvfrom(1024)
print("assist get client info: %s" % str(conn))
data = "%s:%d" % (conn[0], conn[1])
sock.sendto(data.encode("utf-8"), serv)
# [2. check "Full-Cone NAT", "Restricted NAT" and "Restricted-Port NAT"]
# recevie client request and forward to server
data, conn = sock.recvfrom(1024)
print("assist get client info: %s" % str(conn))
data = "%s:%d" % (conn[0], conn[1])
sock.sendto(data.encode("utf-8"), serv)
# assist()
def client(serv, ast):
print("[NAT CHECK launch as client to server=%s && assist=%s]" %
(str(serv), str(ast)))
# [1. check "Open Internet" and "Symmetric NAT"]
print("send data to server and assist")
# get local address
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(serv)
localaddr = sock.getsockname()
# send data to server and assist with same socket
# and register so that the server can obtain the export ip
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto("register".encode("utf-8"), serv)
sock.sendto("register".encode("utf-8"), ast)
# receive export ip from server
data, conn = sock.recvfrom(1024)
exportaddr = data.decode("utf-8")
print("get export ip: %s, localaddr: %s" % (exportaddr, str(localaddr)))
# check it is "Open Internet"
if exportaddr.split(":")[0] == localaddr[0]:
print("[Open Internet]")
return
# end if
# receive another export ip (assist) from server
data, conn = sock.recvfrom(1024)
anotheraddr = data.decode("utf-8")
print("get export ip(assist): %s, export ip(server): %s" % (anotheraddr, exportaddr))
# check it is "Symmetric NAT"
if exportaddr != anotheraddr:
print("[Symmetric NAT]")
return
# end if
# [2. check "Full-Cone NAT", "Restricted NAT" and "Restricted-Port NAT"]
# send data to server and assist with different socket
# receive the data sent back by the server through the export ip(assist) mapping
ssock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ssock.sendto("register".encode("utf-8"), serv)
asock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
asock.sendto("register".encode("utf-8"), ast)
asock.settimeout(5)
try:
data, conn = asock.recvfrom(1024)
print("[Full-Cone NAT]")
return
except:
pass
# receive the data sent back by the server with different port
ssock.sendto("NEXT".encode("utf-8"), serv)
ssock.settimeout(5)
try:
data, conn = ssock.recvfrom(1024)
print("[Restricted NAT]")
return
except:
pass
# receive the data sent back by the server
ssock.sendto("NEXT".encode("utf-8"), serv)
ssock.settimeout(5)
try:
data, conn = ssock.recvfrom(1024)
print("[Restricted-Port NAT]")
except:
print("[Unknown, something error]")
# client()
def usage():
print("Usage:")
print(" python3 nat_check.py server [ip:port]")
print(" python3 nat_check.py assist [ip:port] [server]")
print(" python3 nat_check.py client [server] [assist]")
# end usage()
if __name__ == "__main__":
if len(sys.argv) < 3:
usage()
exit(0)
# end if
role = sys.argv[1]
array = sys.argv[2].split(":")
address1 = (array[0], int(array[1]))
if role == "assist" or role == "client":
if len(sys.argv) > 3:
array = sys.argv[3].split(":")
address2 = (array[0], int(array[1]))
else:
usage()
exit(0)
# end if
# server/client launch
if role == "server":
server(address1)
elif role == "assist":
assist(address1, address2)
elif role == "client":
client(address1, address2)
else:
usage()
# end main()
實際網絡往往都更加復雜,比如:防火墻、多層 NAT 等原因,會導致無法準確的探測 NAT 類型。
0x05 UDP穿透
在 NAT 的網絡環境下,p2p 網絡通信需要穿透 NAT 才能夠實現。在熟悉 NAT 原理過后,我們就可以很好的理解如何來進行 NAT 穿透了。NAT 穿透的思想在于:如何復用 NAT 中的映射關系?
在 錐型NAT 中,同一個內網地址端口訪問不同的目標只會建立一條映射關系,所以可以復用,而 對稱型NAT 不行。同時,由于 TCP 工作比較復雜,在 NAT 穿透中存在一些局限性,所以在實際場景中 UDP 穿透使用得更廣泛一些,這里我們詳細看看 UDP 穿透的原理和流程。
我們以
Restricted-Port NAT類型作為例子,因為其使用得最為廣泛,同時權限也是最為嚴格的,在理解Restricted-Port NAT類型穿透后,Full-Cone NAT和Restricted NAT就觸類旁通了;
在實際網絡場景下往往都是非常復雜的,比如:防火墻、多層NAT、單側NAT,這里我們選擇了兩端都處于一層 NAT 的場景來進行演示講解,可以讓我們更容易的進行理解。
在我們的演示環境下,有 PC1,Router1,PC2,Router2,Server 五臺設備;公網服務器用于獲取客戶端實際的出口地址端口,UDP 穿透的流程如下:
PC1(192.168.1.1:7777)發送 UDP 請求到Server(9.9.9.9:1024),此時 Server 可以獲取到 PC1 的出口地址端口(也就是 Router1 的出口地址端口)1.2.3.4:10000,同時 Router1 添加一條映射192.168.1.1:7777 <=> 1.2.3.4:10000 <=> 9.9.9.9:1024PC2(192.168.2.1:8888)同樣發送 UDP 請求到 Server,Router2 添加一條映射192.168.2.1:8888 <=> 5.6.7.8:20000 <=> 9.9.9.9:1024- Server 將 PC2 的出口地址端口(
5.6.7.8:20000) 發送給 PC1 - Server 將 PC1 的出口地址端口(
1.2.3.4:10000) 發送給 PC2 - PC1 使用相同的內網地址端口(
192.168.1.1:7777)發送 UDP 請求到 PC2 的出口地址端口(Router2 5.6.7.8:20000),此時 Router1 添加一條映射192.168.1.1:7777 <=> 1.2.3.4:10000 <=> 5.6.7.8:20000,與此同時 Router2 沒有關于1.2.3.4:10000的映射,這個請求將被 Router2 丟棄 - PC2 使用相同的內網地址端口(
192.168.2.1:8888)發送 UDP 請求到 PC1 的出口地址端口(Router1 1.2.3.4:10000),此時 Router2 添加一條映射192.168.2.1:8888 <=> 5.6.7.8:20000 <=> 1.2.3.4:10000,與此同時 Router1 有一條關于5.6.7.8:20000的映射(上一步中添加的),Router1 將報文轉發給PC1(192.168.1.1:7777) - 在 Router1 和 Router2 都有了對方的映射關系,此時 PC1 和 PC2 通過 UDP 穿透建立通信。
按照該步驟,我們編寫了 UDP 穿透的示例腳本:
server.py
#!/usr/bin/python3
#coding=utf-8
import socket
if __name__ == "__main__":
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 1024))
# 1.receive message and get one export ip:port (PC1)
data, conn1 = sock.recvfrom(1024)
addr1 = "%s:%d" % (conn1[0], conn1[1])
print("1.get PC1 export ip:port = %s" % addr1)
# 2.receive message and get another export ip:port (PC2)
data, conn2 = sock.recvfrom(1024)
addr2 = "%s:%d" % (conn2[0], conn2[1])
print("2.get PC2 export ip:port = %s" % addr2)
# 3.send export address of PC1 to PC2
sock.sendto(addr1.encode("utf-8"), conn2)
print("3.send export address of PC1(%s) to PC2(%s)" % (addr1, addr2))
# 4.send export address of PC2 to PC1
sock.sendto(addr2.encode("utf-8"), conn1)
print("4.send export address of PC2(%s) to PC1(%s)" % (addr2, addr1))
print("done")
sock.close()
# end main()
client.py
#!/usr/bin/python3
#coding=utf-8
import random
import socket
import string
import time
if __name__ == "__main__":
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
#serv = ("10.0.1.1", 1024)
serv = ("192.168.50.55", 1024)
print("server =>", serv)
# 1/2.send message to server, server can get our export ip:port
sock.sendto("REGISTER".encode("utf-8"), serv)
print("1/2.send REGISTER message to server")
# 3/4.receive the export address of the peer from the server
data, conn = sock.recvfrom(1024)
array = data.decode("utf-8").split(":")
addr = (array[0], int(array[1]))
print("3/4.receive the export address of the peer, %s" % str(addr))
# 5/6.send KNOCK message to export address of peer
wait = random.randint(2, 5)
print("5/6.send KNOCK message to export address of peer (wait %d s)" % wait)
# in order to stagger the two clients
# so that the router can better create the mapping
time.sleep(wait)
sock.sendto("KNOCK".encode("utf-8"), addr)
name = "".join(random.sample(string.ascii_letters, 8))
print("my name is %s, start to communicate" % name)
# 7.communicate each other
count = 0
while True:
sock.settimeout(5)
try:
data, conn = sock.recvfrom(1024)
print("%s => %s" % (str(conn), data.decode("utf-8")))
except Exception as e:
print(e)
msg = "%s: %d" % (name, count)
count += 1
sock.sendto(msg.encode("utf-8"), conn)
time.sleep(1)
# end while()
sock.close()
# end main()
0x06 拓展
在實踐了以上步驟后,我們對 錐型NAT 下的 UDP 穿透已經有了大致的了解,那我們接著再拓展研究一下「其他場景」。
1.Symmetric NAT可以穿透嗎?
根據 Symmetric NAT 的特性我們可以知道當請求的目標端口地址改變后,會創建新的一對映射關系,我們無法知曉新的映射關系中的端口號;但是在實際場景下,部分路由器對于 Symmetric NAT 的生成算法過于簡單,新的端口可能呈現于:遞增、遞減、跳躍等特征,所以這種條件下,我們可以基于端口猜測,來穿透 Symmetric NAT。
如果兩端的
Symmetric NAT路由器是已知的,我們可以直接逆向分析映射生成算法,即可準確預測端口號。
2.TCP穿透有哪些難點?
TCP 穿透的流程基本和 UDP 穿透一樣。
在標準 socket 規范中,UDP 可以允許多個 socket 綁定到同一個本地端口,但 TCP 不行,在 TCP 中我們不能在同一個端口上既 listen 又進行 connect;不過在部分操作系統下 socket 提供了端口復用選項(SO_REUSEADDR / SO_REUSEPORT) 可以允許 TCP 綁定多個 socket。
在使用端口復用選項后,TCP 就按照 UDP 穿透的流程一樣借助公網服務器然后向對端發送 syn 報文了,其中靠后的 syn 報文就可以正確穿透完成 TCP 握手并建立連接。
但是在實際場景下還有諸多的阻礙,不同廠商的 NAT 實現機制有一些差異,比如某些針對 TCP 的實現有:
- 對端 NAT 在接收到
syn由于沒有找到映射而返回RST報文,而本端 NAT 在接收到RST報文后刪除了此條映射 - 由于主機生成的
syn報文中的seq序號為隨機值,如果 NAT 開啟了syn過濾,對于沒有標記過的seq的報文將直接丟棄 - 等等
3.無第三方服務器的穿透
我們回到文章開頭提到的「不需要第三方服務器實現 NAT 穿透」的方法,文中作者先提出了一種便于理解的網絡拓撲,客戶端位于公網,服務器位于 NAT 下,我們必須預先知道服務器的公網地址;在這個方法下,服務器不斷的向外部未分配的地址發送 ICMP(ECHO REQUEST) 消息,服務器端的 NAT 將保留一條 ICMP 響應的映射,由于目的地址未分配所以沒有設備會響應服務器發出的請求,此時由客戶端發送一條偽裝的 ICMP(DESTINATION UNREACHABLE) 給服務器,服務器可以收到該條消息并從中獲取到客戶端的地址;隨后便可以根據預先約定的端口進行穿透并通信了。
但是如果客戶端也位于 NAT 下呢,由于 NAT 可能會更改源端口信息(不同廠商的NAT實現不同),導致無法向上文一樣使用預設端口進行通信,所以這里需要和 Symmetric NAT 穿透一樣進行端口猜測。
0x07 總結
本文從 NAT 原理出發,詳細介紹了不同 NAT 類型的工作流程和原理,在此基礎上我們深入學習和實現了 錐型NAT 的穿透,并拓展介紹了一些特殊的穿透場景。
NAT 的出現極大的緩解了 IPv4 地址短缺,同時也延遲了 IPv6 的推廣,但 IPv6 是大勢所趨,未來使用 NAT 的場景可能會慢慢減少;但無論怎樣, NAT 的原理和策略都非常值得我們學習,比如:1.NAT 是一個天然的防火墻,2.NAT 其實可以看作是代理服務器,3.NAT 可以作為負載均衡服務器,4.等等。
References:
https://en.wikipedia.org/wiki/Network_address_translation
https://tools.ietf.org/html/rfc1631
https://tools.ietf.org/html/rfc2663
https://tools.ietf.org/html/rfc3022
https://tools.ietf.org/html/rfc7857
https://www.cnblogs.com/GO-NO-1/p/7241556.html
http://xdxd.love/2016/10/18/對稱NAT穿透的一種新方法/
https://tools.ietf.org/html/rfc3489
https://www.cnblogs.com/monjeo/p/9394825.html
http://midcom-p2p.sourceforge.net/draft-ford-midcom-p2p-01.txt
https://www.linkinstar.wiki/2020/04/25/network/nat/
https://bford.info/pub/net/p2pnat/index.html
https://stackoverflow.com/questions/39545461/tcp-based-hole-punching
https://github.com/samyk/pwnat
http://samy.pl/pwnat/pwnat.pdf
http://tutorials.ptnetacad.net/tutorials80.htm
https://help.cisco.yueplus.ink/Simplified%20Chinese/index.htm
https://so.csdn.net/so/search/blog?q=packet&t=blog&p=1&s=0&tm=0&lv=-1&ft=0&l=&u=gengkui9897
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1561/