Commit 5d35b98d for libheif
commit 5d35b98d72cc38558ed70b04a410e7e30447cc1d
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Fri Oct 2 02:59:58 2026 +0200
unci: decompress an item that is compressed as a whole only once (GHSA-6fqc-p7r8-2g36)
The uncC tiling of an 'unci' image describes how the uncompressed data is
stored. With generic compression, a tile can only be decoded on its own when
the compressed units (cmpC, icef) are not larger than a tile. When a unit spans
several tiles (the full item, the full image of a component), the item has to
be decompressed completely to get the data of any tile.
unc_decoder::get_compressed_image_data_uncompressed() did this for each tile:
it decompressed the whole item and cut out the range of one tile. Decoding an
image took time proportional to num_tiles * item_size. A 1 kB file with a
512x512 image of 1x1 tiles kept a CPU busy for several minutes, and a valid
4096x4096 image with 64x64 tiles decoded 700 times slower than the same image
without tiles.
This is fixed as follows:
- UncompressedImageCodec::get_tile_access() classifies how the data of a tile
can be obtained: directly (no generic compression), from the compressed units
of the tile, or only by decompressing the whole item.
- unc_decoder::decode_image() decompresses an item of the last kind once,
charges the data to the memory budget and decodes all tiles from it.
- Such an image is exposed as a single tile in heif_image_tiling, like the
images of the other codecs that use tiles internally. Decoding this tile
takes the full image path. An application that decodes the image tile by tile
does not decompress the item once per tile anymore.
- Row and pixel units are parts of a tile and are listed in the order of the
uncompressed data, so each tile is a run of consecutive units. These images
keep their tiling, and decoding a tile now only reads and decompresses the
units of this tile instead of all units of the item.
- The size of the decompressed data is known in all cases. The decompression
stops with an error as soon as more data is produced, and less data is
rejected, too.
- The unci image item rejects tile positions outside of its tiling. They were
only checked when the image transformations are applied.
- A raw DataExtent does not fall through to reading from the file anymore
when it holds no data. It has no file, which was a null pointer dereference.
Images with compressed units that span several tiles now report a single tile.
Files in which the item does not decompress to exactly the size of the image
data are not decoded anymore.
diff --git a/libheif/codecs/decoder.cc b/libheif/codecs/decoder.cc
index e12fb18d..7adaaa22 100644
--- a/libheif/codecs/decoder.cc
+++ b/libheif/codecs/decoder.cc
@@ -64,7 +64,8 @@ void DataExtent::set_file_range(std::shared_ptr<HeifFile> file, uint64_t offset,
Result<std::vector<uint8_t>*> DataExtent::read_data() const
{
- if (!m_raw.empty()) {
+ // A raw extent has no file to read from. All its data is in m_raw, even when that is empty.
+ if (!m_raw.empty() || m_source == Source::Raw) {
return &m_raw;
}
else if (m_source == Source::Image) {
@@ -112,7 +113,7 @@ Result<std::vector<uint8_t>> DataExtent::read_data(uint64_t offset, uint64_t siz
{
std::vector<uint8_t> data;
- if (!m_raw.empty()) {
+ if (!m_raw.empty() || m_source == Source::Raw) {
// No caller currently reaches this cached path with an out-of-range request, so
// hitting it indicates an internal logic error rather than malformed input. Guard
// it defensively anyway. The subtraction form avoids a uint64_t wrap in
diff --git a/libheif/codecs/uncompressed/unc_codec.cc b/libheif/codecs/uncompressed/unc_codec.cc
index 22f22fe2..c35daa19 100644
--- a/libheif/codecs/uncompressed/unc_codec.cc
+++ b/libheif/codecs/uncompressed/unc_codec.cc
@@ -396,6 +396,62 @@ Result<std::shared_ptr<HeifPixelImage>> UncompressedImageCodec::create_image(con
}
+UncompressedImageCodec::TileAccess
+UncompressedImageCodec::get_tile_access(const std::shared_ptr<const Box_uncC>& uncC,
+ const std::shared_ptr<const Box_cmpC>& cmpC,
+ const std::shared_ptr<const Box_icef>& icef,
+ uint32_t* out_units_per_tile)
+{
+ if (!cmpC) {
+ return TileAccess::direct;
+ }
+
+ // Without an icef box, the complete item is a single compressed unit.
+ if (!icef || !uncC) {
+ return TileAccess::whole_item;
+ }
+
+ switch (cmpC->get_compressed_unit_type()) {
+ case heif_cmpC_compressed_unit_type_image_tile:
+ if (out_units_per_tile) {
+ *out_units_per_tile = 1;
+ }
+ return TileAccess::compressed_units;
+
+ case heif_cmpC_compressed_unit_type_image_row:
+ case heif_cmpC_compressed_unit_type_image_pixel: {
+ // Rows and pixels are parts of a tile and the units are listed in the order of
+ // the uncompressed data. As all tiles have the same layout, each tile consists of
+ // the same number of consecutive units. We do not have to know what exactly the
+ // file writer considers a row.
+
+ // With tile-component interleave, the data of a tile is not contiguous.
+ if (uncC->get_interleave_type() == interleave_mode_tile_component) {
+ return TileAccess::whole_item;
+ }
+
+ uint64_t num_tiles = static_cast<uint64_t>(uncC->get_number_of_tile_columns()) * uncC->get_number_of_tile_rows();
+ uint64_t num_units = icef->get_units().size();
+
+ if (num_tiles == 0 || num_units == 0 || num_units % num_tiles != 0) {
+ return TileAccess::whole_item;
+ }
+
+ if (out_units_per_tile) {
+ *out_units_per_tile = static_cast<uint32_t>(num_units / num_tiles);
+ }
+ return TileAccess::compressed_units;
+ }
+
+ case heif_cmpC_compressed_unit_type_full_item:
+ case heif_cmpC_compressed_unit_type_image:
+ break;
+ }
+
+ return TileAccess::whole_item;
+}
+
+
Error UncompressedImageCodec::decode_uncompressed_image_tile(const HeifContext* context,
heif_item_id ID,
std::shared_ptr<HeifPixelImage>& img,
diff --git a/libheif/codecs/uncompressed/unc_codec.h b/libheif/codecs/uncompressed/unc_codec.h
index 62596694..88819471 100644
--- a/libheif/codecs/uncompressed/unc_codec.h
+++ b/libheif/codecs/uncompressed/unc_codec.h
@@ -59,6 +59,27 @@ public:
std::shared_ptr<HeifPixelImage>& img,
uint32_t tile_x0, uint32_t tile_y0);
+ // How the data of a single tile of the uncC tiling can be obtained.
+ enum class TileAccess : uint8_t {
+ // No generic compression. The tile data is read directly from the item data.
+ direct,
+
+ // Each tile is a run of consecutive compressed units: a single unit for
+ // 'image_tile' units, several units for 'image_row' and 'image_pixel' units.
+ compressed_units,
+
+ // The compressed units span several tiles (full item, full image of a component),
+ // or they cannot be assigned to the tiles. The item has to be decompressed as a whole,
+ // hence the tiles cannot be decoded independently.
+ whole_item
+ };
+
+ // 'out_units_per_tile' is only set for TileAccess::compressed_units.
+ static TileAccess get_tile_access(const std::shared_ptr<const Box_uncC>& uncC,
+ const std::shared_ptr<const Box_cmpC>& cmpC,
+ const std::shared_ptr<const Box_icef>& icef,
+ uint32_t* out_units_per_tile = nullptr);
+
struct unci_properties {
std::shared_ptr<const Box_ispe> ispe;
std::shared_ptr<const Box_cmpd> cmpd;
diff --git a/libheif/codecs/uncompressed/unc_decoder.cc b/libheif/codecs/uncompressed/unc_decoder.cc
index 07348ecb..63d89343 100644
--- a/libheif/codecs/uncompressed/unc_decoder.cc
+++ b/libheif/codecs/uncompressed/unc_decoder.cc
@@ -59,6 +59,22 @@ unc_decoder::unc_decoder(uint32_t width, uint32_t height,
}
+// The size of the data of one tile, as the sum of the sizes from get_tile_data_sizes().
+static Result<uint64_t> sum_tile_data_sizes(const std::vector<uint64_t>& sizes)
+{
+ uint64_t tile_size = 0;
+ for (uint64_t size : sizes) {
+ if (size > UINT64_MAX - tile_size) {
+ return Error{heif_error_Invalid_input, heif_suberror_Invalid_image_size,
+ "uncompressed tile size exceeds 64-bit range"};
+ }
+ tile_size += size;
+ }
+
+ return tile_size;
+}
+
+
Error unc_decoder::fetch_tile_data(const DataExtent& dataExtent,
const UncompressedImageCodec::unci_properties& properties,
uint32_t tile_x, uint32_t tile_y,
@@ -79,28 +95,29 @@ Error unc_decoder::fetch_tile_data(const DataExtent& dataExtent,
// May be nullptr for a raw data extent, in which case no limit applies.
const heif_security_limits* limits = dataExtent.m_file ? dataExtent.m_file->get_security_limits() : nullptr;
- const bool one_compressed_unit_per_tile = (properties.cmpC && properties.icef &&
- properties.cmpC->get_compressed_unit_type() == heif_cmpC_compressed_unit_type_image_tile);
+ const auto tile_access = UncompressedImageCodec::get_tile_access(m_uncC, properties.cmpC, properties.icef);
- if (one_compressed_unit_per_tile) {
- // The compressed unit is the complete tile, and get_compressed_image_data_uncompressed()
- // returns the whole unit irrespective of the requested range. Fetch it only once.
- // The scattered per-component reads below would decompress the same unit again for
+ if (tile_access == UncompressedImageCodec::TileAccess::whole_item) {
+ // decode_image() decompresses such an item once for all tiles, and the image item
+ // does not offer decoding of individual tiles (GHSA-6fqc-p7r8-2g36).
+ return {heif_error_Decoder_plugin_error, heif_suberror_Unspecified,
+ "Internal error: unci tiles cannot be decoded independently for this generic compression"};
+ }
+ else if (tile_access == UncompressedImageCodec::TileAccess::compressed_units) {
+ // The compressed units are the complete tile, and get_compressed_image_data_uncompressed()
+ // returns the whole tile irrespective of the requested range. Fetch it only once.
+ // The scattered per-component reads below would decompress the same units again for
// each component and concatenate the copies, growing 'tile_data' to num_components
- // times the unit size without any memory accounting (GHSA-fcmw-5764-7rq8).
+ // times the tile size without any memory accounting (GHSA-fcmw-5764-7rq8).
- uint64_t tile_size = 0;
- for (uint64_t size : sizes) {
- if (size > UINT64_MAX - tile_size) {
- return {heif_error_Invalid_input, heif_suberror_Invalid_image_size,
- "uncompressed tile size exceeds 64-bit range"};
- }
- tile_size += size;
+ Result<uint64_t> tileSizeResult = sum_tile_data_sizes(sizes);
+ if (!tileSizeResult) {
+ return tileSizeResult.error();
}
- // The requested size is the size of the whole tile. A unit that decompresses to
- // a different size is rejected.
- Error err = get_compressed_image_data_uncompressed(dataExtent, properties, &tile_data, 0, tile_size, tileIdx, nullptr);
+ // The requested size is the size of the whole tile. Units that decompress to
+ // a different size are rejected.
+ Error err = get_compressed_image_data_uncompressed(dataExtent, properties, &tile_data, 0, *tileSizeResult, tileIdx, nullptr);
if (err) {
return err;
}
@@ -175,62 +192,123 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent
return Error::Ok;
}
- if (icef_box && cmpC_box->get_compressed_unit_type() == heif_cmpC_compressed_unit_type_image_tile) {
- const auto& units = icef_box->get_units();
- if (tile_idx >= units.size()) {
- return {
- heif_error_Invalid_input,
- heif_suberror_Unspecified,
- "no icef-box entry for tile index"
- };
- }
+ uint32_t units_per_tile = 0;
+ const auto tile_access = UncompressedImageCodec::get_tile_access(m_uncC, cmpC_box, icef_box, &units_per_tile);
+
+ if (tile_access != UncompressedImageCodec::TileAccess::compressed_units) {
+ // An item that has to be decompressed as a whole is handled by decode_image(),
+ // which decompresses it once for all tiles (GHSA-6fqc-p7r8-2g36).
+ return {heif_error_Decoder_plugin_error, heif_suberror_Unspecified,
+ "Internal error: unci tiles cannot be decoded independently for this generic compression"};
+ }
+
+ // --- the tile consists of 'units_per_tile' consecutive compressed units
- const auto unit = units[tile_idx];
+ const auto& units = icef_box->get_units();
+
+ const uint64_t first_unit = static_cast<uint64_t>(tile_idx) * units_per_tile;
+ if (first_unit >= units.size() ||
+ units_per_tile > units.size() - first_unit) {
+ return {
+ heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "no icef-box entry for tile index"
+ };
+ }
+
+ // Decompress only the units of this tile. 'range_size' is the size of the tile, so
+ // there cannot be more data than this in a valid file. Stopping there prevents small
+ // units from inflating far beyond the tile size (GHSA-fcmw-5764-7rq8).
+ uint64_t remaining_size = range_size;
+
+ // The decompression functions only bound the memory of the unit they are working on.
+ // This handle accounts for the units of the tile that have been decompressed already.
+ // The complete tile is charged by the caller afterwards.
+ MemoryHandle accumulated_memory_handle;
+
+ for (uint32_t i = 0; i < units_per_tile; i++) {
+ const auto unit = units[first_unit + i];
- // get data needed for one tile
Result<std::vector<uint8_t> > readingResult = dataExtent.read_data(unit.unit_offset, unit.unit_size);
if (!readingResult) {
return readingResult.error();
}
- const std::vector<uint8_t>& compressed_bytes = *readingResult;
-
- // Decompress only the unit. The unit holds a single tile and 'range_size' is the
- // size of that tile, so there cannot be more data than this in a valid file.
- // Stopping there prevents a small unit from inflating far beyond the tile size
- // (GHSA-fcmw-5764-7rq8).
- auto dataResult = do_decompress_data(cmpC_box, compressed_bytes, limits, range_size);
+ auto dataResult = do_decompress_data(cmpC_box, *readingResult, limits, remaining_size);
if (!dataResult) {
return dataResult.error();
}
- // Too little data is rejected as well. The tile decoders would read the missing
- // data as zeros.
- if (dataResult->size() < range_size) {
+ if (dataResult->size() > remaining_size) {
return {
heif_error_Invalid_input,
- heif_suberror_End_of_data,
- "compressed unit of unci image contains less data than the image tile"
+ heif_suberror_Decompression_invalid_data,
+ "compressed units of unci image contain more data than the image tile"
};
}
- *data = std::move(*dataResult);
- }
- else if (icef_box) {
- // get all data and decode all
- Result<std::vector<uint8_t>*> readResult = dataExtent.read_data();
- if (!readResult) {
- return readResult.error();
+ remaining_size -= dataResult->size();
+
+ if (Error memErr = accumulated_memory_handle.alloc(dataResult->size(), limits,
+ "unci icef decompressed units")) {
+ return memErr;
+ }
+
+ if (units_per_tile == 1) {
+ *data = std::move(*dataResult);
}
+ else {
+ data->insert(data->end(), dataResult->begin(), dataResult->end());
+ }
+ }
+
+ // Too little data is rejected as well. The tile decoders would read the missing
+ // data as zeros.
+ if (remaining_size != 0) {
+ return {
+ heif_error_Invalid_input,
+ heif_suberror_End_of_data,
+ "compressed units of unci image contain less data than the image tile"
+ };
+ }
- const std::vector<uint8_t> compressed_bytes = std::move(**readResult);
+ return Error::Ok;
+}
+
+
+Result<std::vector<uint8_t> > unc_decoder::decompress_whole_item(const DataExtent& dataExtent,
+ const UncompressedImageCodec::unci_properties& properties,
+ uint64_t expected_size) const
+{
+ std::shared_ptr<const Box_cmpC> cmpC_box = properties.cmpC;
+ std::shared_ptr<const Box_icef> icef_box = properties.icef;
+
+ // Security limits used to bound the (potentially highly amplified) decompressed
+ // output. May be nullptr for a raw data extent, in which case no limit applies.
+ const heif_security_limits* limits = dataExtent.m_file ? dataExtent.m_file->get_security_limits() : nullptr;
+
+ Result<std::vector<uint8_t>*> readResult = dataExtent.read_data();
+ if (!readResult) {
+ return readResult.error();
+ }
+ // Do not move the data out of the extent. This is the read cache of the extent and
+ // an emptied cache makes the next access read the data again, which a raw extent cannot do.
+ const std::vector<uint8_t>& compressed_bytes = **readResult;
+
+ // 'expected_size' is the size of the data of all tiles, so there cannot be more data
+ // than this in a valid file. Stopping there bounds the decompression by the image
+ // size instead of the memory budget.
+
+ std::vector<uint8_t> data;
+
+ if (icef_box) {
// Bound the total decompressed output accumulated across all units. The units
// may overlap (nothing forces them to be disjoint), so N units can point at the
// same compressed slice and be decompressed N times into `data`. Without this
// accounting, that amplifies a tiny icef box into an unbounded allocation
// (GHSA-24wx-9w62-c96w). The handle is local, so it only bounds the peak while
- // building `data`; the cropped result is accounted by the caller.
+ // building `data`; the result is accounted by the caller.
MemoryHandle accumulated_memory_handle;
for (Box_icef::CompressedUnitInfo unit_info : icef_box->get_units()) {
@@ -250,70 +328,48 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent
auto unit_end = unit_start + unit_info.unit_size;
std::vector<uint8_t> compressed_unit_data = std::vector<uint8_t>(unit_start, unit_end);
- auto dataResult = do_decompress_data(cmpC_box, compressed_unit_data, limits);
+ auto dataResult = do_decompress_data(cmpC_box, compressed_unit_data, limits, expected_size - data.size());
if (!dataResult) {
return dataResult.error();
}
- const std::vector<uint8_t> uncompressed_unit_data = std::move(*dataResult);
+ if (dataResult->size() > expected_size - data.size()) {
+ return Error{
+ heif_error_Invalid_input,
+ heif_suberror_Decompression_invalid_data,
+ "compressed unci image contains more data than the image tiles"
+ };
+ }
- if (Error memErr = accumulated_memory_handle.alloc(uncompressed_unit_data.size(), limits,
+ if (Error memErr = accumulated_memory_handle.alloc(dataResult->size(), limits,
"unci icef decompressed units")) {
return memErr;
}
- data->insert(data->end(), uncompressed_unit_data.data(), uncompressed_unit_data.data() + uncompressed_unit_data.size());
- }
-
- if (range_start_offset > data->size() ||
- range_size > data->size() - range_start_offset) {
- return {
- heif_error_Invalid_input,
- heif_suberror_Unspecified,
- "Data range out of existing range"
- };
+ data.insert(data.end(), dataResult->begin(), dataResult->end());
}
-
- // cut out the range that we actually need
- memcpy(data->data(), data->data() + range_start_offset, range_size);
- data->resize(range_size);
}
else {
- // get all data and decode all
- Result<std::vector<uint8_t>*> readResult = dataExtent.read_data();
- if (!readResult) {
- return readResult.error();
- }
-
- std::vector<uint8_t> compressed_bytes = std::move(**readResult);
-
// Decode as a single blob
- auto dataResult = do_decompress_data(cmpC_box, compressed_bytes, limits);
+ auto dataResult = do_decompress_data(cmpC_box, compressed_bytes, limits, expected_size);
if (!dataResult) {
return dataResult.error();
}
- *data = std::move(*dataResult);
-
- // Use subtraction form to avoid a uint64_t wrap in 'range_start_offset + range_size'.
- // A crafted tiling can make the requested tile range wrap to zero, passing the
- // addition-form check and leading to an out-of-bounds read in the memcpy() below
- // (GHSA-hh47-fhqr-cj2r; same root cause as the icef sibling branch above, GHSA-73p7-m7gg-w2jv).
- if (range_start_offset > data->size() ||
- range_size > data->size() - range_start_offset) {
- return {
- heif_error_Invalid_input,
- heif_suberror_Unspecified,
- "Data range out of existing range"
- };
- }
+ data = std::move(*dataResult);
+ }
- // cut out the range that we actually need
- memcpy(data->data(), data->data() + range_start_offset, range_size);
- data->resize(range_size);
+ // Too little data is rejected as well. The tile decoders would read the missing
+ // data as zeros.
+ if (data.size() < expected_size) {
+ return Error{
+ heif_error_Invalid_input,
+ heif_suberror_End_of_data,
+ "compressed unci image contains less data than the image tiles"
+ };
}
- return Error::Ok;
+ return data;
}
@@ -368,11 +424,66 @@ Error unc_decoder::decode_image(const DataExtent& extent,
ensure_channel_list(img);
+ // The extent and properties the tile data is fetched from.
+ const DataExtent* tile_extent = &extent;
+ const UncompressedImageCodec::unci_properties* tile_properties = &properties;
+
+ DataExtent decompressed_extent;
+ UncompressedImageCodec::unci_properties decompressed_properties;
+
+ if (UncompressedImageCodec::get_tile_access(m_uncC, properties.cmpC, properties.icef) ==
+ UncompressedImageCodec::TileAccess::whole_item) {
+ // The compressed units do not correspond to the tiles, so the item has to be
+ // decompressed completely to get the data of any tile. Do this once for all tiles
+ // and continue as if the item was not compressed. Decompressing the item again for
+ // each tile needs time proportional to num_tiles * item_size (GHSA-6fqc-p7r8-2g36).
+
+ auto sizesResult = get_tile_data_sizes();
+ if (!sizesResult) {
+ return sizesResult.error();
+ }
+
+ Result<uint64_t> tileSizeResult = sum_tile_data_sizes(*sizesResult);
+ if (!tileSizeResult) {
+ return tileSizeResult.error();
+ }
+
+ const uint64_t num_tiles = static_cast<uint64_t>(m_uncC->get_number_of_tile_columns()) * m_uncC->get_number_of_tile_rows();
+ if (num_tiles != 0 && *tileSizeResult > UINT64_MAX / num_tiles) {
+ return {heif_error_Invalid_input, heif_suberror_Invalid_image_size,
+ "uncompressed image data size exceeds 64-bit range"};
+ }
+
+ auto dataResult = decompress_whole_item(extent, properties, *tileSizeResult * num_tiles);
+ if (!dataResult) {
+ return dataResult.error();
+ }
+
+ // May be nullptr for a raw data extent, in which case no limit applies.
+ const heif_security_limits* limits = extent.m_file ? extent.m_file->get_security_limits() : nullptr;
+
+ // The decompressed data is kept until all tiles are decoded, so charge it to the memory budget.
+ if (Error memErr = decompressed_extent.m_raw_memory_handle.alloc(dataResult->size(), limits,
+ "decompressed unci image data")) {
+ return memErr;
+ }
+
+ decompressed_extent.m_file = extent.m_file;
+ decompressed_extent.m_raw = std::move(*dataResult);
+
+ decompressed_properties = properties;
+ decompressed_properties.cmpC = nullptr;
+ decompressed_properties.icef = nullptr;
+
+ tile_extent = &decompressed_extent;
+ tile_properties = &decompressed_properties;
+ }
+
for (uint32_t tile_y0 = 0; tile_y0 < m_height; tile_y0 += tile_height)
for (uint32_t tile_x0 = 0; tile_x0 < m_width; tile_x0 += tile_width) {
std::vector<uint8_t> tile_data;
MemoryHandle tile_data_memory_handle;
- Error error = fetch_tile_data(extent, properties, tile_x0 / tile_width, tile_y0 / tile_height, tile_data, tile_data_memory_handle);
+ Error error = fetch_tile_data(*tile_extent, *tile_properties, tile_x0 / tile_width, tile_y0 / tile_height, tile_data, tile_data_memory_handle);
if (error) {
return error;
}
diff --git a/libheif/codecs/uncompressed/unc_decoder.h b/libheif/codecs/uncompressed/unc_decoder.h
index 086727a8..270e1438 100644
--- a/libheif/codecs/uncompressed/unc_decoder.h
+++ b/libheif/codecs/uncompressed/unc_decoder.h
@@ -85,6 +85,13 @@ protected:
uint32_t tile_idx,
const Box_iloc::Item* item) const;
+ // Decompress the complete item. This is used when the compressed units do not give
+ // access to the individual tiles (UncompressedImageCodec::TileAccess::whole_item).
+ // The item has to decompress to exactly 'expected_size' bytes.
+ Result<std::vector<uint8_t>> decompress_whole_item(const DataExtent& dataExtent,
+ const UncompressedImageCodec::unci_properties& properties,
+ uint64_t expected_size) const;
+
Result<std::vector<uint8_t>> do_decompress_data(std::shared_ptr<const Box_cmpC>& cmpC_box,
const std::vector<uint8_t>& compressed_data,
const heif_security_limits* limits,
diff --git a/libheif/image-items/unc_image.cc b/libheif/image-items/unc_image.cc
index 32e0552d..f3315e15 100644
--- a/libheif/image-items/unc_image.cc
+++ b/libheif/image-items/unc_image.cc
@@ -151,6 +151,25 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_uncompressed::decode_compresse
Error err;
+ if (decode_tile_only) {
+ uint32_t num_columns, num_rows;
+ const bool tiles_are_decodable = get_exposed_tiling(num_columns, num_rows);
+
+ // The tile position is not validated on all paths that lead here.
+ if (tile_x0 >= num_columns || tile_y0 >= num_rows) {
+ return Error{heif_error_Usage_error,
+ heif_suberror_Invalid_parameter_value,
+ "Tile position is outside of the image tiling."};
+ }
+
+ // When the uncC tiles cannot be decoded independently, the image is exposed as a
+ // single tile. That tile is the whole image, which the full image path decodes
+ // with a single decompression of the item (GHSA-6fqc-p7r8-2g36).
+ if (!tiles_are_decodable) {
+ decode_tile_only = false;
+ }
+ }
+
if (decode_tile_only) {
err = UncompressedImageCodec::decode_uncompressed_image_tile(get_context(),
get_id(),
@@ -388,6 +407,32 @@ Error ImageItem_uncompressed::add_image_tile(uint32_t tile_x, uint32_t tile_y, c
}
+bool ImageItem_uncompressed::get_exposed_tiling(uint32_t& num_columns, uint32_t& num_rows) const
+{
+ num_columns = 1;
+ num_rows = 1;
+
+ auto uncC = get_property<Box_uncC>();
+ if (!uncC) {
+ return true;
+ }
+
+ // Generic compression with units that span several tiles (e.g. the full item) does not
+ // give access to the individual tiles: the item has to be decompressed completely to
+ // get the data of any tile. Such an image is exposed as a single tile, like the images
+ // of the other codecs that may use tiles internally. Otherwise, decoding the image
+ // tile by tile would decompress the whole item once for each tile (GHSA-6fqc-p7r8-2g36).
+ if (UncompressedImageCodec::get_tile_access(uncC, get_property<Box_cmpC>(), get_property<Box_icef>()) ==
+ UncompressedImageCodec::TileAccess::whole_item) {
+ return false;
+ }
+
+ num_columns = uncC->get_number_of_tile_columns();
+ num_rows = uncC->get_number_of_tile_rows();
+ return true;
+}
+
+
void ImageItem_uncompressed::get_tile_size(uint32_t& w, uint32_t& h) const
{
auto ispe = get_property<Box_ispe>();
@@ -397,8 +442,11 @@ void ImageItem_uncompressed::get_tile_size(uint32_t& w, uint32_t& h) const
w = h = 0;
}
else {
- w = ispe->get_width() / uncC->get_number_of_tile_columns();
- h = ispe->get_height() / uncC->get_number_of_tile_rows();
+ uint32_t num_columns, num_rows;
+ get_exposed_tiling(num_columns, num_rows);
+
+ w = ispe->get_width() / num_columns;
+ h = ispe->get_height() / num_rows;
}
}
@@ -411,8 +459,7 @@ heif_image_tiling ImageItem_uncompressed::get_heif_image_tiling() const
auto uncC = get_property<Box_uncC>();
assert(ispe && uncC);
- tiling.num_columns = uncC->get_number_of_tile_columns();
- tiling.num_rows = uncC->get_number_of_tile_rows();
+ get_exposed_tiling(tiling.num_columns, tiling.num_rows);
tiling.tile_width = ispe->get_width() / tiling.num_columns;
tiling.tile_height = ispe->get_height() / tiling.num_rows;
diff --git a/libheif/image-items/unc_image.h b/libheif/image-items/unc_image.h
index 97c892a9..94d744c8 100644
--- a/libheif/image-items/unc_image.h
+++ b/libheif/image-items/unc_image.h
@@ -102,6 +102,11 @@ protected:
std::shared_ptr<Encoder> get_encoder() const override;
private:
+ // The tiling that is exposed through the API. This is the uncC tiling, unless its
+ // tiles cannot be decoded independently because of the generic compression. In that
+ // case, the image is exposed as a single tile and false is returned.
+ bool get_exposed_tiling(uint32_t& num_columns, uint32_t& num_rows) const;
+
std::shared_ptr<class Decoder_uncompressed> m_decoder;
std::shared_ptr<class Encoder_uncompressed> m_encoder;
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index ef8e081c..4494d37a 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -184,6 +184,7 @@ if (WITH_UNCOMPRESSED_CODEC)
add_libheif_test(uncompressed_decode_generic_compression)
add_libheif_test(uncompressed_tile_range_overflow)
add_libheif_test(uncompressed_tile_data_fetch)
+ add_libheif_test(uncompressed_generic_compression_tiles)
else()
message(WARNING "Generic compress tests of the 'uncompressed codec' are not compiled because zlib was not found")
endif ()
diff --git a/tests/uncompressed_generic_compression_tiles.cc b/tests/uncompressed_generic_compression_tiles.cc
new file mode 100644
index 00000000..07b39210
--- /dev/null
+++ b/tests/uncompressed_generic_compression_tiles.cc
@@ -0,0 +1,810 @@
+/*
+ libheif unit tests
+
+ MIT License
+
+ Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+*/
+
+// Regression tests for GHSA-6fqc-p7r8-2g36.
+//
+// The uncC tiling of an 'unci' image describes how the uncompressed data is
+// stored. Whether a tile can be decoded on its own depends on the compressed units
+// of the generic compression (cmpC, icef):
+//
+// - Units that span several tiles (the full item, the full image of a component)
+// force the decoder to decompress the item completely to get the data of any
+// tile. The decoder did this once for each tile, which needs time proportional
+// to num_tiles * item_size. A 4 kB file with 256x256 tiles kept a CPU busy for
+// minutes.
+//
+// Such an item is now decompressed once when the image is decoded, and the image
+// is exposed as a single tile in heif_image_tiling. Because the decompression
+// cannot be observed through the security limits, the tests read the file
+// through a heif_reader that counts the bytes read from the item data: they have
+// to be read exactly once.
+//
+// - Units that are parts of a tile (rows, pixels) are listed in the order of the
+// uncompressed data, so each tile is a run of consecutive units. These images
+// keep their tiling, and decoding a tile only reads the units of this tile.
+//
+// The decompressed size is known in both cases. More or less data is rejected.
+//
+// The files use deflate generic compression, so the tests only run when the
+// library was built with zlib (guarded in tests/CMakeLists.txt).
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <cstring>
+#include <string>
+#include <vector>
+
+namespace {
+
+// A 4x4 RGB image with 2x2 tiles of 2x2 pixels.
+constexpr uint32_t kWidth = 4;
+constexpr uint32_t kHeight = 4;
+constexpr uint32_t kTileColumns = 2;
+constexpr uint32_t kTileRows = 2;
+constexpr uint32_t kTileWidth = kWidth / kTileColumns;
+constexpr uint32_t kTileHeight = kHeight / kTileRows;
+constexpr uint32_t kNumComponents = 3;
+constexpr uint32_t kNumTiles = kTileColumns * kTileRows;
+constexpr uint32_t kTileDataSize = kTileWidth * kTileHeight * kNumComponents;
+
+constexpr uint8_t kInterleaveComponent = 0;
+constexpr uint8_t kInterleavePixel = 1;
+constexpr uint8_t kInterleaveTileComponent = 4;
+
+constexpr uint8_t kUnitFullItem = 0;
+constexpr uint8_t kUnitImage = 1;
+constexpr uint8_t kUnitRow = 3;
+constexpr uint8_t kUnitPixel = 4;
+
+// Each sample of the image has a different value.
+uint8_t pixel_value(uint32_t component, uint32_t x, uint32_t y) {
+ return static_cast<uint8_t>((component + 1) * 50 + y * kWidth + x);
+}
+
+void append_tile_component(std::vector<uint8_t>& out, uint32_t tile, uint32_t component) {
+ uint32_t x0 = (tile % kTileColumns) * kTileWidth;
+ uint32_t y0 = (tile / kTileColumns) * kTileHeight;
+
+ for (uint32_t y = 0; y < kTileHeight; y++) {
+ for (uint32_t x = 0; x < kTileWidth; x++) {
+ out.push_back(pixel_value(component, x0 + x, y0 + y));
+ }
+ }
+}
+
+// The uncompressed data of the image in the order in which the file stores it.
+std::vector<uint8_t> make_image_data(uint8_t interleave_type) {
+ std::vector<uint8_t> data;
+
+ switch (interleave_type) {
+ case kInterleaveComponent:
+ for (uint32_t tile = 0; tile < kNumTiles; tile++) {
+ for (uint32_t c = 0; c < kNumComponents; c++) {
+ append_tile_component(data, tile, c);
+ }
+ }
+ break;
+
+ case kInterleavePixel:
+ for (uint32_t tile = 0; tile < kNumTiles; tile++) {
+ uint32_t x0 = (tile % kTileColumns) * kTileWidth;
+ uint32_t y0 = (tile / kTileColumns) * kTileHeight;
+
+ for (uint32_t y = 0; y < kTileHeight; y++) {
+ for (uint32_t x = 0; x < kTileWidth; x++) {
+ for (uint32_t c = 0; c < kNumComponents; c++) {
+ data.push_back(pixel_value(c, x0 + x, y0 + y));
+ }
+ }
+ }
+ }
+ break;
+
+ case kInterleaveTileComponent:
+ for (uint32_t c = 0; c < kNumComponents; c++) {
+ for (uint32_t tile = 0; tile < kNumTiles; tile++) {
+ append_tile_component(data, tile, c);
+ }
+ }
+ break;
+
+ default:
+ REQUIRE(false);
+ }
+
+ return data;
+}
+
+// Split the data into pieces of 'unit_size' bytes.
+std::vector<std::vector<uint8_t>> split_into_units(const std::vector<uint8_t>& data, size_t unit_size) {
+ std::vector<std::vector<uint8_t>> units;
+
+ for (size_t pos = 0; pos < data.size(); pos += unit_size) {
+ size_t end = std::min(pos + unit_size, data.size());
+ units.emplace_back(data.begin() + pos, data.begin() + end);
+ }
+
+ return units;
+}
+
+// Wrap the data into a raw deflate stream consisting of a single stored block.
+std::vector<uint8_t> deflate_stored(const std::vector<uint8_t>& data) {
+ auto len = static_cast<uint16_t>(data.size());
+ auto nlen = static_cast<uint16_t>(~len);
+
+ std::vector<uint8_t> out;
+ out.push_back(0x01); // BFINAL=1, BTYPE=00 (stored)
+ out.push_back(static_cast<uint8_t>(len & 0xFF));
+ out.push_back(static_cast<uint8_t>(len >> 8));
+ out.push_back(static_cast<uint8_t>(nlen & 0xFF));
+ out.push_back(static_cast<uint8_t>(nlen >> 8));
+ append(out, data);
+ return out;
+}
+
+struct UnciFileSpec {
+ const char* description = "";
+
+ uint8_t interleave_type = kInterleaveComponent;
+
+ // cmpC compressed_unit_type
+ uint8_t unit_type = kUnitFullItem;
+
+ // Without an icef box, there has to be exactly one unit.
+ bool with_icef = false;
+
+ // The uncompressed content of the compressed units.
+ std::vector<std::vector<uint8_t>> units;
+};
+
+struct UnciFile {
+ std::vector<uint8_t> data;
+
+ // The item data is at the end of the file.
+ size_t item_size = 0;
+
+ // The sizes of the compressed units in the item data.
+ std::vector<size_t> compressed_unit_sizes;
+};
+
+// Build an 'unci' image with deflate generic compression. The item data is stored
+// in 'idat', which is the last box of the file.
+UnciFile build_heif_unci(const UnciFileSpec& spec) {
+ UnciFile file;
+
+ std::vector<uint8_t> item_data;
+ for (const auto& unit : spec.units) {
+ std::vector<uint8_t> compressed = deflate_stored(unit);
+ file.compressed_unit_sizes.push_back(compressed.size());
+ append(item_data, compressed);
+ }
+
+ file.item_size = item_data.size();
+
+ std::vector<uint8_t> ftyp_payload;
+ append_fourcc(ftyp_payload, "mif1");
+ put_u32_be(ftyp_payload, 0);
+ append_fourcc(ftyp_payload, "mif1");
+ append_fourcc(ftyp_payload, "heic");
+ auto ftyp = make_box("ftyp", ftyp_payload);
+
+ std::vector<uint8_t> hdlr_payload;
+ put_u32_be(hdlr_payload, 0);
+ append_fourcc(hdlr_payload, "pict");
+ put_u32_be(hdlr_payload, 0);
+ put_u32_be(hdlr_payload, 0);
+ put_u32_be(hdlr_payload, 0);
+ hdlr_payload.push_back(0);
+ auto hdlr = make_box("hdlr", hdlr_payload, /*full=*/true);
+
+ std::vector<uint8_t> pitm_payload;
+ put_u16_be(pitm_payload, 1);
+ auto pitm = make_box("pitm", pitm_payload, /*full=*/true);
+
+ std::vector<uint8_t> infe_payload;
+ put_u16_be(infe_payload, 1);
+ put_u16_be(infe_payload, 0);
+ append_fourcc(infe_payload, "unci");
+ append_cstr(infe_payload, "");
+ auto infe = make_box("infe", infe_payload, /*full=*/true, /*version=*/2);
+
+ std::vector<uint8_t> iinf_payload;
+ put_u16_be(iinf_payload, 1);
+ append(iinf_payload, infe);
+ auto iinf = make_box("iinf", iinf_payload, /*full=*/true);
+
+ // ispe
+ std::vector<uint8_t> ispe_payload;
+ put_u32_be(ispe_payload, kWidth);
+ put_u32_be(ispe_payload, kHeight);
+ auto ispe = make_box("ispe", ispe_payload, /*full=*/true);
+
+ // cmpd
+ std::vector<uint8_t> cmpd_payload;
+ put_u32_be(cmpd_payload, kNumComponents);
+ put_u16_be(cmpd_payload, heif_cmpd_component_type_red);
+ put_u16_be(cmpd_payload, heif_cmpd_component_type_green);
+ put_u16_be(cmpd_payload, heif_cmpd_component_type_blue);
+ auto cmpd = make_box("cmpd", cmpd_payload);
+
+ // uncC (v0): 8-bit components
+ std::vector<uint8_t> uncC_payload;
+ put_u32_be(uncC_payload, 0); // profile
+ put_u32_be(uncC_payload, kNumComponents);
+ for (uint16_t c = 0; c < kNumComponents; c++) {
+ put_u16_be(uncC_payload, c); // component_index
+ uncC_payload.push_back(7); // component_bit_depth_minus_one -> 8 bit
+ uncC_payload.push_back(0); // component_format (unsigned)
+ uncC_payload.push_back(0); // component_align_size
+ }
+ uncC_payload.push_back(0); // sampling_type (no subsampling)
+ uncC_payload.push_back(spec.interleave_type);
+ uncC_payload.push_back(0); // block_size
+ uncC_payload.push_back(0); // flags
+ put_u32_be(uncC_payload, 0); // pixel_size
+ put_u32_be(uncC_payload, 0); // row_align_size
+ put_u32_be(uncC_payload, 0); // tile_align_size
+ put_u32_be(uncC_payload, kTileColumns - 1);
+ put_u32_be(uncC_payload, kTileRows - 1);
+ auto uncC = make_box("uncC", uncC_payload, /*full=*/true);
+
+ // cmpC: deflate
+ std::vector<uint8_t> cmpC_payload;
+ append_fourcc(cmpC_payload, "defl");
+ cmpC_payload.push_back(spec.unit_type);
+ auto cmpC = make_box("cmpC", cmpC_payload, /*full=*/true);
+
+ std::vector<uint8_t> ipco_payload;
+ append(ipco_payload, ispe);
+ append(ipco_payload, cmpd);
+ append(ipco_payload, uncC);
+ append(ipco_payload, cmpC);
+
+ uint8_t num_properties = 4;
+
+ if (spec.with_icef) {
+ // icef: implied unit offsets, 32-bit unit sizes
+ std::vector<uint8_t> icef_payload;
+ icef_payload.push_back(3 << 2); // unit_offset_code = 0, unit_size_code = 3
+ put_u32_be(icef_payload, static_cast<uint32_t>(file.compressed_unit_sizes.size()));
+ for (size_t size : file.compressed_unit_sizes) {
+ put_u32_be(icef_payload, static_cast<uint32_t>(size));
+ }
+ append(ipco_payload, make_box("icef", icef_payload, /*full=*/true));
+
+ num_properties = 5;
+ }
+ else {
+ REQUIRE(spec.units.size() == 1);
+ }
+
+ auto ipco = make_box("ipco", ipco_payload);
+
+ std::vector<uint8_t> ipma_payload;
+ put_u32_be(ipma_payload, 1); // entry_count
+ put_u16_be(ipma_payload, 1); // item_ID 1
+ ipma_payload.push_back(num_properties); // association_count
+ for (uint8_t i = 1; i <= num_properties; i++) {
+ ipma_payload.push_back(0x80 | i); // essential
+ }
+ auto ipma = make_box("ipma", ipma_payload, /*full=*/true);
+
+ std::vector<uint8_t> iprp_payload;
+ append(iprp_payload, ipco);
+ append(iprp_payload, ipma);
+ auto iprp = make_box("iprp", iprp_payload);
+
+ auto idat = make_box("idat", item_data);
+
+ // iloc (version 1): item 1 stored in idat (construction_method=1).
+ std::vector<uint8_t> iloc_payload;
+ put_u16_be(iloc_payload, (4 << 12) | (4 << 8) | (0 << 4) | 0); // offset_size=4, length_size=4
+ put_u16_be(iloc_payload, 1); // item_count
+ put_u16_be(iloc_payload, 1); // item_ID
+ put_u16_be(iloc_payload, 0x0001); // construction_method=1 (idat)
+ put_u16_be(iloc_payload, 0); // data_reference_index
+ put_u16_be(iloc_payload, 1); // extent_count
+ put_u32_be(iloc_payload, 0); // extent_offset (within idat)
+ put_u32_be(iloc_payload, static_cast<uint32_t>(item_data.size())); // extent_length
+ auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+ // idat has to be the last box: the tests locate the item data at the end of the file.
+ std::vector<uint8_t> meta_payload;
+ append(meta_payload, hdlr);
+ append(meta_payload, pitm);
+ append(meta_payload, iinf);
+ append(meta_payload, iprp);
+ append(meta_payload, iloc);
+ append(meta_payload, idat);
+ auto meta = make_box("meta", meta_payload, /*full=*/true);
+
+ append(file.data, ftyp);
+ append(file.data, meta);
+ return file;
+}
+
+
+// A heif_reader on a memory buffer that counts the bytes read from the item data.
+struct CountingReader {
+ const std::vector<uint8_t>* data = nullptr;
+ int64_t position = 0;
+
+ int64_t watched_start = 0;
+ int64_t watched_end = 0;
+ uint64_t watched_bytes_read = 0;
+};
+
+int64_t reader_get_position(void* userdata) {
+ return static_cast<CountingReader*>(userdata)->position;
+}
+
+int reader_read(void* dst, size_t size, void* userdata) {
+ auto* reader = static_cast<CountingReader*>(userdata);
+ auto file_size = static_cast<int64_t>(reader->data->size());
+ auto read_end = reader->position + static_cast<int64_t>(size);
+
+ if (read_end > file_size) {
+ return 1;
+ }
+
+ memcpy(dst, reader->data->data() + reader->position, size);
+
+ int64_t overlap_start = std::max(reader->position, reader->watched_start);
+ int64_t overlap_end = std::min(read_end, reader->watched_end);
+ if (overlap_end > overlap_start) {
+ reader->watched_bytes_read += static_cast<uint64_t>(overlap_end - overlap_start);
+ }
+
+ reader->position = read_end;
+ return 0;
+}
+
+int reader_seek(int64_t position, void* userdata) {
+ auto* reader = static_cast<CountingReader*>(userdata);
+ if (position < 0 || position > static_cast<int64_t>(reader->data->size())) {
+ return 1;
+ }
+
+ reader->position = position;
+ return 0;
+}
+
+heif_reader_grow_status reader_wait_for_file_size(int64_t target_size, void* userdata) {
+ auto* reader = static_cast<CountingReader*>(userdata);
+ return (target_size <= static_cast<int64_t>(reader->data->size()))
+ ? heif_reader_grow_status_size_reached
+ : heif_reader_grow_status_size_beyond_eof;
+}
+
+
+// Opens the primary image of a file through the counting reader.
+class OpenedImage {
+public:
+ explicit OpenedImage(const UnciFile& file) {
+ m_counting_reader.data = &file.data;
+ m_counting_reader.watched_start = static_cast<int64_t>(file.data.size() - file.item_size);
+ m_counting_reader.watched_end = static_cast<int64_t>(file.data.size());
+
+ m_reader.reader_api_version = 1;
+ m_reader.get_position = reader_get_position;
+ m_reader.read = reader_read;
+ m_reader.seek = reader_seek;
+ m_reader.wait_for_file_size = reader_wait_for_file_size;
+
+ m_ctx = heif_context_alloc();
+ REQUIRE(m_ctx != nullptr);
+
+ heif_error err = heif_context_read_from_reader(m_ctx, &m_reader, &m_counting_reader, nullptr);
+ INFO("read error: " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+
+ err = heif_context_get_primary_image_handle(m_ctx, &m_handle);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(m_handle != nullptr);
+ }
+
+ ~OpenedImage() {
+ heif_image_handle_release(m_handle);
+ heif_context_free(m_ctx);
+ }
+
+ OpenedImage(const OpenedImage&) = delete;
+ OpenedImage& operator=(const OpenedImage&) = delete;
+
+ heif_image_handle* handle() const { return m_handle; }
+
+ heif_image_tiling tiling() const {
+ heif_image_tiling tiling{};
+ heif_error err = heif_image_handle_get_image_tiling(m_handle, 1, &tiling);
+ REQUIRE(err.code == heif_error_Ok);
+ return tiling;
+ }
+
+ // The error message is owned by the context, so it is only valid as long as this object lives.
+ heif_error decode_image(heif_image** out_img) {
+ m_counting_reader.watched_bytes_read = 0;
+ return heif_decode_image(m_handle, out_img, heif_colorspace_RGB, heif_chroma_444, nullptr);
+ }
+
+ heif_error decode_tile(heif_image** out_img, uint32_t tile_x, uint32_t tile_y, bool ignore_transformations = false) {
+ heif_decoding_options* options = heif_decoding_options_alloc();
+ options->ignore_transformations = ignore_transformations;
+
+ m_counting_reader.watched_bytes_read = 0;
+ heif_error err = heif_image_handle_decode_image_tile(m_handle, out_img, heif_colorspace_RGB, heif_chroma_444,
+ options, tile_x, tile_y);
+ heif_decoding_options_free(options);
+ return err;
+ }
+
+ // The number of bytes of the item data that the last decoding call has read.
+ uint64_t item_bytes_read() const { return m_counting_reader.watched_bytes_read; }
+
+private:
+ CountingReader m_counting_reader;
+ heif_reader m_reader{};
+ heif_context* m_ctx = nullptr;
+ heif_image_handle* m_handle = nullptr;
+};
+
+
+// Check that the image shows the area of the test image that starts at (x0;y0).
+void check_pixels(const heif_image* img, uint32_t x0, uint32_t y0, uint32_t width, uint32_t height) {
+ const heif_channel channels[kNumComponents] = {heif_channel_R, heif_channel_G, heif_channel_B};
+
+ for (uint32_t c = 0; c < kNumComponents; c++) {
+ REQUIRE(heif_image_get_width(img, channels[c]) == static_cast<int>(width));
+ REQUIRE(heif_image_get_height(img, channels[c]) == static_cast<int>(height));
+
+ int stride = 0;
+ const uint8_t* plane = heif_image_get_plane_readonly(img, channels[c], &stride);
+ REQUIRE(plane != nullptr);
+
+ for (uint32_t y = 0; y < height; y++) {
+ for (uint32_t x = 0; x < width; x++) {
+ INFO("component " << c << ", pixel (" << x << "," << y << ")");
+ REQUIRE(static_cast<int>(plane[y * stride + x]) == static_cast<int>(pixel_value(c, x0 + x, y0 + y)));
+ }
+ }
+ }
+}
+
+} // namespace
+
+
+TEST_CASE("unci image with compressed units spanning several tiles is decompressed once") {
+ std::vector<UnciFileSpec> specs;
+
+ {
+ UnciFileSpec spec;
+ spec.description = "full item without icef";
+ spec.units = {make_image_data(kInterleaveComponent)};
+ specs.push_back(spec);
+ }
+
+ {
+ UnciFileSpec spec;
+ spec.description = "full item with icef";
+ spec.with_icef = true;
+ spec.units = {make_image_data(kInterleaveComponent)};
+ specs.push_back(spec);
+ }
+
+ {
+ UnciFileSpec spec;
+ spec.description = "one unit for the full image of each component";
+ spec.interleave_type = kInterleaveTileComponent;
+ spec.unit_type = kUnitImage;
+ spec.with_icef = true;
+ spec.units = split_into_units(make_image_data(kInterleaveTileComponent), kWidth * kHeight);
+ specs.push_back(spec);
+ }
+
+ {
+ // The rows of a tile are not contiguous in the uncompressed data.
+ UnciFileSpec spec;
+ spec.description = "row units with tile-component interleave";
+ spec.interleave_type = kInterleaveTileComponent;
+ spec.unit_type = kUnitRow;
+ spec.with_icef = true;
+ spec.units = split_into_units(make_image_data(kInterleaveTileComponent), kTileWidth);
+ specs.push_back(spec);
+ }
+
+ {
+ // Three units for four tiles
+ UnciFileSpec spec;
+ spec.description = "row units that cannot be assigned to the tiles";
+ spec.interleave_type = kInterleavePixel;
+ spec.unit_type = kUnitRow;
+ spec.with_icef = true;
+ spec.units = split_into_units(make_image_data(kInterleavePixel), kWidth * kHeight);
+ REQUIRE(spec.units.size() == 3);
+ specs.push_back(spec);
+ }
+
+ for (const UnciFileSpec& spec : specs) {
+ INFO("compressed units: " << spec.description);
+
+ UnciFile file = build_heif_unci(spec);
+ OpenedImage image(file);
+
+ // --- The uncC tiles cannot be decoded independently, so the image is a single tile.
+
+ heif_image_tiling tiling = image.tiling();
+ REQUIRE(tiling.num_columns == 1);
+ REQUIRE(tiling.num_rows == 1);
+ REQUIRE(tiling.tile_width == kWidth);
+ REQUIRE(tiling.tile_height == kHeight);
+ REQUIRE(tiling.image_width == kWidth);
+ REQUIRE(tiling.image_height == kHeight);
+
+ // --- Decoding the image reads (and decompresses) the item once, not once per uncC tile.
+
+ heif_image* img = nullptr;
+ heif_error err = image.decode_image(&img);
+ INFO("decode error: " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ REQUIRE(image.item_bytes_read() == file.item_size);
+ check_pixels(img, 0, 0, kWidth, kHeight);
+ heif_image_release(img);
+
+ // --- The single tile is the whole image.
+
+ for (bool ignore_transformations : {false, true}) {
+ INFO("ignore_transformations: " << ignore_transformations);
+
+ img = nullptr;
+ err = image.decode_tile(&img, 0, 0, ignore_transformations);
+ INFO("tile decode error: " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ REQUIRE(image.item_bytes_read() == file.item_size);
+ check_pixels(img, 0, 0, kWidth, kHeight);
+ heif_image_release(img);
+
+ // --- The uncC tiles are not accessible.
+
+ img = nullptr;
+ err = image.decode_tile(&img, 1, 0, ignore_transformations);
+ REQUIRE(err.code == heif_error_Usage_error);
+ REQUIRE(img == nullptr);
+
+ err = image.decode_tile(&img, 0, 1, ignore_transformations);
+ REQUIRE(err.code == heif_error_Usage_error);
+ REQUIRE(img == nullptr);
+ }
+ }
+}
+
+
+TEST_CASE("unci image with compressed units that are parts of a tile keeps its tiles") {
+ std::vector<UnciFileSpec> specs;
+
+ {
+ // Two units per tile
+ UnciFileSpec spec;
+ spec.description = "row units with pixel interleave";
+ spec.interleave_type = kInterleavePixel;
+ spec.unit_type = kUnitRow;
+ spec.units = split_into_units(make_image_data(kInterleavePixel), kTileWidth * kNumComponents);
+ specs.push_back(spec);
+ }
+
+ {
+ // Six units per tile: two rows for each of the three components
+ UnciFileSpec spec;
+ spec.description = "row units with component interleave";
+ spec.interleave_type = kInterleaveComponent;
+ spec.unit_type = kUnitRow;
+ spec.units = split_into_units(make_image_data(kInterleaveComponent), kTileWidth);
+ specs.push_back(spec);
+ }
+
+ {
+ // Four units per tile
+ UnciFileSpec spec;
+ spec.description = "pixel units with pixel interleave";
+ spec.interleave_type = kInterleavePixel;
+ spec.unit_type = kUnitPixel;
+ spec.units = split_into_units(make_image_data(kInterleavePixel), kNumComponents);
+ specs.push_back(spec);
+ }
+
+ for (UnciFileSpec& spec : specs) {
+ INFO("compressed units: " << spec.description);
+
+ spec.with_icef = true;
+
+ UnciFile file = build_heif_unci(spec);
+ OpenedImage image(file);
+
+ REQUIRE(file.compressed_unit_sizes.size() % kNumTiles == 0);
+ size_t units_per_tile = file.compressed_unit_sizes.size() / kNumTiles;
+
+ heif_image_tiling tiling = image.tiling();
+ REQUIRE(tiling.num_columns == kTileColumns);
+ REQUIRE(tiling.num_rows == kTileRows);
+ REQUIRE(tiling.tile_width == kTileWidth);
+ REQUIRE(tiling.tile_height == kTileHeight);
+
+ // --- Decoding a tile only reads the compressed units of this tile.
+
+ for (uint32_t ty = 0; ty < kTileRows; ty++) {
+ for (uint32_t tx = 0; tx < kTileColumns; tx++) {
+ INFO("tile (" << tx << "," << ty << ")");
+
+ uint32_t tile_idx = ty * kTileColumns + tx;
+
+ size_t tile_units_size = 0;
+ for (size_t i = 0; i < units_per_tile; i++) {
+ tile_units_size += file.compressed_unit_sizes[tile_idx * units_per_tile + i];
+ }
+
+ heif_image* img = nullptr;
+ heif_error err = image.decode_tile(&img, tx, ty);
+ INFO("tile decode error: " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ REQUIRE(image.item_bytes_read() == tile_units_size);
+ check_pixels(img, tx * kTileWidth, ty * kTileHeight, kTileWidth, kTileHeight);
+ heif_image_release(img);
+ }
+ }
+
+ // --- A tile position outside of the tiling is rejected.
+
+ heif_image* img = nullptr;
+ heif_error err = image.decode_tile(&img, kTileColumns, 0, /*ignore_transformations=*/true);
+ REQUIRE(err.code == heif_error_Usage_error);
+ REQUIRE(img == nullptr);
+
+ // --- Decoding the whole image reads each unit once.
+
+ err = image.decode_image(&img);
+ INFO("decode error: " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ REQUIRE(image.item_bytes_read() == file.item_size);
+ check_pixels(img, 0, 0, kWidth, kHeight);
+ heif_image_release(img);
+ }
+}
+
+
+TEST_CASE("unci item that is decompressed as a whole must have the size of the image data") {
+ for (bool with_icef : {false, true}) {
+ INFO("with icef: " << with_icef);
+
+ std::vector<uint8_t> image_data = make_image_data(kInterleaveComponent);
+
+ UnciFileSpec spec;
+ spec.with_icef = with_icef;
+
+ // --- one byte too much
+
+ std::vector<uint8_t> oversized_data = image_data;
+ oversized_data.push_back(0);
+ spec.units = {oversized_data};
+
+ {
+ UnciFile file = build_heif_unci(spec);
+ OpenedImage image(file);
+
+ heif_image* img = nullptr;
+ heif_error err = image.decode_image(&img);
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Decompression_invalid_data);
+ REQUIRE(img == nullptr);
+ }
+
+ // --- one byte missing
+
+ std::vector<uint8_t> short_data = image_data;
+ short_data.pop_back();
+ spec.units = {short_data};
+
+ {
+ UnciFile file = build_heif_unci(spec);
+ OpenedImage image(file);
+
+ heif_image* img = nullptr;
+ heif_error err = image.decode_image(&img);
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_End_of_data);
+ REQUIRE(img == nullptr);
+ }
+ }
+}
+
+
+TEST_CASE("unci compressed units of a tile must have the size of the tile") {
+ // Row units with pixel interleave: two units per tile.
+ UnciFileSpec spec;
+ spec.interleave_type = kInterleavePixel;
+ spec.unit_type = kUnitRow;
+ spec.with_icef = true;
+
+ const std::vector<std::vector<uint8_t>> units = split_into_units(make_image_data(kInterleavePixel),
+ kTileWidth * kNumComponents);
+ REQUIRE(units.size() == 2 * kNumTiles);
+ REQUIRE(units[0].size() + units[1].size() == kTileDataSize);
+
+ // --- the second unit of the first tile has one byte too much
+
+ spec.units = units;
+ spec.units[1].push_back(0);
+
+ {
+ UnciFile file = build_heif_unci(spec);
+ OpenedImage image(file);
+
+ heif_image* img = nullptr;
+ heif_error err = image.decode_tile(&img, 0, 0);
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Decompression_invalid_data);
+ REQUIRE(img == nullptr);
+
+ // The other tiles do not depend on these units.
+ err = image.decode_tile(&img, 1, 0);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ check_pixels(img, kTileWidth, 0, kTileWidth, kTileHeight);
+ heif_image_release(img);
+
+ img = nullptr;
+ err = image.decode_image(&img);
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(img == nullptr);
+ }
+
+ // --- the second unit of the first tile has one byte missing
+
+ spec.units = units;
+ spec.units[1].pop_back();
+
+ {
+ UnciFile file = build_heif_unci(spec);
+ OpenedImage image(file);
+
+ heif_image* img = nullptr;
+ heif_error err = image.decode_tile(&img, 0, 0);
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_End_of_data);
+ REQUIRE(img == nullptr);
+
+ err = image.decode_tile(&img, 1, 0);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ check_pixels(img, kTileWidth, 0, kTileWidth, kTileHeight);
+ heif_image_release(img);
+ }
+}
diff --git a/tests/uncompressed_tile_range_overflow.cc b/tests/uncompressed_tile_range_overflow.cc
index 56aa6afe..82517b55 100644
--- a/tests/uncompressed_tile_range_overflow.cc
+++ b/tests/uncompressed_tile_range_overflow.cc
@@ -27,26 +27,32 @@
// Regression test for GHSA-hh47-fhqr-cj2r (incomplete fix of
// GHSA-73p7-m7gg-w2jv / CVE-2026-62292).
//
-// A crafted 'unci' image with generic (zlib, full_item) compression advertises a
-// 4096x4096 grid of 1x1 tiles. Large alignment values make the computed size of
-// the last tile 2^40 bytes, so for the final tile index (2^24 - 1) the range
-// arithmetic in unc_decoder::get_compressed_image_data_uncompressed() overflows:
+// A crafted 'unci' image advertises a 4096x4096 grid of 1x1 tiles. Large alignment
+// values make the computed size of a tile 2^40 bytes, so for the final tile index
+// (2^24 - 1) the range of the tile data wraps around:
//
// range_start_offset = 2^40 * (2^24 - 1) = 2^64 - 2^40
// range_size = 2^40
// range_start_offset + range_size = 2^64 -> 0 (uint64_t wrap)
//
-// The old addition-form check `range_start_offset + range_size > data->size()`
-// was bypassed (0 > 1 is false) and the code reached memcpy() with an
+// With generic (zlib, full_item) compression, unc_decoder cut this range out of the
+// decompressed item. Its addition-form check `range_start_offset + range_size >
+// data->size()` was bypassed (0 > 1 is false) and the code reached memcpy() with an
// out-of-range source pointer and a 1 TiB length: an out-of-bounds read / SIGSEGV
// reachable through the public heif_image_handle_decode_image_tile().
//
-// The fix uses the overflow-safe subtraction form. This test builds the fixture
-// in memory, opens it (structurally valid, must succeed), reads the advertised
-// tiling and requests the last tile: decoding must now return a structured
-// heif_error_Invalid_input instead of crashing.
+// The tests build the fixture in memory, open it (structurally valid, must succeed)
+// and request the last tile: decoding must return a structured error instead of
+// crashing.
//
-// The file uses real zlib generic compression, so the test only runs when the
+// - Without generic compression, the tile range is read from the item data.
+//
+// - With full_item compression, the tiles cannot be decoded independently and the
+// image is exposed as a single tile (GHSA-6fqc-p7r8-2g36), so the last uncC tile
+// is not accessible anymore. The single tile is the whole image and has to be
+// rejected as well.
+//
+// One file uses real zlib generic compression, so the tests only run when the
// library was built with zlib (guarded in tests/CMakeLists.txt).
#include "catch_amalgamated.hpp"
@@ -68,9 +74,9 @@ constexpr uint32_t COMPONENTS = 256;
const std::vector<uint8_t> kZlibOneByte = {
0x78, 0x9c, 0x73, 0x04, 0x00, 0x00, 0x42, 0x00, 0x42};
-// Build a minimal HEIF file with a single 'unci' item using generic zlib
-// (full_item) compression and a 4096x4096 tile grid.
-std::vector<uint8_t> build_heif_unci_overflow_tiling() {
+// Build a minimal HEIF file with a single 'unci' item and a 4096x4096 tile grid,
+// optionally using generic zlib (full_item) compression.
+std::vector<uint8_t> build_heif_unci_overflow_tiling(bool generic_compression) {
std::vector<uint8_t> ftyp_payload;
append_fourcc(ftyp_payload, "mif1");
put_u32_be(ftyp_payload, 0);
@@ -149,17 +155,21 @@ std::vector<uint8_t> build_heif_unci_overflow_tiling() {
append(ipco_payload, ispe);
append(ipco_payload, cmpd);
append(ipco_payload, uncC);
- append(ipco_payload, cmpC);
+ if (generic_compression) {
+ append(ipco_payload, cmpC);
+ }
auto ipco = make_box("ipco", ipco_payload);
std::vector<uint8_t> ipma_payload;
put_u32_be(ipma_payload, 1); // entry_count
put_u16_be(ipma_payload, 1); // item_ID 1
- ipma_payload.push_back(4); // association_count
+ ipma_payload.push_back(generic_compression ? 4 : 3); // association_count
ipma_payload.push_back(0x80 | 1); // essential, ispe
ipma_payload.push_back(0x80 | 2); // essential, cmpd
ipma_payload.push_back(0x80 | 3); // essential, uncC
- ipma_payload.push_back(0x80 | 4); // essential, cmpC
+ if (generic_compression) {
+ ipma_payload.push_back(0x80 | 4); // essential, cmpC
+ }
auto ipma = make_box("ipma", ipma_payload, /*full=*/true);
std::vector<uint8_t> iprp_payload;
@@ -167,7 +177,8 @@ std::vector<uint8_t> build_heif_unci_overflow_tiling() {
append(iprp_payload, ipma);
auto iprp = make_box("iprp", iprp_payload);
- // idat: the zlib payload (decompresses to 1 byte).
+ // idat: the zlib payload (decompresses to 1 byte). Without generic compression,
+ // these are 9 bytes of image data.
auto idat = make_box("idat", kZlibOneByte);
// iloc (version 1): item 1 stored in idat (construction_method=1).
@@ -197,16 +208,13 @@ std::vector<uint8_t> build_heif_unci_overflow_tiling() {
return file;
}
-} // namespace
-
-TEST_CASE("unci tile range overflow returns error instead of crashing") {
- std::vector<uint8_t> file = build_heif_unci_overflow_tiling();
-
+// Open the file and get the handle of its primary image.
+void open_primary_image(const std::vector<uint8_t>& file, heif_context** out_ctx, heif_image_handle** out_handle) {
heif_context* ctx = heif_context_alloc();
REQUIRE(ctx != nullptr);
// The file is structurally valid, so opening it must succeed. The bug is only
- // reachable by then decoding a high-index advertised tile.
+ // reachable by then decoding a tile.
heif_error err = heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr);
REQUIRE(err.code == heif_error_Ok);
@@ -215,25 +223,88 @@ TEST_CASE("unci tile range overflow returns error instead of crashing") {
REQUIRE(err.code == heif_error_Ok);
REQUIRE(handle != nullptr);
+ *out_ctx = ctx;
+ *out_handle = handle;
+}
+
+} // namespace
+
+TEST_CASE("unci tile range overflow returns error instead of crashing") {
+ std::vector<uint8_t> file = build_heif_unci_overflow_tiling(/*generic_compression=*/false);
+
+ heif_context* ctx = nullptr;
+ heif_image_handle* handle = nullptr;
+ open_primary_image(file, &ctx, &handle);
+
heif_image_tiling tiling;
heif_image_handle_get_image_tiling(handle, 1, &tiling);
REQUIRE(tiling.num_columns == TILE_COLS);
REQUIRE(tiling.num_rows == TILE_ROWS);
- // Request the last advertised tile. Before the fix this reached memcpy() with a
- // wrapped source pointer and a 1 TiB length (out-of-bounds read / SIGSEGV). It
- // must now return a structured invalid-input error.
+ // Request the last advertised tile. Its data range wraps around the 64 bit range.
+ // This must return a structured invalid-input error.
heif_image* img = nullptr;
- err = heif_image_handle_decode_image_tile(handle, &img,
- heif_colorspace_undefined, heif_chroma_undefined,
- nullptr,
- tiling.num_columns - 1, tiling.num_rows - 1);
+ heif_error err = heif_image_handle_decode_image_tile(handle, &img,
+ heif_colorspace_undefined, heif_chroma_undefined,
+ nullptr,
+ tiling.num_columns - 1, tiling.num_rows - 1);
REQUIRE(err.code == heif_error_Invalid_input);
REQUIRE(img == nullptr);
- if (img) {
- heif_image_release(img);
+ heif_image_handle_release(handle);
+ heif_context_free(ctx);
+}
+
+
+TEST_CASE("unci tile range overflow with full_item compression returns error instead of crashing") {
+ std::vector<uint8_t> file = build_heif_unci_overflow_tiling(/*generic_compression=*/true);
+
+ heif_context* ctx = nullptr;
+ heif_image_handle* handle = nullptr;
+ open_primary_image(file, &ctx, &handle);
+
+ // The uncC tiles cannot be decoded independently, so the image is a single tile.
+ heif_image_tiling tiling;
+ heif_image_handle_get_image_tiling(handle, 1, &tiling);
+ REQUIRE(tiling.num_columns == 1);
+ REQUIRE(tiling.num_rows == 1);
+ REQUIRE(tiling.tile_width == WIDTH);
+ REQUIRE(tiling.tile_height == HEIGHT);
+
+ heif_decoding_options* options = heif_decoding_options_alloc();
+
+ for (bool ignore_transformations : {false, true}) {
+ INFO("ignore_transformations: " << ignore_transformations);
+ options->ignore_transformations = ignore_transformations;
+
+ // Before the image was exposed as a single tile, requesting the last uncC tile
+ // reached memcpy() with a wrapped source pointer and a 1 TiB length
+ // (out-of-bounds read / SIGSEGV). The tile does not exist anymore.
+ heif_image* img = nullptr;
+ heif_error err = heif_image_handle_decode_image_tile(handle, &img,
+ heif_colorspace_undefined, heif_chroma_undefined,
+ options,
+ TILE_COLS - 1, TILE_ROWS - 1);
+ REQUIRE(err.code == heif_error_Usage_error);
+ REQUIRE(img == nullptr);
+
+ // The single tile is the whole image, which has to be rejected because of its
+ // alignment values.
+ err = heif_image_handle_decode_image_tile(handle, &img,
+ heif_colorspace_undefined, heif_chroma_undefined,
+ options,
+ 0, 0);
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(img == nullptr);
}
+
+ heif_decoding_options_free(options);
+
+ heif_image* img = nullptr;
+ heif_error err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(img == nullptr);
+
heif_image_handle_release(handle);
heif_context_free(ctx);
}