Commit 8bfecb52 for libheif
commit 8bfecb52a931de419582a5a93db2d6fa1e9f9d06
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sat Sep 5 23:44:33 2026 +0200
Reject color conversion of images with components wider than 16 bits
ISO/IEC 23001-17 lets an 'unci' component declare a bit depth of up to 256
bits. libheif accepts up to 128 of those deliberately: the byte-aligned
component decoder stores 32/64/128 bit samples so they can be read back
through the component API.
The color-conversion operators are a different story. They are all written
for 8-bit or 16-bit integer samples, accessing the planes through uint8_t*
or uint16_t* and deriving shift amounts and midpoint values from the bit
depth. A monochrome 'unci' track with a 64-bit component decoded fine and
was then handed to Op_mono_to_YCbCr420, which computes the chroma midpoint
as '128 << (bit_depth - 8)'. That shifts an 'int' by 56: undefined
behaviour, reported by OSS-Fuzz (testcase 5154611212910592, UBSan abort in
sequence_fuzzer).
Add a catch-all at the entry point of the conversion pipeline. The check
sits behind the is_nop() test, so an untransformed decode still returns the
wide plane and the component API keeps working; only a real conversion is
refused, with heif_suberror_Unsupported_bit_depth.
The constraint properly belongs in each operator's state_after_conversion(),
the way Op_YCbCr_to_RGB already declines an input wider than 16 bits. Several
operators bound the depth from below only, so until they state their own
limits the central check covers them. It goes away once an operator actually
supports more than 16 bits per component.
diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index 27c5ee5b..3e33998c 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -618,6 +618,31 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
return input;
}
else {
+ // Every color-conversion operator is written for 8-bit or 16-bit integer samples.
+ // They access the planes through uint8_t* / uint16_t* and derive shift amounts and
+ // midpoint values from the bit depth (e.g. '128 << (bpp - 8)' in Op_mono_to_YCbCr420).
+ // An 'unci' component may however declare a bit depth of up to 256 bits, of which we
+ // accept up to 128 (64-bit integers, 32/64-bit floats, complex numbers). Those are
+ // stored by HeifPixelImage so that they can be read through the component API. A
+ // 64-bit monochrome component reached Op_mono_to_YCbCr420 and shifted an 'int' by
+ // 56 (OSS-Fuzz 5154611212910592). A nop conversion is handled above and still hands
+ // the image through untouched, so wide components stay accessible to the caller.
+ //
+ // This is a catch-all. The constraint properly belongs in each operator's
+ // state_after_conversion(), where Op_YCbCr_to_RGB already declines an input wider
+ // than 16 bits: an operator that cannot handle a bit depth should not offer itself
+ // to the pipeline for it. Several operators currently bound the depth from below
+ // only (e.g. '(bits_per_pixel > 8) != hdr'). Remove this check once an operator
+ // actually supports more than 16 bits per component.
+
+ for (heif_channel channel : channels) {
+ if (input->get_bits_per_pixel(channel) > 16) {
+ return Error{heif_error_Unsupported_feature,
+ heif_suberror_Unsupported_bit_depth,
+ "Color conversion of images with more than 16 bits per component is not supported."};
+ }
+ }
+
// The YCbCr color-conversion operators assume that luma and chroma share a
// single bit depth: several of them read the chroma planes with a sample width
// derived from the luma bit depth. A file may however declare per-component bit
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index eb2c945c..5d7b4b6a 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -143,6 +143,7 @@ if (WITH_UNCOMPRESSED_CODEC)
add_libheif_test(uncompressed_mixed_chroma_depth_overflow)
add_libheif_test(uncompressed_mixed_chroma_depth_colorconv)
add_libheif_test(uncompressed_mixed_chroma_depth_encode)
+ add_libheif_test(uncompressed_wide_component_colorconv)
add_libheif_test(uncompressed_block_pixel_overpacked)
add_libheif_test(uncompressed_idat_tiled)
add_libheif_test(uncompressed_encode)
diff --git a/tests/uncompressed_wide_component_colorconv.cc b/tests/uncompressed_wide_component_colorconv.cc
new file mode 100644
index 00000000..7dae5f4f
--- /dev/null
+++ b/tests/uncompressed_wide_component_colorconv.cc
@@ -0,0 +1,247 @@
+/*
+ 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 test for OSS-Fuzz 5154611212910592 (sequence_fuzzer).
+//
+// ISO/IEC 23001-17 lets an 'unci' component declare a bit depth of up to 256
+// bits. libheif accepts up to 128 of those on purpose: the byte-aligned
+// component decoder stores 32/64/128 bit samples so that they can be read back
+// through the component API. The color-conversion operators, however, are all written
+// for 8-bit or 16-bit integer samples. They access the planes through
+// uint8_t* / uint16_t* and derive shift amounts and midpoint values from the
+// bit depth. A 64-bit monochrome component converted to YCbCr 4:2:0 evaluated
+// '128 << (bit_depth - 8)', shifting an 'int' by 56: undefined behaviour
+// (UndefinedBehaviorSanitizer abort in Op_mono_to_YCbCr420::convert_colorspace).
+//
+// The fix rejects a real (non-nop) color conversion of any image with a channel
+// wider than 16 bits, at the single entry point of the pipeline in
+// convert_colorspace(). This test builds such a file, confirms the decoder path
+// itself is unaffected (the untransformed decode still returns the 64-bit
+// monochrome plane), and requires the conversions that used to run the
+// unsupported operators to fail cleanly instead.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <vector>
+
+namespace {
+
+constexpr uint32_t WIDTH = 4;
+constexpr uint32_t HEIGHT = 4;
+constexpr uint32_t BYTES_PER_SAMPLE = 8; // 64 bit
+
+// Build a minimal HEIF file with a single 'unci' item: one monochrome
+// component of 64 bits, component interleave, no subsampling, uncompressed.
+std::vector<uint8_t> build_heif_unci_mono64() {
+ 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);
+
+ // iinf: item 1 = 'unci'.
+ 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, WIDTH);
+ put_u32_be(ispe_payload, HEIGHT);
+ auto ispe = make_box("ispe", ispe_payload, /*full=*/true);
+
+ // cmpd: a single monochrome component.
+ std::vector<uint8_t> cmpd_payload;
+ put_u32_be(cmpd_payload, 1);
+ put_u16_be(cmpd_payload, 0); // monochrome
+ auto cmpd = make_box("cmpd", cmpd_payload);
+
+ // uncC (v0): component interleave, no subsampling, one 64-bit component.
+ std::vector<uint8_t> uncC_payload;
+ put_u32_be(uncC_payload, 0); // profile
+ put_u32_be(uncC_payload, 1); // component_count
+ put_u16_be(uncC_payload, 0); // component_index
+ uncC_payload.push_back(63); // component_bit_depth_minus_one -> 64 bit
+ uncC_payload.push_back(0); // component_format (unsigned)
+ uncC_payload.push_back(0); // component_align_size
+ uncC_payload.push_back(0); // sampling_type = none
+ uncC_payload.push_back(0); // interleave_type = component
+ uncC_payload.push_back(0); // block_size
+ uncC_payload.push_back(0); // flags (big-endian components)
+ 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, 0); // num_tile_cols_minus_one
+ put_u32_be(uncC_payload, 0); // num_tile_rows_minus_one
+ auto uncC = make_box("uncC", uncC_payload, /*full=*/true);
+
+ std::vector<uint8_t> ipco_payload;
+ append(ipco_payload, ispe);
+ append(ipco_payload, cmpd);
+ append(ipco_payload, uncC);
+ 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(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
+ 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);
+
+ // Tile data: one plane of 64-bit big-endian samples.
+ std::vector<uint8_t> tile_data;
+ tile_data.reserve(WIDTH * HEIGHT * BYTES_PER_SAMPLE);
+ for (uint32_t i = 0; i < WIDTH * HEIGHT; i++) {
+ for (uint32_t b = 0; b < BYTES_PER_SAMPLE; b++) {
+ tile_data.push_back(static_cast<uint8_t>(i + b));
+ }
+ }
+ auto idat = make_box("idat", tile_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>(tile_data.size())); // extent_length
+ auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+ 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);
+
+ std::vector<uint8_t> file;
+ append(file, ftyp);
+ append(file, meta);
+ return file;
+}
+
+} // namespace
+
+TEST_CASE("unci with a 64-bit component refuses color conversion instead of shifting out of range") {
+ std::vector<uint8_t> file = build_heif_unci_mono64();
+
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ heif_error err = heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_image_handle* handle = nullptr;
+ err = heif_context_get_primary_image_handle(ctx, &handle);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(handle != nullptr);
+
+ REQUIRE(heif_image_handle_get_width(handle) == static_cast<int>(WIDTH));
+ REQUIRE(heif_image_handle_get_height(handle) == static_cast<int>(HEIGHT));
+
+ // The decoder itself still handles the wide component: decoding without a
+ // colorspace conversion returns the monochrome plane at its declared 64 bits.
+ // The guard sits behind the nop check, so this path must keep working.
+ {
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+ INFO("native decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ REQUIRE(heif_image_get_bits_per_pixel(img, heif_channel_Y) == 64);
+ heif_image_release(img);
+ }
+
+ // Converting to YCbCr 4:2:0 selects Op_mono_to_YCbCr420, which computed the
+ // chroma midpoint as '128 << (bit_depth - 8)'. With a 64-bit component that
+ // shifted an 'int' by 56. The conversion must now be refused cleanly.
+ {
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_YCbCr, heif_chroma_420, nullptr);
+ INFO("YCbCr decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+ REQUIRE(err.code == heif_error_Unsupported_feature);
+ REQUIRE(err.subcode == heif_suberror_Unsupported_bit_depth);
+ REQUIRE(img == nullptr);
+
+ if (img != nullptr) {
+ heif_image_release(img);
+ }
+ }
+
+ // The same applies to an RGB target, which would read the 8-byte samples
+ // through a uint8_t* / uint16_t* view of the plane.
+ {
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_RGB, heif_chroma_interleaved_RGB, nullptr);
+ INFO("RGB decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+ REQUIRE(err.code != heif_error_Ok);
+ REQUIRE(img == nullptr);
+
+ if (img != nullptr) {
+ heif_image_release(img);
+ }
+ }
+
+ heif_image_handle_release(handle);
+ heif_context_free(ctx);
+}