15.2 主機探測與路由追蹤

2023-10-19 09:00:28

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請求,如下圖所示;

本文作者: 王瑞
本文連結: https://www.lyshark.com/post/6ffe4618.html
版權宣告: 本部落格所有文章除特別宣告外,均採用 BY-NC-SA 許可協定。轉載請註明出處!