利用ICMPv4协议实现一个ping程序

文章目录
  1. 1. 抓包
  2. 2. ICMP协议数据报
  3. 3. ping程序原理
  4. 4. Cpp代码实现
  5. 5. traceroute
  6. 6. 问题记录
  7. 7. 参考

Icmp(Internet Control Message Protocol)协议一般与IP协议结合使用,以便给IP协议提供诊断和控制信息。
Icmp通常被认为是Ip协议的一部分,传输的时候也是被封装在Ip报文内。
我们在判断网络状况时用的ping程序就利用了ICMP协议。接下来先运行系统上的ping程序,用tcpdump抓包查看一下传输的数据。
然后解释一下icmp数据报的各个字段。最后思考一下ping程序的结构,然后用c++实现一个自己的ping程序。

抓包

首先在1号终端运行tcpdump。

1
tcpdump -X > icmp.txt   # -X是以十六进制和ascii显示数据

在2号终端执行ping程序,然后ctrl+C退出ping程序。

1
2
3
4
5
6
7
8
root@yifei:/home/yifei# ping baidu.com
PING baidu.com (220.181.38.148) 56(84) bytes of data.
64 bytes from 220.181.38.148 (220.181.38.148): icmp_seq=1 ttl=44 time=5.36 ms
64 bytes from 220.181.38.148 (220.181.38.148): icmp_seq=2 ttl=44 time=5.30 ms
^C
--- baidu.com ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1000ms
rtt min/avg/max/mdev = 5.300/5.330/5.360/0.030 ms

查看icmp.txt中的icmp数据包,查找到跟220.181.38.148的通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
13:10:07.606759 IP yifei > 220.181.38.148: ICMP echo request, id 7657, seq 1, length 64
0x0000: 4500 0054 e26f 4000 4001 a2ee ac15 05ec E..T.o@.@.......
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
0x0010: dcb5 2694 0800 324a 1de9 0001 af58 195e ..&...2J.....X.^
^^^^^^^^^ *******************
0x0020: 0000 0000 1742 0900 0000 0000 1011 1213 .....B..........
0x0030: 1415 1617 1819 1a1b 1c1d 1e1f 2021 2223 .............!"#
0x0040: 2425 2627 2829 2a2b 2c2d 2e2f 3031 3233 $%&'()*+,-./0123
0x0050: 3435 3637 4567
13:10:07.612103 IP 220.181.38.148 > yifei: ICMP echo reply, id 7657, seq 1, length 64
0x0000: 4500 0054 e26f 4000 2c01 b6ee dcb5 2694 E..T.o@.,.....&.
0x0010: ac15 05ec 0000 3a4a 1de9 0001 af58 195e ......:J.....X.^
0x0020: 0000 0000 1742 0900 0000 0000 1011 1213 .....B..........
0x0030: 1415 1617 1819 1a1b 1c1d 1e1f 2021 2223 .............!"#
0x0040: 2425 2627 2829 2a2b 2c2d 2e2f 3031 3233 $%&'()*+,-./0123
0x0050: 3435 3637 4567

可以看到本机先给baidu发送一个ICMP请求,然后baidu给回复了一条消息。
在第一个数据包中,用^标示的是ip首部,用*标示的是icmp首部,剩下的是icmp数据,具体每个字段的意义,在本文下一节中讲解。

ICMP协议数据报

下图是icmp报文被ip协议封装后的结构:

1
2
3
----------------------------------------
| Ipv4头部 | Icmp头部 | Icmp数据 |
----------------------------------------

下图为icmp协议头部的结构:

1
2
3
4
5
6
7
|------------------------------------------------|
| 类型(8位) | 代码(8位) | 校验和(16位) |
|------------------------------------------------|
| 依赖类型和代码的内容 |
|------------------------------------------------|
| 数据(可选) |
|------------------------------------------------|

ping程序的回显请求中,类型为8,代码为0时,结构是下图这样,多了标识符和序列号两个字段:

1
2
3
4
5
6
7
|------------------------------------------------|
| 类型(8位) | 代码(8位) | 校验和(16位) |
|------------------------------------------------|
| 标识符(16位) | 序列号(16位) |
|------------------------------------------------|
| 数据(可选) |
|------------------------------------------------|

  • 其中的类型和代码字段都是8位,这两个字段决定了Icmp数据报的用途,两个字段表示的组合如下:
    20200106 icmp.jpeg
  • 校验和16位,涵盖了icmp的报头和数据两部分。
  • 标识符可以设置为进程pid号,因为没有端口号来区分数据报了。
  • 序列号可以是从0开始的序号,用来标识icmp数据报。

ping程序原理

了解了icmp协议之后,ping程序的原理就很好理解了,可以分为以下几步。
1.将输入的域名转为ip地址。
2.填充icmp数据报。
3.创建原始套接字。
4.使用sendoto发送icmp报文。
5.使用recvfrom接受数据报。

Cpp代码实现

*code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <netdb.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/time.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <ctime>
#include <signal.h>
using namespace std;

enum{ICMP_DATA_LEN=1024};
enum{IP_DATA_LEN=1024};
enum{RECV_MAX_LEN=1024};

/* 暂时用不着ip头部的结构体
//IP报头
typedef struct{
unsigned char hdr_len; //4位头部长度 & 4位版本号
unsigned char tos; //8位服务类型
unsigned short total_len; //16位总长度
unsigned short identifier; //16位标识符
unsigned short frag_and_flags; //3位标志加13位片偏移
unsigned char ttl; //8位生存时间
unsigned char protocol; //8位上层协议号
unsigned short checksum; //16位效验和
unsigned int sourceIP; //32位源IP地址
unsigned int destIP; //32位目的IP地址
}IpHeader;

//整个ip数据报
typedef struct{
IpHeader ih;
char data[IP_DATA_LEN];
}IpComplete;
*/

//ICMP报头
typedef struct{
unsigned char type; //8位类型字段
unsigned char code; //8位代码字段
unsigned short cksum=0; //16位效验和
unsigned short id; //16位标识符
unsigned short seq; //16位序列号
}IcmpHeader;

//整个icmp数据报
typedef struct{
IcmpHeader icmph;
char data[ICMP_DATA_LEN];
}IcmpComplete;

pid_t pid; //当前进程pid
int seqnum=0; //icmp头部中的序列号
struct sockaddr_in sai; //目的ip地址
char ipaddr[20]; //char类型ip地址
clock_t second,first; //记录每个数据报发出~接受到回复的时间
int lostpacks=0,sumpacks=0;//记录数据报总数和丢包数

//计算校验和
unsigned short CheckSum(unsigned short *addr,int len){
int nleft=len;
unsigned int sum=0;
unsigned short *w=addr;
unsigned short answer=0;
while(nleft>1){
sum+=*w++;
nleft-=2;
}
if(nleft==1){
*(unsigned short *)(&answer)=*(unsigned char *)w;
sum+=answer;
}
sum=(sum>>16)+(sum&0xffff);
sum+=(sum>>16);
answer=~sum;
return answer;
}

//填充整个icmp数据报
void FillIcmpCom(IcmpComplete *icmpcom){
memset(icmpcom,0,8+sizeof(icmpcom->data));
icmpcom->icmph.type=(unsigned char)8;
icmpcom->icmph.code=0;
icmpcom->icmph.cksum=0;
pid=getpid();
icmpcom->icmph.id=pid;
icmpcom->icmph.seq=seqnum;
icmpcom->icmph.cksum=CheckSum((unsigned short*)icmpcom,sizeof(icmpcom));
}

//根据域名获取ip地址
void GetIpByName(char *argv){
struct addrinfo*res;
if(getaddrinfo(argv,nullptr,nullptr,&res)!=0){
cout<<"地址解析出错!"<<endl;
return ;
}else{
//网络字节序的ip地址转为ascii字符数组中点分十进制的ip地址
inet_ntop(AF_INET,(void*)&((sockaddr_in*)(res->ai_addr))->sin_addr,ipaddr,16);
sai.sin_family=AF_INET;
sai.sin_addr.s_addr=inet_addr(ipaddr);
cout<<"PING "<<argv<<"("<<ipaddr<<")"<<endl;
}
freeaddrinfo(res);
}

//处理SIGINT信号
void handler(int data){
cout<<endl<<"------------statistics-------------"<<endl;
cout<<sumpacks<<" packets trasimitted,"<<lostpacks<<" packets loss"<<endl;
exit(0);
}

int main(int argc,char **argv){
GetIpByName(argv[1]);

int sockfd;
//创建icmp协议的原生套接字,需要自己构造icmp头部(ip头部系统自动填充,可以通过设置IP_HDRINCL套接字选项自己构造ip头部)
sockfd=socket(AF_INET,SOCK_RAW,IPPROTO_ICMP);
if(sockfd<0){
cout<<"创建socket出错。"<<endl;
return 0;
}

struct timeval time_out;
time_out.tv_sec=1;
time_out.tv_usec=0;
//设置recvfrom函数的接收超时为1秒。
if(setsockopt(sockfd,SOL_SOCKET,SO_RCVTIMEO,(void*)&time_out,sizeof(time_out))<0){
cout<<"设置接收超时出错!"<<endl;
}
int errnum=0;
char recbuf[RECV_MAX_LEN];
IcmpComplete icmpcomplete;
signal(SIGINT,handler); //设置ctrl+c中断的处理函数
for(;;){
seqnum++;
FillIcmpCom(&icmpcomplete);

//发送整个icmp数据报
if((errnum=sendto(sockfd,(void*)&icmpcomplete,sizeof(icmpcomplete),0,(struct sockaddr *)&sai,sizeof(sai)))<0){
cout<<"ICMP数据报发送失败! "<<errno<<" "<<strerror(errno)<<endl;;
return 0;
}else{
first=clock();
}

//收到的recbuf数据包括ip头部,icmp头部,icmp数据
if(recvfrom(sockfd,recbuf,sizeof(recbuf),0,nullptr,nullptr)<0){
cout<<"*"<<endl;
lostpacks++;
}else{
second=clock();
cout<<sizeof(recbuf)<<" bytes from("<<ipaddr<<"): "
<<"icmp_seq="<<seqnum<<" "
<<"ttl="<<(int)recbuf[8]<<" "
<<"time="<<(second-first)<<" ms"<<endl;
}
sleep(1);
sumpacks++;
}
return 0;
}

  • 编译运行
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    \> gcc myping.cpp -o myping
    \> ./myping www.baidu.com
    PING baidu.com(39.156.69.79)
    1024 bytes from(39.156.69.79): icmp_seq=1 ttl=40 time=73 ms
    1024 bytes from(39.156.69.79): icmp_seq=2 ttl=40 time=47 ms
    1024 bytes from(39.156.69.79): icmp_seq=3 ttl=40 time=40 ms
    1024 bytes from(39.156.69.79): icmp_seq=4 ttl=40 time=39 ms
    1024 bytes from(39.156.69.79): icmp_seq=5 ttl=40 time=40 ms
    ^C
    ------------statistics-------------
    4 packets trasimitted,0 packets loss

traceroute

traceroute工具可以查看数据报在传输过程中都是经过了哪些节点。这利用了ip协议中的ttl字段。
traceroute首先发送ttl字段为1的ip数据报,然后分别发送ttl字段为2,3,…的数据报。
当数据报经过一个节点,ttl变为0之后,就会给发送方回复一个icmp数据报。这样就能获得每个节点的ip地址了。
将之前的程序进行简单改动,就能实现traceroute的效果。

1
2
3
1.在每次sendto发送icmp数据报之前,使用该函数可以直接设置IP数据报中的ttl字段,不用自己填充ip头部了。
setsockopt(sockfd,IPPROTO_IP,IP_TTL,(void*)&ittl,sizeof(ittl))
2.接受到返回的消息后,输出ip头部中的源ip地址。

问题记录

  • sendto函数的地址参数,直接传入getaddrinfo()得到的res->ai_addr,总是返回invalid argument错误,自己填充一个sockaddr_in传入就好了。
  • 自己的ping程序测试的time要比Linux自带的高一些。

参考

  • 《TCP/IP详解(卷1:协议)》第二版

欢迎与我分享你的看法。
转载请注明出处:http://taowusheng.cn/