kaspterio code as a hacker

25 December 2015

prerequisites

socket api是网络编程的基础,是对两台主机间利用各个通信协议进行通信行为的封装,操作系统以系统调用的形式将这个统一的接口提供给应用程序。各个编程语言的网络编程接口的底层都是基于操作系统的socket api,因此,了解它们的行为非常重要。仅仅了解每个系统调用是干什么的是不够的,本文将以TCP协议为例,从内核和tcp协议栈的角度去介绍这些系统调用干了什么、怎么做的以及一些实现机制。

TCP协议一些概念

  • TCP的可靠性:TCP协议在传输数据时,接收方需要ack发送方告知数据收到了。如果在一定时间内没有ack,发送方将重发,重发几次任然失败的话将放弃并告知上层(这个过程一般在4-10分钟)
  • RTT(round-trip time):TCP持续估计一个连接的RTT时间,以确定等多长时间没有收到ack后重传
  • 滑动窗口:TCP通过滑动窗口来进行流控,在任何时候,滑动窗口就是接收方的receive buffer可用空间大小,它是随时间动态变化的。
  • 全双工:TCP连接的两端都可以发送数据和接受数据,因此两端都得有自己的send buffer和receive buffer,都得维护自己的TCP连接状态机

  • 多说一点:TCP协议规定的行为,比如对buffer中数据的发送、收到报文后处理都是操作系统内核协议栈自动进行的,不需要应用程序干涉,当然操作系统提供给应用程序一些接口,可以控制TCP协议栈的行为。

TCP连接建立:

三次握手: three-way handshake 上图中已经包括了TCP连接建立过程中客户端和服务器端的状态机状态转换过程,完整的状态转换图见下: state machine

建立连接API

socket:

1
2
3
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
Returns: file (socket) descriptor if OK, 1 on error

指定domain(记住AF_INET), type(记住SOCK_STREAM和SOCK_DGRAM), protocol(记住IPPROTO_TCP和IPPROTO_UDP), 打开一个socket。

返回值是int类型,是一个socket资源描述符。socket在内核中是一系列数据结构的集合,是一种资源。一般来说,一个普通的socket关联一个send buffer、一个receive buffer、一个state machine以及一个可选的TCP连接(四元组)

listening socket关联的则是不同于上面普通数据通信socket的数据结构,当调用 listen时, socket就成为listening socket,后面会提到


getaddrinfo:

1
2
3
4
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *restrict host, const char *restrict service, const struct addrinfo *restrict hint, struct addrinfo **restrict res);
//Returns: 0 if OK, nonzero error code on error

根据host和服务名称查找addr,返回的addrinfo结构里有sockaddr成员。客户端在建立socket连接之处如果不知道服务器端某个服务的ip和port,则可使用这个函数来查找。


bind:

1
2
3
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
//Returns: 0 if OK, −1 on error

将一个socket和一个sockaddr关联,TCP协议中需要指定IP地址和端口。对于客户端而言,这个关联无关紧要,可以让系统自动去选择一个默认的地址就ok。然而对于服务器端,则需要将一个大家都知道的地址关联到客户端要连的那个socket上, 这样客户端就可以通过这个公开的地址和服务器的socket进行通信了

如果客户端或者服务器端未指定端口,那么内核会在 connect或者 listen被调用时默认选择一个未被占用的短生端口。(一般来说,服务器端都需要指定一个well-known的端口)

如果客户端没有指定IP地址,那么在调用 connect时内核会根据实际的网络接口选择IP。如果服务器端没有指定IP地址(或者指定了为IPADDR_ANY),内核将使用客户端SYN请求中的destination IP地址作为该连接的服务器端IP地址。


connect: (客户端调用)

1
2
3
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t len);
//Returns: 0 if OK, −1 on error

现在两边的socket也打开了,地址也绑定了,客户端要和服务器端通信的话,就调connect连吧。对于客户端而言,如果之前没有bind一个地址,connect会自动分配一个地址给这个socket。

对于TCP而言,connect其实就是发起一个三次握手的过程,向服务器发SYN后阻塞,至客户端收到服务器的SYN时, connect返回,并state machine进入ESTABLISHED状态。

connect失败时返回-1,并置error标示原因,一般来说error取值有以下几种:

  1. ETIMEOUT:超时,即服务器端在客服端重发了好几次SYN请求后还没响应。超时的原因可能是网络链路不通、服务器端的连接队列没有空间而导致的没有响应
  2. ECONNREFUSED:服务器拒绝,即服务器用RST去响应客户端的连接请求,产生RST的原因有:
    • 目标端口没有server在监听
    • TCP终止一个连接
    • TCP接受到一个不存在的连接的报文
  3. EHOSTUNREACH/ENETUNREACH:目标不可达,客户端的SYN在到达目前服务器过程中诱发路径上的一个路由器产生了ICMP的”destination unreachable”错误,这可能是由于该路由器导致的临时错误,因此,内核遇到这种错误时一般会保存下来,然后重试,重试几次后还没响应后,则将该错误返回给应用层。

如果connect失败了怎么办?对于一个重负载的系统往往采用指数退避算法去重试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "apue.h"
#include <sys/socket.h>
#define MAXSLEEP 128
int  connect_retry(int sockfd, const struct sockaddr *addr, socklen_t alen){
    int numsec;
    for (numsec = 1; numsec <= MAXSLEEP; numsec <<= 1) {
        if (connect(sockfd, addr, alen) == 0) {
            return(0); 
        }
        if (numsec <= MAXSLEEP/2)
            sleep(numsec);   
    }
    return(-1);
}

上面的代码在BSD-based系统上(比如mac)是不能正确工作的,原因在于:如果对于某个socket的第一个connect调用fail了,后续的connect也会fail。因此,改进的方法就是再for循环里每次新建一个socket,此外,成功时就不能再返回0了,而是要把socket的fd返回

注意:connect并不仅仅用于面向连接型socket间通信,同样也适用于面向非连接的,这看上去矛盾,其实不然。对于SOCK_DGRAM类型的socket,connect调用使得我们不必再每次发消息时都加上addr信息。

socket与阻塞和非阻塞模式之分,因此connect调用也会根据socket类型有相应的行为。


listen:(服务器端调用)

1
2
3
#include <sys/socket.h>
int listen(int sockfd, int backlog);
//Returns: 0 if OK, −1 on error

服务器通过调用listen表示自己可以接受客户端的连接请求,backlog参数是对系统the num of outstanding connect requests的建议.

listening socket在内核中关联着两个socket队列,未完成的连接队列 和 已完成的连接队列。

  • 未完成的连接队列中:是那些已经完成两次握手的socket,这些socket在等待客户端的最后一次ack,它们处于SYN_RCVD状态
  • 已完成的连接队列:是那些已经完成三次握手,等待被应用程序accept的socket,这些socket处于ESTABLISHED状态。

当一个客户端的SYN请求到达listening socket时,TCP协议栈就在未完成的连接队列中创建一个entry,同时响应客户端以SYN和ACK。这个entry有着和listening socket相同的参数,且会一直存在于未完成的连接队列中,直到客户端最后一次握手到达或者超时。如果最后一次握手到达,entry将变成一个完整的socket(已经有了send buffer和receive buffer),并置入已完成的连接队列中。当进程调用accept时,取到的就是已完成的连接队列中第一个socket,如果队列为空,accept将阻塞。如果accept前已经有客户端数据到达,客户端能发送的数据量取决于服务器端socket的receive buffer大小

backlog参数一直没有一个正式的定义,各个操作系统的实现有着不同的解释,一般来说,它决定着两个队列大小之和,然而,也有系统将其只与已完成的连接队列大小关联。这样做有两个好处:

  • 对于有着大量请求的server,无须再使用一个非常大的backlog值
  • 防范SYN flooding攻击


accept:(服务器端调用)

1
2
3
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len);
Returns: file (socket) descriptor if OK, 1 on error

服务器通过accept取得一个socket fd,这个socket与一个client相连着。 注意到accept调用的第一个参数也是一个socket(listening socket),这个socket与返回的socket有着同样的type和address family,此外,这个socket与建立好的client-server连接没有任何关系,他还可以用来获取其他连接请求。

connect一样,accept会根据socket类型分为阻塞和非阻塞两种行为,当server要处理多个连接请求时,可基于poll、select调用来wait io可用。

注意accept调用还是可能会出错的,(虽然看上去不像,因为不涉及主机间交互,只是本地操作)。设想这样一种情况:客户端发起了一个建立连接请求,三次握手后服务器端的listening socket的已完成的连接队列中已经有了一个建立好的socket,此时服务端尚未调用accept去获取这个socket,客户端发来一个RST请求,如下图所示:

服务器端收到客户端的RST请求后,再accept就会返回错误。然而各个操作系统的实现对此情况的处理是不一样的,posix规定error应该置为ECONNABORTED,这样应用程序知道后重试就行了。有些操作系统在内核里直接就给处理了(把相应的socket从队列中移除,释放掉socket资源),应用程序甚至都不知道。

TCP连接关闭

四次挥手:

上图中,客户端发起关闭连接请求(第一个FIN),称之为active close,相应的,服务器端被称为passive close。

先看服务器端,接收到客户端的FIN后,响应一个ACK同时进入CLOSE_WAIT状态。此时,之前如果有read调用在阻塞,那么将返回0,表示end-of-file。 在随后的一段时间内,服务器端通过调用close向客户端发一个FIN 请求,同时进入LAST_ACK状态。在接受到客户端的ACK后,服务器端socket进入CLOSED状态。

再看客户端,通过close调用发完FIN后,进入FIN_WAIT_1状态,等到收到服务器端的ACK后,进入FIN_WAIT_2状态,在受到服务器的FIN后进入TIME_WAIT状态,并发最后的ACK。在等两个MSL后,才进入CLOSED状态。

这个TIME_WAIT状态是干嘛用的?为什么不让客户端在收到服务器端的FIN后直接进入CLOSED状态?MSL又是啥?为啥等两个MSL?

  1. MSL(Maximum Segmant Lifetime) : 报文的最长生存时间,我们知道IP协议有TTL一说,就是一个报文最大的路由跳数,当报文的转发次数超过TTL时会被丢弃,也就是说报文在网络中有生存时间限制。

  2. 为什么需要TIME_WAIT状态?主要基于两个原因:
    • active close方的最后ACK可能丢失,因此需要一个状态来重传最后的ACK
    • TCP连接由一个四元组唯一确定,假设某个四元组<A_s,P_s,A_d,P_d>表示的连接关闭了,随即该客户端和服务端又已同样的四元组建立了一个连接。假如上一个连接的某个报文由于网络原因在新的连接有效过程中到达服务器端,服务器端无法辨别是上一个连接的报文,因而不能正确处理。如果有TIME_WAIT状态则客户端在2MSL时间内没法用同一个四元组建立与服务器端的连接(因为2MSL时间内上一个连接还没有关闭,短生端口还被占用着在)。
  3. 为什么是2MSL? 做最坏的打算

TCP连接关闭API

close

1
2
3
#include <unistd.h>
int close(int sockfd);
return: 0 if OK, -1 on error

close 默认的行为是将sockfd的reference count减一,如果为0则将socket标记为关闭,然后立即返回。一旦调用close,针对这个socket的read和write都将失效。TCP协议栈会自己将send buffer中还没发送的数据发送至另一端,然后自动进行四次挥手。

注意,服务器端父进程fork出子进程后要及时去close掉accept返回的的socket fd


shutdown

1
2
3
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
returns: 0 if ok, -1 on error

shutdown没有像close调用那样的reference count限制,一旦调用,关闭行为立即生效。

howto可为:SHUT_WR、SHUT_RD、SHUT_RDWR,分别表示关闭写、关闭读和关闭读写。对于SHUT_WR,shutdown会先把send buffer中还没发送的数据发送,然后,执行四次挥手。对于SHUT_RD,shutdown使得socket不能在进行读取,同时丢弃receive buffer中尚未读取的数据。

数据传输API

read and write :

与正常file一样,socket可使用 readwrite来进行数据传输,这点非常重要,使得我们可以将一个socket描述符传给子进程(比如通过管道),子进程甚至都不需要知道它是个socket。

此外,UNIX还提供了一些有这额外功能的API来传输数据。

send : (for TCP)

1
2
3
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
Returns: number of bytes sent if OK, 1 on error

可以看出,比 write多了flags参数,这些flags参见single UNIX标准,这里就不具体说了。 值得一提的是 send的行为:与 write 一致,也分为阻塞和非阻塞,取决于socket的模式。 另外,当 send正确返回了,并不一定意味着另一端正确收到数据了, send只能保证用户空间的数据发往socket的send buffer中,至于什么时候发往网络,什么时候对方接收等,socket api层不做保证,由操作系统的协议栈自动完成。

连接建立过程中边界条件已经基本说到了,下面结合场景说下数据传输过程中的边界条件(出错情况)。

设想这样一个场景,echo服务的客户端和服务端之间通信:客户端不断循环地读取从用户终端输入的内容、发往服务器、服务器再原样echo回给客户端、客户端再输出到终端,这么一个流程。

假如当服务器进程echo回一次内容后,就被kill掉了。我们知道进程被终止前会自动关闭它所打开的资源描述符的(也就是会对所有socket进行close),意味会向客户端发一个FIN,然后进程终止了、listening socket不在了(listening socket不需要四次挥手,没挥的对象!),但是数据通信socket还在、TCP连接还在(还在等四次挥手呢)。客户端收到FIN后自动ACK了,进入CLOSE_WAIT状态,服务器收到客户端的ACK后进入FIN_WAIT_2状态。假如此时用户又从终端向客户端输入一个内容,客户端进程则又会向socket write一次,会成功么? 没问题,TCP 协议允许(见上一节)。但这种情况下服务器收到后会怎样?实际上TCP协议规定这种情况下会响应一个RST过去。

在上面的场景中,客户端发起一个write成功后,就立即去read准备读服务器端响应了,一看这个socket收到了来自服务器端的FIN报文,就又立即从read返回0表示end-of-file了(这也解释了为什么,close和shutdown只是标示下要关闭这个socket,实际FIN请求的发出得等到把send buffer中未发完的数据确认发完后才进行)。假如此时不是去read,而是发起了另一个write,也就是对一个已经收到RST(由第一个write触发)的socket去write,POSIX规定此时write调用将触发SIGPIPE信号,应用程序捕获它,然后从信号处理程序中返回后,write将返回且置error为EPIPE(与pipe类似),也可以忽略它,SIGPIPE默认的行为是终止进程(意味着会去向服务器发一个FIN,完成四次挥手)。

说了这么多,总结一下:虽然write/send只涉及本机操作,但是仍然可能会产生TCP协议的相关错误(EPIPE)。

再多说一点:如果上面的例子中服务器直接down掉了(不是进程被kill,而是机器挂掉了),那么FIN压根就不会发过来,客户端发起write的时候,会成功返回,然后会数据会一直存在send buffer中,因为不会被ACK。当客户端去对socket发起read准备读服务器端响应时,会一直阻塞着,直到TCP协议栈重传了多次数据仍然失败导致的超时错误(可能已经等了好几分钟了!),read会从阻塞中返回,同时error被置为ETIMEDOUT。如果路径上的某个路由器感知到主机的不可达,则error可能被置为EHOSTUNREACH/ENETUNREACH。(注意:这个错误不是由read直接导致的,正常情况read是会block forever的,如果想尽快从read中返回怎么办:自己设置一个超时时间,比如几秒的一个alarm信号,让read从alarm信号中断中返回。)

在上面的例子中,ETIMEDOUT的产生是由客户端主动去write才会有的,也就是说如果我们不去write的话,比如直接read阻塞,如果服务器down掉了,客户端是感知不到的,真的会block forever的!那么怎么办?只能通过一个socket option——SO_KEEPALIVE来解决。

什么?没看懂!正常,这文章也不是写给您看的,是总结给我自己的,知识还是要看一手的,呐,UNP、 APUE欢迎您