This document outlines security vulnerabilities in zenjpeg and remediation priorities based on libjpeg-turbo's battle-tested approach.
libjpeg-turbo uses several mechanisms to prevent DoS attacks:
| Mechanism | Value | Purpose |
|---|---|---|
JPEG_MAX_DIMENSION |
65500 | Max width/height (just under 64K to prevent overflow) |
max_memory_to_use |
Application-set | Limit virtual array allocation |
TJPARAM_MAXMEMORY |
In megabytes | TurboJPEG API memory limit |
TJPARAM_MAXPIXELS |
Total pixels | Limits allocation tied to dimensions |
Key insight from libjpeg-turbo maintainer: "Applications wishing to guard against [DoS] should set cinfo->mem->max_memory_to_use" - responsibility is shared between library and application.
Sources:
- libjpeg-turbo jmorecfg.h
- Issue #249: Memory DoS
- Issue #735: TurboJPEG memory limits
- Mozilla Bug 1252200
Problem: All vec![] allocations panic on OOM, causing ungraceful crashes.
Solution: Use Rust's stable fallible allocation APIs.
// BEFORE (panics on OOM)
let mut plane = vec![0u8; width * height];
// AFTER (returns error on OOM)
let mut plane = Vec::new();
plane.try_reserve_exact(width * height)
.map_err(|_| Error::AllocationFailed { bytes: width * height })?;
plane.resize(width * height, 0u8);
// Or use a helper function
fn try_alloc_zeroed<T: Default + Clone>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count)
.map_err(|_| Error::AllocationFailed { bytes: count * std::mem::size_of::<T>() })?;
v.resize(count, T::default());
Ok(v)
}Files to update:
decode.rs: Lines 674, 757, 940, 977, 988, 1004encode.rs: Lines 227, 464, 499, 558, 1125-1126, 1792-1794adaptive_quant.rs: Lines 135, 310, 427, 525color.rs: Lines 335, 404xyb.rs: Line 281
Rust API Reference: Vec::try_reserve
Problem: 65535×65535 image = 4.3 billion pixels = 12.8GB RGB buffer.
Solution: Add configurable pixel limit (like TJPARAM_MAXPIXELS).
// In consts.rs
/// Default maximum pixels (100 megapixels)
/// Matches common server-side limits
pub const DEFAULT_MAX_PIXELS: u64 = 100_000_000;
/// Absolute maximum per JPEG spec (65500 × 65500)
pub const JPEG_MAX_DIMENSION: u32 = 65500;
// In decode.rs validation
fn validate_dimensions(&self, width: u32, height: u32, max_pixels: u64) -> Result<()> {
if width == 0 || height == 0 {
return Err(Error::InvalidDimensions { width, height, reason: "zero dimension" });
}
if width > JPEG_MAX_DIMENSION || height > JPEG_MAX_DIMENSION {
return Err(Error::InvalidDimensions {
width, height,
reason: "exceeds JPEG_MAX_DIMENSION (65500)"
});
}
let total_pixels = (width as u64).checked_mul(height as u64)
.ok_or(Error::InvalidDimensions { width, height, reason: "pixel count overflow" })?;
if total_pixels > max_pixels {
return Err(Error::ImageTooLarge {
pixels: total_pixels,
max: max_pixels
});
}
Ok(())
}Problem: width * height * 3 can overflow on 32-bit systems.
Solution: Use checked_mul chains.
// BEFORE
let size = width * height * bytes_per_pixel;
// AFTER
let size = width
.checked_mul(height)
.and_then(|p| p.checked_mul(bytes_per_pixel))
.ok_or(Error::DimensionOverflow)?;Create helper:
/// Calculate allocation size with overflow checking
pub fn checked_alloc_size(width: usize, height: usize, bpp: usize) -> Result<usize> {
width
.checked_mul(height)
.and_then(|p| p.checked_mul(bpp))
.ok_or(Error::AllocationOverflow {
width, height, bpp,
reason: "size calculation overflow"
})
}Problem: No way to limit total memory usage like libjpeg-turbo's max_memory_to_use.
Solution: Add DecoderConfig::max_memory_bytes option.
pub struct DecoderConfig {
// ... existing fields ...
/// Maximum memory to use for decoding (default: 1GB)
/// Set to 0 for unlimited
pub max_memory_bytes: usize,
/// Maximum pixels to decode (default: 100MP)
pub max_pixels: u64,
}
impl Default for DecoderConfig {
fn default() -> Self {
Self {
// ...
max_memory_bytes: 1024 * 1024 * 1024, // 1GB
max_pixels: 100_000_000, // 100MP
}
}
}Problem: ICC profiles concatenated without limit.
Solution: Add size cap (16MB is generous; typical profiles are <1MB).
// In icc.rs
const MAX_ICC_PROFILE_SIZE: usize = 16 * 1024 * 1024; // 16MB
pub fn extract_icc_profile(jpeg_data: &[u8]) -> Option<Vec<u8>> {
let mut chunks: Vec<(u8, Vec<u8>)> = Vec::new();
let mut total_size: usize = 0;
// ... existing parsing ...
// Check accumulated size
total_size = total_size.saturating_add(icc_data.len());
if total_size > MAX_ICC_PROFILE_SIZE {
return None; // Or return Result with error
}
// ...
}Problem: Malformed progressive JPEGs can have excessive scans.
Solution: Limit number of scans (libjpeg-turbo allows hundreds but malicious files have thousands).
const MAX_SCANS: usize = 256; // Generous limit
// In decode loop
let mut scan_count = 0;
loop {
match marker {
MARKER_SOS => {
scan_count += 1;
if scan_count > MAX_SCANS {
return Err(Error::TooManyScans { count: scan_count });
}
self.parse_scan()?;
}
// ...
}
}Create fuzz/ directory with libFuzzer targets:
// fuzz/fuzz_targets/decode_jpeg.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use zenjpeg::Decoder;
fuzz_target!(|data: &[u8]| {
let decoder = Decoder::new()
.max_memory(64 * 1024 * 1024) // 64MB limit for fuzzing
.max_pixels(10_000_000); // 10MP limit
let _ = decoder.decode(data);
});# fuzz/Cargo.toml
[package]
name = "jpegli-fuzz"
version = "0.0.0"
edition = "2021"
[dependencies]
libfuzzer-sys = "0.4"
jpegli = { path = "../jpegli" }
[[bin]]
name = "decode_jpeg"
path = "fuzz_targets/decode_jpeg.rs"
test = false
doc = falseRun with: cargo +nightly fuzz run decode_jpeg
Problem: Retry loop can iterate many times with adversarial input.
Solution: Add iteration cap.
// In huffman.rs build_code_lengths()
const MAX_TREE_RETRIES: usize = 32;
let mut retry_count = 0;
loop {
retry_count += 1;
if retry_count > MAX_TREE_RETRIES {
// Force-clamp depths and exit
for d in &mut depth {
*d = (*d).min(max_length);
}
break;
}
// ... existing loop body ...
}Problem: Restart markers (RST0-RST7) not validated against expected sequence.
Solution: Track and validate sequence.
struct EntropyDecoder {
// ...
expected_restart_num: u8,
}
fn handle_restart_marker(&mut self, marker: u8) -> Result<()> {
let rst_num = marker - 0xD0;
if rst_num != self.expected_restart_num {
// Warning, not error - matches libjpeg behavior
log::warn!("unexpected restart marker: got RST{}, expected RST{}",
rst_num, self.expected_restart_num);
}
self.expected_restart_num = (self.expected_restart_num + 1) & 7;
Ok(())
}Track total allocations to enforce max_memory_bytes:
struct AllocationTracker {
total_allocated: usize,
max_allowed: usize,
}
impl AllocationTracker {
fn try_allocate(&mut self, bytes: usize) -> Result<()> {
let new_total = self.total_allocated.checked_add(bytes)
.ok_or(Error::AllocationOverflow)?;
if new_total > self.max_allowed {
return Err(Error::MemoryLimitExceeded {
requested: bytes,
total: new_total,
limit: self.max_allowed
});
}
self.total_allocated = new_total;
Ok(())
}
}Like libjpeg-turbo's djpeg -strict, make warnings fatal:
pub struct DecoderConfig {
// ...
/// Treat warnings as errors (matches libjpeg -strict)
pub strict: bool,
}pub enum Error {
// ... existing variants ...
/// Allocation failed (OOM or limit exceeded)
AllocationFailed { bytes: usize },
/// Size calculation overflowed
AllocationOverflow { width: usize, height: usize, bpp: usize },
/// Image exceeds pixel limit
ImageTooLarge { pixels: u64, max: u64 },
/// Memory limit exceeded
MemoryLimitExceeded { requested: usize, total: usize, limit: usize },
/// Too many progressive scans
TooManyScans { count: usize },
/// ICC profile too large
IccProfileTooLarge { size: usize, max: usize },
}- P0-1: Replace
vec![]withtry_reservepattern in all files - P0-2: Add
JPEG_MAX_DIMENSIONandmax_pixelsvalidation - P0-3: Use
checked_mulfor all size calculations - P1-1: Add
DecoderConfig::max_memory_bytes - P1-2: Limit ICC profile to 16MB
- P1-3: Limit scans to 256
- P2-1: Set up cargo-fuzz infrastructure
- P2-2: Cap Huffman tree retries at 32
- P2-3: Validate restart marker sequence
- P3-1: Implement allocation tracking
- P3-2: Add strict mode