作者: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 地址,也可以正常与互联网进行通信。
NAT 可以分为两大类:
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 在网络拓扑图上像圆锥,我们在下文进行深入了解。
目前常见的都是 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 的网络拓扑图,和对称型进行比较
按照上文描述,我们可以很好的理解 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 响应后就会删除映射关系,当然超时后也会自动失效。具体的实现还和各个厂商有关系。
探测 NAT 的类型是 NAT 穿透中的第一步,我们可以通过客户端和两个服务器端的交互来探测 NAT 的工作类型,以下是来源于 STUN 协议(https://tools.ietf.org/html/rfc3489) 的探测流程图,在其上添加了一些标注:
如图所示,我们可以整理出:
Open Internet
中。Symmetric NAT
中。Full-Cone NAT
中。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 穿透的流程如下:
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
PC2(192.168.2.1:8888)
同样发送 UDP 请求到 Server,Router2 添加一条映射 192.168.2.1:8888 <=> 5.6.7.8:20000 <=> 9.9.9.9:1024
5.6.7.8:20000
) 发送给 PC11.2.3.4:10000
) 发送给 PC2192.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 丢弃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)
按照该步骤,我们编写了 UDP 穿透的示例脚本:
#!/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()
#!/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()
在实践了以上步骤后,我们对 锥型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 的实现有:
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
穿透一样进行端口猜测。
本文从 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 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1561/