diff --git a/src/build-scripts/ci-test.bash b/src/build-scripts/ci-test.bash index 89f3f5138b..79b1e502e8 100755 --- a/src/build-scripts/ci-test.bash +++ b/src/build-scripts/ci-test.bash @@ -47,6 +47,11 @@ echo "Default timeout ${CTEST_TEST_TIMEOUT}" echo "Test exclusions '${CTEST_EXCLUSIONS}'" echo "CTEST_ARGS '${CTEST_ARGS}'" +# Adjustments to OPENIMAGEIO_OPTIONS +# - limits:imagesize_MB=32768 forces the same limit regardless of physical +# memory size of the CI runner, for the sake of uniform error messages. +export OPENIMAGEIO_OPTIONS="limits:imagesize_MB=32768,${OPENIMAGEIO_OPTIONS}" + pushd build time ctest -C ${CMAKE_BUILD_TYPE} --force-new-ctest-process --output-on-failure \ -E "${CTEST_EXCLUSIONS}" --timeout ${CTEST_TEST_TIMEOUT} ${CTEST_ARGS} diff --git a/src/cineon.imageio/cineoninput.cpp b/src/cineon.imageio/cineoninput.cpp index cf76059cba..f7704e4b42 100644 --- a/src/cineon.imageio/cineoninput.cpp +++ b/src/cineon.imageio/cineoninput.cpp @@ -7,6 +7,7 @@ #include "libcineon/Cineon.h" #include +#include #include #include #include @@ -130,7 +131,9 @@ CineonInput::open(const std::string& name, ImageSpec& newspec) m_spec = ImageSpec(m_cin.header.Width(), m_cin.header.Height(), nchannels, typedesc); - if (!check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 8 })) { + if (!check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 8 }) + || !check_compression_ratio(m_spec, Filesystem::file_size(name))) { + close(); return false; } diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index 15605b35c2..8aa6ca0bf1 100644 --- a/src/cmake/testing.cmake +++ b/src/cmake/testing.cmake @@ -432,6 +432,9 @@ macro (oiio_add_all_tests) # Regression test (compiles its own helper and generates its own image) # for a partial edge-tile heap overflow in the OpenEXR readers. oiio_add_tests (openexr-partialtile) + # Self-contained decompression-bomb regression (ships its own tiny fixture); + # exercises both the C++ and C-API readers via the openexr:core attribute. + oiio_add_tests (openexr-bomb) # if (NOT DEFINED ENV{${PROJECT_NAME}_CI}) # oiio_add_tests (openexr-damaged # IMAGEDIR openexr-images @@ -446,6 +449,8 @@ macro (oiio_add_all_tests) oiio_add_tests (png png-damaged ENABLEVAR ENABLE_PNG IMAGEDIR oiio-images/png) + # Self-contained decompression-bomb regression (ships its own tiny fixture). + oiio_add_tests (png-bomb ENABLEVAR ENABLE_PNG) oiio_add_tests (pnm ENABLEVAR ENABLE_PNM IMAGEDIR oiio-images) diff --git a/src/dds.imageio/ddsinput.cpp b/src/dds.imageio/ddsinput.cpp index 4bf4806438..355cddd3b2 100644 --- a/src/dds.imageio/ddsinput.cpp +++ b/src/dds.imageio/ddsinput.cpp @@ -944,6 +944,13 @@ DDSInput::seek_subimage(int subimage, int miplevel) return false; } + // Decompression-bomb guard: reject a tiny file that declares a huge + // decoded image before internal_readimg() commits to the m_buf allocation. + // For compressed formats the declared bytes are the decoded (RGBA/half) + // size; genuine BCn ratios are well under the default cap. + if (!check_compression_ratio(m_spec, ioproxy()->size())) + return false; + m_subimage = subimage; m_miplevel = miplevel; return true; diff --git a/src/dpx.imageio/dpxinput.cpp b/src/dpx.imageio/dpxinput.cpp index 63934f6450..878552fad8 100644 --- a/src/dpx.imageio/dpxinput.cpp +++ b/src/dpx.imageio/dpxinput.cpp @@ -213,7 +213,8 @@ DPXInput::seek_subimage(int subimage, int miplevel) m_spec = ImageSpec(m_dpx.header.Width(), m_dpx.header.Height(), m_dpx.header.ImageElementComponentCount(subimage), typedesc); - if (!check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1 << 16, 0, 8 })) + if (!check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1 << 16, 0, 8 }) + || !check_compression_ratio(m_spec, m_filesize)) return false; // xOffset/yOffset are defined as unsigned 32-bit integers, but m_spec.x/y are signed diff --git a/src/fits.imageio/fitsinput.cpp b/src/fits.imageio/fitsinput.cpp index ec36509fa7..648c697996 100644 --- a/src/fits.imageio/fitsinput.cpp +++ b/src/fits.imageio/fitsinput.cpp @@ -191,6 +191,19 @@ FitsInput::set_spec_info() m_spec.set_format(TypeDesc::FLOAT); else if (m_bitpix == -64) m_spec.set_format(TypeDesc::DOUBLE); + + // Validate dimensions and implied size for non-empty images. FITS permits + // an empty (0x0) primary HDU, which we pass through as metadata-only. + if (m_spec.width > 0 && m_spec.height > 0) { + if (m_spec.format == TypeDesc::UNKNOWN) { + errorfmt("Unsupported FITS BITPIX value {}", m_bitpix); + return false; + } + if (!check_open(m_spec, { 0, 1 << 20, 0, 1 << 20, 0, 1 << 16, 0, 4 })) + return false; + if (!check_compression_ratio(m_spec, Filesystem::file_size(m_filename))) + return false; + } return true; } diff --git a/src/fuzz/populate_corpora.py b/src/fuzz/populate_corpora.py index 4c85905a7c..8468b58a8e 100644 --- a/src/fuzz/populate_corpora.py +++ b/src/fuzz/populate_corpora.py @@ -56,6 +56,7 @@ def _find_images_root() -> Path: ("../bmpsuite", ["bmp", "BMP"]), ], "cineon": [ + ("testsuite/cineon/src", ["cin"]), ("../oiio-images/cineon", ["cin"]), ("../oiio-images", ["cin"]), ], @@ -68,25 +69,32 @@ def _find_images_root() -> Path: ("testsuite/dicom/src", ["dcm"]), ], "dpx": [ + ("testsuite/dpx/src", ["dpx"]), ("../oiio-images/dpx", ["dpx"]), ("../dpx-images-spi", ["dpx"]), ], "openexr": [ + ("testsuite/openexr-bomb/src", ["exr"]), ("../oiio-images", ["exr"]), ("testsuite/oiio-images", ["exr"]), ], "ffmpeg": [ ("testsuite/ffmpeg/ref", ["mkv", "mov", "mp4", "avi"]), + ("testsuite/ffmpeg/src", ["mkv", "mov", "mp4", "avi"]), ], "fits": [ + ("testsuite/fits/src", ["fits", "fit"]), ("../fits-images/ftt4b", ["fits", "fit"]), ("../fits-images/pg93", ["fits", "fit"]), ], "gif": [ + ("testsuite/gif/src", ["gif"]), ("../oiio-images/gif", ["gif"]), ], # hdr: synthetic seed committed; no further sources needed - "hdr": [], + "hdr": [ + ("testsuite/hdr/src", ["hdr", "rgbe"]), + ], "heif": [ ("../oiio-images/heif", ["heif", "heic", "avif"]), ("../heif-images", ["heif", "heic", "avif"]), @@ -96,22 +104,30 @@ def _find_images_root() -> Path: ("../oiio-images/ico", ["ico"]), ], # iff: synthetic seed committed; no further sources needed - "iff": [], + "iff": [ + ("testsuite/iff/src", ["iff", "z"]), + ], "jpeg": [ + ("testsuite/jpeg-corrupt/src", ["jpg", "jpeg"]), ("../oiio-images/jpeg", ["jpg", "jpeg"]), ("testsuite/jpeg/src", ["jpg", "jpeg"]), ("../oiio-images", ["jpg", "jpeg"]), ], "jpeg2000": [ + ("testsuite/htj2k/src", ["j2c"]), ("../oiio-images/jpeg2000", ["jp2", "j2k"]), ("../j2kp4files_v1_5/codestreams_profile0", ["j2k"]), ], - # jpegxl: synthetic seed committed; no further sources needed - "jpegxl": [], + "jpegxl": [ + ("testsuite/jxl/src", ["jxl"]), + ], "openvdb": [ ("testsuite/openvdb/src", ["vdb"]), + ("testsuite/openvdb-damaged/src", ["vdb"]), ], "png": [ + ("testsuite/png-bomb/src", ["png"]), + ("testsuite/png-damaged/src", ["png"]), ("../oiio-images/png", ["png"]), ], "pnm": [ @@ -126,6 +142,7 @@ def _find_images_root() -> Path: ("testsuite/ptex/src", ["ptx", "ptex"]), ], "raw": [ + ("testsuite/raw/src", ["dng"]), ("../oiio-images/raw", ["cr2", "nef", "arw", "raf", "rw2", "orf", "CR2", "NEF", "ARW", "RAF", "RW2", "ORF"]), ], @@ -133,8 +150,10 @@ def _find_images_root() -> Path: ("testsuite/rla/src", ["rla"]), ("../oiio-images/rla", ["rla"]), ], - # sgi: synthetic seed committed; no further sources needed - "sgi": [], + "sgi": [ + ("testsuite/sgi/src", ["sgi", "rgb", "rgba", "bw", "int", "inta"]), + ("../oiio-images", ["sgi", "rgb"]), + ], "softimage": [ ("testsuite/softimage/src", ["pic"]), ("../oiio-images/softimage", ["pic"]), @@ -149,10 +168,12 @@ def _find_images_root() -> Path: ("testsuite/tiff-suite/src", ["tif", "tiff"]), ], "webp": [ + ("testsuite/webp/src", ["webp"]), ("../oiio-images/webp", ["webp"]), ], # zfile: use the reference output produced by the testsuite (a valid zfile) "zfile": [ + ("testsuite/zfile/src", ["zfile"]), ("testsuite/zfile/ref", ["zfile"]), ], } diff --git a/src/gif.imageio/gifinput.cpp b/src/gif.imageio/gifinput.cpp index 4fa9c5e842..a13ffa45af 100644 --- a/src/gif.imageio/gifinput.cpp +++ b/src/gif.imageio/gifinput.cpp @@ -460,7 +460,8 @@ GIFInput::seek_subimage(int subimage, int miplevel) m_spec.full_width = m_spec.width; m_spec.full_depth = m_spec.depth; - if (!check_open(m_spec, { 0, 32768, 0, 32768, 0, 1, 0, 4 })) { + if (!check_open(m_spec, { 0, 32768, 0, 32768, 0, 1, 0, 4 }) + || !check_compression_ratio(m_spec, ioproxy()->size())) { return false; } diff --git a/src/hdr.imageio/hdrinput.cpp b/src/hdr.imageio/hdrinput.cpp index d6ae224369..22a8d7e5a2 100644 --- a/src/hdr.imageio/hdrinput.cpp +++ b/src/hdr.imageio/hdrinput.cpp @@ -256,7 +256,8 @@ HdrInput::open(const std::string& name, ImageSpec& newspec) m_spec.full_width = m_spec.width; m_spec.full_height = m_spec.height; // Validation of resolution - if (!check_open(m_spec, { 0, 65535, 0, 65535, 0, 1, 0, 4 })) { + if (!check_open(m_spec, { 0, 65535, 0, 65535, 0, 1, 0, 4 }) + || !check_compression_ratio(m_spec, ioproxy()->size())) { close(); return false; } diff --git a/src/heif.imageio/heifinput.cpp b/src/heif.imageio/heifinput.cpp index fa703b63bb..b135f4b8fe 100644 --- a/src/heif.imageio/heifinput.cpp +++ b/src/heif.imageio/heifinput.cpp @@ -299,6 +299,17 @@ HeifInput::seek_subimage(int subimage, int miplevel) : heif_channel_interleaved; const int nchannels = is_monochrome ? 1 : m_has_alpha ? 4 : 3; + // Validate the handle's declared dimensions against OIIO's limits BEFORE + // asking libheif to decode (and allocate) the full image. libheif has its + // own internal security limits, but this additionally enforces OIIO's + // limits:* policy and rejects degenerate (zero/negative) dimensions. + m_spec = ImageSpec(m_ihandle.get_width(), m_ihandle.get_height(), nchannels, + (m_bitdepth > 8) ? TypeUInt16 : TypeUInt8); + if (!check_open(m_spec, { 0, 1 << 18, 0, 1 << 18, 0, 1, 0, 4 })) { + m_ctx.reset(); + return false; + } + #if 0 try { m_himage = m_ihandle.decode_image(heif_colorspace_RGB, chroma); diff --git a/src/ico.imageio/icoinput.cpp b/src/ico.imageio/icoinput.cpp index f25740415d..946ceee911 100644 --- a/src/ico.imageio/icoinput.cpp +++ b/src/ico.imageio/icoinput.cpp @@ -243,7 +243,8 @@ ICOInput::seek_subimage(int subimage, int miplevel) bool ok = PNG_pvt::read_info(m_png, m_info, m_bpp, m_color_type, m_interlace_type, m_bg, m_spec, true); if (!ok || m_err - || !check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 4 })) { + || !check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 4 }) + || !check_compression_ratio(m_spec, ioproxy()->size())) { return false; } diff --git a/src/iff.imageio/iffinput.cpp b/src/iff.imageio/iffinput.cpp index e965dfa298..602a4cf7b1 100644 --- a/src/iff.imageio/iffinput.cpp +++ b/src/iff.imageio/iffinput.cpp @@ -283,10 +283,9 @@ IffInput::open(const std::string& name, ImageSpec& spec) // we save this position - it will be helpful in read_native_tile m_tbmp_start = m_header.tbmp_start; - // Validity check resolutions. Width and height are a uint16, but this is - // an old format, we are guessing that there are no Maya IFF files that - // are > 64k pixels per side. - if (!check_open(m_spec, { 0, 1 << 16, 0, 1 << 16, 0, 1, 0, 5 })) + // Validity check resolutions and check for decompression bombs. + if (!check_open(m_spec, { 0, 1 << 16, 0, 1 << 16, 0, 1, 0, 5 }) + || !check_compression_ratio(m_spec, ioproxy()->size())) return false; spec = m_spec; diff --git a/src/jpeg.imageio/jpeginput.cpp b/src/jpeg.imageio/jpeginput.cpp index cacd06d337..53f136f711 100644 --- a/src/jpeg.imageio/jpeginput.cpp +++ b/src/jpeg.imageio/jpeginput.cpp @@ -274,9 +274,16 @@ JpgInput::open(const std::string& name, ImageSpec& newspec) m_spec = ImageSpec(m_cinfo.output_width, m_cinfo.output_height, nchannels, TypeDesc::UINT8); + // Validity check resolutions. if (!check_open(m_spec, { 0, 1 << 16, 0, 1 << 16, 0, 1, 0, 3 })) return false; + // check_open's size cap still admits dimensions that are absurd for a tiny + // file, so also bound the declared-vs-compressed ratio. + imagesize_t filesize = m_io ? m_io->size() : Filesystem::file_size(name); + if (!check_compression_ratio(m_spec, filesize)) + return false; + // Assume JPEG is in sRGB unless the Exif or XMP tags say otherwise. m_spec.set_colorspace("srgb_rec709_scene"); diff --git a/src/libOpenImageIO/imageinput.cpp b/src/libOpenImageIO/imageinput.cpp index e400d6eff2..2e3c87dc36 100644 --- a/src/libOpenImageIO/imageinput.cpp +++ b/src/libOpenImageIO/imageinput.cpp @@ -1725,6 +1725,25 @@ ImageInput::check_open(const ImageSpec& spec, ROI range, uint64_t /*flags*/) return false; } + // Check for sensible tile sizes. A tile dimension of 0 means "not + // tiled", which is always fine; only reject negative sizes and tile + // dimensions that exceed the same per-format ceiling already applied + // to the image resolution above. + if (spec.tile_width < 0 || spec.tile_height < 0 || spec.tile_depth < 0) { + errorfmt( + "{} tile size may not be negative, but was {}x{}x{}. Possible corrupt input?", + format_name(), spec.tile_width, spec.tile_height, spec.tile_depth); + return false; + } + if (spec.tile_width > range.width() || spec.tile_height > range.height() + || spec.tile_depth > range.depth()) { + errorfmt( + "{} tile size may not exceed {}x{}x{}, but was {}x{}x{}. Possible corrupt input?", + format_name(), range.width(), range.height(), range.depth(), + spec.tile_width, spec.tile_height, spec.tile_depth); + return false; + } + // Check for invalid full_* values for sensibility if (spec.full_width == 0 && spec.full_height == 0 && spec.full_depth == 0 && supports("noimage")) { diff --git a/src/null.imageio/nullimageio.cpp b/src/null.imageio/nullimageio.cpp index 89afc9c0c6..917c1684e4 100644 --- a/src/null.imageio/nullimageio.cpp +++ b/src/null.imageio/nullimageio.cpp @@ -319,6 +319,14 @@ NullInput::open(const std::string& name, ImageSpec& newspec, } } + // The query arguments above are parsed input, even though no file is + // ever read. Without this, "foo.null?CHANNELS=-1" sign-extended its way + // into a std::length_error that nothing catches, "RES=-4x-4" handed the + // caller a negative-sized spec, and a large enough CHANNELS hung building + // channel names. check_open() also rejects a negative or oversized TILE. + if (!check_open(m_topspec, ROI(0, 1 << 20, 0, 1 << 20, 0, 1 << 20, 0, 1024))) + return false; + m_value.resize(m_topspec.pixel_bytes()); // default fills with 0's if (fvalue.size()) { // Convert float to the native type diff --git a/src/openexr.imageio/exrinput.cpp b/src/openexr.imageio/exrinput.cpp index 31dcb516a2..c2015b85f6 100644 --- a/src/openexr.imageio/exrinput.cpp +++ b/src/openexr.imageio/exrinput.cpp @@ -1137,6 +1137,13 @@ OpenEXRInput::seek_subimage(int subimage, int miplevel) if (!check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 1 << 12 })) return false; + // check_open's size cap still admits a dataWindow that is absurd for a tiny + // compressed file, so also bound the declared-vs-compressed ratio. + imagesize_t filesize = m_io ? m_io->size() + : Filesystem::file_size(m_filename); + if (!check_compression_ratio(m_spec, filesize)) + return false; + if (miplevel == 0 && part.levelmode == Imf::ONE_LEVEL) { return true; } diff --git a/src/openexr.imageio/exrinput_c.cpp b/src/openexr.imageio/exrinput_c.cpp index 8933d3e77e..f4f7b6a8cc 100644 --- a/src/openexr.imageio/exrinput_c.cpp +++ b/src/openexr.imageio/exrinput_c.cpp @@ -1121,6 +1121,13 @@ OpenEXRCoreInput::seek_subimage(int subimage, int miplevel) if (!check_open(m_spec, { 0, 1 << 30, 0, 1 << 30, 0, 1, 0, 1 << 12 })) return false; + // check_open's size cap still admits a dataWindow that is absurd for a tiny + // compressed file, so also bound the declared-vs-compressed ratio. + imagesize_t filesize = m_userdata.m_io ? m_userdata.m_io->size() + : Filesystem::file_size(m_filename); + if (!check_compression_ratio(m_spec, filesize)) + return false; + if (miplevel == 0 && part.levelmode == EXR_TILE_ONE_LEVEL) { return true; } diff --git a/src/png.imageio/pnginput.cpp b/src/png.imageio/pnginput.cpp index 02841868b8..55afc89ee8 100644 --- a/src/png.imageio/pnginput.cpp +++ b/src/png.imageio/pnginput.cpp @@ -183,6 +183,15 @@ PNGInput::open(const std::string& name, ImageSpec& newspec) return false; } + // check_open's size cap still admits dimensions that are absurd for a tiny + // compressed file, so also bound the declared-vs-compressed ratio. + imagesize_t filesize = ioproxy() ? ioproxy()->size() + : Filesystem::file_size(m_filename); + if (!check_compression_ratio(m_spec, filesize)) { + close(); + return false; + } + string_view colorspace = m_spec.get_string_attribute("oiio:ColorSpace", "srgb_rec709_scene"); const ColorConfig& colorconfig(ColorConfig::default_colorconfig()); diff --git a/src/pnm.imageio/pnminput.cpp b/src/pnm.imageio/pnminput.cpp index 959d3a749e..6d1cb90bf5 100644 --- a/src/pnm.imageio/pnminput.cpp +++ b/src/pnm.imageio/pnminput.cpp @@ -515,6 +515,13 @@ PNMInput::open(const std::string& name, ImageSpec& newspec) if (!check_open(m_spec)) // check for apparently invalid values return false; + // Reject a tiny file that declares a huge image before the caller + // allocates the full (declared) pixel buffer. Binary PNM data must be + // present in the file and ASCII values cost >=2 bytes each, so a + // legitimate file never has an extreme ratio. + if (!check_compression_ratio(m_spec, m_io->size())) + return false; + m_remaining = append_remainder_to_buffer(m_file_contents, m_io, m_remaining); m_after_header = m_remaining; diff --git a/src/rla.imageio/rlainput.cpp b/src/rla.imageio/rlainput.cpp index 68ef176f9f..9f73542f34 100644 --- a/src/rla.imageio/rlainput.cpp +++ b/src/rla.imageio/rlainput.cpp @@ -331,6 +331,11 @@ RLAInput::seek_subimage(int subimage, int miplevel) 3 + 3 + 256 })) return false; + // Check for uncompressed size that is wildly out of proportion to + // file size. + if (!check_compression_ratio(m_spec, ioproxy()->size())) + return false; + // set channel formats and stride int z_channel = -1; m_stride = 0; diff --git a/src/sgi.imageio/sgiinput.cpp b/src/sgi.imageio/sgiinput.cpp index 9b6efa6843..2c1364628e 100644 --- a/src/sgi.imageio/sgiinput.cpp +++ b/src/sgi.imageio/sgiinput.cpp @@ -159,7 +159,8 @@ SgiInput::open(const std::string& name, ImageSpec& spec) m_sgi_header.bpc == 1 ? TypeDesc::UINT8 : TypeDesc::UINT16); - if (!check_open(m_spec, { 0, 65535, 0, 65535, 0, 1, 0, 4 })) { + if (!check_open(m_spec, { 0, 65535, 0, 65535, 0, 1, 0, 4 }) + || !check_compression_ratio(m_spec, ioproxy()->size())) { close(); return false; } diff --git a/src/softimage.imageio/softimageinput.cpp b/src/softimage.imageio/softimageinput.cpp index 69c9956b0e..47b0336285 100644 --- a/src/softimage.imageio/softimageinput.cpp +++ b/src/softimage.imageio/softimageinput.cpp @@ -212,7 +212,8 @@ SoftimageInput::open(const std::string& name, ImageSpec& spec) m_spec = ImageSpec(m_pic_header.width, m_pic_header.height, nchannels, widest); - if (!check_open(m_spec, { 0, 65535, 0, 65535, 0, 1, 0, 4 })) { + if (!check_open(m_spec, { 0, 65535, 0, 65535, 0, 1, 0, 4 }) + || !check_compression_ratio(m_spec, Filesystem::file_size(m_filename))) { close(); return false; } diff --git a/src/webp.imageio/webpinput.cpp b/src/webp.imageio/webpinput.cpp index ae2b36c57d..d30b568f68 100644 --- a/src/webp.imageio/webpinput.cpp +++ b/src/webp.imageio/webpinput.cpp @@ -241,6 +241,13 @@ WebpInput::open(const std::string& name, ImageSpec& spec, } } + // Validate the declared canvas extents and the implied uncompressed size + // BEFORE allocating the decoded-image buffer, so a malformed header cannot + // drive a large allocation. + if (!check_open(m_spec, { 0, (1 << 14) - 1, 0, (1 << 14) - 1, 0, 1, 0, 4 }) + || !check_compression_ratio(m_spec, m_image_size)) + return false; + // Make space for the decoded image m_decoded_image.reset(new uint8_t[m_spec.image_bytes()]); @@ -250,9 +257,6 @@ WebpInput::open(const std::string& name, ImageSpec& spec, m_spec.attribute("oiio:UnassociatedAlpha", 1); } - if (!check_open(m_spec, { 0, (1 << 14) - 1, 0, (1 << 14) - 1, 0, 1, 0, 4 })) - return false; - seek_subimage(0, 0); spec = m_spec; return true; diff --git a/src/zfile.imageio/zfile.cpp b/src/zfile.imageio/zfile.cpp index d2e326aa4a..56273a9197 100644 --- a/src/zfile.imageio/zfile.cpp +++ b/src/zfile.imageio/zfile.cpp @@ -189,6 +189,12 @@ ZfileInput::open(const std::string& name, ImageSpec& newspec) } m_spec = ImageSpec(header.width, header.height, 1, TypeDesc::FLOAT); + // The reader had no open-time validation: header.width/height are signed + // 16-bit and could be negative or zero, and a tiny file could declare a + // huge (gzip-bomb) image. Reject both before the caller allocates. + if (!check_open(m_spec, { 0, 32767, 0, 32767, 0, 1, 0, 1 }) + || !check_compression_ratio(m_spec, Filesystem::file_size(m_filename))) + return false; if (m_spec.channelnames.size() == 0) m_spec.channelnames.resize(1); m_spec.channelnames[0] = std::string("z"); diff --git a/testsuite/cineon/ref/out.txt b/testsuite/cineon/ref/out.txt index 07f06a1928..e1a2ef796f 100644 --- a/testsuite/cineon/ref/out.txt +++ b/testsuite/cineon/ref/out.txt @@ -39,3 +39,6 @@ Full command line was: oiiotool ERROR: read : "src/broken_bitdepth2.cin": Invalid bitdepth in channel 0: 26 bits Full command line was: > oiiotool --info --hash src/broken_bitdepth2.cin +oiiotool ERROR: read : "src/bomb-46341.cin": cineon header claims a 12288 MB image from a 2048 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info --hash src/bomb-46341.cin diff --git a/testsuite/cineon/run.py b/testsuite/cineon/run.py index 9e01f91ed5..a8bc24b3d7 100755 --- a/testsuite/cineon/run.py +++ b/testsuite/cineon/run.py @@ -17,5 +17,9 @@ # Regression test for a per-channel bit depth libcineon doesn't recognize # (used to assert/abort inside CineonHeader.cpp instead of erroring out). command += info_command ("src/broken_bitdepth2.cin", verbose=False, failureok=True) +# Regression test: a 2 KB Cineon header declaring a ~12 GB image +# (46341x46341x3) -- a decompression bomb the compression-ratio guard must +# reject before any large allocation. +command += info_command ("src/bomb-46341.cin", verbose=False, failureok=True) outputs = [ "out.txt" ] diff --git a/testsuite/cineon/src/bomb-46341.cin b/testsuite/cineon/src/bomb-46341.cin new file mode 100644 index 0000000000..c7a5abe2dd Binary files /dev/null and b/testsuite/cineon/src/bomb-46341.cin differ diff --git a/testsuite/dpx/ref/out.txt b/testsuite/dpx/ref/out.txt index d4c13b5d2e..df2109bf80 100644 --- a/testsuite/dpx/ref/out.txt +++ b/testsuite/dpx/ref/out.txt @@ -219,3 +219,6 @@ src/crash-1chan-10bit-filled-methodA.dpx : 80 x 60, 3 channel, uint10 dpx oiio:BitsPerSample: 10 oiio:subimages: 2 smpte:TimeCode: 00:00:00:00 +oiiotool ERROR: read : "src/bomb-46341.dpx": dpx header claims a 12288 MB image from a 21248 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info -v -a --no-metamatch DateTime|Software|OriginatingProgram|ImageHistory --hash src/bomb-46341.dpx diff --git a/testsuite/dpx/run.py b/testsuite/dpx/run.py index 1acaee89e7..1f6c46a2db 100755 --- a/testsuite/dpx/run.py +++ b/testsuite/dpx/run.py @@ -45,3 +45,8 @@ # remained for the last (partial) group in the scanline, writing/reading # one uint16 past the end of the caller's scanline buffer. command += info_command("src/crash-1chan-10bit-filled-methodA.dpx", safematch=True) + +# Regression test: a 21 KB DPX whose header declares a ~12 GB image +# (46341x46341x3) -- a decompression bomb the compression-ratio guard must +# reject before any large allocation. +command += info_command("src/bomb-46341.dpx", safematch=True, failureok=True) diff --git a/testsuite/dpx/src/bomb-46341.dpx b/testsuite/dpx/src/bomb-46341.dpx new file mode 100644 index 0000000000..da9413b8f5 Binary files /dev/null and b/testsuite/dpx/src/bomb-46341.dpx differ diff --git a/testsuite/gif/ref/out.txt b/testsuite/gif/ref/out.txt index 30ce27631e..aebb422f76 100644 --- a/testsuite/gif/ref/out.txt +++ b/testsuite/gif/ref/out.txt @@ -109,6 +109,6 @@ tahoe-tiny.gif : 128 x 96, 4 channel, uint8 gif oiiotool ERROR: read : "src/crash_4163.gif": Wrong record type detected Full command line was: > oiiotool -nostderr -oiioattrib try_all_readers 0 src/crash_4163.gif -o test.exr -oiiotool ERROR: read : "src/gif_idx_overflow_32768x16385_top16384_1x1.gif": GIF image canvas is too large +oiiotool ERROR: read : "src/gif_idx_overflow_32768x16385_top16384_1x1.gif": gif header claims a 2048 MB image from a 35 byte file; probably a corrupt or malicious header Full command line was: > oiiotool --info -oiioattrib try_all_readers 0 src/gif_idx_overflow_32768x16385_top16384_1x1.gif diff --git a/testsuite/hdr/ref/out.txt b/testsuite/hdr/ref/out.txt index e4fbc081ee..f8d8c37169 100644 --- a/testsuite/hdr/ref/out.txt +++ b/testsuite/hdr/ref/out.txt @@ -25,3 +25,6 @@ MtTamWest.hdr : 1214 x 732, 3 channel, float hdr oiiotool ERROR: read : "src/crash-res.hdr": hdr image resolution may not exceed 65535x65535, but the file appears to be 8888888x4. Possible corrupt input? Full command line was: > oiiotool -nostderr -oiioattrib try_all_readers 0 src/crash-res.hdr -o test.exr +oiiotool ERROR: read : "src/bomb-40000.hdr": hdr header claims a 18310 MB image from a 53 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool -nostderr -oiioattrib try_all_readers 0 -info src/bomb-40000.hdr diff --git a/testsuite/hdr/run.py b/testsuite/hdr/run.py index 3c21d3d05b..14102337a3 100755 --- a/testsuite/hdr/run.py +++ b/testsuite/hdr/run.py @@ -14,3 +14,8 @@ # Regression test: invalid resolution command += oiiotool ("-nostderr -oiioattrib try_all_readers 0 src/crash-res.hdr -o test.exr", failureok = True) + +# Regression test: a 53-byte header declaring a ~18 GB image (40000x40000). +# The compression-ratio guard must reject it before the caller allocates the +# full pixel buffer. +command += oiiotool ("-nostderr -oiioattrib try_all_readers 0 -info src/bomb-40000.hdr", failureok=True) diff --git a/testsuite/hdr/src/bomb-40000.hdr b/testsuite/hdr/src/bomb-40000.hdr new file mode 100644 index 0000000000..e8d2f7e946 --- /dev/null +++ b/testsuite/hdr/src/bomb-40000.hdr @@ -0,0 +1,4 @@ +#?RADIANCE +FORMAT=32-bit_rle_rgbe + +-Y 40000 +X 40000 diff --git a/testsuite/ico/ref/out.txt b/testsuite/ico/ref/out.txt index e3fd37e584..e7da25958b 100644 --- a/testsuite/ico/ref/out.txt +++ b/testsuite/ico/ref/out.txt @@ -50,3 +50,4 @@ iconvert ERROR copying "src/crash-002c.ico" to "out.tif" : Read error: hit end of file in ico reader iconvert ERROR copying "src/bad-palette.ico" to "out.tif" : Possible corruption: index exceeds palette size +iconvert ERROR: ico header claims a 6143 MB image from a 375 byte file; probably a corrupt or malicious header diff --git a/testsuite/ico/run.py b/testsuite/ico/run.py index d4f5cf8ba7..93487d6ea8 100755 --- a/testsuite/ico/run.py +++ b/testsuite/ico/run.py @@ -12,4 +12,7 @@ command += iconvert ("src/crash-002c.ico out.tif") command += iconvert ("src/bad-not-8x-wide.ico out.tif") command += iconvert ("src/bad-palette.ico out.tif") +# ICO embedding a PNG whose IHDR declares a ~6 GB image (46340x46340). The +# compression-ratio guard must reject it before the PNG buffer is allocated. +command += iconvert ("src/bomb-png-46340.ico out.tif") diff --git a/testsuite/ico/src/bomb-png-46340.ico b/testsuite/ico/src/bomb-png-46340.ico new file mode 100644 index 0000000000..11914a3a49 Binary files /dev/null and b/testsuite/ico/src/bomb-png-46340.ico differ diff --git a/testsuite/iff/ref/out.txt b/testsuite/iff/ref/out.txt index 25960a1142..51e226716a 100644 --- a/testsuite/iff/ref/out.txt +++ b/testsuite/iff/ref/out.txt @@ -19,3 +19,6 @@ oiiotool ERROR: read : "src/zbuffer_only.iff": IFF error ZBUFFER-only (Z-depth) IFF error could not read header Full command line was: > oiiotool --info -v -a --hash src/zbuffer_only.iff +oiiotool ERROR: read : "src/bomb-46341.iff": iff header claims a 8192 MB image from a 336 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info -v -a --hash src/bomb-46341.iff diff --git a/testsuite/iff/run.py b/testsuite/iff/run.py index 9a0c940b46..395740718c 100755 --- a/testsuite/iff/run.py +++ b/testsuite/iff/run.py @@ -20,3 +20,7 @@ # ImageSpec while the internal pixel size stayed 32-bit, causing # read_native_tile to write past the caller's tile buffer. Must be rejected. command += info_command("src/zbuffer_only.iff", hash=True, failureok=True) +# Regression test: a 336-byte IFF whose TBHD declares a ~8 GB image +# (46341x46341x4) -- a decompression bomb the compression-ratio guard must +# reject before any large allocation. +command += info_command("src/bomb-46341.iff", hash=True, failureok=True) diff --git a/testsuite/iff/src/bomb-46341.iff b/testsuite/iff/src/bomb-46341.iff new file mode 100644 index 0000000000..c3fc83b8df Binary files /dev/null and b/testsuite/iff/src/bomb-46341.iff differ diff --git a/testsuite/openexr-bomb/ref/out-exr22.txt b/testsuite/openexr-bomb/ref/out-exr22.txt new file mode 100644 index 0000000000..f9eea90c8e --- /dev/null +++ b/testsuite/openexr-bomb/ref/out-exr22.txt @@ -0,0 +1,6 @@ +oiiotool ERROR: read : OpenEXR exception: Cannot read image file "src/bomb.exr". Unexpected end of file. +Full command line was: +> oiiotool -oiioattrib openexr:core 0 --info -v src/bomb.exr +oiiotool ERROR: read : "src/bomb.exr": openexr header claims a 12287 MB image from a 464 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool -oiioattrib openexr:core 1 --info -v src/bomb.exr diff --git a/testsuite/openexr-bomb/ref/out.txt b/testsuite/openexr-bomb/ref/out.txt new file mode 100644 index 0000000000..df5f642624 --- /dev/null +++ b/testsuite/openexr-bomb/ref/out.txt @@ -0,0 +1,6 @@ +oiiotool ERROR: read : "src/bomb.exr": openexr header claims a 12287 MB image from a 464 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool -oiioattrib openexr:core 0 --info -v src/bomb.exr +oiiotool ERROR: read : "src/bomb.exr": openexr header claims a 12287 MB image from a 464 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool -oiioattrib openexr:core 1 --info -v src/bomb.exr diff --git a/testsuite/openexr-bomb/run.py b/testsuite/openexr-bomb/run.py new file mode 100644 index 0000000000..aa2de94ca6 --- /dev/null +++ b/testsuite/openexr-bomb/run.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + + +failureok = True +redirect = ' >> out.txt 2>&1 ' + +# src/bomb.exr is a tiny (464-byte) EXR whose dataWindow declares a ~12 GB +# image: a decompression bomb. Both the C++ reader (openexr:core=0) and the +# C-API core reader (openexr:core=1) must reject it with a bounded error +# instead of attempting the enormous allocation. +command += oiiotool("-oiioattrib openexr:core 0 --info -v src/bomb.exr", + failureok=True) +command += oiiotool("-oiioattrib openexr:core 1 --info -v src/bomb.exr", + failureok=True) diff --git a/testsuite/openexr-bomb/src/bomb.exr b/testsuite/openexr-bomb/src/bomb.exr new file mode 100644 index 0000000000..a6d501ed93 Binary files /dev/null and b/testsuite/openexr-bomb/src/bomb.exr differ diff --git a/testsuite/png-bomb/ref/out.txt b/testsuite/png-bomb/ref/out.txt new file mode 100644 index 0000000000..a56cf0f4e0 --- /dev/null +++ b/testsuite/png-bomb/ref/out.txt @@ -0,0 +1,3 @@ +oiiotool ERROR: read : "src/bomb.png": png header claims a 6143 MB image from a 353 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info -v src/bomb.png diff --git a/testsuite/png-bomb/run.py b/testsuite/png-bomb/run.py new file mode 100644 index 0000000000..368d36bb37 --- /dev/null +++ b/testsuite/png-bomb/run.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + + +failureok = True +redirect = ' >> out.txt 2>&1 ' + +# src/bomb.png is a tiny (353-byte) PNG whose IHDR declares a ~6 GB image +# (46340x46340x3): a decompression bomb. The reader must reject it with a +# bounded error instead of attempting the enormous allocation. +command += oiiotool("--info -v src/bomb.png", failureok=True) diff --git a/testsuite/png-bomb/src/bomb.png b/testsuite/png-bomb/src/bomb.png new file mode 100644 index 0000000000..50b5c8321d Binary files /dev/null and b/testsuite/png-bomb/src/bomb.png differ diff --git a/testsuite/pnm/ref/out.txt b/testsuite/pnm/ref/out.txt index c1d480004f..3c2807f91f 100644 --- a/testsuite/pnm/ref/out.txt +++ b/testsuite/pnm/ref/out.txt @@ -83,3 +83,6 @@ Full command line was: oiiotool ERROR: read : "src/bad-4553.pgm": pnm image resolution may not exceed 65535x65535, but the file appears to be 2147483647x255. Possible corrupt input? Full command line was: > oiiotool --info -v -a --hash --oiioattrib try_all_readers 0 --printstats src/bad-4553.pgm +oiiotool ERROR: read : "src/bomb-65000.pgm": pnm header claims a 4029 MB image from a 19 byte file; probably a corrupt or malicious header +Full command line was: +> oiiotool --info -v -a --no-metamatch DateTime|Software|OriginatingProgram|ImageHistory --hash src/bomb-65000.pgm diff --git a/testsuite/pnm/run.py b/testsuite/pnm/run.py index be6a094e82..7729debcba 100755 --- a/testsuite/pnm/run.py +++ b/testsuite/pnm/run.py @@ -23,3 +23,8 @@ files = [ "src/bad-4552.pgm", "src/bad-4553.pgm" ] for f in files: command += info_command (f, extraargs="--oiioattrib try_all_readers 0 --printstats", failureok=True) + +# Decompression bomb: a 19-byte header declaring a ~4 GB image (65000x65000). +# The compression-ratio guard must reject it before the caller allocates the +# full pixel buffer. +command += info_command ("src/bomb-65000.pgm", failureok=True, safematch=True) diff --git a/testsuite/pnm/src/.gitattributes b/testsuite/pnm/src/.gitattributes new file mode 100644 index 0000000000..72c3181b54 --- /dev/null +++ b/testsuite/pnm/src/.gitattributes @@ -0,0 +1 @@ +bomb-65000.pgm binary diff --git a/testsuite/pnm/src/bomb-65000.pgm b/testsuite/pnm/src/bomb-65000.pgm new file mode 100644 index 0000000000..5f63882f7e --- /dev/null +++ b/testsuite/pnm/src/bomb-65000.pgm @@ -0,0 +1,3 @@ +P5 +65000 65000 +255 diff --git a/testsuite/sgi/ref/out.txt b/testsuite/sgi/ref/out.txt index 63f1b50ff1..ca8c0019e6 100644 --- a/testsuite/sgi/ref/out.txt +++ b/testsuite/sgi/ref/out.txt @@ -49,3 +49,4 @@ src/broken_rle16_len1.sgi : 1 x 1, 1 channel, uint16 sgi channel list: Y compression: "rle" iinfo ERROR: "src/broken_rle16_len1.sgi" : Corrupt RLE data +iinfo ERROR: "src/bomb-65535.sgi" : sgi header claims a 16383 MB image from a 512 byte file; probably a corrupt or malicious header diff --git a/testsuite/sgi/run.py b/testsuite/sgi/run.py index c15f74cdde..0cc96db5ee 100755 --- a/testsuite/sgi/run.py +++ b/testsuite/sgi/run.py @@ -28,3 +28,8 @@ # broken_rle16_len1.sgi tests a 16 bit RLE file that has an odd byte count. command += info_command ("--stats src/broken_rle16_len1.sgi", info_program="iinfo", failureok=True) +# bomb-65535.sgi declares a ~16 GB image (65535x65535x4) from a 512-byte +# header: a decompression bomb the compression-ratio guard must reject before +# any large allocation. +command += info_command ("--stats src/bomb-65535.sgi", + info_program="iinfo", failureok=True) diff --git a/testsuite/sgi/src/bomb-65535.sgi b/testsuite/sgi/src/bomb-65535.sgi new file mode 100644 index 0000000000..1442f8ded2 Binary files /dev/null and b/testsuite/sgi/src/bomb-65535.sgi differ diff --git a/testsuite/softimage/ref/out.txt b/testsuite/softimage/ref/out.txt index 471e196acf..255fa1078d 100644 --- a/testsuite/softimage/ref/out.txt +++ b/testsuite/softimage/ref/out.txt @@ -44,6 +44,7 @@ src/broken01.pic : 16 x 4, 1 channel, uint8 softimage Stats FiniteCount: 64 Constant: No Monochrome: Yes +iinfo ERROR: "src/bomb-40000.pic" : softimage header claims a 6103 MB image from a 118 byte file; probably a corrupt or malicious header src/mixed-bitdepth.pic : 2 x 1, 2 channel, uint16 softimage SHA-1: 52D6B72AAD7084E2ECF1550D500A66C42F408ED1 channel list: R, G diff --git a/testsuite/softimage/run.py b/testsuite/softimage/run.py index 8c80afe8d6..a31841ff4a 100755 --- a/testsuite/softimage/run.py +++ b/testsuite/softimage/run.py @@ -13,6 +13,10 @@ # Regression testing of error handling and corrupt files command += info_command ("--stats src/broken01.pic", info_program="iinfo", failureok=True) +# A 118-byte header declaring a ~6 GB image (40000x40000): the +# compression-ratio guard must reject it before the caller allocates. +command += info_command ("--stats src/bomb-40000.pic", + info_program="iinfo", failureok=True) # A file whose channel packets use different bit depths (here a 16-bit R # packet and an 8-bit G packet): narrower channels get promoted to the # widest depth present. This used to overrun the scanline buffer because a diff --git a/testsuite/softimage/src/bomb-40000.pic b/testsuite/softimage/src/bomb-40000.pic new file mode 100644 index 0000000000..d8eb5e2823 Binary files /dev/null and b/testsuite/softimage/src/bomb-40000.pic differ diff --git a/testsuite/targa/ref/out.txt b/testsuite/targa/ref/out.txt index d7b8d03467..d1dedde899 100644 --- a/testsuite/targa/ref/out.txt +++ b/testsuite/targa/ref/out.txt @@ -209,6 +209,9 @@ Full command line was: oiiotool ERROR: read : "src/crash-20250423.tga": Corrupt palette index Full command line was: > oiiotool --oiioattrib try_all_readers 0 src/crash-20250423.tga -o out.null +oiiotool ERROR: read : "src/crash-bomb-65535.tga": TGA header claims image size 12884508675 bytes, implausible for 0 bytes of remaining file data +Full command line was: +> oiiotool --oiioattrib try_all_readers 0 src/crash-bomb-65535.tga -o out.null Reading src/1x1.tga src/1x1.tga : 1 x 1, 3 channel, uint2 targa SHA-1: DB9237F28F9622F7B7E73B783A8822B63BDB3708 diff --git a/testsuite/targa/run.py b/testsuite/targa/run.py index ca4219cbc8..13f0b6f92f 100755 --- a/testsuite/targa/run.py +++ b/testsuite/targa/run.py @@ -28,6 +28,8 @@ "--oiioattrib try_all_readers 0 " "src/crash3952.tga -o crash3952.exr", failureok = True) command += oiiotool("--oiioattrib try_all_readers 0 src/crash-20250423.tga -o out.null", failureok=True); +# Tiny file declaring a huge image (decompression bomb) is rejected +command += oiiotool("--oiioattrib try_all_readers 0 src/crash-bomb-65535.tga -o out.null", failureok=True); # Test odds and ends, unusual files command += rw_command("src", "1x1.tga") diff --git a/testsuite/targa/src/crash-bomb-65535.tga b/testsuite/targa/src/crash-bomb-65535.tga new file mode 100644 index 0000000000..09ba4a62f1 Binary files /dev/null and b/testsuite/targa/src/crash-bomb-65535.tga differ diff --git a/testsuite/webp/ref/out-webp1.1.txt b/testsuite/webp/ref/out-webp1.1.txt index edbdaa0cf1..76ec534165 100644 --- a/testsuite/webp/ref/out-webp1.1.txt +++ b/testsuite/webp/ref/out-webp1.1.txt @@ -29,3 +29,6 @@ short-exif-len4.webp-ok short-exif-len5.webp-ok short-exif-len6.webp-ok short-exif-len13.webp-ok +oiiotool ERROR: read : "src/bomb-16383.webp": Couldn't decode +Full command line was: +> oiiotool --info -v -a --no-metamatch DateTime|Software|OriginatingProgram|ImageHistory --hash src/bomb-16383.webp diff --git a/testsuite/webp/run.py b/testsuite/webp/run.py index 091accdf1e..dbd1108e20 100755 --- a/testsuite/webp/run.py +++ b/testsuite/webp/run.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: Apache-2.0 # https://github.com/AcademySoftwareFoundation/OpenImageIO +redirect = ' >> out.txt 2>&1 ' + files = [ "1.webp", "2.webp", "3.webp", "4.webp" ] for f in files: command += info_command (OIIO_TESTSUITE_IMAGEDIR + "/" + f) @@ -27,3 +29,9 @@ ] for f in short_exif_files: command += iconvert(f + " out.null", successmessage=f + "-ok") + +# Regression test: a 76-byte WebP whose VP8X header declares a 16383x16383 +# canvas inconsistent with its tiny frame. Must be rejected cleanly (libwebp's +# demux catches the mismatch; the open-time check_open/compression-ratio guard +# now runs before the decoded-image allocation regardless). +command += info_command ("src/bomb-16383.webp", failureok=True, safematch=True) diff --git a/testsuite/webp/src/bomb-16383.webp b/testsuite/webp/src/bomb-16383.webp new file mode 100644 index 0000000000..e193a0f613 Binary files /dev/null and b/testsuite/webp/src/bomb-16383.webp differ