SEEDLab-VPN隧道
【实验目的】
本实验通过从零构建一个简单的VPN隧道,深入理解虚拟专用网络(VPN)的工作原理。实验涵盖TUN/TAP虚拟网络接口的创建与配置、IP隧道技术(将原始IP数据包封装在UDP载荷中传输)、路由配置与双向流量转发等核心内容,最终实现主机U通过VPN隧道安全访问专用网络内部主机V的功能。
【实验原理】
VPN与IP隧道。
虚拟专用网络(VPN)建立在公共网络之上,使得远程主机能够像在专用网络中一样安全通信。IP隧道是VPN的核心技术之一,其实质是将一个IP数据包作为载荷封装在另一个IP数据包的UDP(或TCP)段中进行传输。发送端从虚拟网络接口读取原始IP数据包,将其放入UDP数据报的载荷字段,通过公共网络发送到VPN服务器;服务器收到后从UDP载荷中还原原始IP数据包,再通过虚拟网络接口注入内核协议栈,由内核路由到最终目的地。
TUN/TAP虚拟接口。
TUN和TAP是Linux内核提供的虚拟网络驱动程序。TUN工作在第3层(网络层),处理IP数据包;TAP工作在第2层(数据链路层),处理以太网帧。用户空间程序通过文件描述符与TUN/TAP接口交互:从接口读取的数据包是内核协议栈转发来的,向接口写入的数据包则被注入内核,如同从外部网络收到的一样。这种机制使得用户空间程序能够在不修改内核代码的前提下拦截和注入网络数据包。
select()多路复用。
当程序需要同时监听TUN接口和UDP套接字两个文件描述符时,使用select()系统调用可以阻塞等待任一接口上有数据到达,避免忙轮询浪费CPU资源。select()返回就绪的文件描述符集合,程序据此判断数据来源并分别处理。
路由转发。
VPN服务器需要启用IP转发功能(net.ipv4.ip_forward=1),使其在TUN接口和物理网络接口之间转发数据包,充当网关角色。专用网络内的主机也需要正确配置路由表,确保返回数据包能通过VPN服务器路由回隧道另一端。
Task 1: 设置网络环境
本实验需要三台机器:VPN客户端(主机U,10.9.0.5)、VPN服务器/路由器(10.9.0.11和192.168.60.11)和专用网络内的主机V(192.168.60.5)。VPN客户端和服务器通过模拟互联网的10.9.0.0/24网络连接,主机V通过192.168.60.0/24网络连接到VPN服务器。
使用docker-compose启动实验环境后,首先验证各主机之间的基本连通性。在VPN客户端容器中测试能否ping通VPN服务器(10.9.0.11),以及能否ping通主机V(192.168.60.5)。预期结果是客户端能与服务器通信,但无法直接与主机V通信,因为主机V位于专用网络内部,不能从"互联网"直接访问。
在VPN服务器(Router)上使用tcpdump分别在两个网络接口上抓包,验证可以捕获到两个网络上的数据包,确保网络环境搭建正确。
Task 2: 创建和配置TUN接口
2.a 创建TUN接口
TUN接口的创建通过打开/dev/net/tun设备文件,使用ioctl系统调用设置IFF_TUN标志来完成。程序使用姓氏拼音(高)作为接口名称前缀,内核会自动分配序号。运行tun.py程序后,在另一个终端中执行ip
address命令,可以看到系统中多出了一个名为gao0的TUN接口。
1 2 3 4 tun = os.open ("/dev/net/tun" , os.O_RDWR) ifr = struct.pack('16sH' , b'gao%d' , IFF_TUN | IFF_NO_PI) ifname_bytes = fcntl.ioctl(tun, TUNSETIFF, ifr) ifname = ifname_bytes.decode('UTF-8' )[:16 ].strip("\x00" )
2.b 配置TUN接口
新创建的TUN接口处于down状态且没有IP地址,需要手动配置。通过ip addr
add为接口分配IP地址192.168.53.99/24,再通过ip link
set将其设置为up状态。配置完成后,TUN接口在系统中处于激活状态,可以收发数据包。
1 2 os.system("ip addr add 192.168.53.99/24 dev {}" .format (ifname)) os.system("ip link set dev {} up" .format (ifname))
2.c 从TUN接口读取数据包
修改tun.py,将while循环改为从TUN接口读取数据包并解析为Scapy
IP对象。当在主机U上ping
192.168.53.0/24网络中的地址时,由于该网段与TUN接口的IP地址在同一子网内,内核会将数据包路由到TUN接口,tun.py程序便能读取到这些ICMP
echo request数据包并打印其摘要信息。
然而,当ping
192.168.60.0/24网络中的主机时,tun.py不会打印任何内容。这是因为去往192.168.60.0/24的流量默认走物理接口而非TUN接口,除非手动添加路由规则将其引导到TUN接口。在图6中可以看到这两种情况的区别。
2.d 将数据包写入TUN接口
从TUN接口读取到ICMP echo request后,程序构造对应的ICMP echo
reply数据包写回TUN接口。写入的数据包被内核当作从外部网络收到的报文处理,因此ping命令能够收到回复。这证明了TUN接口的双向数据传递能力:读取的数据来自内核,写入的数据注入内核。
1 2 3 if ICMP in ip and ip[ICMP].type == 8 : reply = IP(src=ip.dst, dst=ip.src) / ICMP(type =0 , code=0 ) / ip[ICMP].payload os.write(tun, bytes (reply))
当向TUN接口写入任意非IP数据时,内核会报错,因为TUN接口只接受有效的IP数据包。这验证了TUN接口工作在网络层(第3层),写入的数据必须是完整的IP数据包。
Task 3:
通过隧道将IP数据包发送到VPN服务器
(1)VPN服务器程序
在VPN服务器上运行tun_server.py,该程序创建一个UDP套接字监听9090端口。收到数据后,将UDP载荷解析为Scapy
IP对象,打印出源IP和目的IP地址。
1 2 3 4 5 6 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("0.0.0.0" , 9090 ))while True : data, (ip, port) = sock.recvfrom(2048 ) pkt = IP(data) print (" Inside: {} --> {}" .format (pkt.src, pkt.dst))
(2)VPN客户端程序
在客户端上运行tun_client.py,程序从TUN接口读取IP数据包后,通过UDP套接字将其发送到VPN服务器(10.9.0.11:9090)。当在客户端ping
192.168.53.0/24网络中的地址时,ICMP数据包被路由到TUN接口,客户端程序将其封装在UDP中发送到服务器,服务器成功接收并打印。
(3)添加路由规则
当ping主机V(192.168.60.5)时,ICMP数据包不会通过隧道发送,因为内核不知道去往192.168.60.0/24网络的流量应该路由到TUN接口。需要手动添加路由规则:
1 ip route add 192.168.60.0/24 dev gao0
添加路由后,去往192.168.60.0/24的数据包被引导到TUN接口,客户端程序读取后通过UDP隧道发送到服务器。VPN服务器成功接收到这些ICMP数据包,证明IP隧道在去程方向上工作正常。
Task 4: 设置VPN服务器
服务器从隧道接收到数据包后,需要将其注入内核以便路由到最终目的地。为此,服务器也需要创建一个TUN接口,将从UDP套接字接收到的数据写入TUN接口。内核收到数据包后,通过路由表查找目的地址,将其转发到主机V。
同时,VPN服务器需要启用IP转发功能,使其在TUN接口和物理网络接口之间转发数据包。在docker-compose.yml中已通过sysctls配置了net.ipv4.ip_forward=1。
1 2 3 data, (ip, port) = sock.recvfrom(2048 ) os.write(tun, data)
Task 5: 处理双向流量
此前的隧道仅实现了单向通信(客户端到服务器)。为实现双向流量,客户端和服务器都需要同时监听TUN接口和UDP套接字。使用select()系统调用可以同时监视多个文件描述符,当任一接口有数据到达时唤醒进程进行处理。
1 2 3 4 5 6 7 8 9 while True : ready, _, _ = select.select([sock, tun], [], []) for fd in ready: if fd is sock: data, (ip, port) = sock.recvfrom(2048 ) os.write(tun, data) if fd is tun: packet = os.read(tun, 2048 ) sock.sendto(packet, (SERVER_IP, SERVER_PORT))
双向流量处理完成后,主机U可以成功ping通主机V,ICMP echo request和echo
reply都能通过隧道往返传输。
进一步使用telnet登录主机V,输入用户名和密码后成功建立远程登录会话,验证了TCP连接也能通过VPN隧道正常工作。
通过Wireshark抓包可以观察到,telnet的数据包在VPN客户端处被封装在UDP中,经由10.9.0.0/24网络到达VPN服务器后被解封装,再转发到192.168.60.0/24网络中的主机V。返回数据的流程则相反。
Task 6: 隧道中断实验
首先建立双方的VPN连接,通过telnet输入"hello",在保持telnet连接的同时,终止tun_client.py程序以中断VPN隧道。此时在telnet窗口中继续输入字符’h’,可以看到输入的内容无法显示,因为TCP数据包无法通过已中断的隧道传输。如图15所示。
如果在隧道中断后较短时间内重新启动客户端和服务器程序并恢复路由配置,已建立的TCP连接可以自动恢复,之前输入的字符全部一次出现在telnet会话中,telnet会话中的输入重新变得可响应。这是因为TCP连接的状态仍保留在两端主机的内核中,隧道恢复后数据包可以继续正常传输。如图16所示。
Task 7: 主机V上的路由实验
在真实VPN场景中,返回流量必须经由同一隧道返回。为模拟这种情况,从主机V中删除默认路由条目,然后添加一条更具体的静态路由,确保去往隧道另一端的数据包被路由到VPN服务器。
首先查看主机V的当前路由表,确认存在默认路由条目。然后删除默认路由:
删除默认路由后,主机V无法与10.9.0.0/24网络通信,ping主机V的ICMP数据包可以到达主机V,但返回的数据包因没有路由而无法送达。
添加一条具体的路由条目,将去往10.9.0.0/24网络的数据包路由到VPN服务器(192.168.60.11):
1 ip route add 10.9.0.0/24 via 192.168.60.11
配置完成后,主机V能够正确将返回数据包路由到VPN服务器,通信恢复正常。
Task 8: 专用网络之间的VPN
在真实应用中,VPN常用于连接两个分布在不同地点的专用网络。本任务使用docker-compose2.yml搭建包含两个专用网络的实验环境:站点A(192.168.50.0/24)和站点B(192.168.60.0/24),两个站点各有一台VPN路由器通过模拟互联网(10.9.0.0/24)相连。
需要在两台VPN路由器上分别运行tun_client.py和tun_server.py,并配置正确的路由规则,使得两个专用网络之间的数据包能够通过VPN隧道传输。具体需要在站点A的路由器上添加去往192.168.60.0/24的路由指向TUN接口,在站点B的路由器上添加去往192.168.50.0/24的路由指向TUN接口。
从站点A的主机ping站点B的主机,可以成功通信,验证了两个专用网络之间的VPN隧道工作正常。
通过抓包可以观察到,两个专用网络之间的数据包确实经过了VPN隧道封装传输。
Task 9: 用TAP接口实验
TAP接口与TUN接口的创建方式类似,区别在于TAP使用IFF_TAP标志,工作在第2层(数据链路层)。从TAP接口读取的数据是以太网帧,包含MAC头部,可以使用Scapy的Ether对象进行解析。
1 2 3 tap = os.open ("/dev/net/tun" , os.O_RDWR) ifr = struct.pack('16sH' , b'tap%d' , IFF_TAP | IFF_NO_PI) ifname_bytes = fcntl.ioctl(tap, TUNSETIFF, ifr)
当对TAP接口所在网段的IP地址执行ping操作时,系统首先需要通过ARP协议获取目标IP对应的MAC地址。由于TAP接口背后没有真实的ARP响应者,ARP请求会被TAP程序捕获。
程序检测到ARP请求后,构造伪造的ARP响应写回TAP接口,使得上层协议认为目标MAC地址已被解析,后续的IP数据包便可以正常封装和发送。使用arping命令可以验证伪造的ARP响应是否生效。
1 2 3 4 5 6 7 if ARP in ether and ether[ARP].op == 1 : arp = ether[ARP] newether = Ether(dst=ether.src, src=FAKE_MAC) newarp = ARP(psrc=arp.pdst, hwsrc=FAKE_MAC, pdst=arp.psrc, hwdst=ether.src, op=2 ) newpkt = newether/newarp os.write(tap, bytes (newpkt))
【思考与总结】
通过本次VPN隧道实验,对VPN的核心技术——TUN/TAP虚拟接口、IP隧道和路由配置——有了深入的理解。
TUN/TAP接口是VPN实现的基石。TUN接口工作在网络层,用户空间程序通过它读写IP数据包;TAP接口工作在数据链路层,处理完整的以太网帧。两者的创建方式几乎相同,仅IFF_TUN与IFF_TAP标志不同。这种虚拟接口机制使得用户空间程序无需修改内核即可拦截和注入网络数据包,是构建VPN、网络仿真等工具的基础。
IP隧道的本质是封装:将原始IP数据包作为载荷放入另一个IP数据包的UDP段中传输。这种"包中有包"的结构虽然增加了少量头部开销,但使得原始数据包可以在不兼容的网络之间穿越。实验中,从主机U发出的ICMP数据包被封装在UDP中穿越10.9.0.0/24网络到达VPN服务器,再被解封装转发到192.168.60.0/24网络中的主机V,完整展示了隧道的封装与解封装过程。
路由配置是VPN正确工作的关键。实验中多次遇到因路由缺失导致通信失败的情况:未将192.168.60.0/24的流量引导到TUN接口时数据包无法进入隧道,主机V删除默认路由后返回数据包无法送达。这些问题深刻说明了路由表在网络通信中的核心地位——即使隧道本身工作正常,错误的路由配置也会导致通信失败。
select()多路复用机制是实现双向隧道的关键技术。通过同时监视TUN接口和UDP套接字,程序能够在任一方向有数据时及时处理,避免了忙轮询的低效。在实际的VPN产品中,这一机制被广泛用于处理多接口并发数据。
隧道中断实验揭示了VPN对上层TCP连接的影响。隧道中断时TCP数据包无法传输,连接进入超时等待;隧道恢复后TCP连接可以自动恢复,因为TCP的状态仍然保留在两端内核中。这说明VPN隧道对上层协议是透明的——隧道的建立和断开不影响已建立的TCP连接状态。
从专用网络之间的VPN实验可以看到,两个隔离的专用网络可以通过VPN隧道实现互通。在实际的企业网络中,这种技术被广泛用于连接分布在不同地点的办公网络,员工无论身处何地都能安全访问公司内部资源。
在实际应用中,VPN还需要对隧道中的数据进行加密以保证安全性。本实验仅关注隧道部分,未涉及加密。完整的VPN还需要使用TLS等协议保护隧道中的数据,防止中间人攻击和数据泄露。
【附录-完整源代码】
tun.py — TUN接口基础实验程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import fcntlimport structimport osimport timefrom scapy.all import * TUNSETIFF = 0x400454ca IFF_TUN = 0x0001 IFF_TAP = 0x0002 IFF_NO_PI = 0x1000 tun = os.open ("/dev/net/tun" , os.O_RDWR) ifr = struct.pack('16sH' , b'gao%d' , IFF_TUN | IFF_NO_PI) ifname_bytes = fcntl.ioctl(tun, TUNSETIFF, ifr) ifname = ifname_bytes.decode('UTF-8' )[:16 ].strip("\x00" )print ("Interface Name: {}" .format (ifname)) os.system("ip addr add 192.168.53.99/24 dev {}" .format (ifname)) os.system("ip link set dev {} up" .format (ifname))while True : packet = os.read(tun, 2048 ) if not packet: continue ip = IP(packet) print ("==> Received packet: {}" .format (ip.summary())) if ip.haslayer(ICMP) and ip[ICMP].type == 8 : print (" Detected ICMP Echo Request. Constructing Reply..." ) newip = IP(src=ip.dst, dst=ip.src) newicmp = ICMP(type =0 , id =ip[ICMP].id , seq=ip[ICMP].seq) newpkt = newip / newicmp / ip[ICMP].payload os.write(tun, bytes (newpkt)) print ("<== Sent ICMP Echo Reply: {}" .format (newpkt.summary()))
tun_client.py — VPN客户端程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import fcntlimport structimport osimport socketimport selectfrom scapy.all import * TUNSETIFF = 0x400454ca IFF_TUN = 0x0001 IFF_NO_PI = 0x1000 SERVER_IP = "10.9.0.11" SERVER_PORT = 9090 tun = os.open ("/dev/net/tun" , os.O_RDWR) ifr = struct.pack('16sH' , b'gao%d' , IFF_TUN | IFF_NO_PI) ifname_bytes = fcntl.ioctl(tun, TUNSETIFF, ifr) ifname = ifname_bytes.decode('UTF-8' )[:16 ].strip("\x00" )print ("Client TUN Interface Name: {}" .format (ifname)) os.system("ip addr add 192.168.53.99/24 dev {}" .format (ifname)) os.system("ip link set dev {} up" .format (ifname)) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)print ("Client is running using select()..." )while True : ready, _, _ = select.select([sock, tun], [], []) for fd in ready: if fd is sock: data, (ip, port) = sock.recvfrom(2048 ) pkt = IP(data) print ("From socket <==: {} --> {}" .format (pkt.src, pkt.dst)) os.write(tun, data) if fd is tun: packet = os.read(tun, 2048 ) pkt = IP(packet) print ("From tun ==>: {} --> {}" .format (pkt.src, pkt.dst)) sock.sendto(packet, (SERVER_IP, SERVER_PORT))
tun_server.py — VPN服务器程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import fcntlimport structimport osimport socketimport selectfrom scapy.all import * TUNSETIFF = 0x400454ca IFF_TUN = 0x0001 IFF_NO_PI = 0x1000 tun = os.open ("/dev/net/tun" , os.O_RDWR) ifr = struct.pack('16sH' , b'gao%d' , IFF_TUN | IFF_NO_PI) ifname_bytes = fcntl.ioctl(tun, TUNSETIFF, ifr) ifname = ifname_bytes.decode('UTF-8' )[:16 ].strip("\x00" )print ("Server TUN Interface Name: {}" .format (ifname)) os.system("ip addr add 192.168.53.1/24 dev {}" .format (ifname)) os.system("ip link set dev {} up" .format (ifname)) IP_A = "0.0.0.0" PORT = 9090 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((IP_A, PORT)) client_addr = None print ("Server is running using select()..." )while True : ready, _, _ = select.select([sock, tun], [], []) for fd in ready: if fd is sock: data, (ip, port) = sock.recvfrom(2048 ) client_addr = (ip, port) pkt = IP(data) print ("From socket <==: {} --> {}" .format (pkt.src, pkt.dst)) os.write(tun, data) if fd is tun: packet = os.read(tun, 2048 ) pkt = IP(packet) print ("From tun ==>: {} --> {}" .format (pkt.src, pkt.dst)) if client_addr: sock.sendto(packet, client_addr) else : print ("Warning: Received packet from TUN, but no client has connected yet." )
tap_read.py — TAP接口实验程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import fcntlimport structimport osfrom scapy.all import * TUNSETIFF = 0x400454ca IFF_TAP = 0x0002 IFF_NO_PI = 0x1000 tap = os.open ("/dev/net/tun" , os.O_RDWR) ifr = struct.pack('16sH' , b'tap%d' , IFF_TAP | IFF_NO_PI) ifname_bytes = fcntl.ioctl(tap, TUNSETIFF, ifr) ifname = ifname_bytes.decode('UTF-8' )[:16 ].strip("\x00" )print ("Interface Name: {}" .format (ifname)) os.system("ip addr add 192.168.53.99/24 dev {}" .format (ifname)) os.system("ip link set dev {} up" .format (ifname))print ("TAP interface is UP. Listening for Ethernet frames..." )while True : packet = os.read(tap, 2048 ) if packet: ether = Ether(packet) print (ether.summary())
tap_arp_spoof.py — TAP接口ARP欺骗实验程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 import fcntlimport structimport osfrom scapy.all import * TUNSETIFF = 0x400454ca IFF_TAP = 0x0002 IFF_NO_PI = 0x1000 tap = os.open ("/dev/net/tun" , os.O_RDWR) ifr = struct.pack('16sH' , b'tap%d' , IFF_TAP | IFF_NO_PI) ifname_bytes = fcntl.ioctl(tap, TUNSETIFF, ifr) ifname = ifname_bytes.decode('UTF-8' )[:16 ].strip("\x00" )print ("Interface Name: {}" .format (ifname)) os.system("ip addr add 192.168.53.99/24 dev {}" .format (ifname)) os.system("ip link set dev {} up" .format (ifname)) FAKE_MAC = "aa:bb:cc:dd:ee:ff" print ("TAP Spoof Server is running..." )while True : try : packet = os.read(tap, 2048 ) if not packet: continue ether = Ether(packet) if ARP in ether and ether[ARP].op == 1 : print ("--------------------------------" ) print ("Received ARP Request: " + ether.summary()) arp = ether[ARP] newether = Ether(dst=ether.src, src=FAKE_MAC) newarp = ARP( psrc=arp.pdst, hwsrc=FAKE_MAC, pdst=arp.psrc, hwdst=ether.src, op=2 ) newpkt = newether / newarp print ("***** Sending Fake ARP Response: {}" .format (newpkt.summary())) os.write(tap, bytes (newpkt)) except KeyboardInterrupt: print ("\nExiting TAP Server..." ) break except Exception as e: print ("Error: {}" .format (e))