专栏名称: Seebug漏洞平台
Seebug,原 Sebug 漏洞平台,洞悉漏洞,让你掌握第一手漏洞情报!
目录
相关文章推荐
51好读  ›  专栏  ›  Seebug漏洞平台

NAT 原理以及 UDP 穿透

Seebug漏洞平台  · 公众号  ·  · 2021-04-20 15:02

正文


作者: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 可以分为两大类:
1.基础NAT:网络地址转换(Network Address Translation)
2.NAPT:网络地址端口转换(Network Address Port Translation)


[1.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 在网络拓扑图上像圆锥,我们在下文进行深入了解。

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.完全锥型NAT]

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]

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 )

[5.对称型NAT]

可以这么来理解,在 锥型NAT 中:映射关系和目标地址端口无关,而在 对称型NAT 中则有关。锥型NAT 正因为其于目标地址端口无关,所以网络拓扑是圆锥型的。
补充下 锥型NAT 的网络拓扑图,和对称型进行比较

[6.锥型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

[7.PacketTracer模拟环境下的NAT表]

当然还有一些特殊的协议,比如 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 ) 的探测流程图,在其上添加了一些标注:

[8.NAT类型探测流程]

如图所示,我们可以整理出:

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 socketimport 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 穿透的流程如下:

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 有一条关于







请到「今天看啥」查看全文