正文

半连接攻击是一种针对协议栈的攻击,或者说是一中针对主机的攻击,皮之不存毛将焉附,主机一旦被攻击而耗尽了内存资源,用户态的应用程序也将无法运行。TCP半连接攻击可以通过syn cookie机制或者syn中继机制等进行防范,对于tcp服务来讲还有一种可以称为“全连接攻击”的攻击类型,这种攻击是针对用户态运行的tcp服务器的,当然,它可能间接地导致主机瘫痪。所谓的全连接攻击说的就是客户端仅仅“连接”到服务器,然后再也不发送任何数据,直到服务器超时后处理或者耗尽服务器的处理进程。为何不发送任何数据呢?因为一旦发送了数据,服务器检测到数据不合法后就可能断开此次连接,如果不发送数据的话,很多服务器只能阻塞在recv或者read调用上。很多的服务器架构都是每连接一个进程的方式,这种服务器更容易受到全连接攻击,即使是进程池/线程池的方式也不例外,症状就是服务器主机建立了大量的客户端处理进程,然后阻塞在recv/read而无所事事,大量的这种连接会耗尽服务器主机的处理进程。如果处理进程数量达到了主机允许的最大值,那么就会影响到该主机的正常运作,比如你再也无法ssh到该主机上了。

     半连接攻击耗尽的是全局的内存,因此可以用不为半连接分配内存的方式加以预防--syn cookie,而全连接攻击耗尽的是主机的处理进程和连接数量,因此可以限制处理进程的创建或者限制预创建的进程池进程的分配,具体到操作上就是只有到了客户端真实发送数据的时候才为其指派处理进程,进一步具体到代码运作上的体现就是服务器的accept在数据到来之前是不返回的,以apache的prefork为例,预先创建了N个处理子进程,每个子进程继承父进程的侦听套接字,因此每一个子进程都有权accept,然而一个客户端要连接的时候,只有在某个子进程accept返回的时候,该子进程才指派给了该客户端,否则该子进程继续等待连接,如果一个客户端仅仅完成了到服务器的连接而没有发送数据,那么对于服务器来讲,任何子进程的accept都是不会返回的,用netstat察看的话,这种连接处于SYN_RECV状态,内核协议栈会为这种状态的完成三次握手的连接保留一段时间,如果这段时间过去了,仍然没有非握手数据的到来,那么就会断开这次连接,如果不限制一个期限的话,虽然防止了ESTABLISHED连接数据的膨胀以及无所事事的处理进程数量的膨胀,但是仍然防止不了SYN_RECV状态连接数据的膨胀,因此内核协议栈的实现中就增加了这么一个限制。有了这个机制,apache(的新版本)以及很多基于子进程的服务器就可以利用它来避免产生大量的无所事事的阻塞在read/recv的进程,内核协议栈保证用户态的进程在accept返回后就一定有数据可以读取,一旦处理子进程读取到了非法的数据的话,服务器负责断开此次连接。

     这一切是通过TCP_DEFER_ACCEPT这个套接字参数来实现的,它的接口形式如下:

setsockopt(listen_socket, SOL_TCP, TCP_DEFER_ACCEPT, &val, sizeof(val))

其中val是一个数字,它代表一个时间,字面上理解,在这个时间过去后仍没有数据到来的话就会在不指派服务进程(accept不返回)的情况下断开连接,可是这只是一个方面,协议栈的实现中还有另外一个方面,那就是服务器协议栈会试图重传自己的synack好几次,因此这个限制时间是受到tcp协议栈的synack的重传次数和defer_accept的值共同决定的。

     在探讨defer_accept和synack的重发的关系之前,首先看一下总体的流程。accept函数实际上是很简单的,每一个侦听套接字都会有一个accept对列,如果没有连接到来,调用accept的进程将睡眠在该对列上,协议栈的tcp模块负责往这个对列上放入新的客户端套接字,然后唤醒accept的调用进程,accept返回前创建BSD套接字,然后返回用户空间,每次accept仅仅处理accept对列最前面的一个套接字。在协议栈中tcp_check_req函数是建立accept返回套接字的函数,并且它还负责唤醒accept的调用进程,它内部视是否定义defer_accept而采取了不同的行为:

//在定义了defer_accept的情况下,协议栈将不以为握手包的最后一个ack(来自客户端)的到来为连接的建立,从而不分配accept返回套接字,直接返回NULL。注意,此后客户端发送真正数据的时候,由于连接没有建立(在established连接中找不到),因此还是会调用tcp_check_req函数的,此时由于有数据,TCP_SKB_CB(skb)->end_seq == req->rcv_isn+1将不再正确,执行流将继续往下走。

if (tp->defer_accept && TCP_SKB_CB(skb)->end_seq == req->rcv_isn+1) {

        req->acked = 1;

        return NULL;

}

child = tp->af_specific->syn_recv_sock(sk, skb, req, NULL);

...//将新建的套接字放入到用户空间accept进程的accept对列中,唤醒该进程,这样这个请求就指派给该进程了。

tcp_acceptq_queue(sk, req, child);

return child;

     前面提到过,synack的重传受到内核可调参数sysctl_tcp_synack_retries和defer_accept的共同影响,接下来看一下用户空间通过setsockopt设置的defer_accept的值是怎么和synack重传联系在一起的,在setsockopt中为tcp连接的defer_accept字段赋值:

case TCP_DEFER_ACCEPT:

    tp->defer_accept = 0;

    if (val > 0) { //这个逻辑很简单,就是将值很“策略”化的转化成重传的次数

        while (tp->defer_accept < 32 && val > ((TCP_TIMEOUT_INIT / HZ) <<

             tp->defer_accept))

            tp->defer_accept++;

        tp->defer_accept++;

    }

    break;

在每一个tcp侦听套接字上都有一个很特殊的timer,这就是tcp_synack_timer,虽然它是在keepalive这个timer的function中调用的,但是它却是很独立的一个timer,在tcp_synack_timer函数中有下面的代码:

if (tp->defer_accept)

    max_retries = tp->defer_accept;

budget = 2*(TCP_SYNQ_HSIZE/(TCP_TIMEOUT_INIT/TCP_SYNQ_INTERVAL));

i = lopt->clock_hand;

do { //针对所有的连接请求进行必要的synack的重传处理,连接请求之所以还没有成功有以下几个原因:

    /*

    1.客户端的最后一次握手ack还没有来。

    1.1.服务器端的synack丢失;

    1.2.客户端的握手ack丢失;

    2.距离太远了,ack还在路上。

    3.这是一次syn攻击,不要指望ack会到来。

    4.ack已经来了,三次握手已经成功,只是设置了defer_accept,协议栈硬是不让连接成功。

    */

    reqp=&lopt->syn_table[i];  

    while ((req = *reqp) != NULL) {

        if (time_after_eq(now, req->expires)) {

            if ((req->retrans < thresh ||

                (req->acked && req->retrans < max_retries))

                && !req->class->rtx_syn_ack(sk, req, NULL)) {  //重传synack

                ... //下一次的重传间隔会“更长”一些,这也是一种试探策略,既然上次n秒没回来,这次就试一下比n更大的数。

                timeo = min((TCP_TIMEOUT_INIT << req->retrans), TCP_RTO_MAX);

                req->expires = now + timeo;

                reqp = &req->dl_next;

                continue;

            }

            //这里丢弃没有通过上面if的连接请求

        }

        reqp = &req->dl_next;

    }

    i = (i+1)&(TCP_SYNQ_HSIZE-1);

} while (--budget > 0);

     理解了内核的实现原理,下面就剩下测试了,还是用一个简单的程序和tcpdump来测试,用户程序的源码如下:

int  main (int argc, char **argv)

{

    int err;

    int listen_sd;

    int sd;

    struct sockaddr_in sa_serv;

    struct sockaddr_in sa_cli;

    size_t client_len;

    listen_sd = socket (AF_INET, SOCK_STREAM, 0);   

    memset (&sa_serv, '/0', sizeof(sa_serv));

    sa_serv.sin_family      = AF_INET;

    sa_serv.sin_addr.s_addr = INADDR_ANY;

    sa_serv.sin_port        = htons (6800);         

    int val = 10;

    setsockopt(listen_sd, 1, 2, &val, sizeof(val)) ;  //这就是defer_accept的设置,本机的头文件被偷走了,所以直接用数字

    bind(listen_sd, (struct sockaddr*) &sa_serv, sizeof (sa_serv));                   

    listen (listen_sd, 5);                    

    client_len = sizeof(sa_cli);

    sd = accept (listen_sd, (struct sockaddr*) &sa_cli, &client_len);

      close (listen_sd);

    while (1) {

        read(sd, buf, sizeof(buf) - 1);              

    }

    close (sd);

}

运行之,将synack的内核参数设置为0:

sysctl -w net.ipv4.tcp_synack_retries=0

然后用tcpdump抓包如下:

tcpdump tcp port 6800 and host 192.168.x.y

在另外一台机器上执行:

telnet 192.168.a.b 6800

不要敲入任何字符,空格也不行...

结果发现,在应用程序val为10的情况下三次握手之外又进行了3次额外的synack重传,为何是三次呢?看看setsockopt中那个逻辑吧,如果将val设置为1,而将tcp_synack_retries内核参数设置为2的话,则会有两次重传,这个也很显然。接下来最重要的就是核对一下重传的间隔时间是不是两倍的往上增长啊,第一次间隔6秒,然后12秒,然后24秒,最终再过48秒后在telnet的机器上敲入一个字符,可悲的是Connection closed by foreign host.映入了眼帘,超时了,服务器成功的阻止了“全连接”攻击。

文章版权声明:除非注明,否则均为枫叶博客原创文章,转载或复制请以超链接形式并注明出处。
-- 展开阅读全文 --