ICMP 协议用于在IP 主机、路由器之间传递控制消息,这里的控制消息可以包括很多种,例如数据报错误信息、网络状况信息、主机状况信息等,这些控制消息虽然并不传输用户数据,但是对于用户数据报的有效递交起着重要作用。从 TCP/IP 的分层结构上看,ICMP 属于网络,它配合 IP 协议完成数据报的递交,提高数据报递交的有效性,但是 ICMP 协议报文有着自己的组织结构,且 ICMP 报文是被封装在 IP 数据报中发送的。本章将包括以下基本内容:
ICMP 概念与 ICMP 报文的交付;
ICMP 报文的分类及其报文格式;
源代码中实现 ICMP 协议的数据结构和函数;
ping 命令与 ICMP 洪水。
一、基础知识
1、ICMP协议
IP 协议本身不提供差错报告和差错控制机制来保证数据报递交的有效性,在路由器无法递交一个数据报,或者数据报生存时间为 0 时,路由器都会直接丢弃掉这个数据报。尽管路由器 IP 层认为这样的处理是合理的(可以提高数据报处理效率),但是在很多情况下,源主机还是期望在数据报递交出现异常的情况下得到相关的失败信息,以便进行重传或者其他处理。
另一方面,IP 协议缺少一个辅助机制,即主机的管理和查询机制。在某些情况下,源主机需要确定另一个主机或者路由器是否是活跃的,对于不活跃的主机,就没有必要再向它发送数据报了,因为这是徒劳的。在另外一些情况下,一个主机的管理员期望能获得另一个主机或者路由器上的信息,以根据这些信息进行主机自身的配置、数据报发送控制等。
为了解决上述两个问题,设计人员给 TCP/IP 协议引进了一种具有特殊用途的报文机制,称为网际报文控制协议(Internet Control Message Protocol,简称 ICMP),ICMP 协议是支持 IP 协议的重要机制,从 TCP/IP 的分层结构上来看,它同 IP 协议一样,处于网络层,但 ICMP 协议有自己的一套报文格式,且它需要使用 IP 协议来递交报文,即 ICMP 报文是放在 IP 数据报中的数据区域发送的 。总之,ICMP 协议是支持 IP 协议实现的不可或缺的机制,每一种 TCP/IP 协议的实现都应该支持它。
2、报文交付
ICMP 使用 IP 进行交互,是因为一个报文可能要经过几个物理网络才能到达其最终目的地,因此它不可能单独通过某个物理传输进行交付,必须使用 IP 提供的交付服务,屏蔽掉各种底层物理结构的差异。由于 IP 数据报本身被放在物理数据帧中进行发送,因此,ICMP 报文本身也可能丢失或者出现传输错误。
3、报文类型
从功能上划分,ICMP 报文可以分为两大类:ICMP 差错报告报文和 ICMP 查询报文,分别用于解决上面说到的两个问题。差错报告报文主要用来向 IP 数据报源主机返回一个差错报告信息, 这个错误报告信息产生的原因是路由器或主机不能对当前数据报进行正常的处理,例如无法将数据报递交给有效的协议上层,又例如数据报因为生存时间 TTL 为 0 而被删除等。
查询报文用于一台主机向另一台主机查询特定的信息,通常查询报文都是成对出现的,即源主机发起一个查询报文,在目的主机收到该报文后,会按照查询报文约定的格式为源主机返回一个应答报文。两大种类的 ICMP 报文及其常见类型如表 11-1 所示。
4、报文格式
5、差错校验
在表 111 中所示的 5 种差错报文类型中,LwIP 协议栈能够根据数据报的处理异常情况发送目的站不可达报文和数据报超时报文;另一方面,当协议栈收到任何类型的差错报告报文时,会直接将报文丢弃,不做任何处理。因此,本小节重点讲解目的站不可达报文和数据报超时报文。 目的站不可达
当路由器不能给数据报找到合适的路由路径,或者主机不能将数据报递交给上层协议时,相应的 IP 数据报就会被丢弃,然后一个目的站不可达差错控制报文将会被返回给源主机。目的站不可达差错可以由很多因素引起,例如网络不可达、主机不可达、协议不可达、端口不可达等,可以在报文首部中的代码字段指出具体原因。
在第 10 章的讲解中,已经看到了 ICMP 目的站不可达差错报文的身影:IP 层数据报处理函数 ip_input 通过首部中的协议字段值来判断数据报应该递交给哪个上层协议处理,例如,若是 UDP 协议,则调用 udp_input 函数处理;若是 ICMP 协议,则调用 icmp_input 函数处理;如果没有任何协议能接受这个数据报,则调用 icmp_dest_unreach 函数发送一个目的(协议)不可达 ICMP 差错报文给源主机。目的站不可达报文具体格式如图 113所示。
为什么还需要装载引起差错的数据报数据区的前 8 个字节呢?因为这 8 个字节恰好覆盖了 TCP 报文或UDP 报文中的端口号字段,IP 层能够根据这个端口号把 ICMP 报文传递给具体的上层处理。
数据报超时
数据报超时可以用来防止数据报在网络中被循环的路由,在 IP 首部中都有一个生存时间计数器(TTL),数据报每被转发一次,TTL 的值便会减 1,当 TTL 的值被减为 0 时,数据报会被网络丢弃,同时一个 ICMP 超时报文会被返回给源主机。 事实上,在讲解 IP 层的数据报处理时,也已经见到过了 ICMP 超时报文的身影。在数据报转发函数 ip_forward 中,需要将数据报首部中的 TTL值减 1,若此时 TTL 值变为 0,则该数据报被丢弃,同时调用函数 icmp_time_exceeded 向源主机返回一份 ICMP 超时报文;此外,在分片重装的周期性处理函数中,也用到了 ICMP 超时报文,若某个数据报在重装过程中,由于其重装时间超时,而数据分片还没有全部到达,此时与该数据报所有相关的分片将被删除,同时,一个 ICMP 超时报文将被返回给源主机。数据报超时报文结构如图114 所示,从图中可以看出,它与目的站不可达报文的结构完全相同。
6、查询报文
这里将重点讲解的,也是 LwIP 中唯一实现的一种查询报文:回送请求或回答报文。LwIP 协议栈能接收外部主机的回送请求报文,并根据报文返回一个 ICMP 回答报文。主机或路由器指明某个目的主机发送 ICMP 回送请求报文,任何收到回送请求的目的主机都会生成一个回送回答报文,并返回给源主机。回收请求和回送回答报文可以直接确定两台主机的 IP 协议是否能够正常通信。
事实上,在前面的章节中已经见到过回送请求和回答的身影,在协议栈移植时,我们在 Windows控制台上使用 ping 命令来测试开发板上的协议栈是否移植成功,其本质上就是向开发板发送一个ICMP 回送请求报文;开发板上的协议栈收到这样的报文后,会产生一个回答报文并返还给主机;当 Windows 主机正确地接收到这个回答报文时,说明我们的移植成功了,开发板协议栈成功运行了起来。回送请求和回答的报文格式如图 115 所示。
类型字段指出了是请求报文(8)还是回答报文(0);
代码段无特殊取值,始终为 0;
首部中的标识符和序号两个字段在 ICMP 协议中没有正式定义其取值规范,因此发送方可以自由使用这两个字段,例如可以用序号来记录源主机发送出去的回送请求报文编号。
可选数据区域表示回送请求报文中可包含的数据,其长度是可选的,发送方应该选择合适的长度并填充相应的数据。在接收方,它将根据这个回送请求产生一个回送回答,回送回答中的数据与回送请求中的数据应该完全相同。
二、ICMP的实现
1、数据结构
源文件中的 icmp.h 和 icmp.c 实现了与 ICMP 协议相关的数据结构和函数。先看看相关的数据结构的定义:
————icmp.h———————————————— //首先是宏定义,定义常见的 ICMP 报文类型,如表 11-1 所示 #define ICMP_ER 0 //回送回答 #define ICMP_DUR 3 //目的站不可达 #define ICMP_SQ 4 //源站抑制 #define ICMP_RD 5 //重定向(改变路由) #define ICMP_ECHO 8 //回送请求 #define ICMP_TE 11 //数据报超时 #define ICMP_PP 12 //数据报参数错误 #define ICMP_TS 13 //时间戳请求 #define ICMP_TSR 14 //时间戳回答 #define ICMP_IRQ 15 //信息请求 #define ICMP_IR 16 //信息回答 //枚举类型,定义目的站不可达报文中的代码字段常用取值,如表 11-2 所示 enum icmp_dur_type { ICMP_DUR_NET = 0, //网络不可达 ICMP_DUR_HOST = 1, //主机不可达 ICMP_DUR_PROTO = 2, //协议不可达 ICMP_DUR_PORT = 3, //端口不可达 ICMP_DUR_FRAG = 4, //需要分片但不分片位置位 ICMP_DUR_SR = 5 //源路由失败 }; //枚举类型,定义数据报超时报文中的代码字段取值,如表 113 所示 enum icmp_te_type { ICMP_TE_TTL = 0, //生存时间计数器超时 ICMP_TE_FRAG = 1 //分片重装超时 }; //定义 ICMP 回送请求报文首部结构,由于所有类型 ICMP 报文首部都有很大的相似性, //所以这个结构也可以用于其他类型的 ICMP 报文 PACK_STRUCT_BEGIN struct icmp_echo_hdr { //参见图 115 PACK_STRUCT_FIELD(u8_t type); //类型 PACK_STRUCT_FIELD(u8_t code); //代码 PACK_STRUCT_FIELD(u16_t chksum); //校验和 PACK_STRUCT_FIELD(u16_t id); //标识符 PACK_STRUCT_FIELD(u16_t seqno); //序号 } PACK_STRUCT_STRUCT; PACK_STRUCT_END //定义两个宏,用于读取 ICMP 首部中的字段 #define ICMPH_TYPE(hdr) ((hdr)>type) #define ICMPH_CODE(hdr) ((hdr)>code) //定义两个宏,用于向 ICMP 首部字段中写入相应值 #define ICMPH_TYPE_SET(hdr, t) ((hdr)>type = (t)) #define ICMPH_CODE_SET(hdr, c) ((hdr)>code = (c)) ——————————————————————————————
上面这些宏以及数据结构的定义很简单,只有一个需要特别指出的地方。对于不同类型的报文,虽然它们各自的结构存在一定的差异性,但是首部的差异还是很小的,特别是首部的前 4 个字节在所有类型的报文中都一样。源代码中只定义了 ICMP 回送请求报文首部结构,但是这个结构也可以拿来描述其他类型的首部。
2、发送差错报文
在数据报不能递交给任何一个上层协议时,函数 icmp_dest_unreach 会被调用,以发送一个目的不可达 ICMP 差错报文给源主机,引起目的不可达的具体原因是协议不可达;
另一种差错报文是超时报文,发送超时报文的函数叫做 icmp_time_exceeded,在数据报转发和分片重装的过程中,都可能调用该函数,引发超时的具有原因可能有两种:一是数据报 TTL 为 0;二是分片重装时间超时。
这里,来看看上述两种差错报文是怎么样被发送的。
————icmp.c———————————————————— //定义宏,引起差错的 IP 数据报数据区将被差错报文装载的长度 #define ICMP_DEST_UNREACH_DATASIZE 8 //函数功能:发送一个目的地址不可达差错报文 //参数 p:引起差错的 IP 数据报 pbuf 指针 //参数 t:目的不可达的原因(报文的代码字段) void icmp_dest_unreach(struct pbuf *p, enum icmp_dur_type t) { icmp_send_response(p, ICMP_DUR, t); //调用函数发送一个 ICMP_DUR 类型的差错报文 } //函数功能:发送一个数据报超时差错报文 //参数 p:引起超时的 IP 数据报 pbuf 指针 //参数 t:超时的原因(报文的代码字段) void icmp_time_exceeded(struct pbuf *p, enum icmp_te_type t) { icmp_send_response(p, ICMP_TE, t); //调用函数发送一个 ICMP_TE 类型的差错报文 } //函数功能:发送一个 ICMP 差错报文 //参数 p:引起差错的 IP 数据报 pbuf 指针 //参数 type:差错报文的具体类型 //参数 code:差错报文的代码字段 static void icmp_send_response(struct pbuf *p, u8_t type, u8_t code) { struct pbuf *q; struct ip_hdr *iphdr; //这里,用一个回送请求报文首部来描述差错报文的首部 struct icmp_echo_hdr *icmphdr; //为差错报文申请 pbuf 空间,pbuf 中预留 IP 首部和以太网首部空间,pbuf 的数据区 //长度为差错报文首部长度+差错报文数据长度(IP 首部长度+8) q = pbuf_alloc(PBUF_IP,sizeof(struct icmp_echo_hdr) + IP_HLEN + ICMP_DEST_UNREACH_DATASIZE,PBUF_RAM); if (q == NULL) { //申请失败,则直接返回 return; } iphdr = p>payload; //指向引起差错的 IP 数据报首部 icmphdr = q>payload; //指向差错报文首部 icmphdr>type = type; //填写类型字段 icmphdr>code = code; //填写代码字段 icmphdr>id = 0; //对于目的不可达和数据报超时 icmphdr>seqno = 0; //报文,首部剩余的 4 个字节都为 0 //将引起差错的 IP 数据报的 IP 首部+8 字节数据拷贝到差错报文的数据区域 SMEMCPY((u8_t *)q>payload + sizeof(struct icmp_echo_hdr), (u8_t *)p>payload,IP_HLEN + ICMP_DEST_UNREACH_DATASIZE); icmphdr>chksum = 0; //将报文中的校验和字段清 0 icmphdr>chksum = inet_chksum(icmphdr, q>len); //计算并填写校验和 //调用 IP 层函数输出 ICMP 报文 ip_output(q, NULL, &(iphdr>src), ICMP_TTL, 0, IP_PROTO_ICMP); pbuf_free(q); //释放报文占用的 pbuf } ————————————————————————————————————
这里的重点在于函数 icmp_send_response,它为报文申请空间,然后根据报文类型和代码字段值填写数据,然后计算校验和,最后发送,一切都是如此的简单自然,一气呵成,不多说了!
3、ICMP报文的处理
IP 层收到 ICMP 报文,会调用 icmp_input 函数处理,该函数根据报文的不同类型做出不同处理。目前 LwIP 只支持 ICMP 回送请求报文的处理,而对其他类型的 ICMP 报文直接丢弃,不做任何响应,这在嵌入式产品中足够用了。对于 ICMP 回送请求,icmp_input 需要生成一个回送回答报文并返回给源主机。
//函数功能:处理协议栈收到的 ICMP 报文,在 ip_input 中被调用 //参数 p:收到的 ICMP 报文 pbuf,pbuf 的 payload 指向装载该报文的 IP 数据报首部 //参数 inp:接收到 ICMP 报文的网络接口结构 void icmp_input(struct pbuf *p, struct netif *inp) { u8_t type; struct icmp_echo_hdr *iecho; struct ip_hdr *iphdr; struct ip_addr tmpaddr; //IP 地址结构 s16_t hlen; iphdr = p->payload; //指向 IP 数据报首部 hlen = IPH_HL(iphdr) * 4; //计算 IP 首部长度 //调整 pbuf 的 payload 指针,使其指向 ICMP 报文首部,若调整失败,或者 ICMP 报文 //首部太小(小于 4 字节),直接跳到 lenerr 处执行返回操作 if (pbuf_header(p, -hlen) || (p->tot_len < sizeof(u16_t)*2)) { goto lenerr; } type = *((u8_t *)p->payload); //获得 ICMP 首部中的类型字段值 switch (type) { //根据不同类型做出不同处理 case ICMP_ECHO: //若是回送请求,则做如下处理 {//首先检查报文的目的地址是否合法 int accepted = 1; //局部标志量,标志是否对 ICMP 回送请求进行回应 if (ip_addr_ismulticast(&iphdr->dest)) { //如果目的地址为多播地址,不回应 accepted = 0; } if (ip_addr_isbroadcast(&iphdr->dest, inp)) {//目的地址为广播地址,不回应 accepted = 0; } if (!accepted) { //如果不回应标志有效 pbuf_free(p); //则释放接收到的报文 return; //直接返回 } } //再检查报文长度是否合法 if (p->tot_len < sizeof(struct icmp_echo_hdr)) {//如果报文长度比 ICMP 首部还小 goto lenerr; //不合法,跳到 lenerr 处执行返回操作 } //再判断校验和是否正确 if (inet_chksum_pbuf(p) != 0) { //校验和不正确 pbuf_free(p); //则释放接收到的报文 return; //直接返回 } //到这里,所有的校验工作都通过了,我们直接调整回送请求报文中的相关字段, //生成回送回答报文:交换数据报中的源 IP 地址与目的 IP 地址,填写报文类型 //字段,重新计算 ICMP 报文校验和。 iecho = p->payload; //指向请求报文首部 tmpaddr.addr = iphdr->src.addr; //将数据报中的源 IP 地址进行暂存 iphdr->src.addr = iphdr->dest.addr; //填写数据报中的源 IP 地址 iphdr->dest.addr = tmpaddr.addr; //填写数据报中的目的 IP 地址 ICMPH_TYPE_SET(iecho, ICMP_ER); //填写报文类型为回送回答(0) //重新填写报文的校验和字段,这里的计算方法比较特殊,因为回送回答相对于 //回送请求来说,只有报文首部类型值改变了,只适当调整原来的校验和即可 if (iecho->chksum >= htons(0xffff - (ICMP_ECHO << 8))) {//分两种情况调整校 iecho->chksum += htons(ICMP_ECHO << 8) + 1;//验和,见后续讲解 } else { iecho->chksum += htons(ICMP_ECHO << 8); } IPH_TTL_SET(iphdr, ICMP_TTL); //设置 IP 数据报中的 TTL 字段 IPH_CHKSUM_SET(iphdr, 0); //IP 首部校验和清 0 IPH_CHKSUM_SET(iphdr, inet_chksum(iphdr, IP_HLEN)); //计算并填写首部检验和 //在将报文递交给 IP 层发送前,需要先将 pbuf 的 payload 指针指向 IP 首部 if(pbuf_header(p, hlen)) { //调整 payload 指针,失败该函数返回 1 LWIP_ASSERT("Can't move over header in packet", 0); //打印相关信息 } else { //调整指针成功,则执行发送工作 err_t ret; //调用 ip_output_if 直接发送,并设置 IP_HDRINCL,表示 ret = ip_output_if(p, &(iphdr->src), IP_HDRINCL, //IP 首部已被组装好 ICMP_TTL, 0, IP_PROTO_ICMP, inp); } break; // case ICMP_ECHO default: //对于其他类型的 ICMP 报文,不做任何处理,删除后直接返回 pbuf_free(p); return; }//switch lenerr: //报文校验错误,跳到这里执行并返回 pbuf_free(p); return; }
来看看 icmp_input 函数做了哪些工作。
首先是一系列的校验工作,将传进来的数据报 pbuf 的 payload 指针调整为指向 ICMP 首部,并判断 ICMP 头部长度是否小于 4 个字节,若是,则说明是个错误的 ICMP 数据包,该包被丢弃。对于正确的 ICMP 包,函数根据其头部类型字段的值判断该做什么样的处理。对于 ICMP 回送请求报文,需判断该报文的目的地址是否合法,对目的地址为多播地址和广播地址的请求报文,不做处理;接下来判断该报文大小是否小于 ICMP 回送请求首部长度,是则丢弃报文。
当所有校验工作成功后,就可以根据这个请求报文产生一个回答报文了,回答报文不用重新去开辟一个新的内存空间,直接重复利用请求报文的空间即可,它们二者之间只有报文的类型字段有差异。过程很简单,利用请求报文的 pbuf 空间,将该 ICMP 报文类型字段变为 0,重新计算校验和,最后将 IP 首部中的源 IP 地址和目的 IP 地址交换位置,这样就生成了回送回答报文。
最后使用 ip_output_if 函数将整个报文发送出去。ICMP 回送回答将回送请求报文中的数据原样返回给源主机,ip_output_if 函数调用时,设置了 HDRINCL 标志,表示这个 pbuf 中已经包含了组装好的 IP 首部,这样函数就不用再填充 IP 首部了。源主机在收到这份回送回答报文后,通过处理回送回答报文,可以计算出往返时间、路由跳数等参数。
最后,整个代码中还有一个值得重点讲解的,就是 ICMP 回答报文中的校验和计算。这里 ICMP回答报文校验和的计算有一个很大的技巧,它并不是直接调用函数将所有的数据进行重新求和,而
是用了一个简单的算式来调整首部中的校验和字段 。
4、ping命令
“ping”这个名字源于声纳定位操作,目的是为了测试另一台主机是否可达。ping 程序的本质就是发送一份 ICMP 回送请求报文给目的主机,并等待返回 ICMP 回送回答,因此,整个过程读者一定应该清楚了。
一般来说,如果不能 ping 到某台主机,那么就不能 Telnet 或者 FTP 到那台主机,反过来,如果不能 Telnet 到某台主机,那么通常可以用 ping 程序来确定问题出在哪里。ping 程序还能测出数据报到目的主机的往返时间,以表明目的主机离我们有“多远”,此外 ping 程序还可以在数据报的路由过程中记录下路由路径(利用 IP 首部中的选项字段)。
如图 116 所示,它显示了 Windows 下 ping 命令的格式。在前面移植协议栈的过程中,我们曾用过它来测试开发板是否通信良好
ping 命令的各个选项在上图中有了清晰的说明,来说说几个比较重要的选项。首先是t,若在ping 命令中使用该选项,则 ping 程序会周期性、不间断地向目的主机发送 ping 包;n 则可以指出我们需要发送的 ping 包的数目,若未指定这个值,则默认的数目为 4;l 表示每个 ping 包中携带的数据长度,在最前面的实验中可以看到,这个默认长度为 32;剩下的几个选项是针对 IP 数据报首部的,例如i,指出该数据报的最大转发次数等。
这里,可以用下面这条语句来 ping 我们的开发板:
ping n 8 l 4000 192.168.1.37
它表示向开发板发送 8 个 ping 数据包,每个数据包携带的数据长度为 4000 字节。相应的 ping测试结果如图 117 所示。对于如此大的一个数据报,在 IP 层必然产生分片和重装操作。图中测试出的往返时间很长,一方面是因为分片的重装,而另一方面是因为笔者测试时打印输出各种调试信息的缘故。
5、ICMP洪水
如果在 ping 开发板时,再加大 ping 数据包的大小,如图 118 所示,则测试结果也在图中有了显示,我们的板子 ping 不通了,这是什么原因呢?因为在协议栈内部,为数据报接收而预留的空 间并不能放下如此大的一个 ICMP 数据报文,或者如此大的一个报文在分片重装过程中,超出了重装条件的限制(如 LwIP 中的 pbuf 使用个数限制),因此,协议栈会直接将报文丢弃,不做任何回应,这导致了主机一直接收不到 ICMP 回送回答。
上面这个测试描述出了目前普遍存在的一种网络攻击——ICMP 洪水攻击的雏形。其原理可以看成是网络黑客利用他能控制的多台中间人计算机(傀儡主机,或者更专业点,称之为肉机)一起向目标机器发送大量看似合法的 ICMP 回送请求数据包,造成目标机器网络阻塞或服务器资源耗尽而导致拒绝服务产生,而另一方面,合法的网络数据报被虚假的数据报淹没而无法在网络中被转发,合法的用户不能正常使用网络。当很多机器长时间、连续、大量地发送 ICMP 请求数据包给某个受攻击主机时,会最终导致目标主机耗费大量的 CPU 资源处理和内存资源,从而出现死机等情况,无法对正常用户提供网络服务。
对于 ICMP 洪水攻击,可以采取两种方法进行防范:第一种方法是在路由器上对 ICMP 数据包进行带宽限制,将 ICMP 占用的带宽控制在一定的范围内,这样即使有 ICMP 攻击,它所占用的带宽也是非常有限的,对整个网络的影响将会非常少;第二种方法就是在主机上设置 ICMP 数据包的处理规则,如果允许,可以拒绝向所有的 ICMP 数据包服务 。