From 7bf48b02be5544557f5a8b8d3f677cbf0557f36c Mon Sep 17 00:00:00 2001 From: Philippe Antoine Date: Sun, 3 May 2026 21:35:26 +0200 Subject: [PATCH] http2: protection against decompression bombs Ticket: 8513 During decompression, fail early if we have a big decompression ratio, and enough data. Track this data also during a tx lifetime, and even a flow/state lifetime, so that we set event and fail also if the compression bomb is split over multiple packets --- rules/http2-events.rules | 1 + rust/src/http2/decompression.rs | 54 +++++++++++++++++++++++++++------ rust/src/http2/http2.rs | 47 ++++++++++++++++++++++++++-- suricata.yaml.in | 2 ++ 4 files changed, 91 insertions(+), 13 deletions(-) diff --git a/rules/http2-events.rules b/rules/http2-events.rules index 5a4fccf51e..d6ada2c0a6 100644 --- a/rules/http2-events.rules +++ b/rules/http2-events.rules @@ -26,3 +26,4 @@ alert http2 any any -> any any (msg:"SURICATA HTTP2 dns response too long"; flow alert http2 any any -> any any (msg:"SURICATA HTTP2 data on stream zero"; flow:established; app-layer-event:http2.data_stream_zero; classtype:protocol-command-decode; sid:2290018; rev:1;) # disabled by default, as it can happen in legit cases depending on the max-frames config value # alert http2 any any -> any any (msg:"SURICATA HTTP2 too many frames"; flow:established; app-layer-event:http2.too_many_frames; classtype:protocol-command-decode; sid:2290019; rev:1;) +alert http2 any any -> any any (msg:"SURICATA HTTP2 compression bomb"; flow:established; app-layer-event:http2.compression_bomb; classtype:protocol-command-decode; sid:2290020; rev:1;) diff --git a/rust/src/http2/decompression.rs b/rust/src/http2/decompression.rs index 8456b43267..dacc31b131 100644 --- a/rust/src/http2/decompression.rs +++ b/rust/src/http2/decompression.rs @@ -15,6 +15,7 @@ * 02110-1301, USA. */ +use super::http2::HTTP2_COMPRESSION_BOMB_LIMIT; use crate::direction::Direction; use brotli; use flate2::read::{DeflateDecoder, GzDecoder}; @@ -24,6 +25,8 @@ use std::io::{Cursor, Read, Write}; pub const HTTP2_DECOMPRESSION_CHUNK_SIZE: usize = 0x1000; // 4096 +pub(super) const DEFAULT_BOMB_RATIO: u64 = 2048; + #[repr(u8)] #[derive(Copy, Clone, PartialOrd, PartialEq, Eq, Debug)] pub enum HTTP2ContentEncoding { @@ -102,9 +105,11 @@ impl std::fmt::Debug for HTTP2Decompresser { } #[derive(Debug)] -struct HTTP2DecoderHalf { +pub(super) struct HTTP2DecoderHalf { encoding: HTTP2ContentEncoding, decoder: HTTP2Decompresser, + pub input_len: u64, + pub output_len: u64, } pub trait GetMutCursor { @@ -141,6 +146,7 @@ fn http2_decompress<'a>( let mut offset = 0; decoder.get_mut().set_position(0); output.resize(HTTP2_DECOMPRESSION_CHUNK_SIZE, 0); + let max_len = DEFAULT_BOMB_RATIO * input.len() as u64; loop { match decoder.read(&mut output[offset..]) { Ok(0) => { @@ -149,6 +155,14 @@ fn http2_decompress<'a>( Ok(n) => { offset += n; if offset == output.len() { + if output.len() + HTTP2_DECOMPRESSION_CHUNK_SIZE > max_len as usize + && output.len() > unsafe { HTTP2_COMPRESSION_BOMB_LIMIT as usize } + { + return Err(io::Error::new( + io::ErrorKind::OutOfMemory, + "Decompression bomb detected", + )); + } output.resize(output.len() + HTTP2_DECOMPRESSION_CHUNK_SIZE, 0); } } @@ -170,6 +184,8 @@ impl HTTP2DecoderHalf { HTTP2DecoderHalf { encoding: HTTP2ContentEncoding::Unknown, decoder: HTTP2Decompresser::Unassigned, + input_len: 0, + output_len: 0, } } @@ -202,23 +218,41 @@ impl HTTP2DecoderHalf { match self.decoder { HTTP2Decompresser::Gzip(ref mut gzip_decoder) => { let r = http2_decompress(&mut *gzip_decoder.as_mut(), input, output); - if r.is_err() { - self.decoder = HTTP2Decompresser::Unassigned; + match r { + Err(_) => { + self.decoder = HTTP2Decompresser::Unassigned; + } + Ok(o) => { + self.output_len += o.len() as u64; + } } + self.input_len += input.len() as u64; return r; } HTTP2Decompresser::Brotli(ref mut br_decoder) => { let r = http2_decompress(&mut *br_decoder.as_mut(), input, output); - if r.is_err() { - self.decoder = HTTP2Decompresser::Unassigned; + match r { + Err(_) => { + self.decoder = HTTP2Decompresser::Unassigned; + } + Ok(o) => { + self.output_len += o.len() as u64; + } } + self.input_len += input.len() as u64; return r; } HTTP2Decompresser::Deflate(ref mut df_decoder) => { let r = http2_decompress(&mut *df_decoder.as_mut(), input, output); - if r.is_err() { - self.decoder = HTTP2Decompresser::Unassigned; + match r { + Err(_) => { + self.decoder = HTTP2Decompresser::Unassigned; + } + Ok(o) => { + self.output_len += o.len() as u64; + } } + self.input_len += input.len() as u64; return r; } _ => {} @@ -228,9 +262,9 @@ impl HTTP2DecoderHalf { } #[derive(Debug)] -pub struct HTTP2Decoder { - decoder_tc: HTTP2DecoderHalf, - decoder_ts: HTTP2DecoderHalf, +pub(super) struct HTTP2Decoder { + pub decoder_tc: HTTP2DecoderHalf, + pub decoder_ts: HTTP2DecoderHalf, } impl HTTP2Decoder { diff --git a/rust/src/http2/http2.rs b/rust/src/http2/http2.rs index 06c144606a..0fad70095f 100644 --- a/rust/src/http2/http2.rs +++ b/rust/src/http2/http2.rs @@ -22,7 +22,7 @@ use super::range; use super::range::{SCHTPFileCloseHandleRange, SCHttpRangeFreeBlock}; use crate::applayer::{self, *}; -use crate::conf::conf_get; +use crate::conf::{conf_get, get_memval}; use crate::core::*; use crate::direction::Direction; use crate::dns::dns::DnsVariant; @@ -81,6 +81,7 @@ pub static mut HTTP2_MAX_TABLESIZE: u32 = 65536; // 0x10000 static mut HTTP2_MAX_REASS: usize = 102400; static mut HTTP2_MAX_STREAMS: usize = 4096; // 0x1000 static mut HTTP2_MAX_FRAMES: usize = 65536; +pub(super) static mut HTTP2_COMPRESSION_BOMB_LIMIT: u64 = 1_048_576; #[derive(AppLayerFrameType)] pub enum Http2FrameType { @@ -519,8 +520,12 @@ impl HTTP2Transaction { self.handle_dns_data(dir, flow); } } - _ => { - self.set_event(HTTP2Event::FailedDecompression); + Err(e) => { + if e.kind() == io::ErrorKind::OutOfMemory { + self.set_event(HTTP2Event::CompressionBomb); + } else { + self.set_event(HTTP2Event::FailedDecompression); + } } } } @@ -561,6 +566,7 @@ pub enum HTTP2Event { DnsResponseTooLong, DataStreamZero, TooManyFrames, + CompressionBomb, } pub struct HTTP2DynTable { @@ -603,6 +609,9 @@ pub struct HTTP2State { transactions: VecDeque, progress: HTTP2ConnectionState, + comp_len: u64, + decomp_len: u64, + c2s_buf: HTTP2HeaderReassemblyBuffer, s2c_buf: HTTP2HeaderReassemblyBuffer, } @@ -637,6 +646,8 @@ impl HTTP2State { dynamic_headers_tc: HTTP2DynTable::new(), transactions: VecDeque::new(), progress: HTTP2ConnectionState::Http2StateInit, + comp_len: 0, + decomp_len: 0, c2s_buf: HTTP2HeaderReassemblyBuffer::default(), s2c_buf: HTTP2HeaderReassemblyBuffer::default(), } @@ -1234,6 +1245,7 @@ impl HTTP2State { &mut reass_limit_reached, ); + let (comp_len, decomp_len) = (self.comp_len, self.decomp_len); let tx = self.find_or_create_tx(&head, &txdata, dir); if tx.is_none() { return AppLayerResult::err(); @@ -1290,6 +1302,28 @@ impl HTTP2State { tx.tx_data.set_event(HTTP2Event::DataStreamZero as u8); } else if ftype == parser::HTTP2FrameType::Data as u8 && sid > 0 { tx.handle_data_frame(rem, hlsafe, dir, flow, padded, over); + let (il, ol) = if dir == Direction::ToClient { + ( + tx.decoder.decoder_tc.input_len, + tx.decoder.decoder_tc.output_len, + ) + } else { + ( + tx.decoder.decoder_ts.input_len, + tx.decoder.decoder_ts.output_len, + ) + }; + let (il, ol) = (il + comp_len, ol + decomp_len); + if ol > decompression::DEFAULT_BOMB_RATIO * il { + if ol > unsafe { HTTP2_COMPRESSION_BOMB_LIMIT } { + tx.set_event(HTTP2Event::CompressionBomb); + return AppLayerResult::err(); + } + if over { + self.comp_len += il; + self.decomp_len += ol; + } + } } sc_app_layer_parser_trigger_raw_stream_inspection(flow, dir as i32); input = &rem[hlsafe..]; @@ -1600,6 +1634,13 @@ pub unsafe extern "C" fn SCRegisterHttp2Parser() { SCLogError!("Invalid value for http2.max-reassembly-size"); } } + if let Some(val) = conf_get("app-layer.protocols.http2.compression-bomb-limit") { + if let Ok(v) = get_memval(val) { + HTTP2_COMPRESSION_BOMB_LIMIT = v; + } else { + SCLogWarning!("Invalid value for http2.compression-bomb-limit"); + } + } SCAppLayerParserRegisterLogger(IPPROTO_TCP, ALPROTO_HTTP2); SCLogDebug!("Rust http2 parser registered."); } else { diff --git a/suricata.yaml.in b/suricata.yaml.in index 1527cc573c..b2c63cb5f8 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -1032,6 +1032,8 @@ app-layer: #max-reassembly-size: 102400 # Maximum number of frames per tx #max-frames: 65536 + # Maximum data to decompress if the decompress ratio is too high + #compression-bomb-limit: 1MiB smtp: enabled: yes raw-extraction: no