Commit 8d4f85da for libheif
commit 8d4f85da6df96864397dcf9367a604b4615a5377
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sun Sep 6 00:07:35 2026 +0200
Decline mismatched alpha bit depth in the sharp-yuv operator
Op_Any_RGB_to_YCbCr_420_Sharp reads the alpha plane with the sample width and
step derived from the color channels: input_bytes_per_sample comes from
heif_channel_R, and the alpha loop reads two bytes per sample whenever the
color bit depth is above 8. For interleaved input that is correct, because
alpha is interleaved with RGB and shares their sample width. For planar 444
input the alpha plane is separate and carries its own bit depth, so 10-bit
R/G/B next to an 8-bit alpha plane made the loop walk a 1-byte-per-sample
plane with a step of 2 and read past its end.
That state is not exotic. Op_YCbCr_to_RGB deliberately copies the alpha plane
through at its own depth while converting the color channels ("We only copy
the alpha, do not access it as 16 bit"), so decoding a HEIC with 10-bit color
and an 8-bit alpha auxiliary image produces it on the way to YCbCr 4:2:0.
Reported by OSS-Fuzz as a heap-buffer-overflow READ (testcase
6503781601443840, file_fuzzer).
Every other RGB operator already declines a mismatched alpha depth with this
same guard, which lets the pipeline insert Op_adjust_alpha_bit_depth to
normalize the plane first. The sharp operator was the one missing it, so add
it there rather than teaching the operator a second sample width. The image
still converts; it just goes through the alpha adjustment first.
diff --git a/libheif/color-conversion/rgb2yuv_sharp.cc b/libheif/color-conversion/rgb2yuv_sharp.cc
index ebe86b87..8b9c3f4a 100644
--- a/libheif/color-conversion/rgb2yuv_sharp.cc
+++ b/libheif/color-conversion/rgb2yuv_sharp.cc
@@ -96,6 +96,19 @@ Op_Any_RGB_to_YCbCr_420_Sharp::state_after_conversion(
return {};
}
+ // The alpha plane is read with the sample width and step derived from the color
+ // channels (input_bytes_per_sample below comes from heif_channel_R), so a planar
+ // input whose alpha plane has a different bit depth would be indexed with the wrong
+ // stride and read two bytes per 1-byte sample: a heap over-read past the alpha plane
+ // (OSS-Fuzz 6503781601443840). Such a state is reachable because Op_YCbCr_to_RGB
+ // deliberately copies the alpha plane through at its own depth while converting the
+ // color channels, so 10-bit R/G/B next to an 8-bit alpha is normal here. Decline it,
+ // exactly as every other RGB operator does; the pipeline then inserts
+ // Op_adjust_alpha_bit_depth first and hands us a matched-depth image.
+ if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+ return {};
+ }
+
if (target_state.chroma != heif_chroma_420) {
return {};
}
diff --git a/tests/conversion.cc b/tests/conversion.cc
index a23e54dd..61a8d782 100644
--- a/tests/conversion.cc
+++ b/tests/conversion.cc
@@ -894,6 +894,67 @@ TEST_CASE("Mismatched alpha bit depth - conversion correctness") {
CHECK(p[2] == 192); // B (unchanged)
CHECK(p[3] == 200); // A (10-bit 800 >> 2 = 200)
}
+
+#ifdef HAVE_LIBSHARPYUV
+ // Regression test for OSS-Fuzz 6503781601443840 (file_fuzzer, ASan
+ // heap-buffer-overflow READ in Op_Any_RGB_to_YCbCr_420_Sharp).
+ //
+ // Op_YCbCr_to_RGB copies the alpha plane through at its own bit depth while
+ // converting the color channels, so a decoded HEIC with 10-bit color and an 8-bit
+ // alpha auxiliary image produces exactly this planar RGB state. The sharp-yuv
+ // operator then read the alpha plane with the sample width and step taken from the
+ // color channels: it walked a 1-byte-per-sample plane with a step of 2 and read two
+ // bytes per sample, running off the end of the plane. Every other RGB operator
+ // declines a mismatched alpha depth, which lets Op_adjust_alpha_bit_depth normalize
+ // the plane first; the sharp operator was missing that guard.
+ //
+ // The image must be big enough that the doubled indexing leaves the allocation
+ // rather than landing in the stride padding: 64x64 (the size of the original PoC)
+ // over-reads, a small image like the 4x2 above would not.
+ SECTION("10-bit RGB color with 8-bit alpha -> YCbCr 420 with sharp yuv") {
+ const uint32_t width = 64;
+ const uint32_t height = 64;
+
+ heif_color_conversion_options sharp_options{};
+ sharp_options.preferred_chroma_downsampling_algorithm = heif_chroma_downsampling_sharp_yuv;
+ sharp_options.preferred_chroma_upsampling_algorithm = heif_chroma_upsampling_bilinear;
+ sharp_options.only_use_preferred_chroma_algorithm = true;
+
+ auto img = std::make_shared<HeifPixelImage>();
+ img->create(width, height, heif_colorspace_RGB, heif_chroma_444);
+
+ img->fill_new_channel(heif_channel_R, 512, width, height, 10, nullptr);
+ img->fill_new_channel(heif_channel_G, 256, width, height, 10, nullptr);
+ img->fill_new_channel(heif_channel_B, 768, width, height, 10, nullptr);
+ img->fill_new_channel(heif_channel_Alpha, 200, width, height, 8, nullptr);
+
+ REQUIRE(img->get_bits_per_pixel(heif_channel_R) == 10);
+ REQUIRE(img->get_bits_per_pixel(heif_channel_Alpha) == 8);
+
+ nclx_profile target_nclx = nclx_profile::defaults();
+ target_nclx.set_matrix_coefficients(heif_matrix_coefficients_ITU_R_BT_601_6);
+
+ auto result = convert_colorspace(img, heif_colorspace_YCbCr, heif_chroma_420,
+ target_nclx, 10, sharp_options, nullptr,
+ heif_get_disabled_security_limits());
+ REQUIRE(result);
+ auto out = *result;
+
+ CHECK(out->get_colorspace() == heif_colorspace_YCbCr);
+ CHECK(out->get_chroma_format() == heif_chroma_420);
+ REQUIRE(out->has_channel(heif_channel_Alpha));
+ CHECK(out->get_bits_per_pixel(heif_channel_Alpha) == 10);
+
+ // The 8-bit alpha is widened to 10 bits by bit replication before the sharp
+ // conversion runs: 200 -> (200 << 2) | (200 >> 6) = 803.
+ size_t stride;
+ const uint8_t* p_a = out->get_channel_memory(heif_channel_Alpha, &stride);
+ REQUIRE(p_a != nullptr);
+ const uint16_t* a16 = reinterpret_cast<const uint16_t*>(p_a);
+ CHECK(a16[0] == 803);
+ CHECK(a16[(height - 1) * (stride / 2) + (width - 1)] == 803);
+ }
+#endif
}