Commit 1b22ef41 for libheif
commit 1b22ef41d62c76cc32230f4c4d4ea8f57745e268
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sun Sep 20 15:02:24 2026 +0200
Declare the shared colour depth in the YCbCr operators, drop the backstop
The YCbCr operators derive their shifts and midpoints from one bit depth
but only declared the sample width they read in state_after_conversion().
A YCbCr image whose planes differ in depth ('unci' declares a depth per
component) was therefore kept away from them by a blanket check at the
entry of convert_colorspace() (GHSA-w7mc-p8jc-p853), which also refused
conversions that would have been safe: Op_to_sdr_planes lowers every plane
on its own and can equalize such an image to 8 bits, as it already did for
RGB. YCbCr 16/16/8 to 8-bit RGB was refused while RGB 16/16/8 converted.
Op_YCbCr_to_RGB and the four chroma-sampling operators now require
color_channels_have_same_bpp() at plan time (their runtime guards already
returned InternalError for mixed depths), and the three 4:2:0-to-RGB
operators test the uniform colour depth instead of the luma depth alone.
With every YCbCr operator declaring the constraint, the entry check is
removed; the wider-than-16-bit backstop stays.
The GHSA-w7mc regression test keeps requiring a clean refusal for the
reporter's 16-bit target (no operator can widen the 8-bit chroma planes),
now with the generic Unsupported_color_conversion subcode, and adds the two
8-bit routes that succeed: interleaved RGB, and the native layout with
convert_hdr_to_8bit, whose samples are checked exactly.
diff --git a/libheif/color-conversion/chroma_sampling.cc b/libheif/color-conversion/chroma_sampling.cc
index 2a2e82f8..b93a8095 100644
--- a/libheif/color-conversion/chroma_sampling.cc
+++ b/libheif/color-conversion/chroma_sampling.cc
@@ -44,8 +44,11 @@ Op_YCbCr444_to_YCbCr420_average<Pixel>::state_after_conversion(const ColorState&
}
// The three colour planes are read through the same 'Pixel' type, so they must be stored
- // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
- if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
+ // with sizeof(Pixel) bytes per sample, and the conversion derives its shifts and midpoints
+ // from one bit depth, so they must also share it ('unci' may declare a depth per plane).
+ // The alpha plane is copied through at its own width.
+ if (!input_state.color_channels_have_same_bpp() ||
+ !input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
return {};
}
@@ -254,8 +257,11 @@ Op_YCbCr444_to_YCbCr422_average<Pixel>::state_after_conversion(const ColorState&
}
// The three colour planes are read through the same 'Pixel' type, so they must be stored
- // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
- if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
+ // with sizeof(Pixel) bytes per sample, and the conversion derives its shifts and midpoints
+ // from one bit depth, so they must also share it ('unci' may declare a depth per plane).
+ // The alpha plane is copied through at its own width.
+ if (!input_state.color_channels_have_same_bpp() ||
+ !input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
return {};
}
@@ -442,8 +448,11 @@ Op_YCbCr420_bilinear_to_YCbCr444<Pixel>::state_after_conversion(const ColorState
}
// The three colour planes are read through the same 'Pixel' type, so they must be stored
- // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
- if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
+ // with sizeof(Pixel) bytes per sample, and the conversion derives its shifts and midpoints
+ // from one bit depth, so they must also share it ('unci' may declare a depth per plane).
+ // The alpha plane is copied through at its own width.
+ if (!input_state.color_channels_have_same_bpp() ||
+ !input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
return {};
}
@@ -709,8 +718,11 @@ Op_YCbCr422_bilinear_to_YCbCr444<Pixel>::state_after_conversion(const ColorState
}
// The three colour planes are read through the same 'Pixel' type, so they must be stored
- // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
- if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
+ // with sizeof(Pixel) bytes per sample, and the conversion derives its shifts and midpoints
+ // from one bit depth, so they must also share it ('unci' may declare a depth per plane).
+ // The alpha plane is copied through at its own width.
+ if (!input_state.color_channels_have_same_bpp() ||
+ !input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
return {};
}
diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index 82ddd947..e2f71052 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -901,9 +901,9 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
}
{
- // The two checks below also run when no pipeline could be built, so that the caller
- // gets the specific reason (a plane wider than 16 bits, or YCbCr planes of differing
- // depth) instead of the generic "unsupported color conversion" error.
+ // The check below also runs when no pipeline could be built, so that the caller gets
+ // the specific reason (a plane wider than 16 bits) instead of the generic "unsupported
+ // color conversion" error.
//
// 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
@@ -922,6 +922,13 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
// HeifPixelImage uses), so construct_pipeline() above already fails for a wider
// input and a real conversion never reaches this loop. Keep it until an operator
// actually supports more than 16 bits per component, then remove it.
+ //
+ // Planes of differing depth within one image (GHSA-w7mc-p8jc-p853 read 8-bit chroma
+ // planes with the 16-bit luma sample width) are handled the same way: each operator
+ // declares in state_after_conversion() whether it needs the colour planes to share
+ // one depth (ColorState::color_channels_have_same_bpp()), so no pipeline is built
+ // through an operator that cannot handle the image, while Op_to_sdr_planes, which
+ // lowers every plane on its own, can still equalize such an image to 8 bits.
for (heif_channel channel : channels) {
if (input->get_bits_per_pixel(channel) > 16) {
@@ -930,29 +937,6 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
"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
- // depths (e.g. 'unci'), so we reject a real (non-nop) conversion of any YCbCr
- // image whose Y/Cb/Cr channels do not agree, rather than over-reading a narrower
- // chroma plane (GHSA-w7mc-p8jc-p853). An identity decode (is_nop() above)
- // returns the image untouched and is unaffected. RGB is intentionally not
- // restricted here: the RGB operators support differing per-channel bit depths
- // (e.g. 5/6/5). Alpha is handled separately by the individual operators.
-
- if (input->get_colorspace() == heif_colorspace_YCbCr &&
- input->has_channel(heif_channel_Y) &&
- input->has_channel(heif_channel_Cb) &&
- input->has_channel(heif_channel_Cr)) {
- int bpp_y = input->get_bits_per_pixel(heif_channel_Y);
- if (input->get_bits_per_pixel(heif_channel_Cb) != bpp_y ||
- input->get_bits_per_pixel(heif_channel_Cr) != bpp_y) {
- return Error{heif_error_Unsupported_feature,
- heif_suberror_Unsupported_bit_depth,
- "Color conversion of YCbCr images with differing luma and chroma bit depths is not supported."};
- }
- }
}
if (!success) {
diff --git a/libheif/color-conversion/yuv2rgb.cc b/libheif/color-conversion/yuv2rgb.cc
index a8ad28e5..00735274 100644
--- a/libheif/color-conversion/yuv2rgb.cc
+++ b/libheif/color-conversion/yuv2rgb.cc
@@ -59,8 +59,11 @@ Op_YCbCr_to_RGB<Pixel>::state_after_conversion(const ColorState& input_state,
// The three colour planes are read through the same 'Pixel' type, so they must be stored
- // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
- if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
+ // with sizeof(Pixel) bytes per sample, and the conversion derives its shifts and midpoints
+ // from one bit depth, so they must also share it ('unci' may declare a depth per plane).
+ // The alpha plane is copied through at its own width.
+ if (!input_state.color_channels_have_same_bpp() ||
+ !input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
return {};
}
@@ -306,9 +309,11 @@ Op_YCbCr420_to_RGB24::state_after_conversion(const ColorState& input_state,
}
}
+ // All three planes are read as 8-bit samples, so they must all be 8 bits
+ // (get_uniform_color_bits_per_pixel() is 0 when they differ).
if (input_state.colorspace != heif_colorspace_YCbCr ||
input_state.chroma != heif_chroma_420 ||
- input_state.bits_per_pixel_Y != 8 ||
+ input_state.get_uniform_color_bits_per_pixel() != 8 ||
input_state.has_alpha()) {
return {};
}
@@ -439,9 +444,11 @@ Op_YCbCr420_to_RGB32::state_after_conversion(const ColorState& input_state,
// Note: no input alpha channel required. It will be filled up with 0xFF.
+ // All three planes are read as 8-bit samples, so they must all be 8 bits
+ // (get_uniform_color_bits_per_pixel() is 0 when they differ).
if (input_state.colorspace != heif_colorspace_YCbCr ||
input_state.chroma != heif_chroma_420 ||
- input_state.bits_per_pixel_Y != 8) {
+ input_state.get_uniform_color_bits_per_pixel() != 8) {
return {};
}
@@ -573,9 +580,11 @@ Op_YCbCr420_to_RRGGBBaa::state_after_conversion(const ColorState& input_state,
}
}
+ // The conversion derives its shifts from one bit depth, so the three planes must share
+ // it (get_uniform_color_bits_per_pixel() is 0 when they differ) and it must be > 8.
if (input_state.colorspace != heif_colorspace_YCbCr ||
input_state.chroma != heif_chroma_420 ||
- input_state.bits_per_pixel_Y <= 8) {
+ input_state.get_uniform_color_bits_per_pixel() <= 8) {
return {};
}
diff --git a/tests/uncompressed_mixed_chroma_depth_colorconv.cc b/tests/uncompressed_mixed_chroma_depth_colorconv.cc
index d2cb4438..db8922ae 100644
--- a/tests/uncompressed_mixed_chroma_depth_colorconv.cc
+++ b/tests/uncompressed_mixed_chroma_depth_colorconv.cc
@@ -34,14 +34,21 @@
// sample past the end of each chroma plane: a heap out-of-bounds read whose
// bytes reach the decoded RGB output (information disclosure).
//
-// The fix rejects a color conversion whose color channels (Y/Cb/Cr or R/G/B) do
-// not all share one bit depth, at the entry of convert_colorspace(). This test
-// builds such a file, confirms it still decodes natively to the mismatched-depth
-// planar image (so the decoder path itself is unaffected), and then requires the
-// conversion to a high-bit-depth interleaved RGB target to be refused cleanly
-// with an error rather than over-reading the chroma planes. Under the unfixed
-// code the RGB decode reproduces the reporter's ASAN trace (heap-buffer-overflow
-// READ in Op_YCbCr420_to_RRGGBBaa::convert_colorspace).
+// The fix is made in the pipeline planner: ColorState carries one bit depth per
+// plane, and every YCbCr operator declares in state_after_conversion() that it
+// needs the colour planes to share one depth (color_channels_have_same_bpp()), so
+// no pipeline is ever built through an operator that would read a chroma plane
+// with the luma sample width. Op_to_sdr_planes lowers every plane on its own and
+// can therefore still equalize such an image to 8 bits.
+//
+// This test builds such a file, confirms it still decodes natively to the
+// mismatched-depth planar image (so the decoder path itself is unaffected), then
+// requires the conversion to the reporter's 16-bit interleaved RGB target to be
+// refused cleanly (no operator can widen the 8-bit chroma planes to 16 bits), and
+// checks that the two 8-bit routes (interleaved RGB, and convert_hdr_to_8bit)
+// succeed with the expected samples. Under the unfixed code the 16-bit RGB decode
+// reproduces the reporter's ASAN trace (heap-buffer-overflow READ in
+// Op_YCbCr420_to_RRGGBBaa::convert_colorspace).
#include "catch_amalgamated.hpp"
#include "libheif/heif.h"
@@ -214,7 +221,7 @@ std::vector<uint8_t> build_heif_unci_ycbcr_mismatched_luma_depth() {
} // namespace
-TEST_CASE("unci YCbCr with mismatched luma/chroma bit depths refuses RGB conversion without heap overread") {
+TEST_CASE("unci YCbCr with mismatched luma/chroma bit depths converts without heap overread") {
std::vector<uint8_t> file = build_heif_unci_ycbcr_mismatched_luma_depth();
heif_context* ctx = heif_context_alloc();
@@ -233,18 +240,18 @@ TEST_CASE("unci YCbCr with mismatched luma/chroma bit depths refuses RGB convers
REQUIRE(heif_image_handle_get_width(handle) == static_cast<int>(WIDTH));
REQUIRE(heif_image_handle_get_height(handle) == static_cast<int>(HEIGHT));
- // Converting to a high-bit-depth interleaved RGB target selects
- // Op_YCbCr420_to_RRGGBBaa, which is where the over-read occurred. With the fix
- // the mismatched color-channel bit depths make the conversion unsupported, so
- // the decode must fail cleanly (with the specific bit-depth suberror) rather
- // than reading past the chroma planes. Under the unfixed code this decode
- // reproduces the reporter's ASAN heap-buffer-overflow READ.
+ // Converting to a high-bit-depth interleaved RGB target used to select
+ // Op_YCbCr420_to_RRGGBBaa, which is where the over-read occurred. That operator
+ // now declines colour planes of differing depth, and no other operator can widen
+ // the 8-bit chroma planes to the 16 bits the target needs, so the decode must fail
+ // cleanly rather than read past the chroma planes. Under the unfixed code this
+ // decode reproduces the reporter's ASAN heap-buffer-overflow READ.
{
heif_image* img = nullptr;
err = heif_decode_image(handle, &img, heif_colorspace_RGB, heif_chroma_interleaved_RRGGBB_LE, nullptr);
INFO("decode error (" << err.code << "/" << err.subcode << "): " << err.message);
REQUIRE(err.code == heif_error_Unsupported_feature);
- REQUIRE(err.subcode == heif_suberror_Unsupported_bit_depth);
+ REQUIRE(err.subcode == heif_suberror_Unsupported_color_conversion);
REQUIRE(img == nullptr);
if (img != nullptr) {
@@ -252,6 +259,55 @@ TEST_CASE("unci YCbCr with mismatched luma/chroma bit depths refuses RGB convers
}
}
+ // An 8-bit target is reachable: Op_to_sdr_planes lowers the 16-bit luma plane
+ // and copies the 8-bit chroma planes, after which the planes agree.
+ {
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_RGB, heif_chroma_interleaved_RGB, nullptr);
+ INFO("decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ CHECK(heif_image_get_bits_per_pixel_range(img, heif_channel_interleaved) == 8);
+ CHECK(heif_image_get_width(img, heif_channel_interleaved) == static_cast<int>(WIDTH));
+ CHECK(heif_image_get_height(img, heif_channel_interleaved) == static_cast<int>(HEIGHT));
+ heif_image_release(img);
+ }
+
+ // The same in the native layout with convert_hdr_to_8bit: the planes keep their
+ // values (luma 0x1000 >> 8 = 0x10 at the origin, chroma unchanged). The file's colr
+ // box is kept as the output profile; otherwise the default sRGB target would add a
+ // YCbCr -> RGB -> YCbCr round trip that clips the extreme test chroma.
+ {
+ heif_decoding_options* options = heif_decoding_options_alloc();
+ REQUIRE(options != nullptr);
+ options->convert_hdr_to_8bit = true;
+ options->output_image_nclx_profile_passthrough = 1;
+
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, options);
+ INFO("decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+
+ for (heif_channel channel : {heif_channel_Y, heif_channel_Cb, heif_channel_Cr}) {
+ CHECK(heif_image_get_bits_per_pixel_range(img, channel) == 8);
+ }
+
+ size_t stride = 0;
+ const uint8_t* p = heif_image_get_plane_readonly2(img, heif_channel_Y, &stride);
+ REQUIRE(p != nullptr);
+ CHECK(p[0] == 0x10);
+ p = heif_image_get_plane_readonly2(img, heif_channel_Cb, &stride);
+ REQUIRE(p != nullptr);
+ CHECK(p[0] == 0x40);
+ p = heif_image_get_plane_readonly2(img, heif_channel_Cr, &stride);
+ REQUIRE(p != nullptr);
+ CHECK(p[0] == 0x80);
+
+ heif_image_release(img);
+ heif_decoding_options_free(options);
+ }
+
heif_image_handle_release(handle);
heif_context_free(ctx);
}