security: bound decompression output to prevent memory-exhaustion DoS

decompress_chunk() already threaded chunk_size (the pipeline's declared
decompressed size) into the scale-offset/nbit/szip decoders to bound their
output, but not into deflate/lz4/zstd/pcodec, all four of which allocated
based on attacker-controlled input with no cap:

- lz4: read a raw u32 "orig_size" straight from the compressed payload's
  first 4 bytes and passed it directly to lz4_flex::block::decompress with
  no upper bound — a 4-byte attacker-controlled field could request ~4 GiB.
- deflate (non-macOS path): unbounded flate2 read_to_end into a fresh Vec.
- zstd: zstd::decode_all with no output cap (classic decompression-bomb
  vector, ratios can exceed 1000:1).
- pcodec: simple_decompress with no cap.

All four now take the expected chunk size and reject output that exceeds it
(or a 256 MiB absolute ceiling when the size is unavailable), matching the
pattern the other three filters already used. Also fixes the same unbounded
read_to_end in clawhdf5-filters' fast_deflate streaming fallback (used when
no size hint is available).

Added tests for each codec plus one exercising the actually-exploited path
through the public decompress_chunk() entrypoint.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-05 07:38:57 -07:00
co-authored by Claude Sonnet 5
parent 88195d1c33
commit b9898c2a9c
2 changed files with 232 additions and 28 deletions
+16 -1
View File
@@ -270,14 +270,29 @@ pub(crate) fn flate2_decompress_preallocated(
Ok(output) Ok(output)
} }
/// Absolute ceiling on decompressed output when the caller has no size hint,
/// preventing unbounded allocation from a hostile/corrupted zlib stream.
const MAX_DECOMPRESS_SIZE: usize = 256 * 1024 * 1024;
/// Streaming decompress with dynamic sizing (when output size is unknown). /// Streaming decompress with dynamic sizing (when output size is unknown).
///
/// Bounded by [`MAX_DECOMPRESS_SIZE`] since there is no chunk-size hint to
/// validate against here — an unbounded `read_to_end` would let a hostile
/// zlib stream force arbitrarily large allocation (a "zlib bomb").
pub(crate) fn flate2_decompress_streaming(data: &[u8]) -> Result<Vec<u8>, String> { pub(crate) fn flate2_decompress_streaming(data: &[u8]) -> Result<Vec<u8>, String> {
use std::io::Read; use std::io::Read;
let mut decoder = flate2::read::ZlibDecoder::new(data); let decoder = flate2::read::ZlibDecoder::new(data);
let mut result = Vec::new(); let mut result = Vec::new();
decoder decoder
.take(MAX_DECOMPRESS_SIZE as u64 + 1)
.read_to_end(&mut result) .read_to_end(&mut result)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if result.len() > MAX_DECOMPRESS_SIZE {
return Err(format!(
"decompressed output exceeds {} MiB limit",
MAX_DECOMPRESS_SIZE / 1024 / 1024
));
}
Ok(result) Ok(result)
} }
+216 -27
View File
@@ -12,6 +12,11 @@ use crate::filter_pipeline::{
FILTER_SCALEOFFSET, FILTER_SHUFFLE, FILTER_SZIP, FILTER_ZSTD, FilterPipeline, FILTER_SCALEOFFSET, FILTER_SHUFFLE, FILTER_SZIP, FILTER_ZSTD, FilterPipeline,
}; };
/// Absolute ceiling on a single decompressed chunk's output size, used only
/// when the pipeline's declared `chunk_size` is unavailable (0). Prevents
/// unbounded-allocation DoS from a malicious/corrupted compressed chunk.
pub(crate) const MAX_DECOMPRESS_SIZE: usize = 256 * 1024 * 1024;
/// Apply a filter pipeline to decompress a chunk. /// Apply a filter pipeline to decompress a chunk.
/// Filters are applied in REVERSE order for decompression. /// Filters are applied in REVERSE order for decompression.
pub fn decompress_chunk( pub fn decompress_chunk(
@@ -25,11 +30,15 @@ pub fn decompress_chunk(
for filter in pipeline.filters.iter().rev() { for filter in pipeline.filters.iter().rev() {
data = match filter.filter_id { data = match filter.filter_id {
FILTER_SHUFFLE => shuffle_decompress(&data, element_size as usize)?, FILTER_SHUFFLE => shuffle_decompress(&data, element_size as usize)?,
FILTER_DEFLATE => deflate_decompress(&data)?, // `chunk_size` is the expected decompressed size (shuffle/fletcher32
FILTER_LZ4 => lz4_decompress(&data)?, // are size-preserving, so it bounds these too); pass it so these
FILTER_ZSTD => zstd_decompress(&data)?, // decoders can't be forced into unbounded allocation by a hostile
// or corrupted compressed payload.
FILTER_DEFLATE => deflate_decompress(&data, chunk_size)?,
FILTER_LZ4 => lz4_decompress(&data, chunk_size)?,
FILTER_ZSTD => zstd_decompress(&data, chunk_size)?,
FILTER_FLETCHER32 => fletcher32_verify(&data)?, FILTER_FLETCHER32 => fletcher32_verify(&data)?,
FILTER_PCODEC => pcodec_decompress(&data, element_size as usize)?, FILTER_PCODEC => pcodec_decompress(&data, element_size as usize, chunk_size)?,
// `chunk_size` is the expected decompressed size; pass it so these // `chunk_size` is the expected decompressed size; pass it so these
// decoders can reject an element count that would over-allocate. // decoders can reject an element count that would over-allocate.
FILTER_SCALEOFFSET => scaleoffset_decompress(&data, &filter.client_data, chunk_size)?, FILTER_SCALEOFFSET => scaleoffset_decompress(&data, &filter.client_data, chunk_size)?,
@@ -569,24 +578,48 @@ fn nbit_decompress(data: &[u8], cd: &[u32], expected_bytes: usize) -> Result<Vec
} }
/// Decompress zlib-compressed data. /// Decompress zlib-compressed data.
///
/// `expected_bytes` is the pipeline's declared decompressed chunk size (0 if
/// unavailable); output is rejected if it exceeds this bound (or, when
/// unavailable, [`MAX_DECOMPRESS_SIZE`]), preventing a hostile/corrupted
/// compressed payload from forcing unbounded allocation (a "zlib bomb").
#[cfg(feature = "deflate")] #[cfg(feature = "deflate")]
fn deflate_decompress(data: &[u8]) -> Result<Vec<u8>, FormatError> { fn deflate_decompress(data: &[u8], expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
let limit = if expected_bytes != 0 {
expected_bytes
} else {
MAX_DECOMPRESS_SIZE
};
// Try system zlib first on macOS (Apple's ARM64-optimized libz is ~1.4x // Try system zlib first on macOS (Apple's ARM64-optimized libz is ~1.4x
// faster at decompression than zlib-ng on Apple Silicon). // faster at decompression than zlib-ng on Apple Silicon).
#[cfg(all(target_os = "macos", feature = "system-zlib-decompress"))] #[cfg(all(target_os = "macos", feature = "system-zlib-decompress"))]
{ {
if let Ok(result) = sysz::decompress(data) { if let Ok(result) = sysz::decompress(data) {
if result.len() > limit {
return Err(FormatError::DecompressionError(
"deflate: output exceeds expected chunk size".into(),
));
}
return Ok(result); return Ok(result);
} }
// Fall through to flate2 on error // Fall through to flate2 on error
} }
use std::io::Read; use std::io::Read;
let mut decoder = flate2::read::ZlibDecoder::new(data); let decoder = flate2::read::ZlibDecoder::new(data);
let mut result = Vec::new(); let mut result = Vec::with_capacity(limit.min(1 << 20));
// Read one byte past the limit so an over-size stream is distinguishable
// from one that legitimately ends exactly at the limit.
decoder decoder
.take(limit as u64 + 1)
.read_to_end(&mut result) .read_to_end(&mut result)
.map_err(|e| FormatError::DecompressionError(e.to_string()))?; .map_err(|e| FormatError::DecompressionError(e.to_string()))?;
if result.len() > limit {
return Err(FormatError::DecompressionError(
"deflate: output exceeds size limit".into(),
));
}
Ok(result) Ok(result)
} }
@@ -659,7 +692,7 @@ mod sysz {
} }
#[cfg(not(feature = "deflate"))] #[cfg(not(feature = "deflate"))]
fn deflate_decompress(_data: &[u8]) -> Result<Vec<u8>, FormatError> { fn deflate_decompress(_data: &[u8], _expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
Err(FormatError::UnsupportedFilter(FILTER_DEFLATE)) Err(FormatError::UnsupportedFilter(FILTER_DEFLATE))
} }
@@ -682,20 +715,35 @@ fn deflate_compress(_data: &[u8], _level: u32) -> Result<Vec<u8>, FormatError> {
} }
/// Decompress LZ4 data. Format: 4 bytes LE original size + LZ4 block data. /// Decompress LZ4 data. Format: 4 bytes LE original size + LZ4 block data.
///
/// The 4-byte "original size" header is part of the attacker-controlled
/// compressed payload itself, so it is bounded against `expected_bytes` (the
/// pipeline's declared chunk size) before being used to size the output
/// allocation — otherwise a crafted 4-byte value can request up to ~4 GiB.
#[cfg(feature = "lz4")] #[cfg(feature = "lz4")]
fn lz4_decompress(data: &[u8]) -> Result<Vec<u8>, FormatError> { fn lz4_decompress(data: &[u8], expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
if data.len() < 4 { if data.len() < 4 {
return Err(FormatError::DecompressionError( return Err(FormatError::DecompressionError(
"lz4: data too short".into(), "lz4: data too short".into(),
)); ));
} }
let orig_size = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize; let orig_size = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
if expected_bytes != 0 && orig_size > expected_bytes {
return Err(FormatError::DecompressionError(
"lz4: declared size exceeds chunk size".into(),
));
}
if orig_size > MAX_DECOMPRESS_SIZE {
return Err(FormatError::DecompressionError(
"lz4: declared size exceeds limit".into(),
));
}
lz4_flex::block::decompress(&data[4..], orig_size) lz4_flex::block::decompress(&data[4..], orig_size)
.map_err(|e| FormatError::DecompressionError(format!("lz4: {e}"))) .map_err(|e| FormatError::DecompressionError(format!("lz4: {e}")))
} }
#[cfg(not(feature = "lz4"))] #[cfg(not(feature = "lz4"))]
fn lz4_decompress(_data: &[u8]) -> Result<Vec<u8>, FormatError> { fn lz4_decompress(_data: &[u8], _expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
Err(FormatError::UnsupportedFilter(FILTER_LZ4)) Err(FormatError::UnsupportedFilter(FILTER_LZ4))
} }
@@ -715,13 +763,35 @@ fn lz4_compress(_data: &[u8]) -> Result<Vec<u8>, FormatError> {
} }
/// Decompress zstd data. /// Decompress zstd data.
///
/// `expected_bytes` bounds the output (or [`MAX_DECOMPRESS_SIZE`] when
/// unavailable) to guard against a zstd decompression bomb, since zstd's
/// compression ratio can exceed 1000:1.
#[cfg(feature = "zstd")] #[cfg(feature = "zstd")]
fn zstd_decompress(data: &[u8]) -> Result<Vec<u8>, FormatError> { fn zstd_decompress(data: &[u8], expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
zstd::decode_all(data).map_err(|e| FormatError::DecompressionError(format!("zstd: {e}"))) use std::io::Read;
let limit = if expected_bytes != 0 {
expected_bytes
} else {
MAX_DECOMPRESS_SIZE
};
let decoder = zstd::stream::Decoder::new(data)
.map_err(|e| FormatError::DecompressionError(format!("zstd: {e}")))?;
let mut out = Vec::with_capacity(limit.min(1 << 20));
decoder
.take(limit as u64 + 1)
.read_to_end(&mut out)
.map_err(|e| FormatError::DecompressionError(format!("zstd: {e}")))?;
if out.len() > limit {
return Err(FormatError::DecompressionError(
"zstd: output exceeds chunk size".into(),
));
}
Ok(out)
} }
#[cfg(not(feature = "zstd"))] #[cfg(not(feature = "zstd"))]
fn zstd_decompress(_data: &[u8]) -> Result<Vec<u8>, FormatError> { fn zstd_decompress(_data: &[u8], _expected_bytes: usize) -> Result<Vec<u8>, FormatError> {
Err(FormatError::UnsupportedFilter(FILTER_ZSTD)) Err(FormatError::UnsupportedFilter(FILTER_ZSTD))
} }
@@ -982,30 +1052,74 @@ fn pcodec_compress(_data: &[u8], _element_size: usize) -> Result<Vec<u8>, Format
Err(FormatError::UnsupportedFilter(FILTER_PCODEC)) Err(FormatError::UnsupportedFilter(FILTER_PCODEC))
} }
/// `expected_bytes` bounds the number of elements decoded: the output buffer
/// is pre-sized to exactly `expected_bytes / element_size` elements and
/// `simple_decompress_into` never writes past it, so a corrupted/hostile pco
/// stream cannot force over-allocation the way an unbounded `simple_decompress`
/// (which allocates however many elements the stream claims) could.
#[cfg(feature = "pcodec")] #[cfg(feature = "pcodec")]
fn pcodec_decompress(data: &[u8], element_size: usize) -> Result<Vec<u8>, FormatError> { fn pcodec_decompress(
use pco::standalone::simple_decompress; data: &[u8],
element_size: usize,
expected_bytes: usize,
) -> Result<Vec<u8>, FormatError> {
use pco::standalone::simple_decompress_into;
let limit_bytes = if expected_bytes != 0 {
expected_bytes
} else {
MAX_DECOMPRESS_SIZE
};
let n = if element_size != 0 {
limit_bytes / element_size
} else {
0
};
match element_size { match element_size {
4 => { 4 => {
let nums = simple_decompress::<f32>(data) let mut buf = vec![0f32; n];
let progress = simple_decompress_into(data, &mut buf)
.map_err(|e| FormatError::DecompressionError(format!("pco: {e}")))?; .map_err(|e| FormatError::DecompressionError(format!("pco: {e}")))?;
Ok(nums.iter().flat_map(|x| x.to_le_bytes()).collect()) if !progress.finished {
return Err(FormatError::DecompressionError(
"pco: stream contains more data than expected chunk size allows".into(),
));
}
buf.truncate(progress.n_processed);
Ok(buf.iter().flat_map(|x| x.to_le_bytes()).collect())
} }
8 => { 8 => {
let nums = simple_decompress::<f64>(data) let mut buf = vec![0f64; n];
let progress = simple_decompress_into(data, &mut buf)
.map_err(|e| FormatError::DecompressionError(format!("pco: {e}")))?; .map_err(|e| FormatError::DecompressionError(format!("pco: {e}")))?;
Ok(nums.iter().flat_map(|x| x.to_le_bytes()).collect()) if !progress.finished {
return Err(FormatError::DecompressionError(
"pco: stream contains more data than expected chunk size allows".into(),
));
}
buf.truncate(progress.n_processed);
Ok(buf.iter().flat_map(|x| x.to_le_bytes()).collect())
} }
_ => { _ => {
let nums = simple_decompress::<u32>(data) let mut buf = vec![0u32; n];
let progress = simple_decompress_into(data, &mut buf)
.map_err(|e| FormatError::DecompressionError(format!("pco: {e}")))?; .map_err(|e| FormatError::DecompressionError(format!("pco: {e}")))?;
Ok(nums.iter().flat_map(|x| x.to_le_bytes()).collect()) if !progress.finished {
return Err(FormatError::DecompressionError(
"pco: stream contains more data than expected chunk size allows".into(),
));
}
buf.truncate(progress.n_processed);
Ok(buf.iter().flat_map(|x| x.to_le_bytes()).collect())
} }
} }
} }
#[cfg(not(feature = "pcodec"))] #[cfg(not(feature = "pcodec"))]
fn pcodec_decompress(_data: &[u8], _element_size: usize) -> Result<Vec<u8>, FormatError> { fn pcodec_decompress(
_data: &[u8],
_element_size: usize,
_expected_bytes: usize,
) -> Result<Vec<u8>, FormatError> {
Err(FormatError::UnsupportedFilter(FILTER_PCODEC)) Err(FormatError::UnsupportedFilter(FILTER_PCODEC))
} }
@@ -1021,7 +1135,7 @@ mod tests {
fn deflate_compress_decompress_roundtrip() { fn deflate_compress_decompress_roundtrip() {
let data: Vec<u8> = (0..256).map(|i| (i % 256) as u8).collect(); let data: Vec<u8> = (0..256).map(|i| (i % 256) as u8).collect();
let compressed = deflate_compress(&data, 6).unwrap(); let compressed = deflate_compress(&data, 6).unwrap();
let decompressed = deflate_decompress(&compressed).unwrap(); let decompressed = deflate_decompress(&compressed, data.len()).unwrap();
assert_eq!(decompressed, data); assert_eq!(decompressed, data);
} }
@@ -1034,7 +1148,7 @@ mod tests {
let compressed: Vec<u8> = vec![ let compressed: Vec<u8> = vec![
120, 156, 99, 96, 100, 98, 102, 97, 101, 99, 231, 224, 4, 0, 0, 175, 0, 46, 120, 156, 99, 96, 100, 98, 102, 97, 101, 99, 231, 224, 4, 0, 0, 175, 0, 46,
]; ];
let decompressed = deflate_decompress(&compressed).unwrap(); let decompressed = deflate_decompress(&compressed, 10).unwrap();
assert_eq!(decompressed, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); assert_eq!(decompressed, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
} }
@@ -1045,7 +1159,7 @@ mod tests {
let data = vec![0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9]; let data = vec![0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let compressed = deflate_compress(&data, 6).unwrap(); let compressed = deflate_compress(&data, 6).unwrap();
assert!(!compressed.is_empty()); assert!(!compressed.is_empty());
let decompressed = deflate_decompress(&compressed).unwrap(); let decompressed = deflate_decompress(&compressed, data.len()).unwrap();
assert_eq!(decompressed, data); assert_eq!(decompressed, data);
} }
@@ -1246,7 +1360,7 @@ mod tests {
fn lz4_compress_decompress_roundtrip() { fn lz4_compress_decompress_roundtrip() {
let data: Vec<u8> = (0..256).map(|i| (i % 256) as u8).collect(); let data: Vec<u8> = (0..256).map(|i| (i % 256) as u8).collect();
let compressed = lz4_compress(&data).unwrap(); let compressed = lz4_compress(&data).unwrap();
let decompressed = lz4_decompress(&compressed).unwrap(); let decompressed = lz4_decompress(&compressed, data.len()).unwrap();
assert_eq!(decompressed, data); assert_eq!(decompressed, data);
} }
@@ -1301,7 +1415,7 @@ mod tests {
fn zstd_compress_decompress_roundtrip() { fn zstd_compress_decompress_roundtrip() {
let data: Vec<u8> = (0..256).map(|i| (i % 256) as u8).collect(); let data: Vec<u8> = (0..256).map(|i| (i % 256) as u8).collect();
let compressed = zstd_compress(&data, 3).unwrap(); let compressed = zstd_compress(&data, 3).unwrap();
let decompressed = zstd_decompress(&compressed).unwrap(); let decompressed = zstd_decompress(&compressed, data.len()).unwrap();
assert_eq!(decompressed, data); assert_eq!(decompressed, data);
} }
@@ -1623,4 +1737,79 @@ mod tests {
// Missing client data entirely. // Missing client data entirely.
assert!(scaleoffset_decompress(&[0u8; 32], &[2, 0], 4).is_err()); assert!(scaleoffset_decompress(&[0u8; 32], &[2, 0], 4).is_err());
} }
// ----- Decompression-bomb hardening: hostile compressed data must not -----
// ----- force unbounded allocation. -----
#[test]
#[cfg(feature = "lz4")]
fn lz4_decompress_rejects_oversized_orig_size() {
// 4-byte LE header claiming ~4 GiB, followed by a few garbage bytes.
let mut data = u32::MAX.to_le_bytes().to_vec();
data.extend_from_slice(&[0u8; 8]);
assert!(lz4_decompress(&data, 64).is_err());
}
#[test]
#[cfg(feature = "lz4")]
fn lz4_decompress_rejects_size_exceeding_chunk_size() {
// orig_size (1000) is well under MAX_DECOMPRESS_SIZE but exceeds the
// pipeline's declared chunk size (64) — must be rejected by the
// chunk-size check specifically, not just the absolute cap.
let mut data = 1000u32.to_le_bytes().to_vec();
data.extend_from_slice(&[0u8; 8]);
assert!(lz4_decompress(&data, 64).is_err());
}
#[test]
#[cfg(feature = "deflate")]
fn deflate_decompress_rejects_output_exceeding_chunk_size() {
// A highly-compressible deflate bomb (1 MiB of zeros compresses to a
// tiny payload); declared chunk size is far smaller than the real
// decompressed size, so this must be rejected rather than allocating
// the full 1 MiB.
let data = vec![0u8; 1024 * 1024];
let compressed = deflate_compress(&data, 6).unwrap();
assert!(deflate_decompress(&compressed, 64).is_err());
}
#[test]
#[cfg(feature = "zstd")]
fn zstd_decompress_rejects_output_exceeding_chunk_size() {
let data = vec![0u8; 1024 * 1024];
let compressed = zstd_compress(&data, 3).unwrap();
assert!(zstd_decompress(&compressed, 64).is_err());
}
#[test]
#[cfg(feature = "pcodec")]
fn pcodec_decompress_rejects_element_count_exceeding_chunk_size() {
let data: Vec<f32> = (0..1000).map(|i| i as f32).collect();
let raw: Vec<u8> = data.iter().flat_map(|x| x.to_le_bytes()).collect();
let compressed = pcodec_compress(&raw, 4).unwrap();
// Declared chunk size only fits 4 f32 elements, far fewer than the
// 1000 the stream actually contains.
assert!(pcodec_decompress(&compressed, 4, 16).is_err());
}
#[test]
#[cfg(feature = "lz4")]
fn decompress_chunk_rejects_hostile_lz4_size_via_public_entrypoint() {
// The actually-exploited path: a FilterPipeline claiming a small
// chunk_size, but whose LZ4-compressed data header claims a huge
// decompressed size.
use crate::filter_pipeline::{FilterDescription, FilterPipeline};
let mut data = u32::MAX.to_le_bytes().to_vec();
data.extend_from_slice(&[0u8; 8]);
let pipeline = FilterPipeline {
version: 2,
filters: vec![FilterDescription {
filter_id: FILTER_LZ4,
name: None,
flags: 0,
client_data: vec![],
}],
};
assert!(decompress_chunk(&data, &pipeline, 16, 1).is_err());
}
} }