如何实现支持NAT的ICMP隧道

0x00 背景

最近遇到一个客户环境比较特殊,无法使用TCP或UDP访问外网,但是可以ping通外网。于是想到通过ICMP协议建立与外界的通信链路。在尝试了几个开源工具都宣告失败后,准备自己撸一个ICMP隧道。当然,这个隧道工具需要支持穿过NAT访问网络。

0x01 NAT对ICMP的限制

通常来说,NAT对TCP和UDP支持都比较好,但对ICMP一般限制会比较多。例如,常见的限制条件有:

  • 内部机器只能发送PING包;外部机器只能返回PONG包,而且ICMP的idseq字段必须与PING包保持一致
  • 每个PING包只能有一个对应的PONG包,映射关系维持时间较短,最多在几十秒的量级,超时之后即便接收到了PONG包也不会转发给内部机器

在部分对安全性要求更高的场景下,还会有以下一些限制:

  • PING包与PONG包长度必须一致
  • 更短的超时时间(秒级)

因此,ICMP隧道没法实现完美的双向通信,需要使用类似不断PULL的逻辑去保证数据返回通道的畅通。

0x02 实现ICMP隧道的一些关键点

连接管理

ICMP协议与UDP协议类似,是不保证可靠传输的,需要上层逻辑进行连接的管理。这里连接管理的主要目的有:

  • 数据包重组,保证接收顺序与发送顺序一致
  • 数据包确认,丢包重传,保证可靠传输
  • 保活检测,及时发现连接中断

因此,可以看出,需要设计一套类似于TCP的协议与算法,考虑到ICMP无法全双工的问题,这个算法应当尽量的简单、高效。

多路复用

根据上面的分析,ICMP协议在通信过程中含有较多非数据面的通信,而ICMP隧道中一般会包含多条数据流,因此最好能够支持多路复用,从而提升通信效率和降低报错的风险。

这里的多路复用可以考虑在更高的层面支持。

保活机制

常见的保活机制一般是双向发送PING包,然后在超时时间内看对端能否返回PONG包。由于ICMP隧道不支持全双工,只能由客户端发送PING包,服务端返回PONG包(上层定义的PING/PONG,非ICMP层的PING/PONG)。

同时,由于NAT会检查ICMP PING和PONG的id与seq字段的一致性,这里保活时发送的PING包还可以起到穿透NAT的作用。只要客户端发送了足够多的PING包,服务端在返回数据时就有足够多的(id, seq)可用。否则,可能会出现服务端要发送数据给客户端时,却因为没有(id, seq)导致不能及时将数据返回回来,从而导致不必要的时延。

延时确认

如果接收到数据包就立即通过PING包或PONG包进行ACK确认,会导致后面在需要发送数据时还会产生WRITE包。如果这两个包时间非常接近,则前面发送的包其实是没有必要的。可以通过延时确认机制,保证在这种情况下不会产生多余的数据包。其实现原理基本与TCP的延时确认机制是一致的。

流量控制

在网络较差的环境中,过快地发送数据包可能会导致无法及时对数据包进行确认,从而达到超时时间进行重传,又可能进一步降低了网络的可用性。因此,需要需要控制处于发送中状态的数据包数量。对于网络较好的情况,可以提升这个值,网络不好的情况就需要降低这个值。

由于PING包会影响接收数据的效率,因此也需要控制发送PING包的速度,保证服务端有足够的(id, seq)可用,却又不会产生超过网络承受能力的流量。

0x03 协议设计

传输层

传输层协议主要是为了解决连接管理相关的问题,需要保证连接的可靠性。为了减少传输层占用的空间,考虑使用二进制协议,使用网络字节序,基本设计如下:

|             Magic Flag            |
| --------------- 4 --------------- |
|   Total length  |    Checksum     |
| ------ 2 ------ | ------ 2 ------ |
|       Seq       |       Ack       |
| ------ 2 ------ | ------ 2 ------ |
| ------------- Event ------------- |
| --------------- 4 --------------- |
| - Client Port - | - Server Port - |
| ------ 2 ------ | ------ 2 ------ |
|              Padding              |
  • 开头是一个4字节的固定魔术标记,用于协议识别
  • 2字节的数据包总大小和2字节的校验码(校验算法与TCP/IP一致)
  • 2字节的包序号和2字节的确认包序号
  • 4字节的事件ID
  • 2字节的客户端端口号和2字节的服务端端口号
  • 最后面是填充数据

与TCP使用字节数作为序号和确认号不同,这里是以包为单位,因为传输是以包为单位的,无法再进行拆分。

包总大小使用2字节,是因为单个IP数据包受MTU影响,一般最大大小是1500,不会出现大于63335的情况。为了保证请求包和返回包的大小相等,组包时会按照设置的最大包大小进行补0操作,解包时会按照包大小字段去掉后面填充的\x00字节。

事件ID主要起管理连接的作用,主要有:CONNECTOKAYFAILWRITECLOSEPINGPONG等类型事件,内部会有状态机对连接进行管理。除了PINGPONG序号字段为0,其它包都是从1开始递增的;也就是说,只有这两种包不需要确认。

客户端端口号和服务端端口号主要是为了支持多路复用,因为ICMP没有端口的概念,可以使用这两个字段来代替端口。

主要流程如下:

  1. 服务端指定监听端口号开始监听接收到的ICMP包;对于接收到的ICMP包,检查是否以Magic Flag开头,如果不是则直接忽略;并进行其它字段的有效性检查,包括服务端端口号是否与监听的端口号一致
  2. 客户端生成随机2字节整型数字作为本地端口号,指定服务端端口号,Seq字段设为0,组成CONNECT包发送到服务端
  3. 服务端接收到CONNECT包后,判断客户端端口是否已经建立连接,如果没有则返回OKAY包,并创建对应的数据结构;否则返回FAIL
  4. 客户端后接收到OKAY包后,连接建立;并开始定时发送PING包,间隔时间最大为5秒,如果接收到服务端发送过来的WRITE包或PONG包,也会立即发送PING
  5. 客户端和服务端在需要发送上层数据时都会组成WRITE包发送过去,如果上层发送的数据量较大,则需要进行分包后发送,保证最终的每个IP包的大小<=1500字节
  6. 在收到对方发送过来的数据包时,最多会延时100ms进行确认;如果这段时间内有数据要发送,则ACK会跟随待发送数据一起发送出去,否则会使用PING包或PONG进行ACK确认
  7. 当服务端有大量数据需要发送时,会由于缺少足够的(idseq),导致无法及时将数据返回回来。为了提升这种情况的发送效率,收到服务端数据时,会立即进行ACK确认(根据数据包大小是否达到最大来判断);同时,需要根据接收数据的频率控制发送PING包的频率,保证服务端有足够多的(idseq)可用
  8. 为保证服务端有数据要发送时能及时返回回来,需要保证服务端在任意时刻都至少有一个未超时的(idseq);因此,即便是在双方都没有数据需要发送的时候,客户端也会保证每隔5秒发送一次PING包,服务端则会在收到PING包后等待最多5秒返回PONG
  9. 如果客户端连续30秒都没有收到对方发送过来的数据包,则认为连接已断开;服务端也是类似的逻辑

可以看出,这里基本实现了类似TCP的逻辑,只是实现上简化了很多;并针对ICMP穿越NAT的特点做了适配。

应用层

应用层协议的目标是为了多条数据流可以复用同一个传输层连接,在设计上更多考虑可扩展性。因此,选用msgpack格式作为序列化格式。这种格式使用上与json类似,但又是二进制格式的,还支持buffer类型,因此非常适合作为序列化格式。

为了在处理数据时能正常进行分包,需要在数据包前面加上4字节的序列化后的msgpack字节序大小。由于传输层会自动进行分包逻辑,因此上层可以不用关心这一实现细节,允许发送任意长度的数据包。

应用层主要是创建隧道并写数据,以及关闭隧道等操作。因此,主要也是包含这三种类型的操作。

  1. 首先使用create类型的事件创建一条流,并指定要连接的目标地址,服务端连接成功则返回一个随机生成的4字节整型流ID,否则返回-1
  2. 流创建成功后,双方在需要写数据时会使用write类型的事件,指定流ID写数据;服务端将收到的客户端数据转发给目标服务,而客户端是将服务端返回的数据转发给隧道创建者
  3. 隧道创建者或目标服务关闭时都会触发关闭隧道的操作,该操作会通过close事件指定流ID发送给对端,从而关闭流并删除流ID

0x04 总结

详细实现代码可以看:https://github.com/drunkdream/turbo-tunnel/blob/master/turbo_tunnel/icmp.py。目前的实现基本可用,但在传输大量数据的场景还需要再进行进一步的优化。

通过将协议分为传输层和应用层,降低了复杂性,并提升了代码的可读性和可维护性。在这个过程中,也加深了对TCP可靠连接的理解。

分享