路由追踪的原理及实现

路由追踪的原理及实现

Traceroute(路由追踪)的原理及实现 - 简书 (jianshu.com)

TraceRoute的实现(Windows下 C/C++ 基于原始套接字)_traceroute c 实现_YTIANYE的博客-CSDN博客

自己用手机热点ssh连虚拟机时,想到手机热点和连接这个热点的所有主机共同组成了一个局域网,而手机作为热点功能提供者,允许其他设备通过它来访问互联网(不是ISP)。刚刚学过计网,觉得很有意思,遂进行如下实验。

路由追踪

现实世界中的网络是由无数的计算机和路由器组成的一张的大网,应用的数据包在发送到服务器之前都要经过层层的路由转发。Traceroute(Linux下)/Tracert(Windows下)是一种常规的网络分析工具,用来定位到目标主机之间的所有路由器

下面我在Windows下用tracert做以下实验:

  1. 追踪到 www.baidu.com 所经过的路由路径,同时开启Wireshark抓包

    image

    结果显示末端网络适配器的IP是39.156.66.14,访问这个IP,发现确实是百度:

    image

  2. 使用Wireshark抓包的结果如下:

    • image
    • 设置过滤规则为ip.dst == 39.156.66.14 and icmp.type == 11 && icmp.code == 0进一步过滤出生存时间超时的ICMP报文(下图中标*的表示ICMP响应报文丢失了,否则时间就是收到ICMP响应报文的用时)

    image

    image

  3. 于是发现:

    • tracert程序对每个站点发送3个数据报,这些数据报可能丢失
    • 上图中我的电脑首先发送一个TTL=1的报文,在第一跳路由器超时,于是第一跳路由器回复我的电脑一个ICMP超时差错报告报文,我的电脑就知道了第一跳会经过的路由器的IP。【这里的第一跳路由器应该是我的手机所用的移动网络运营商的路由器】

原理

不难得出路由追踪的原理:源主机不断发送TTL=k, k=1,2,3,...的无法交付的UDP数据报(即使用了非法端口号的UDP数据报),IP数据报中TTL的数值从1开始递增,当到达第k个结点时,TTL将减少为0,该结点不会转发该数据报,并向源主机返回一份ICMP超时差错报文,源主机根据返回的ICMP差错报文中源地址字段确定第k个结点的路由。当最终到达终点时,此时TTL为1,但是由于UDP数据报无法交付,因此返回ICMP终点不可达差错报告报文。

路由追踪的实现方案

基于UDP的实现

在基于UDP的实现中,客户端发送的数据包是通过UDP协议来传输的,使用了一个大于30000的端口号,服务器在收到这个数据包的时候会返回一个端口不可达的ICMP差错报告报文,客户端通过判断收到的错误信息是TTL超时还是端口不可达来判断数据包是否到达目标主机,具体的流程如图:

image

基于UDP实现的traceroute

  1. 客户端发送一个TTL为1,端口号大于30000的UDP数据包,到达第一站路由器之后TTL被减去1,返回了一个超时的ICMP数据包,客户端得到第一跳路由器的地址。
  2. 客户端发送一个TTL为2的数据包,在第二跳的路由器节点处超时,得到第二跳路由器的地址。
  3. 客户端发送一个TTL为3的数据包,数据包成功到达目标主机,返回一个端口不可达错误,traceroute结束。

Linux和macOS系统自带了一个traceroute指令,可以结合Wireshark抓包来看看它的实现原理。首先对百度的域名进行traceroute:traceroute www.baidu.com,每一跳默认发送三个数据包,我们会看到下面这样的输出:

img

对该域名的IP:115.239.210.27进行traceroute,此时Wireshark抓包的结果如下:

img

注意看红框处的内容,跟第一张图对比,可以看到traceroute程序首先通过UDP协议向目标地址115.239.210.27发送了一个TTL为1的数据包,然后在第一个路由器中TTL超时,返回一个错误类型为Time-to-live exceeded的ICMP数据包,此时我们通过该数据包的源地址可知第一站路由器的地址为10.242.0.1。之后只需要不停增加TTL的值就能得到每一跳的地址了。

然而一直跑下去会发现,traceroute并不能到达目的地,当TTL增加到一定大小之后,数据包就全部丢失了:

img

其实这个时候数据包已经到达目标服务器,但是因为安全问题大部分的应用服务器都不提供UDP服务(或者被防火墙拦截),所以我们拿不到服务器的任何返回,程序就理所当然的认为还没有结束,一直尝试增加数据包的TTL。

基于ICMP的实现

上述方案失败的原因是由于服务器对于UDP数据包的处理,所以在这一种实现中我们不使用UDP协议,而是直接发送一个ICMP回显请求(echo request)数据包,服务器在收到回显请求的时候会向客户端发送一个ICMP回显应答(echo reply)数据包,在这之后的流程还是跟第一种方案一样。这样就避免了我们的traceroute数据包被服务器的防火墙策略拦截。

采用这种方案的实现流程如下:

image

  1. 客户端发送一个TTL为1的ICMP请求回显数据包,在第一跳的时候超时并返回一个ICMP超时数据包,得到第一跳的地址。
  2. 客户端发送一个TTL为2的ICMP请求回显数据包,得到第二跳的地址。
  3. 客户端发送一个TTL为3的ICMP请求回显数据包,到达目标主机,目标主机返回一个ICMP回显应答,traceroute结束。

可以看出与第一种实现相比,区别主要在发送的数据包类型以及对于结束的判断上,大体的流程还是一致的。

Windows下的tracert程序使用的就是这种方案,因此成功。

实现

相关代码在:Meha555/computer-network-demo - 码云 - 开源中国 (gitee.com)

基于IOS的实现

这里我们主要讨论基于ICMP的实现

采用这种方案时,ICMP数据包的创建、解析、校验都需要我们自己进行,ICMP是封装在IP数据包的数据段中传输的,所以关键在于如何创建和发送ICMP数据,以及接收到返回的数据时如何从IP数据包中将ICMP解析出来:

创建ICMP数据

ICMP数据包头部的格式如下:

img

其中的类型字段用来表示消息的类型。报文中的标识符和序列号由发送端指定,如果这个ICMP报文是一个请求回显的报文(类型为8,代码为0),这两个字段会被原封不动的返回。

根据上图中各个字段的大小可以定义如下类型:

typedef struct ICMPPacket {
    uint8_t     type; // 类型
    uint8_t     code; // 类型代码
    uint16_t    checksum; // 校验码
    uint16_t    identifier; // ID
    uint16_t    sequenceNumber; // 序列号
    // data...
} ICMPPacket;

其中的type字段指定了这个ICMP数据包的类型,是需要重点关注的对象,为此定义一个报文类型的枚举:

// ICMPv4报文类型
typedef enum ICMPv4Type {
    kICMPv4TypeEchoReply = 0, // 回显应答
    kICMPv4TypeEchoRequest = 8, // 回显请求
    kICMPv4TypeTimeOut = 11, // 超时
}ICMPv4Type;

比较麻烦的是校验的计算,这一部分直接使用了苹果官方示例SimplePing中的代码,所涉及到的几个工具方法封装在类型TracerouteCommon中。

在发送数据的时系统会自动加上IP头部不需要自己处理,如此一来我们只需要创建一个ICMPPacket数据包并通过socket发送到目标服务器就可以了。

解析ICMP数据

接下来就是要接收服务器向我们返回的ICMP数据了,我们接收到的是带有IP头部的原始数据,所以必须先进行一些处理将ICMP从IP数据包中提取出来,IP数据包由两部分组成:数据包头部信息部分以及实际的数据部分。下图是IPv4数据包的结构:

img

一眼看上去是不是感觉很混乱,其实这里面只有用红框圈出来的这这三个字段需要我们关心:版本表示该数据包是IPv4还是IPv6;之前说过ICMP协议是通过IP协议来传输的,如果该数据包传输的是ICMP协议则协议字段会被设置为1;由于IPv4数据包带有可选的选项字段,所以其头部的长度是可变的,此时需要根据首部长度字段来获取具体的数据。

根据上面的结构可以定义类型:

typedef struct IPv4Header {
    uint8_t versionAndHeaderLength; // 版本和首部长度
    uint8_t serviceType;
    uint16_t totalLength; 
    uint16_t identifier;
    uint16_t flagsAndFragmentOffset;
    uint8_t timeToLive;
    uint8_t protocol; // 协议类型,1表示ICMP
    uint16_t checksum;
    uint8_t sourceAddress[4];
    uint8_t destAddress[4];
    // options...
    // data...
} IPv4Header;

提取ICMP数据包的方法如下:

+ (ICMPPacket *)unpackICMPv4Packet:(char *)packet len:(int)len {
    if (len < (sizeof(IPv4Header) + sizeof(ICMPPacket))) {
        return NULL;
    }
    
    const struct IPv4Header *ipPtr = (const IPv4Header *)packet;
    if ((ipPtr->versionAndHeaderLength & 0xF0) != 0x40 || // IPv4
        ipPtr->protocol != 1) { // ICMP
        return NULL;
    }
    
    // 获取IP头部长度
    size_t ipHeaderLength = (ipPtr->versionAndHeaderLength & 0x0F) * sizeof(uint32_t); 
    if (len < ipHeaderLength + sizeof(ICMPPacket)) {
        return NULL;
    }
    
    // 返回数据部分的ICMP
    return (ICMPPacket *)((char *)packet + ipHeaderLength);
}

其中出现的如ipPtr->versionAndHeaderLength & 0xF0的判断是因为版本号和首部长度各自只占4个bit,在结构中直接定义了一个1字节的uint8_t类型来表示,所以只能通过位运算符&来获取各自的值。

整体流程

有了上面的两步,剩下的事情就很简单了,下面是整体流程的伪代码:

// 1. 创建一个套接字
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);

// 2. 最多尝试30跳
int ttl = 1;
for (0...30) {
    // 3. 设置TTL,发送3个ICMP数据包,每一跳都将递增TTL
    setsockopt(sock, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl));
    ++ttl;
    for (0...3) {
        // 4. 发送并等待返回的数据包
        sendto(...);
        recvfrom(...);
        
        // 5. 解析数据包,记录数据,成功条件判断
        ICMPPacket *packet = unpack(...);
    }
}

socket的类型采用了SOCK_DGRAM,有些小伙伴可能会感到疑惑:用SOCK_DGRAM创建套接字不还是发送UDP数据么?

确实在许多系统的实现中要直接发送ICMP的话需要使用原始套接字(类型为SOCK_RAW),这在iOS系统中是不被允许使用的,但是查阅资料中了解到macOS支持一种使用参数SOCK_DGRAMIPPROTO_ICMP来直接创建ICMP套接字方式,尝试之下果然iOS也支持这种用法。不过在使用中发现了一个问题:使用IPv4套接字的时候接收到的数据包是带有原始IP头部的,而使用IPv6套接字的时候收到的数据包却没有IP头部,这个问题让我比较疑惑,各位大佬如果有对这一块了解的话还望赐教。

基于Windows的实现

  1. 获取IP地址,初始化socket,设置序号为0,TTL为1;
  2. 如果没到达目标且跳数不超过30,执行步骤3,否则执行步骤7;
  3. 记录当前时间和序列号,填充ICMP数据部分;
  4. 发送ICMP的EchoRequest数据报;
  5. 接收ICMP的EchoReply数据报;接收正确的数据报执行步骤6,否则执行步骤7;
  6. 打印结果,TTL+1,执行步骤4;
  7. 关闭socket,退出程序;

img

总结

posted @ 2023-04-25 16:34  3的4次方  阅读(42)  评论(0编辑  收藏  举报