Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## 2025-05-15 - [Decompression Bomb and Path Collision Protection]
**Vulnerability:** Resource exhaustion via decompression bombs and application crash (SIGBUS) due to self-overwriting memory-mapped files.
**Learning:** Bzip2 blocks are typically 900KB but can be maliciously crafted. Memory-mapped files in Rust can trigger a SIGBUS if the underlying file is truncated (e.g., via `File::create`).
**Prevention:** Enforce a strict uncompressed size limit per block (2MB) using `Read::take`. Use `std::fs::canonicalize` to ensure input and output file paths are distinct before opening any output file.
**Prevention:** Enforce a strict uncompressed size limit per block (2MB) using `Read::take` and a compressed block size limit (4MB) to prevent OOM/resource exhaustion. Use `std::fs::canonicalize` to ensure input and output file paths are distinct before opening any output file.
16 changes: 15 additions & 1 deletion bz2zstd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ use std::path::PathBuf;
use std::thread;

mod writer;
use parallel_bzip2_decoder::{extract_bits, MarkerType, Scanner, MAX_BLOCK_SIZE};
use parallel_bzip2_decoder::{
extract_bits, MarkerType, Scanner, MAX_BLOCK_SIZE, MAX_COMPRESSED_BLOCK_SIZE,
};
use writer::OutputWriter;

/// Command-line arguments for bz2zstd.
Expand Down Expand Up @@ -309,6 +311,18 @@ fn main() -> Result<()> {
// This avoids lock contention and repeated allocations
|| (Vec::new(), Compressor::new(args.zstd_level).unwrap()),
|(decomp_buf, compressor), (idx, (start_bit, end_bit))| -> Result<()> {
// Security Check: Verify that the compressed block size is within limits.
// This protects against resource exhaustion from maliciously large compressed blocks.
let bit_len = end_bit.saturating_sub(start_bit);
let compressed_byte_len = ((bit_len + 7) / 8) as usize;

if compressed_byte_len > MAX_COMPRESSED_BLOCK_SIZE {
return Err(anyhow::anyhow!(
"Compressed block limit exceeded ({} bytes) at block {}. Possible malicious file.",
MAX_COMPRESSED_BLOCK_SIZE, idx
));
}

// Extract the compressed bzip2 block bits
let mut block_data = Vec::new();
extract_bits(&mmap, start_bit, end_bit, &mut block_data);
Expand Down
16 changes: 16 additions & 0 deletions parallel_bzip2_decoder/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ pub enum Bz2Error {
/// The limit that was exceeded
limit: usize,
},

/// Compressed block size limit exceeded.
CompressedBlockLimitExceeded {
/// Bit offset where the block starts
offset: u64,
/// The limit that was exceeded
limit: usize,
},
}

impl fmt::Display for Bz2Error {
Expand All @@ -51,6 +59,13 @@ impl fmt::Display for Bz2Error {
limit, offset
)
}
Bz2Error::CompressedBlockLimitExceeded { offset, limit } => {
write!(
f,
"Compressed block limit exceeded ({} bytes) at bit offset {}. Possible malicious file.",
limit, offset
)
}
}
}
}
Expand All @@ -63,6 +78,7 @@ impl std::error::Error for Bz2Error {
Bz2Error::MmapFailed(err) => Some(err),
Bz2Error::InvalidFormat(_) => None,
Bz2Error::DecompressionLimitExceeded { .. } => None,
Bz2Error::CompressedBlockLimitExceeded { .. } => None,
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions parallel_bzip2_decoder/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ pub use scanner::{extract_bits, MarkerType, Scanner};
/// This protects against decompression bomb attacks.
pub const MAX_BLOCK_SIZE: usize = 2 * 1024 * 1024;

/// Maximum allowed compressed size for a single bzip2 block (4MB).
/// This protects against resource exhaustion from maliciously large compressed blocks.
pub const MAX_COMPRESSED_BLOCK_SIZE: usize = 4 * 1024 * 1024;

use bzip2::read::BzDecoder;
use crossbeam_channel::bounded;
use std::collections::HashMap;
Expand Down Expand Up @@ -290,6 +294,16 @@ pub fn decompress_block_into(
out: &mut Vec<u8>,
scratch: &mut Vec<u8>,
) -> Result<()> {
let bit_len = end_bit.saturating_sub(start_bit);
let compressed_byte_len = ((bit_len + 7) / 8) as usize;

if compressed_byte_len > MAX_COMPRESSED_BLOCK_SIZE {
return Err(Bz2Error::CompressedBlockLimitExceeded {
offset: start_bit,
limit: MAX_COMPRESSED_BLOCK_SIZE,
});
}

scratch.clear();
// Add minimal bzip2 header (BZh9 = highest compression level)
scratch.extend_from_slice(b"BZh9");
Expand Down Expand Up @@ -375,3 +389,28 @@ pub fn decompress_file<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<u8>> {
pub fn parallel_bzip2_cat<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<u8>> {
decompress_file(path)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_compressed_block_limit() {
let data = vec![0u8; 1024];
let mut out = Vec::new();
let mut scratch = Vec::new();

// 5MB limit exceeded (MAX_COMPRESSED_BLOCK_SIZE is 4MB)
let start_bit = 0;
let end_bit = 5 * 1024 * 1024 * 8;

let result = decompress_block_into(&data, start_bit, end_bit, &mut out, &mut scratch);
match result {
Err(Bz2Error::CompressedBlockLimitExceeded { offset, limit }) => {
assert_eq!(offset, 0);
assert_eq!(limit, MAX_COMPRESSED_BLOCK_SIZE);
}
_ => panic!("Expected CompressedBlockLimitExceeded error"),
}
}
}
Loading