15.2 主机探测与路由追踪

Ping 使用 Internet 控制消息协议(ICMP)来测试主机之间的连接。当用户发送一个 ping 请求时,则对应的发送一个 ICMP Echo 请求消息到目标主机,并等待目标主机回复一个 ICMP Echo 回应消息。如果目标主机接收到请求并且网络连接正常,则会返回一个回应消息,表示主机之间的网络连接是正常的。如果目标主机没有收到请求消息或网络连接不正常,则不会有回应消息返回。

Ping 工作的步骤如下:

  • Ping发送一个ICMP Echo请求消息到目标主机。
  • 目标主机接收到请求消息后,检查消息中的目标IP地址是否正确,并回复一个ICMP Echo回应消息表示收到请求。
  • Ping接收到回应消息后,并计算从发送到接收的时延(即往返时间 RTT)和丢包率等统计信息,然后输出到命令行上。
  • Ping不断进行第1到第3步的操作,直到达到指定的停止条件(如发送一定数量的请求或持续一定的时间等)为止。

Ping的实现依赖于ICMP协议,Internet控制消息协议(Internet Control Message Protocol,简称 ICMP)是一种在IP网络上发送控制消息的协议。主要是用于在 IP 网络上进行错误处理和诊断。ICMP协议是运行在网络层的协议,它的主要作用是向源主机和目标主机发送控制消息,帮助网络诊断和监控。这些控制消息通常是由网络设备(如路由器、交换机、防火墙等)生成或捕获,并在整个网络传输。

ICMP协议的消息格式通常由两个部分组成:消息头和数据。其中,消息头包含以下字段:

  • 消息类型(Type):指示消息的类型(如 Echo 请求、Echo 回应、目标不可达、重定向等)
  • 代码(Code):指示消息的子类型或错误代码
  • 校验和(Checksum):用于检查消息是否被篡改
  • 消息体(Payload):包含特定类型消息所需的数据,如 IP 数据报片段、Echo 请求消息等

ICMP 协议中常见的消息类型包括:

  • Echo 请求(Ping)和 Echo 回应:用于测试主机之间的连通性和计算往返时间(RTT)
  • 目标不可达:通知源主机无法到达某个目标主机或网络
  • 重定向:用于通知主机更改路由器或网关
  • 时间超时:通知主机数据包已超过了最大存活期
  • 地址掩码请求和地址掩码回应:用于向主机查询和设置子网掩码

Windows平台下要实现Ping命令有多种方法,首先我们先来讲解第一种实现方式,通过自己构造ICMP数据包并发包实现,首先该功能的实现需要定义一个icmp_header头部,并定义好所需要的发送与回应定义,如下所示;

// ICMP头部定义部分
struct icmp_header
{
unsigned char icmp_type; // 消息类型
unsigned char icmp_code; // 代码
unsigned short icmp_checksum; // 校验和
unsigned short icmp_id; // ICMP唯一ID
unsigned short icmp_sequence; // 序列号
unsigned long icmp_timestamp; // 时间戳
};

// 计算出ICMP头部长度
#define ICMP_HEADER_SIZE sizeof(icmp_header)

// ICMP回送请求消息代码
#define ICMP_ECHO_REQUEST 0x08

// ICMP回送响应消息代码
#define ICMP_ECHO_REPLY 0x00

当有了结构体定义那么接着就需要实现一个ICMP校验和的计算方法,ICMP报文检验和是一种用于检测 ICMP 报文数据正确性的校验和。它是 ICMP 协议中一种重要的错误检测机制,用于验证发送和接收的 ICMP 报文的数据是否完整、正确。

校验和计算方法如下:

  • 将要计算校验和的数据(即 ICMP 报文)按照16位为一组进行分组
  • 把所有的 16 位数字相加并加上进位,得到一个数
  • 若上一步和的高位不为零,则把进位加到低位上,重复步骤 2
  • 对累加后的结果进行二进制反转
  • 得到校验和值,将其放置于 ICMP 报文的校验和字段中

ICMP 接收到 ICMP 报文时,将立即计算校验和,比对接收到的校验和值与计算所得的校验和值是否相同,从而决定 ICMP 报文是否正确接收及响应。这样做的好处是可以有效地检测数据在传输过程中的误码、中间路由设备的错误操作等问题,保障 ICMP 报文的正确性。

根据上述描述,计算校验和CheckSum函数,首先对报文的数据进行分组,并依次计算每个16位数字的和。当相加的结果有进位时,将进位加到低位上,并将进位部分加到下一组中。处理完所有数字之后,还需要对结果进行二进制反转,得到最终的校验和值。

// 计算校验和
unsigned short CheckSum(struct icmp_header *picmp, int len)
{
long sum = 0;
unsigned short *pusicmp = (unsigned short *)picmp;

// 将数据按16位分组,相邻的两个16位取出并相加,直到处理完所有数据
while (len > 1)
{
sum += *(pusicmp++);

// 如果相加的结果有进位,则将进位加到低16位上
if (sum & 0x80000000)
{
sum = (sum & 0xffff) + (sum >> 16);
}

// 减去已经处理完的字节数
len -= 2;
}

// 如果数据的字节数为奇数,则将最后一个字节视为16位,高8位设为0,低8位取余部分。
if (len)
{
sum += (unsigned short)*(unsigned char *)pusicmp;
}

// 如果计算完校验和后还有进位,则将进位加到低16位上
while (sum >> 16)
{
sum = (sum & 0xffff) + (sum >> 16);
}

// 取反得到最终的校验和
return (unsigned short)~sum;
}

接着就是实现ICMP测试函数,如下函数首先进行初始化,并创建原始套接字,然后构造 ICMP 报文,计算报文的校验和。接着发送 ICMP 报文,并接收 ICMP 回复报文,解析其中的信息,判断延迟超时,最后返回 ping 测试结果。

发送 ICMP 报文使用 sendto 函数,第一个参数是原始套接字,第二个参数是 ICMP 报文数据缓存区,第三个参数是缓存区的长度,第四个参数是标志,第五个参数是目的地址信息。接收 ICMP 回复报文使用 recvfrom 函数,第一个参数和第五个参数与 sendto 函数相同。函数返回时,判断接收到的 IP 地址是否与发送 ICMP 报文的 IP 地址相同,如果相同,解析 ICMP 回复报文中的信息并返回 true,否则返回 false

ICMP 报文构造中,使用了 Winsock 函数库中的 inet_addrIP 地址转换为网络字节序。在计算 ICMP 报文的校验和时,调用了 CheckSum 函数。

BOOL MyPing(char *szDestIp)
{
BOOL bRet = TRUE;
WSADATA wsaData;
int nTimeOut = 1000;
char szBuff[ICMP_HEADER_SIZE + 32] = { 0 };
icmp_header *pIcmp = (icmp_header *)szBuff;
char icmp_data[32] = { 0 };

// 初始化Winsock动态链接库
WSAStartup(MAKEWORD(2, 2), &wsaData);

// 创建原始套接字
SOCKET s = socket(PF_INET, SOCK_RAW, IPPROTO_ICMP);

// 设置接收超时
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char const*)&nTimeOut, sizeof(nTimeOut));

// 设置目的地址
sockaddr_in dest_addr;
dest_addr.sin_family = AF_INET;
dest_addr.sin_addr.S_un.S_addr = inet_addr(szDestIp);
dest_addr.sin_port = htons(0);

// 构造ICMP封包
pIcmp->icmp_type = ICMP_ECHO_REQUEST;
pIcmp->icmp_code = 0;
pIcmp->icmp_id = (USHORT)::GetCurrentProcessId();
pIcmp->icmp_sequence = 0;
pIcmp->icmp_timestamp = 0;
pIcmp->icmp_checksum = 0;

// 拷贝ICMP协议中附带的数据
memcpy((szBuff + ICMP_HEADER_SIZE), "abcdefghijklmnopqrstuvwabcdefghi", 32);

// 计算校验和
pIcmp->icmp_checksum = CheckSum((struct icmp_header *)szBuff, sizeof(szBuff));

// 接收ping返回的ICMP数据包
sockaddr_in from_addr;
char szRecvBuff[1024];
int nLen = sizeof(from_addr);

// 发送UDP数据包
sendto(s, szBuff, sizeof(szBuff), 0, (SOCKADDR *)&dest_addr, sizeof(SOCKADDR));

// 等待响应
recvfrom(s, szRecvBuff, MAXBYTE, 0, (SOCKADDR *)&from_addr, &nLen);

// 判断接收到的是否是自己请求的地址
if (lstrcmp(inet_ntoa(from_addr.sin_addr), szDestIp))
{
bRet = FALSE;
}
else
{
// 如果是自己请求的地址,则解析 ICMP 回复报文中的信息
struct icmp_header *pIcmp1 = (icmp_header *)(szRecvBuff + 20);
printf("%s \r\n", inet_ntoa(from_addr.sin_addr));
}

return bRet;
}

当读者有了上述函数封装那么实现Ping测试将变得很容易,首先如下调用实例中,通过GetHostByName函数获取到对应域名的IP地址信息返回字符串,并将该字符串传入MyPing函数内,该函数会测试当前主机是否可通信,如果可以返回状态值1,否则返回0。

int main(int argc, char **argv)
{
// 获得指定网址的IP地址
char * ptr = GetHostByName("www.lyshark.com");

// 开始测试
for (size_t i = 0; i < 5; i++)
{
int ret = MyPing(ptr);
printf("测试结果 = %d \n", ret);
}

system("pause");
return 0;
}

运行代码后读者可看到如下图所示的提示信息;

除了通过自己封装接口外,Windows系统中还为我们提供了一个专用函数IcmpSendEcho,该函数用于通过 ICMP 协议向远程主机发送 Echo 请求并接收 Echo 回复。如果发送 Echo 请求并成功接收 Echo 回复,则函数返回值为非零,否则为零。

该函数的声明如下:

BOOL IcmpSendEcho
(
HANDLE IcmpHandle,
IPAddr DestinationAddress,
LPVOID RequestData,
WORD RequestSize,
PIP_OPTION_INFORMATION RequestOptions,
LPVOID ReplyBuffer,
DWORD ReplySize,
DWORD Timeout
);

函数参数:

  • IcmpHandle:一个有效的 ICMP 句柄
  • DestinationAddress:目标地址,可以是 IP 地址(IPAddr)或主机名(LPCSTR)
  • RequestData:指向要发送的数据的指针
  • RequestSize:要发送的数据的大小(以字节为单位)
  • RequestOptions:指向 IP 选项的信息(IP_OPTION_INFORMATION)
  • ReplyBuffer:指向缓冲区,该缓冲区将用于存储接收到的回复
  • ReplySize:存储在回复缓冲区中的数据的大小(以字节为单位)
  • Timeout:请求超时之前等待回复的时间(以毫秒为单位)

如下函数则是通过IcmpCreateFileIcmpSendEcho函数实现的Ping测试,函数首先将 IP 地址转换为网络字节序,创建 ICMP 句柄并初始化 IP 选项信息。然后,设置要发送的 ICMP 数据报文,和接收 ICMP 数据报文的大小和缓冲区。接着发送 ICMP 数据报文,等待接收回复,并将回复解析为 ICMP_ECHO_REPLY 结构体。最后,判断回复的状态,如果不为 0 则返回失败,否则输出回复信息并返回成功。

// 调用API实现ping
bool IcmpPing(char *Address)
{
// 设置超时为1000ms
DWORD timeOut = 1000;

// IP地址转为网络字节序
ULONG hAddr = inet_addr(Address);
HANDLE handle = IcmpCreateFile();

IP_OPTION_INFORMATION ipoi;
memset(&ipoi, 0, sizeof(IP_OPTION_INFORMATION));

// Time-To-Live
ipoi.Ttl = 64;

// 设置发送数据包
unsigned char SendData[32] = { "send icmp pack" };
int repSize = sizeof(ICMP_ECHO_REPLY)+32;

// 设置接收数据包
unsigned char pReply[128];
ICMP_ECHO_REPLY* pEchoReply = (ICMP_ECHO_REPLY*)pReply;

// 发送ICMP数据报文
DWORD nPackets = IcmpSendEcho(handle, hAddr, SendData, sizeof(SendData), &ipoi, pReply, repSize, timeOut);

if (pEchoReply->Status != 0)
{
IcmpCloseHandle(handle);
return false;
}

in_addr inAddr;
inAddr.s_addr = pEchoReply->Address;
printf("回复地址: %13s 状态: %1d 初始TTL: %3d 回复: TTL: %3d \n",
inet_ntoa(inAddr), pEchoReply->Status, ipoi.Ttl, pEchoReply->Options.Ttl);
return true;
}

该段代码的调用与上述一致,读者只需要传入主机IP地址的字符串即可,具体调用实现如下所示;

int main(int argc, char *argv[])
{
// 解析域名
char * HostAddress = GetHostByName("www.lyshark.com");
printf("网站IP地址 = %s \n", HostAddress);

// 调用Ping
for (int x = 0; x < 3; x++)
{
IcmpPing(HostAddress);
Sleep(1000);
}

system("pause");
return 0;
}

运行代码后读者可看到如下图所示的提示信息;

通过使用Ping命令我们还可以实现针对主机路由的追踪功能,路由追踪功能的原理是,它实际上是发送一系列ICMP数据包,数据包每经过一个路由节点则TTL值会减去1,假设TTL值等于0时数据包还没有到达目标主机,那么该路由则会回复给目标主机一个数据包不可达,由此我们就可以获取到目标主机的IP地址。

其跟踪原理如下:

  • 1.一开始发送一个TTL为1的包,这样到达第一个路由器的时候就已经超时了,第一个路由器就会返回一个ICMP通知,该通知包含了对端的IP地址,这样就能够记录下所经过的第一个路由器的IP。
  • 2.然后将TTL加1,让其能够安全的通过第一个路由器,而第二个路由器的的处理过程会自动丢包,发通知说包超时了,这样记录下第二个路由器IP,由此能够一直进行下去,直到这个数据包到达目标主机,由此打印出全部经过的路由器。

由上述流程并配合使用IcmpSendEcho函数设置默认最大跳数为64,通过不间断的循环即可输出本机数据包到达目标之间的所有路由信息,代码片段如下所示;

// 实现路由跟中
void Tracert(char *Address)
{
ULONG hAddr = inet_addr(Address);
HANDLE handle = IcmpCreateFile();

IP_OPTION_INFORMATION ipoi;
memset(&ipoi, 0, sizeof(IP_OPTION_INFORMATION));

unsigned char SendData[32] = { "send ttl pack" };
int repSize = sizeof(ICMP_ECHO_REPLY)+32;
unsigned char pReply[128];
ICMP_ECHO_REPLY* pEchoReply = (ICMP_ECHO_REPLY*)pReply;

for (int ttl = 1; ttl < 64; ttl++)
{
ipoi.Ttl = ttl;
DWORD nPackets = IcmpSendEcho(handle, hAddr, SendData, sizeof(SendData), &ipoi, pReply, repSize, 1000);

if (pEchoReply->Status != 0)
{
in_addr inAddr;
inAddr.s_addr = pEchoReply->Address;
printf("-> 第 %2d 跳 --> 地址: %15s -> TTL: %2d \n", ttl, inet_ntoa(inAddr), pEchoReply->Options.Ttl);
}
}
IcmpCloseHandle(handle);
}

上述代码读者可自行运行并传入Tracert(HostAddress)被测试主机IP地址,即可输出当前经过路由的完整信息,如果路由TTL为0则可能是对端路由过滤掉了ICMP请求,如下图所示;