0x00 背景
最近遇到一个客户环境比较特殊,无法使用TCP或UDP访问外网,但是可以ping通外网。于是想到通过ICMP协议建立与外界的通信链路。在尝试了几个开源工具都宣告失败后,准备自己撸一个ICMP隧道。当然,这个隧道工具需要支持穿过NAT访问网络。
0x01 NAT对ICMP的限制
通常来说,NAT对TCP和UDP支持都比较好,但对ICMP一般限制会比较多。例如,常见的限制条件有:
- 内部机器只能发送PING包;外部机器只能返回PONG包,而且ICMP的
id
和seq
字段必须与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主要起管理连接的作用,主要有:CONNECT
、OKAY
、FAIL
、WRITE
、CLOSE
、PING
、PONG
等类型事件,内部会有状态机对连接进行管理。除了PING
和PONG
序号字段为0,其它包都是从1开始递增的;也就是说,只有这两种包不需要确认。
客户端端口号和服务端端口号主要是为了支持多路复用,因为ICMP没有端口的概念,可以使用这两个字段来代替端口。
主要流程如下:
- 服务端指定监听端口号开始监听接收到的ICMP包;对于接收到的ICMP包,检查是否以
Magic Flag
开头,如果不是则直接忽略;并进行其它字段的有效性检查,包括服务端端口号是否与监听的端口号一致 - 客户端生成随机2字节整型数字作为本地端口号,指定服务端端口号,
Seq
字段设为0,组成CONNECT
包发送到服务端 - 服务端接收到
CONNECT
包后,判断客户端端口是否已经建立连接,如果没有则返回OKAY
包,并创建对应的数据结构;否则返回FAIL
包 - 客户端后接收到
OKAY
包后,连接建立;并开始定时发送PING
包,间隔时间最大为5秒,如果接收到服务端发送过来的WRITE
包或PONG
包,也会立即发送PING
包 - 客户端和服务端在需要发送上层数据时都会组成
WRITE
包发送过去,如果上层发送的数据量较大,则需要进行分包后发送,保证最终的每个IP包的大小<=1500字节 - 在收到对方发送过来的数据包时,最多会延时100ms进行确认;如果这段时间内有数据要发送,则ACK会跟随待发送数据一起发送出去,否则会使用
PING
包或PONG
进行ACK确认 - 当服务端有大量数据需要发送时,会由于缺少足够的(
id
、seq
),导致无法及时将数据返回回来。为了提升这种情况的发送效率,收到服务端数据时,会立即进行ACK确认(根据数据包大小是否达到最大来判断);同时,需要根据接收数据的频率控制发送PING
包的频率,保证服务端有足够多的(id
、seq
)可用 - 为保证服务端有数据要发送时能及时返回回来,需要保证服务端在任意时刻都至少有一个未超时的(
id
、seq
);因此,即便是在双方都没有数据需要发送的时候,客户端也会保证每隔5秒发送一次PING
包,服务端则会在收到PING
包后等待最多5秒返回PONG
包 - 如果客户端连续30秒都没有收到对方发送过来的数据包,则认为连接已断开;服务端也是类似的逻辑
可以看出,这里基本实现了类似TCP的逻辑,只是实现上简化了很多;并针对ICMP穿越NAT的特点做了适配。
应用层
应用层协议的目标是为了多条数据流可以复用同一个传输层连接,在设计上更多考虑可扩展性。因此,选用msgpack
格式作为序列化格式。这种格式使用上与json类似,但又是二进制格式的,还支持buffer类型,因此非常适合作为序列化格式。
为了在处理数据时能正常进行分包,需要在数据包前面加上4字节的序列化后的msgpack
字节序大小。由于传输层会自动进行分包逻辑,因此上层可以不用关心这一实现细节,允许发送任意长度的数据包。
应用层主要是创建隧道并写数据,以及关闭隧道等操作。因此,主要也是包含这三种类型的操作。
- 首先使用
create
类型的事件创建一条流,并指定要连接的目标地址,服务端连接成功则返回一个随机生成的4字节整型流ID,否则返回-1 - 流创建成功后,双方在需要写数据时会使用
write
类型的事件,指定流ID写数据;服务端将收到的客户端数据转发给目标服务,而客户端是将服务端返回的数据转发给隧道创建者 - 隧道创建者或目标服务关闭时都会触发关闭隧道的操作,该操作会通过
close
事件指定流ID发送给对端,从而关闭流并删除流ID
0x04 总结
详细实现代码可以看:https://github.com/drunkdream/turbo-tunnel/blob/master/turbo_tunnel/icmp.py。目前的实现基本可用,但在传输大量数据的场景还需要再进行进一步的优化。
通过将协议分为传输层和应用层,降低了复杂性,并提升了代码的可读性和可维护性。在这个过程中,也加深了对TCP可靠连接的理解。