security(raw): cap lossless-JPEG Huffman table builds

Each DHT definition for table id 0..3 calls Huffman::new, which
allocates and fills a ~48 KiB lookup pair. A stream repeating DHT
definitions (and DNG tiles referencing such a stream) could rebuild
tables thousands of times, multiplying header-processing cost and
memory churn while producing little output.

A real lossless JPEG uses a handful of Huffman tables; cap cumulative
table builds per stream at 64 and reject streams that exceed it.

Finding: 7a31b581-d1b7-4f9c-91e5-ce42b47db87f (CWE-400)
This commit is contained in:
Dylan M. Taylor
2026-10-22 07:10:24 -04:00
parent 8c0a9cb185
commit 2e2faec1a3
+31
View File
@@ -299,6 +299,11 @@ fn parse(data: &[u8]) -> Result<Parsed<'_>> {
let mut pos = 2;
let mut frame: Option<Frame> = None;
let mut comp_ids: Vec<u8> = Vec::new();
// A lossless JPEG uses at most a handful of Huffman tables. Each Huffman::new allocates a
// ~48 KiB lookup pair and fills it, so a stream that repeats DHT definitions could rebuild
// tables thousands of times. Cap the cumulative builds to bound that header-processing work.
const MAX_HUFFMAN_BUILDS: usize = 64;
let mut huffman_builds = 0usize;
let mut huff: [Option<Huffman>; 4] = [None, None, None, None];
let mut restart = 0usize;
loop {
@@ -355,6 +360,10 @@ fn parse(data: &[u8]) -> Result<Parsed<'_>> {
let total: usize = counts.iter().map(|&c| usize::from(c)).sum();
let vals = seg.get(p + 17..p + 17 + total).ok_or_else(|| RawError::malformed("short DHT"))?;
if id < 4 {
huffman_builds += 1;
if huffman_builds > MAX_HUFFMAN_BUILDS {
return Err(RawError::malformed("lossless JPEG defines too many Huffman tables"));
}
huff[id] = Some(Huffman::new(&counts, vals)?);
}
p += 17 + total;
@@ -573,4 +582,26 @@ mod tests {
let lossy = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0, 1, 0, 1, 1, 1, 0x11, 0];
assert!(matches!(decode(&lossy, usize::MAX), Err(RawError::Unsupported(_))));
}
#[test]
fn too_many_huffman_tables_are_rejected() {
// A valid stream still decodes; the same stream with thousands of repeated DHT
// definitions injected after the SOI is rejected before rebuilding tables endlessly.
let (w, h) = (8, 8);
let v = samples(w * h, 12, 5);
let j = lj92_encode(&v, w, h, 1, 12, 1, None);
assert!(decode(&j, usize::MAX).is_ok());
// Minimal DHT for id 0 (one length-1 code, value 0): FF C4, len=0x0014, then
// tc_th, 16 count bytes, one value.
let mut dht = vec![0xFF, 0xC4, 0x00, 0x14, 0x00];
dht.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // counts
dht.push(0x00); // one value
let mut flooded = j[..2].to_vec(); // SOI
for _ in 0..5000 {
flooded.extend_from_slice(&dht);
}
flooded.extend_from_slice(&j[2..]); // the rest of the valid stream
assert!(matches!(decode(&flooded, usize::MAX), Err(RawError::Malformed(_))));
}
}