MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

ICMP协议详解:网络诊断与控制

2022-05-037.9k 阅读

ICMP协议基础

ICMP协议概述

ICMP(Internet Control Message Protocol)即网际控制报文协议,它是 TCP/IP 协议族的核心协议之一。ICMP 协议主要用于在 IP 主机、路由器之间传递控制消息。这里的控制消息是指网络通不通、主机是否可达、路由是否可用等网络本身的消息。这些消息虽然并不传输用户数据,但是对于用户数据的传递起着重要的作用。

ICMP 报文作为 IP 层数据报的数据,加上 IP 首部后组成 IP 数据报发送出去。也就是说,ICMP 是基于 IP 协议工作的,但它和传输层的 TCP、UDP 又有所不同,它并不是为了在端到端之间传输数据,而是用于网络层的控制与诊断。

ICMP报文结构

ICMP 报文由首部和数据两部分组成。其首部长度为 8 字节,具体结构如下:

  1. 类型(Type):1 字节,标识 ICMP 报文的类型。不同的类型值对应不同的 ICMP 报文功能。例如,类型 8 表示 Echo 请求,类型 0 表示 Echo 应答。
  2. 代码(Code):1 字节,对类型字段进行进一步细化,提供更详细的信息。比如在目的不可达类型(Type = 3)中,Code 字段可以表示不同的不可达原因,如网络不可达(Code = 0)、主机不可达(Code = 1)等。
  3. 校验和(Checksum):2 字节,用于检验 ICMP 报文的完整性。计算校验和时,包括 ICMP 首部和数据部分,校验和算法采用的是简单的 16 位二进制反码求和再取反。
  4. 其他字段:不同类型的 ICMP 报文,后续字段有所不同。例如,在 Echo 请求和应答报文中,会有标识符(Identifier)和序列号(Sequence Number)字段,用于匹配请求和应答。

ICMP报文类型

  1. 差错报告报文
    • 目的不可达(Type = 3):当路由器或主机无法将 IP 数据报交付给目的地址时,就向源点发送目的不可达报文。根据 Code 字段不同的值,可细分多种不可达情况,如网络不可达、主机不可达、协议不可达、端口不可达等。
    • 源点抑制(Type = 4):当路由器或主机由于拥塞而丢弃数据报时,就向源点发送源点抑制报文,使源点知道应当把数据报的发送速率放慢。
    • 时间超过(Type = 11):有两种情况会发送该报文。一是当路由器收到生存时间(TTL)为 0 的数据报时,除丢弃该数据报外,还要向源点发送时间超过报文;二是当目的站在预先规定的时间内不能收到一个数据报的全部数据报片时,就把已收到的数据报片都丢弃,并向源点发送时间超过报文。
    • 参数问题(Type = 12):当路由器或目的主机收到的数据报的首部中有的字段的值不正确时,就丢弃该数据报,并向源点发送参数问题报文。
  2. 查询报文
    • Echo 请求和应答(Type = 8/0):主机或路由器向特定目的主机发送 Echo 请求报文,目的主机收到后则返回 Echo 应答报文。常用于测试目的主机是否可达,典型的应用就是 ping 命令。
    • 时间戳请求和应答(Type = 13/14):用于请求某个主机或路由器回答当前的日期和时间。可用来进行时钟同步和测量时间。
    • 地址掩码请求和应答(Type = 17/18):主机可以利用地址掩码请求报文,向路由器询问自己所在网络的子网掩码。路由器则以地址掩码应答报文回答。

ICMP协议在网络诊断中的应用

Ping命令原理

Ping 命令是我们日常网络诊断中最常用的工具之一,它的实现原理就是基于 ICMP 的 Echo 请求和应答报文。当我们在命令行中输入 ping [目标 IP 地址] 时,系统会构造一个 ICMP Echo 请求报文,然后将其封装在 IP 数据报中发送给目标 IP 地址。目标主机收到 Echo 请求报文后,会立即返回一个 ICMP Echo 应答报文。发送方根据是否收到应答报文以及收到应答报文的时间,来判断目标主机是否可达以及网络延迟情况。

例如,在 Linux 系统下执行 ping -c 4 192.168.1.1,这里 -c 4 表示发送 4 个 Echo 请求报文。输出结果类似如下:

PING 192.168.1.1 (192.168.1.1) 56(84) bytes of data.
64 bytes from 192.168.1.1: icmp_seq=1 ttl=64 time=0.233 ms
64 bytes from 192.168.1.1: icmp_seq=2 ttl=64 time=0.227 ms
64 bytes from 192.168.1.1: icmp_seq=3 ttl=64 time=0.224 ms
64 bytes from 192.168.1.1: icmp_seq=4 ttl=64 time=0.229 ms

--- 192.168.1.1 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3002ms
rtt min/avg/max/mdev = 0.224/0.228/0.233/0.004 ms

从结果中可以看到每个 Echo 请求对应的应答信息,包括序列号(icmp_seq)、生存时间(ttl)和往返时间(time)。最后还统计了发送和接收的报文数量、丢包率以及往返时间的最小值、平均值、最大值等信息。

Traceroute命令原理

Traceroute(在 Windows 系统中为 Tracert)命令用于跟踪 IP 数据报从源主机到目的主机所经过的路由路径。它也是利用了 ICMP 协议。Traceroute 的工作原理是通过发送 TTL 值逐渐增加的 UDP 数据报(在 Windows 中使用 ICMP Echo 请求报文)来确定路径。

初始时,发送的 UDP 数据报的 TTL 值为 1。当第一个路由器收到该数据报时,由于 TTL 值减为 0,路由器会丢弃该数据报,并向源主机发送一个 ICMP 时间超过报文。源主机根据这个时间超过报文就知道了路径上的第一个路由器的 IP 地址。然后,源主机发送 TTL 值为 2 的 UDP 数据报,这样第二个路由器会返回时间超过报文,源主机从而得知第二个路由器的 IP 地址。依此类推,直到 UDP 数据报到达目的主机。由于目的主机接收到 UDP 数据报后,会发现其目的端口是一个未使用的端口(Traceroute 通常使用一个较大的、不太可能被使用的端口号),目的主机就会返回一个 ICMP 端口不可达报文,此时 Traceroute 就知道已经到达目的主机,跟踪结束。

例如,在 Linux 系统下执行 traceroute 8.8.8.8,输出结果类似如下:

traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets
 1  router1.example.com (192.168.1.1)  0.233 ms  0.227 ms  0.224 ms
 2  router2.example.com (10.0.0.1)  1.234 ms  1.221 ms  1.219 ms
 3  203.0.113.1 (203.0.113.1)  2.345 ms  2.332 ms  2.329 ms
 ...
10  google-public-dns-a.google.com (8.8.8.8)  3.456 ms  3.443 ms  3.439 ms

每一行显示了经过的路由器的 IP 地址以及往返时间。通过这些信息,我们可以了解网络路径的情况,判断是否存在网络延迟、路由异常等问题。

ICMP协议编程实现

使用Python进行ICMP编程

  1. 发送ICMP Echo请求 在 Python 中,可以使用 scapy 库来方便地构造和发送 ICMP 报文。首先需要安装 scapy 库,可以使用 pip install scapy 命令进行安装。

下面是一个简单的发送 ICMP Echo 请求的示例代码:

from scapy.all import *


def send_icmp_echo_request(destination):
    icmp = ICMP(type=8, code=0)
    packet = IP(dst=destination) / icmp
    reply = sr1(packet, timeout=2)
    if reply:
        print(f"Received reply from {reply.src}")
    else:
        print("No reply received.")


if __name__ == "__main__":
    destination_ip = "192.168.1.1"
    send_icmp_echo_request(destination_ip)

在上述代码中,首先导入了 scapy 库中的必要模块。然后定义了 send_icmp_echo_request 函数,该函数构造了一个 ICMP Echo 请求报文(type = 8 表示 Echo 请求),并将其封装在 IP 报文中,目标地址为传入的 destination。接着使用 sr1 函数发送该报文,并等待 2 秒接收应答。如果接收到应答,则打印出应答的源 IP 地址;否则,打印未收到应答的信息。

  1. 接收ICMP报文 同样使用 scapy 库,可以编写一个简单的程序来接收 ICMP 报文。
from scapy.all import sniff


def process_icmp_packet(packet):
    if ICMP in packet:
        icmp_type = packet[ICMP].type
        if icmp_type == 0:  # Echo 应答
            print(f"Received ICMP Echo Reply from {packet[IP].src}")
        elif icmp_type == 8:  # Echo 请求
            print(f"Received ICMP Echo Request from {packet[IP].src}")


if __name__ == "__main__":
    sniff(filter="icmp", prn=process_icmp_packet)

在这段代码中,定义了 process_icmp_packet 函数来处理捕获到的 ICMP 报文。当捕获到 ICMP 报文后,判断其类型,如果是 Echo 应答(type = 0)或 Echo 请求(type = 8),则打印相应的信息。然后使用 sniff 函数,通过 filter="icmp" 过滤出 ICMP 报文,并将每个捕获到的报文传递给 process_icmp_packet 函数进行处理。

使用C语言进行ICMP编程

  1. 发送ICMP Echo请求 在 C 语言中,可以使用套接字(socket)来进行 ICMP 编程。以下是一个发送 ICMP Echo 请求的示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>


#define DEST_IP "192.168.1.1"
#define PACKET_SIZE 1024


void send_icmp_echo_request() {
    int sockfd;
    struct sockaddr_in dest_addr;
    char sendbuf[PACKET_SIZE];
    char recvbuf[PACKET_SIZE];
    struct icmphdr *icmp_hdr;
    struct iphdr *ip_hdr;

    // 创建原始套接字
    if ((sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)) < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&dest_addr, 0, sizeof(dest_addr));
    dest_addr.sin_family = AF_INET;
    dest_addr.sin_addr.s_addr = inet_addr(DEST_IP);

    // 构造ICMP头部
    icmp_hdr = (struct icmphdr *) sendbuf;
    icmp_hdr->type = ICMP_ECHO;
    icmp_hdr->code = 0;
    icmp_hdr->un.echo.id = getpid();
    icmp_hdr->un.echo.sequence = 1;
    icmp_hdr->checksum = 0;

    // 计算ICMP校验和
    icmp_hdr->checksum = in_cksum((unsigned short *) icmp_hdr, sizeof(struct icmphdr));

    // 发送ICMP报文
    if (sendto(sockfd, sendbuf, sizeof(struct icmphdr), MSG_CONFIRM,
               (const struct sockaddr *) &dest_addr, sizeof(dest_addr)) < 0) {
        perror("sendto failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 接收ICMP应答
    socklen_t len = sizeof(dest_addr);
    ssize_t n = recvfrom(sockfd, recvbuf, PACKET_SIZE, MSG_WAITALL,
                         (struct sockaddr *) &dest_addr, &len);
    if (n < 0) {
        perror("recvfrom failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 解析IP头部
    ip_hdr = (struct iphdr *) recvbuf;
    icmp_hdr = (struct icmphdr *) (recvbuf + (ip_hdr->ihl << 2));

    if (icmp_hdr->type == ICMP_ECHOREPLY) {
        printf("Received ICMP Echo Reply from %s\n", inet_ntoa(dest_addr.sin_addr));
    } else {
        printf("Received unexpected ICMP type: %d\n", icmp_hdr->type);
    }

    close(sockfd);
}


unsigned short in_cksum(unsigned short *buf, int nwords) {
    unsigned long sum;
    for (sum = 0; nwords > 0; nwords--)
        sum += *buf++;
    sum = (sum >> 16) + (sum & 0xFFFF);
    sum += (sum >> 16);
    return ~sum;
}


int main() {
    send_icmp_echo_request();
    return 0;
}

在上述代码中,首先创建了一个原始套接字(SOCK_RAW)用于发送和接收 ICMP 报文。然后构造了 ICMP Echo 请求头部,设置了类型为 Echo 请求(ICMP_ECHO),标识符为当前进程 ID,序列号为 1,并计算了校验和。接着使用 sendto 函数发送 ICMP 报文,再使用 recvfrom 函数接收应答。接收到应答后,解析 IP 头部和 ICMP 头部,如果是 Echo 应答,则打印出应答的源 IP 地址。

  1. 接收ICMP报文 下面是一个使用 C 语言接收 ICMP 报文的示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>


#define PACKET_SIZE 1024


void receive_icmp_packet() {
    int sockfd;
    char recvbuf[PACKET_SIZE];
    struct sockaddr_in src_addr;
    socklen_t len = sizeof(src_addr);
    struct iphdr *ip_hdr;
    struct icmphdr *icmp_hdr;

    // 创建原始套接字
    if ((sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)) < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    while (1) {
        // 接收ICMP报文
        ssize_t n = recvfrom(sockfd, recvbuf, PACKET_SIZE, MSG_WAITALL,
                             (struct sockaddr *) &src_addr, &len);
        if (n < 0) {
            perror("recvfrom failed");
            close(sockfd);
            exit(EXIT_FAILURE);
        }

        // 解析IP头部
        ip_hdr = (struct iphdr *) recvbuf;
        icmp_hdr = (struct icmphdr *) (recvbuf + (ip_hdr->ihl << 2));

        if (icmp_hdr->type == ICMP_ECHO) {
            printf("Received ICMP Echo Request from %s\n", inet_ntoa(src_addr.sin_addr));
        } else if (icmp_hdr->type == ICMP_ECHOREPLY) {
            printf("Received ICMP Echo Reply from %s\n", inet_ntoa(src_addr.sin_addr));
        }
    }

    close(sockfd);
}


int main() {
    receive_icmp_packet();
    return 0;
}

这段代码创建了一个原始套接字来接收 ICMP 报文。在一个无限循环中,不断接收 ICMP 报文,并解析 IP 头部和 ICMP 头部。根据 ICMP 报文的类型,如果是 Echo 请求或 Echo 应答,则打印出相应的源 IP 地址。

ICMP协议的安全性考虑

ICMP协议可能带来的安全风险

  1. ICMP洪水攻击:攻击者通过向目标主机发送大量的 ICMP Echo 请求报文,使目标主机忙于处理这些请求,从而导致网络带宽被耗尽或系统资源被过度占用,最终造成拒绝服务(DoS)攻击。例如,攻击者使用工具不断地向目标主机发送海量的 ping 包,使目标主机无法正常处理其他业务流量。
  2. ICMP重定向攻击:ICMP 重定向报文本来是用于路由器优化网络路径的,但攻击者可以利用它来进行中间人攻击。攻击者可以伪造 ICMP 重定向报文,使受害主机将流量发送到攻击者指定的恶意路由器,从而实现对网络流量的监听和篡改。
  3. 利用ICMP隐蔽通道:由于 ICMP 报文通常被防火墙视为正常的网络控制报文而放行,攻击者可能会利用 ICMP 报文来构建隐蔽通道,在受限制的网络环境中传输敏感数据。例如,将敏感数据编码在 ICMP 报文的数据部分,绕过防火墙的检测。

防范ICMP协议相关安全风险的措施

  1. 配置防火墙策略:通过防火墙设置规则,限制 ICMP 报文的进出。例如,可以只允许内部网络主机之间的 ICMP Echo 请求和应答报文,禁止外部网络向内部网络发送大量的 ICMP 报文。同时,限制特定类型的 ICMP 报文,如禁止接收 ICMP 重定向报文,以防止重定向攻击。
  2. 使用入侵检测系统(IDS)和入侵防范系统(IPS):IDS 和 IPS 可以检测到异常的 ICMP 流量模式,如大量的 ICMP Echo 请求报文。当检测到异常流量时,IDS 可以发出警报,IPS 则可以主动采取措施,如阻断相关的网络连接,从而防止 ICMP 洪水攻击等安全事件的发生。
  3. 对ICMP报文进行验证:在网络设备或主机上,可以对接收到的 ICMP 报文进行验证。例如,验证 ICMP 报文的源 IP 地址是否合法,是否来自可信的网络区域。对于关键系统,可以采用更严格的验证机制,如对 ICMP 报文的完整性进行签名验证,以防止 ICMP 报文被篡改。

通过以上对 ICMP 协议的详细介绍,包括其基础原理、在网络诊断中的应用、编程实现以及安全性考虑,希望读者能对 ICMP 协议有更深入的理解,并在实际的网络开发和管理中更好地运用和防范相关风险。