作者:0x7F@知道创宇404实验室
时间:2021年4月12日
一直对 P2P 和 NAT 穿透的知识比较感兴趣,正巧最近看到一篇不需要第三方服务器实现 NAT 穿透的项目(
https://github.com/samyk/pwnat
),经过学习研究后发现这个项目也有很多局限性;借此机会,学习了下 NAT 原理和 UDP 穿透的实现。
本文详细介绍了 NAT 的原理,并以此作为基础介绍了 UDP 穿透的原理和实现。
NAT(Network Address Translation)全称为「网络地址转换」,用于为了解决 IPv4 地址短缺的问题。NAT 可以将私有地址转换为公有 IP 地址,以便多台内网主机只需要一个公有 IP 地址,也可以正常与互联网进行通信。
1.基础NAT:网络地址转换(Network Address Translation)
2.NAPT:网络地址端口转换(Network Address Port Translation)
1.基础NAT
基础NAT 仅对网络地址进行转换,要求对每一个当前连接都要对应一个公网IP地址,所以需要有一个公网 ip 池;基础NAT 内部有一张 NAT 表以记录对应关系,如下
基础NAT又分为:静态NAT 和 动态NAT,其区别在于:静态要求内网ip和外网ip存在固定的一一对应关系,而动态不存在这种固定的对应关系。
2.NAPT
NAPT 需要对网络地址和端口进行转换,这种类型允许多台主机共用一个公网 ip 地址,NAPT 内部同样有一张 NAT 表,并标注了端口,以记录对应关系,如下:
NAPT又分为:锥型NAT 和 对称型NAT,其对于映射关系有不同的权限限制,锥型NAT 在网络拓扑图上像圆锥,我们在下文进行深入了解。
目前常见的都是 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.受限锥型NAT]
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 的网络拓扑图,和对称型进行比较
按照上文描述,我们可以很好的理解 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
:
[7.PacketTracer模拟环境下的NAT表]
当然还有一些特殊的协议,比如 FTP 协议,当请求一个文件传输时,主机在发送请求的同时也通知对方自己想要在哪个端口接受数据,NAT 必须进行特殊处理才能支持这种通信机制。
在 NAT 中有一个应用网关层(Application Layer Gateway, ALG),以此来统一处理这些协议问题。
4.映射老化时间
建立了 NAT 映射关系后,这些映射什么时候失效呢?
不同协议有不同的失效机制,比如 TCP 的通信在收到 RST 过后就会删除映射关系,或 TCP 在某个超时时间后也会自动失效,而 ICMP 在收到 ICMP 响应后就会删除映射关系,当然超时后也会自动失效。具体的实现还和各个厂商有关系。
探测 NAT 的类型是 NAT 穿透中的第一步,我们可以通过客户端和两个服务器端的交互来探测 NAT 的工作类型,以下是来源于 STUN 协议(
https://tools.ietf.org/html/rfc3489
) 的探测流程图,在其上添加了一些标注:
1. 客户端使用同一个内网地址端口分别向主服务器和协助服务器(不同IP)发起 UDP 请求,主服务器获取到客户端出口地址端口后,返回给客户端,客户端对比自己本地地址和出口地址是否一致,如果是则表示处于 Open Internet 中。
2.协助服务器同样也获取到了客户端出口地址端口,将该信息转发给主服务器,同样将该信息返回给客户端,客户端对比两个出口地址端口(1.主服务器返回的,2.协助服务器返回的)是否一致,如果是则表示处于 Symmetric NAT 中。
3.客户端再使用不同的内网地址端口分别向主服务器和协助服务器(不同IP)发起 UDP 请求,主服务器和协助服务器都可以获得一个新的客户端出口地址端口,协助服务器将客户端出口地址端口转发给主服务器。
4.主服务器向协助服务器获取到的客户端出口地址端口发送 UDP 数据,客户端如果可以收到数据,则表示处于 Full-Cone NAT 中。
5.主服务器使用另一个端口,向主服务器获取到的客户端出口地址端口发送 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 类型。
在 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 穿透的流程如下:
1.
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:1024
2.
PC2(192.168.2.1:8888)
同样发送 UDP 请求到 Server,Router2 添加一条映射
192.168.2.1:8888 <=> 5.6.7.8:20000 <=> 9.9.9.9:1024
3.Server 将 PC2 的出口地址端口(
5.6.7.8:20000
) 发送给 PC1
4.Server 将 PC1 的出口地址端口(
1.2.3.4:10000
) 发送给 PC2
5.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 丢弃
6.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 有一条关于