热门课程

免费试听

上课方式

开班时间

当前位置: 首页 -   文章 -   新闻动态 -   正文

NAT 原理以及 UDP 穿透

知了堂姐
2024-07-08 17:22:16
0


1 NAT基础和分类


NAT(Network Address Translation)全称为「网络地址转换」,用于为了解决 IPv4 地址短缺的问题。NAT 可以将私有地址转换为公有 IP 地址,以便多台内网主机只需要一个公有 IP 地址,也可以正常与互联网进行通信。

NAT 可以分为两大类:

1.基础NAT:网络地址转换(Network Address Translation)

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

2 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 的网络拓扑图,和对称型进行比较


3 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

己想要在哪个端口接受数据,NAT 必须进行特殊处理才能支持这种通信机制。
在 NAT 中有一个应用网关层(Application Layer Gateway, ALG),以此来统一处理这些协议问题。

4.映射老化时间

建立了 NAT 映射关系后,这些映射什么时候失效呢?

不同协议有不同的失效机制,比如 TCP 的通信在收到 RST 过后就会删除映射关系,或 TCP 在某个超时时间后也会自动失效,而 ICMP 在收到 ICMP 响应后就会删除映射关系,当然超时后也会自动失效。具体的实现还和各个厂商有关系。


4 NAT类型探测


探测 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 类型。


5 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 有一条关于 5.6.7.8:20000 的映射(上一步中添加的),Router1 将报文转发给 PC1(192.168.1.1:7777)

7.在 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-8import randomimport socketimport stringimport timeif __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()




6 拓展


在实践了以上步骤后,我们对 锥型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 的实现有:

1.对端 NAT 在接收到 syn 由于没有找到映射而返回 RST 报文,而本端 NAT 在接收到 RST 报文后删除了此条映射

2.由于主机生成的 syn 报文中的 seq 序号为随机值,如果 NAT 开启了 syn 过滤,对于没有标记过的 seq 的报文将直接丢弃

3.等等



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.等等。

大家都在看

知了堂应邀参与四川省计算机学会学术年会

2024-07-08 浏览次数:0

怎么制作一个网站?新手快速建站的全套流程

2024-07-08 浏览次数:0

同学,拿到阿里旗下公司offer是一种什么感觉?

2024-07-08 浏览次数:0

重返课堂!知了堂优秀毕业学员精彩分享

2024-07-08 浏览次数:0

启明星辰、飞致云企业莅临知了堂,开展教育、技术产...

2024-07-08 浏览次数:0

交换机原理是什么?简述交换机原理

2024-07-08 浏览次数:0
最新资讯