From a59db233415451d0031054d37cea91c0b786e55f Mon Sep 17 00:00:00 2001 From: Lilith River Date: Thu, 9 Apr 2026 02:42:27 -0600 Subject: [PATCH] chore: replace decode_image fuzz target with comprehensive decoder exerciser Replace decode_image with decode_comprehensive, which exercises per-IFD via a probe_directory helper: - Tag iteration and value parsing for every tag - SubIFD, EXIF, and GPS directory traversal via read_directory - Image metadata (dimensions, colortype, color_map, chunk info) - Layout queries (image_buffer_layout, image_chunk_buffer_layout) - Per-chunk reads at first and last indices - Full image decode via read_image Three IFD traversal strategies: - Linear iteration via next_directory (tolerates invalid intermediate IFDs) - Random-access via seek_to_directory (find_nth_ifd code path) - Image-aware random-access via seek_to_image Dictionary covers classic TIFF and BigTIFF byte order marks, and common IFD tags in both LE and BE (image geometry, strips, tiles, SubIFD, EXIF, GPS, ColorMap). Strict resource limits (512 KB decode/intermediate, 64 KB IFD). Bounded iteration (max 8 linear, max 4 seek). --- README.md | 2 +- fuzz/.gitignore | 1 - fuzz/Cargo.toml | 6 - fuzz/fuzz_targets/decode_comprehensive.rs | 108 ++++++++++++------ fuzz/fuzz_targets/decode_image.rs | 29 ----- fuzz/tiff.dict | 129 +++++++++++++++++++--- 6 files changed, 193 insertions(+), 82 deletions(-) delete mode 100644 fuzz/fuzz_targets/decode_image.rs diff --git a/README.md b/README.md index 62ff836..634b40a 100644 --- a/README.md +++ b/README.md @@ -55,4 +55,4 @@ This crate uses [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) in order t After installing it with `cargo install cargo-fuzz` on a nightly rustc, the fuzzing harness can be run with recommended settings using -`cargo fuzz run decode_image -snone -- -timeout=5`. +`cargo fuzz run decode_comprehensive -snone -- -dict=fuzz/tiff.dict -timeout=5`. diff --git a/fuzz/.gitignore b/fuzz/.gitignore index 1a45eee..a092511 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -1,4 +1,3 @@ target corpus artifacts -coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 90325f2..0563ca7 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -18,12 +18,6 @@ path = ".." [workspace] members = ["."] -[[bin]] -name = "decode_image" -path = "fuzz_targets/decode_image.rs" -test = false -doc = false - [[bin]] name = "decode_comprehensive" path = "fuzz_targets/decode_comprehensive.rs" diff --git a/fuzz/fuzz_targets/decode_comprehensive.rs b/fuzz/fuzz_targets/decode_comprehensive.rs index b0920bc..8b35143 100644 --- a/fuzz/fuzz_targets/decode_comprehensive.rs +++ b/fuzz/fuzz_targets/decode_comprehensive.rs @@ -1,50 +1,96 @@ #![no_main] use libfuzzer_sys::fuzz_target; +use std::io::{Cursor, Read, Seek}; +use tiff::decoder::{Decoder, Limits}; +use tiff::tags::Tag; fuzz_target!(|data: &[u8]| { - let cursor = std::io::Cursor::new(data); + // Standalone header parse (separate code path from Decoder::open) + let _ = tiff::decoder::TiffHeader::parse(Cursor::new(data)); - // Path 1: TiffHeader::parse (probe) - { - let _ = tiff::decoder::TiffHeader::parse(std::io::Cursor::new(data)); - } - - // Path 2: Full decode with strict limits - let mut decoder = match tiff::decoder::Decoder::open(cursor) { + let mut decoder = match Decoder::open(Cursor::new(data)) { Ok(d) => d, Err(_) => return, }; - let mut limits = tiff::decoder::Limits::default(); + let mut limits = Limits::default(); limits.decoding_buffer_size = 512 * 1024; limits.ifd_value_size = 64 * 1024; limits.intermediate_buffer_size = 512 * 1024; - decoder = decoder.with_limits(limits); - // Probe metadata — dimensions() triggers image validation, - // must succeed before get_chunk_type/chunk_dimensions (they unwrap image) - let dims_ok = decoder.dimensions().is_ok(); - let _ = decoder.colortype(); - if dims_ok { - let _ = decoder.get_chunk_type(); - let _ = decoder.chunk_dimensions(); - } - let _ = decoder.more_images(); + // Probe first IFD (opened but image not yet parsed) + probe_directory(&mut decoder); - // Read chunks individually if available - let _ = decoder.read_chunk(0); - - // Read full image - let _ = decoder.read_image(); - - // Iterate through all IFDs - while decoder.more_images() { - if decoder.next_image().is_err() { + // Linear IFD iteration via next_directory — tolerates invalid + // intermediate IFDs that aren't decodable as images + let mut ifd_count = 1u32; + while decoder.more_images() && ifd_count < 8 { + if decoder.next_directory().is_err() { break; } - let _ = decoder.dimensions(); - let _ = decoder.colortype(); - let _ = decoder.read_image(); + probe_directory(&mut decoder); + ifd_count += 1; + } + + // Random-access traversal via seek (different code path: find_nth_ifd + // re-walks the IFD chain from the start each time) + for i in 0..ifd_count.min(4) as usize { + if decoder.seek_to_directory(i).is_ok() { + probe_directory(&mut decoder); + } + // seek_to_image eagerly parses the directory as an Image + let _ = decoder.seek_to_image(i); } }); + +fn probe_directory(decoder: &mut Decoder) { + // Tag iteration — exercises value parsing for every tag in the IFD + for result in decoder.current_ifd().tag_iter() { + let _ = result; + } + + // SubIFD / EXIF / GPS directory traversal — exercises read_directory + // with potentially adversarial offsets + if let Ok(Some(val)) = decoder.current_ifd().find_tag(Tag::SubIfd) { + if let Ok(ptrs) = val.into_ifd_vec() { + for ptr in ptrs.into_iter().take(4) { + let _ = decoder.read_directory(ptr); + } + } + } + for tag in [Tag::ExifDirectory, Tag::GpsDirectory] { + if let Ok(Some(val)) = decoder.current_ifd().find_tag(tag) { + if let Ok(ptr) = val.into_ifd_pointer() { + let _ = decoder.read_directory(ptr); + } + } + } + + // Image-level probing — dimensions() triggers ensure_image, + // which lazily parses the directory as an Image + if decoder.dimensions().is_ok() { + let _ = decoder.colortype(); + let _ = decoder.color_map(); + let _ = decoder.get_chunk_type(); + let _ = decoder.chunk_dimensions(); + let _ = decoder.strip_count(); + let _ = decoder.tile_count(); + let _ = decoder.image_buffer_layout(); + let _ = decoder.image_chunk_buffer_layout(0); + + // Chunk reads at boundaries — first and last exercise + // different offset calculations + let _ = decoder.read_chunk(0); + let chunk_count = decoder + .strip_count() + .or_else(|_| decoder.tile_count()) + .unwrap_or(0); + if chunk_count > 1 { + let _ = decoder.read_chunk(chunk_count - 1); + } + + // Full image decode + let _ = decoder.read_image(); + } +} diff --git a/fuzz/fuzz_targets/decode_image.rs b/fuzz/fuzz_targets/decode_image.rs deleted file mode 100644 index 7efea0d..0000000 --- a/fuzz/fuzz_targets/decode_image.rs +++ /dev/null @@ -1,29 +0,0 @@ -#![no_main] -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - let mut decoder = if let Ok(d) = tiff::decoder::Decoder::open(std::io::Cursor::new(data)) { - d - } else { - return; - }; - - let mut limits = tiff::decoder::Limits::default(); - limits.decoding_buffer_size = 1_000_000; - limits.ifd_value_size = 1_000_000; - limits.intermediate_buffer_size = 1_000_000; - - decoder = decoder.with_limits(limits); - - loop { - if !decoder.more_images() { - break; - } - - if let Err(_) = decoder.next_directory() { - break; - } - - let _ = decoder.read_image(); - } -}); diff --git a/fuzz/tiff.dict b/fuzz/tiff.dict index 3de14f5..00110af 100644 --- a/fuzz/tiff.dict +++ b/fuzz/tiff.dict @@ -1,38 +1,139 @@ -# TIFF byte order and magic +# === File headers === + +# Classic TIFF (LE, BE) "\x49\x49\x2A\x00" "\x4D\x4D\x00\x2A" +# BigTIFF (LE, BE) — version 43, 8-byte offsets, different parsing paths +"\x49\x49\x2B\x00\x08\x00\x00\x00" +"\x4D\x4D\x00\x2B\x00\x08\x00\x00" -# Common IFD tags as 2-byte LE pairs -# 256 = ImageWidth +# === IFD field type codes (2-byte LE) === +# Decoder switches on these; wrong types exercise error paths + +"\x01\x00" +"\x02\x00" +"\x03\x00" +"\x04\x00" +"\x05\x00" +"\x06\x00" +"\x07\x00" +"\x08\x00" +"\x09\x00" +"\x0A\x00" +"\x0B\x00" +"\x0C\x00" +"\x0D\x00" +"\x10\x00" +"\x11\x00" +"\x12\x00" + +# === IFD tag numbers (2-byte LE) === + +# 256=ImageWidth 257=ImageLength 258=BitsPerSample "\x00\x01" -# 257 = ImageLength "\x01\x01" -# 258 = BitsPerSample "\x02\x01" -# 259 = Compression +# 259=Compression 262=PhotometricInterpretation "\x03\x01" -# 262 = PhotometricInterpretation "\x06\x01" -# 273 = StripOffsets +# 273=StripOffsets 274=Orientation "\x11\x01" -# 277 = SamplesPerPixel +"\x12\x01" +# 277=SamplesPerPixel 278=RowsPerStrip 279=StripByteCounts "\x15\x01" -# 278 = RowsPerStrip "\x16\x01" -# 279 = StripByteCounts "\x17\x01" -# 282 = XResolution +# 282=XResolution 283=YResolution 284=PlanarConfiguration "\x1A\x01" -# 283 = YResolution "\x1B\x01" +"\x1C\x01" +# 317=Predictor 320=ColorMap +"\x3D\x01" +"\x40\x01" +# 322=TileWidth 323=TileLength 324=TileOffsets 325=TileByteCounts +"\x42\x01" +"\x43\x01" +"\x44\x01" +"\x45\x01" +# 330=SubIfd 338=ExtraSamples 339=SampleFormat +"\x4A\x01" +"\x52\x01" +"\x53\x01" +# 347=JPEGTables 530=ChromaSubsampling +"\x5B\x01" +"\x12\x02" +# 34665=ExifDirectory 34675=IccProfile 34853=GpsDirectory +"\x69\x87" +"\x73\x87" +"\x25\x88" + +# === IFD tag numbers (2-byte BE) === -# Common IFD tags as 2-byte BE pairs "\x01\x00" "\x01\x01" "\x01\x02" "\x01\x03" "\x01\x06" "\x01\x11" +"\x01\x12" "\x01\x15" "\x01\x16" "\x01\x17" +"\x01\x1A" +"\x01\x1B" +"\x01\x1C" +"\x01\x3D" +"\x01\x40" +"\x01\x42" +"\x01\x43" +"\x01\x44" +"\x01\x45" +"\x01\x4A" +"\x01\x52" +"\x01\x53" +"\x01\x5B" +"\x02\x12" +"\x87\x69" +"\x87\x73" +"\x88\x25" + +# === Compression codes (4-byte LE inline SHORT values) === +# These are the values stored in the IFD entry for tag 259 + +# 1=None 2=Huffman 3=Fax3 4=Fax4 5=LZW +"\x01\x00\x00\x00" +"\x02\x00\x00\x00" +"\x03\x00\x00\x00" +"\x04\x00\x00\x00" +"\x05\x00\x00\x00" +# 6=OldJPEG 7=ModernJPEG 8=Deflate +"\x06\x00\x00\x00" +"\x07\x00\x00\x00" +"\x08\x00\x00\x00" +# 0x8005=PackBits 0x80B2=OldDeflate +"\x05\x80\x00\x00" +"\xB2\x80\x00\x00" + +# === Useful small values (4-byte LE) === +# BitsPerSample=16, =32 (8 already covered as Deflate compression above) + +"\x10\x00\x00\x00" +"\x20\x00\x00\x00" + +# === Minimal IFD entries (12-byte LE: tag + type SHORT + count 1 + value) === +# These let the fuzzer splice complete valid entries into an IFD + +# ImageWidth=1 +"\x00\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00" +# ImageLength=1 +"\x01\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00" +# BitsPerSample=8 +"\x02\x01\x03\x00\x01\x00\x00\x00\x08\x00\x00\x00" +# Compression=None +"\x03\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00" +# PhotometricInterpretation=BlackIsZero +"\x06\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00" +# SamplesPerPixel=1 +"\x15\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00" +# RowsPerStrip=1 +"\x16\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"