使用TCP或UDP时,需要在调用socket()函数时为它的第2个参数指定相应的类型,比如SOCK_STREAM是代表要使用TCP,而SOCK_DGRAM表示要使用UDP协议。除了可以指定这两种类型以外,还可以指定为原始套接字类型,即SOCK_RAW。当socket()函数的第2个参数指定为SOCK_STREAM或SOCK_DGRAM时,第3个参数可以缺省。而当socket()函数的第2个参数指定为SOCK_RAW时,第3个参数就必须明确指定需要使用的协议。
当套接字类型指定为SOCK_RAW时,协议类型的常用取值有IPPROTO_IP、IPPROTO_ICMP、IPPROTO_TCP、IPPROTO_UDP和IPPROTO_RAW。使用前四种类型,当发送数据时,系统会自动为数据加上IP首部并设置IP首部中的上层协议字段(如果有IP_HDRINCL选项,则系统不会自动添加IP首部);当接收数据时,系统不会将IP首部移除,需要程序自行处理。如果使用IPPROTO_RAW,那么系统将数据包直接送到网络层发送数据,并且需要程序自己构造IP首部中的字段。
本文通过介绍原始套接字实现经典的网络命令,即Ping命令。通过完成一个Ping命令来初步了解和掌握原始套接字的使用。
1. Ping命令的使用
Ping命令的目的是为了测试另一台主机是否可达,Ping命令发送一份ICMP回显请求报文给主机,并等待返回ICMP回显应答。一般来说,如果不能Ping到某台主机,那么就不能与该主机进行通信(例外的情况是对方主机的防火墙将进入主机的回显请求报文屏蔽掉了,这种情况虽然Ping不通,但是仍然可以正常进行通信)。
Ping命令有很多参数,打开命令行直接输入Ping后按下回车键,这样就可以看到Ping命令的参数列表,如图1所示。
图1 Ping命令的参数列表
通常情况下,用户都只是简单Ping一下某个主机的地址。Ping命令的参数可以是主机名称、域名和IP地址,后两者是较为常用的。下面简单演示一个Ping的例子,具体如下:
- C:\>ping 8.8.4.4
- Pinging 8.8.4.4 with 32 bytes of data:
- Reply from 8.8.4.4: bytes=32 time=57ms TTL=47
- Reply from 8.8.4.4: bytes=32 time=54ms TTL=47
- Reply from 8.8.4.4: bytes=32 time=54ms TTL=47
- Reply from 8.8.4.4: bytes=32 time=51ms TTL=47
- Ping statistics for 8.8.4.4:
- Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
- Approximate round trip times in milli-seconds:
- Minimum = 51ms, Maximum = 57ms, Average = 54ms
上面就是使用Ping命令对8.8.4.4这个IP进行回显请求后的输出信息。这里来解释一下请求后的回显信息的含义。
- Pinging 8.8.4.4 with 32 bytes of data:
正在将32字节数据发送到远程主机8.8.4.4,如果Ping的是一个域名或主机名的话,这里会将域名(主机名)转换为IP地址显示出来。
- Reply from 8.8.4.4: bytes=32 time=57ms TTL=47
本地主机已经收到回显应答信息,bytes=32表示有32字节,time=57ms表示公用了57毫秒,TTL表示的是生存时间值,该值可以进行设置,该值最大为255。每个处理数据包的路由器都需要把TTL的值减1或减去数据包在路由器中停留的秒数。由于大多数路由器转发数据包的延时都小于1秒,因此TTL最终成为一个跳站的计数器,所经过的每个路由器都将其值减1,当该值被减到0值时,该包将被丢弃。
- Ping statistics for 8.8.4.4:
- Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
- Approximate round trip times in milli-seconds:
- Minimum = 51ms, Maximum = 57ms, Average = 54ms
Ping 8.8.4.4的统计信息为:Sent=4表示发送了4个数据包,Received=4表示接收了4个数据包,Lost=0(0% loss)表示丢失的数据包是0个,丢包率为0%。
发送时间的大概情况:Mininum=51ms,最快是51ms,Maximum=57ms,最慢是57ms,Average=54ms,平均为54ms。
2. Ping命令的构造
Ping命令依赖的不是TCP,也不是UDP,它依赖的是ICMP。ICMP是IP层的协议之一,它传递差错报文以及其他需要注意的信息。ICMP报文通常被IP层或高层协议使用。ICMP封装在IP数据报内部,如图2所示。
图2 ICMP封装在IP数据报内部
ICMP报文的格式如图3所示。
图3 ICMP报文格式
ICMP协议的类型码与代码根据不同的情况,各自取不同的值。Ping命令类型码用到了2个值,分别是0和8。而代码的取值都是0。当类型码取值为0时,代码的0值表示回显应答;当类型码取值为8时,代码的0值表示请求回显。Ping命令发送一个ICMP数据报时,类型码为8,代码为0,表示向对方主机进行请求回显;当收到对方的ICMP数据报时,类型码为0,代码为0,表示收到了对方主机的回显应答。简单来说,Ping命令发出的数据中,类型是8,代码是0,如果对方有回应,那么对方回应的数据中,类型是0,代码是0。
在自己实现Ping命令时,就是去自己构造一个请求回显的ICMP数据报,然后进行发送。ICMP的数据结构定义如下:
- // ICMP 协议结构体定义
- struct icmp_header
- {
- unsigned char icmp_type; // 消息类型
- unsigned char icmp_code; // 代码
- unsigned short icmp_checksum; // 校验和
- unsigned short icmp_id; // 用来唯一标识此请求的 ID 号,通常设置为进程 ID
- unsigned short icmp_sequence; // 序列号
- unsigned long icmp_timestamp; // 时间戳
- };
ICMP的数据结构在网络开发中会经常用到,可以将其保存以备后用。
明白了ICMP协议的数据结构,现在用抓包工具(也可以称为协议分析工具)Wireshark来分析一下ICMP结构真实的情况,如图4所示。
图4 ICMP数据结构分析
在图4中,标识1的部分是对协议进行过滤设置的,在该部分输入“ICMP”可以让Wireshark只显示ICMP的数据记录。相应地,可以输入“TCP”、“UDP”、“HTTP”等协议进行筛选过滤。标识2的部分用于显示筛选后的ICMP记录,从这里可以明显看出源IP地址、目的IP地址和协议的类型。标识3的部分用于显示ICMP数据结构的值和附加的数据内容。最下面的部分显示了数据的原始的二进制数据,在熟练掌握协议后,查看原始的二进制数据也并不是不可能的。
3. Ping命令的实现
有了前面的基础,就可以构造自己的ICMP数据报来构造自己的Ping命令了。首先,定义两个常量,还有计算校验和的函数,具体如下:
- struct icmp_header
- {
- unsigned char icmp_type; // 消息类型
- unsigned char icmp_code; // 代码
- unsigned short icmp_checksum; // 校验和
- unsigned short icmp_id; // 用来唯一标识此请求的 ID 号,通常设置为进程 ID
- unsigned short icmp_sequence; // 序列号
- unsigned long icmp_timestamp; // 时间戳
- };
- #define ICMP_HEADER_SIZE sizeof(icmp_header)
- #define ICMP_ECHO_REQUEST 0x08
- #define ICMP_ECHO_REPLY 0x00
- // 计算校验和
- unsigned short chsum(struct icmp_header *picmp, int len)
- {
- long sum = 0;
- unsigned short *pusicmp = (unsigned short *)picmp;
- while ( len > 1 )
- {
- sum += *(pusicmp++);
- if ( sum & 0x80000000 )
- {
- sum = (sum & 0xffff) + (sum >> 16);
- }
- len -= 2;
- }
- if ( len )
- {
- sum += (unsigned short)*(unsigned char *)pusicmp;
- }
- while ( sum >> 16 )
- {
- sum = (sum & 0xffff) + (sum >> 16);
- }
- return (unsigned short)~sum;
- }
ICMP的校验值是一个16位的无符号整型,它会将ICMP协议头不的数据进行累加,当累加有溢出的话,会将溢出的部分也进行累加。具体计算校验和的算法就不过多介绍了,如果对校验和计算的代码不了解,可以进行单步调试来进行分析。再来看一下对于ICMP结构体的填充,具体代码如下:
- 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 };
- 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;
- // 拷贝数据
- // 这里的数据可以是任意的
- // 这里使用 abc 是为了和系统提供的看起来一样
- memcpy((szBuff + ICMP_HEADER_SIZE), "abcdefghijklmnopqrstuvwabcdefghi", 32);
- // 计算校验和
- pIcmp->icmp_checksum = chsum((struct icmp_header *)szBuff, sizeof(szBuff));
- sockaddr_in from_addr;
- char szRecvBuff[1024];
- int nLen = sizeof(from_addr);
- 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
- {
- struct icmp_header *pIcmp1 = (icmp_header *)(szRecvBuff + 20);
- printf("%s\r\n", inet_ntoa(from_addr.sin_addr));
- }
- return bRet;
- }
这就是Ping命令的全部代码了。自己写一个函数调用它进行测试。
在Windows XP以上的操作系统中运行时,比如Windows 8系统,程序可能会无法正常的运行,这是因为操作系统权限所导致的。在被编译好的程序上单击右键,在弹出的菜单上选择“以管理员身份运行”,这样程序就可以正常的执行了。