谈谈http慢速攻击以及防范策略

2017/11/01 technical

本文介绍了Servlet 3中的异步处理和非阻塞IO,以及http慢速攻击的原理和应对策略

前情回顾

最近我们线上服务器出现了不少慢日志,最终排查结果跟负载均衡的设置有关系,可以点击 此处 来查看我上次问题的排查过程。写完上篇文章之后,我感觉有更多东西可写,有些内容网上也介绍的不够,故催生了此文章。

Servlet异步处理

在Servlet 3.0之前,Servlet采用Thread-Per-Request的方式处理请求,即每一次Http请求都由某一个线程从头到尾负责处理。如果一个请求需要进行IO操作,比如访问数据库、调用第三方服务接口等,那么其所对应的线程将同步地等待IO操作完成, 而IO操作是非常慢的,所以此时的线程并不能及时地释放回线程池以供后续使用,在并发量越来越大的情况下,这将带来严重的性能问题。

为了解决这个问题,Servlet 3.0引入了异步处理,我们可以从HttpServletRequest对象中获得一个AsyncContext对象,该对象构成了异步处理的上下文,Request和Response对象都可从中获取。AsyncContext可以从当前线程传给另外的线程,并在新的线程中完成对请求的处理并返回结果给客户端,初始线程便可以还回给Servlet容器线程池以处理更多的请求。如此,通过将请求从一个线程传给另一个线程处理的过程便构成了Servlet 3.0中的异步处理。 在Servlet 3.0中,我们可以这么写来达到异步处理:

import javax.servlet.AsyncContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet(value = "/simpleAsync", asyncSupported = true)
public class SimpleAsyncHelloServlet extends HttpServlet {

    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        AsyncContext asyncContext = request.startAsync();

        Runnable runnable = () -> {
            //do something here;
            Thread.sleep(1000);

            try {
                asyncContext.getResponse().getWriter().write("Hello World!");
            } catch (IOException e) {
                e.printStackTrace();
            }
            asyncContext.complete();
        };

        //实际使用时你应该提交到你自己定义的线程池中
        new Thread(runnable).start();
    }

}

Servlet non-blocking io

Servlet 3.0提供的异步处理特性不错,但仍然存在问题:Servlet标准中对于IO的处理仍然是阻塞的,也就是说Servlet线程会被阻塞在 HttpServletRequest.getInputStream().read / HttpServletResponse.getOutputStream().write 这样的调用上,如果需要读取/响应的数据量较大,那么Servlet线程会花费较多时间在IO上,这同样会耗尽线程池,带来严重的性能问题。

为了解决这个问题,Servlet 3.1引入了非阻塞IO,通过在HttpServletRequest和HttpServletResponse中分别添加ReadListener和WriterListener方式,只有在IO数据满足一定条件时(比如数据准备好时),才进行后续的操作。 在Servlet 3.1中,我们可以这么写来利用非阻塞IO(注意非阻塞IO必须配合异步处理来使用):

import javax.servlet.AsyncContext;
import javax.servlet.ReadListener;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@WebServlet(value = "/nonBlockingThreadPoolAsync", asyncSupported = true)
public class NonBlockingAsyncHelloServlet extends HttpServlet {

    private static ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 200, 50000L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(100));

    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

        AsyncContext asyncContext = request.startAsync();

        ServletInputStream inputStream = request.getInputStream();

        inputStream.setReadListener(new ReadListener() {
            @Override
            public void onDataAvailable() throws IOException {

            }

            @Override
            public void onAllDataRead() throws IOException {
                executor.execute(() -> {
                    new LongRunningProcess().run();

                    try {
                        asyncContext.getResponse().getWriter().write("Hello World!");
                    } catch (IOException e) {
                        e.printStackTrace();
                    }

                    asyncContext.complete();

                });
            }

            @Override
            public void onError(Throwable t) {
                asyncContext.complete();
            }
        });


    }

}

http慢速攻击

大家平时应该经常听说SQL注入、XSS、CSRF等攻击,对于http慢速攻击可能了解的较少。其实http慢速攻击是CC攻击的一种(CC攻击又属于DDos攻击)。 http慢速攻击的原理是攻击者与服务器建立http连接后,模拟非常慢的速度来接收数据,从而一直占用服务器资源,最终耗尽服务器线程池资源而使服务器拒绝服务。 具体来说,http慢速攻击主要有以下三种类型:

  • Slowloris

    该攻击最初由rsnake发明,又被称为slow headers。 攻击原理:HTTP协议规定,HTTP Request以\r\n\r\n(0d0a0d0a)结尾表示客户端发送结束,服务端开始处理。那么,如果永远不发送\r\n\r\n会如何?Slowloris就是利用这一点来做DDoS攻击的。攻击者在HTTP请求头中将Connection设置为Keep-Alive,要求Web Server保持TCP连接不要断开,随后缓慢地每隔几分钟发送一个key-value格式的数据到服务端,如a:b\r\n,导致服务端认为HTTP头部没有接收完成而一直等待。如果攻击者使用多线程或者傀儡机来做同样的操作,服务器的Web容器很快就被攻击者占满了TCP连接而不再接受新的请求。

  • SlowHTTP POST

    Slowloris的变种–Slow HTTP POST,也称为Slow body。 攻击原理:在POST提交方式中,允许在HTTP的头中声明content-length,也就是POST内容的长度。在提交了头以后,将后面的body部分卡住不发送,这时服务器在接受了POST长度以后,就会等待客户端发送POST的内容,攻击者保持连接并且以10S-100S一个字节的速度去发送,就达到了消耗资源的效果,因此不断地增加这样的链接,就会使得服务器的资源被耗尽,最后可能宕机。

  • Slow Read attack

    攻击原理:客户端与服务器建立连接并发送一个HTTP请求,然后一直保持这个连接,以很低的速度读取Response,比如很长一段时间客户端不读取任何数据,通过发送Zero Window到服务器,让服务器误以为客户端很忙,直到连接快超时前才读取一个字节,以消耗服务器的连接和内存资源。抓包数据可见,客户端把数据发给服务器后,服务器发送响应时,收到了客户端的ZeroWindow提示(表示自己没有缓冲区用于接收数据),服务器不得不持续的向客户端发出ZeroWindowProbe包,询问客户端是否可以接收数据。

现在再回过头来看我前一篇文章的分析,你应该更能深刻理解前文中问题的原因:由于tomcat中Servlet的IO是阻塞的,如果遇到客户端网速慢,就会长时间占用Servlet线程,从而出现超时日志。

深入分析针对tomcat的http慢速攻击

tomcat等Servlet容器非常容易被http慢速攻击影响,我们来做一些深入分析: 当spring mvc响应静态资源给客户端时,spring会在循环中每次从文件读取4KB的内容,然后写入tomcat的Response中; tomcat在Response中有两级缓冲设计,第一级的缓冲在jvm中,大小为8KB, 当第一级缓冲满了,tomcat会把缓冲中的数据通过调用SocketChannel.write输出到socket中,调用SocketChannel.write成功并没有真正得把数据发送到对端,而只是发送到本地的内核缓冲中, 这是第二级缓冲(这其实就是tcp发送缓冲区,调试发现tcp发送缓冲区大小大约为146KB),如果tcp发送缓冲区满了(后面会解释什么情况下tcp发送缓冲区会满),由于tomcat使用的是阻塞式socket,这个调用会阻塞。 换句话说,如果服务器响应的内容不超过146KB,tcp发送缓冲区已经能容纳全部响应内容,后续就由内核接管真正的发送工作,tomcat不再关心,这种情况下tomcat服务器不会出现慢日志。 反之,如果服务器响应的内容超过154KB(8KB + 146KB),则有可能快速填满tcp发送缓冲区,从而导致后续的write调用阻塞,从而使tomcat出现慢日志。(顺带一提,上次出现问题的其中一个js大小为249KB,而较小的js没有出现过慢日志,上述论述跟实际情况是能对应上的)

那么什么情况下tcp发送缓冲区会满呢? tcp建立连接后,会根据设置初始化发送缓冲区和接收缓冲区,客户端会通过ACK报文告知服务端我的滑动窗口大小(滑动窗口大小不会大于接收缓冲区的空闲大小),服务端根据客户端的滑动窗口大小来决定发送多少数据。如果客户端一直没有从接收缓冲区读取数据,则接收缓冲区会很快填满,导致滑动窗口变小直至为0,从而导致服务端不再发送数据,最终发送缓冲区也填满,这是一种可能性; 还有一种可能性是网络慢,这时客户端ACK报文响应的慢,客户端滑动窗口仍然可以接收数据,然而服务端无法及时得知客户端滑动窗口大小,从而降低数据发送速率,最终导致发送缓冲区积压而爆满。 关于滑动窗口,这篇文章分析的不错:http://blog.csdn.net/wdscq1234/article/details/52444277

如何防范http慢速攻击

由于我们现在已经大量使用Servlet的同步处理,改造成Servlet异步处理代价太大,这条路显然行不通,而且实际上Servlet的异步处理也有它的缺点,比如转发到其他线程处理增加了线程上下文切换的成本,部分APM对于异步Servlet支持不够好,开发更复杂等。 在防范http慢速攻击时,Servlet 3.1的非阻塞IO显然对我们更有用,然而它要配合异步处理才能使用,这也成为一道难以迈过的坎。 修改tomcat的发送缓冲区大小也能缓解这个问题,但笔者没有发现调整此参数的地方,而且不够优雅。

那么该怎么防范呢? 引入nginx作为反向代理服务器能解决大部分问题。 nginx是一个event-based http服务器,由于它事件驱动的特性,它的线程数非常少(一般只有几个),它的设计基于非阻塞式IO,所以难以对nginx进行http慢速攻击。 它的设计中包含http请求和响应缓冲(缓冲可调),前述的SocketChannel.write调用发送的数据会被nginx迅速接收并缓冲起来,极大地缓解了tomcat容易被http慢速攻击的问题。 所以这也是tomcat对静态资源的处理效率没有nginx等服务器高的其中一个原因。

然而用了nginx并不代表你就能高枕无忧了,如果你响应的大小超过了nginx的tcp发送缓冲区 + nginx的tcp接收缓冲区 + nginx配置的http结果缓冲区 + tomcat的tcp发送缓冲区这个公式的总大小(为什么这么算请自行理解),那么tomcat还是可能会报出慢日志, 所以对于静态资源,更好的方案是不要让tomcat处理静态资源(如果你的应用访问量较小,使用spring mvc + tomcat来处理静态资源比较方便,这种情况下也不用急着改造)。 对于动态响应内容,如果出现了由tomcat IO引起的慢日志,请优先减小单次响应内容的大小,其次才是调优nginx,增大http结果缓冲区的大小。

传统的流量清洗设备针对CC攻击,主要通过阈值的方式来进行防护,某一个客户在一定的周期内,请求访问量过大,超过了阈值,清洗设备通过返回验证码或者JS代码的方式。这种防护方式的依据是,攻击者们使用肉鸡上的DDoS工具模拟大量http request,这种工具一般不会解析服务端返回数据,更不会解析JS之类的代码。因此当清洗设备截获到HTTP请求时,返回一段特殊JavaScript代码,正常用户的浏览器会处理并正常跳转不影响使用,而攻击程序会攻击到空处。

而对于慢速攻击来说,通过返回验证码或者JS代码的方式依然能达到部分效果。但是根据慢速攻击的特征,可以辅助以下几种防护方式:1、周期内统计报文数量。一个TCP连接,HTTP请求的报文中,报文过多或者报文过少都是有问题的,如果一个周期内报文数量非常少,那么它就可能是慢速攻击;2、限制HTTP请求头的最大许可时间。超过最大许可时间,如果数据还没有传输完成,那么它就有可能是一个慢速攻击。

Show Disqus Comments

Search

    Table of Contents