Commit 8dc32399 for libheif

commit 8dc32399dea3e6e9e6b4a3acf270feb80008649c
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Oct 4 23:39:50 2026 +0200

    Check the sample range before passing RGB samples to libsharpyuv (GHSA-q7mw-2fmm-5q94)

    Op_Any_RGB_to_YCbCr_420_Sharp passed 10 and 12 bit RGB planes to
    SharpYuvConvert() without checking that the samples are within the
    declared bit depth. libsharpyuv uses each sample as an index into a gamma
    table that is sized for that bit depth, and uses the value found there as
    the index of a second lookup. A larger sample therefore caused an
    out-of-bounds read: a crash, or unrelated memory influencing the output
    pixels (GHSA-q7mw-2fmm-5q94).

    Such samples can come from a crafted file. The 'unci' mixed-interleave
    decoder reads chroma samples at the storage width, and the OpenJPEG plugin
    stores the negative values of signed JPEG 2000 components into unsigned
    samples. With matrix_coefficients=0 and full range, Op_YCbCr_to_RGB copies
    them to R/G/B unclipped, and a decode to YCbCr 4:2:0 (e.g. heif-dec to
    JPEG) reaches the sharp-yuv operator with the default options. They can
    also come from an application that fills the planes of an image it encodes
    with values above the declared bit depth.

    Add HeifPixelImage::check_sample_value_ranges(), which checks all unsigned
    integer planes whose bit depth is smaller than their storage word, and
    call it in the operator right before the samples are handed to
    libsharpyuv. Document the value range requirement for
    heif_image_add_plane().

    The decoders that produce the out-of-range samples are not changed by
    this commit.

diff --git a/libheif/api/libheif/heif_image.h b/libheif/api/libheif/heif_image.h
index 347f7e06..317bd646 100644
--- a/libheif/api/libheif/heif_image.h
+++ b/libheif/api/libheif/heif_image.h
@@ -371,6 +371,10 @@ heif_error heif_image_create(int width, int height,
  * <p>For backward compatibility, one can also specify 24bits for RGB and 32bits for RGBA,
  * instead of the preferred 8 bits. However, this use is deprecated.
  *
+ * <p>Planes with a bit depth of 9 to 16 bits store each sample in a 16-bit word. The sample
+ * values that are written into the plane have to be within the range of {@code bit_depth} bits,
+ * i.e. the unused upper bits must be zero.
+ *
  * <p>An image with an interleaved chroma format carries its alpha inside the interleaved
  * plane. Adding a separate {@code heif_channel_Alpha} plane to such an image is rejected
  * with an error. Use one of the interleaved formats with alpha
diff --git a/libheif/color-conversion/rgb2yuv_sharp.cc b/libheif/color-conversion/rgb2yuv_sharp.cc
index b761a8f8..d394a3a0 100644
--- a/libheif/color-conversion/rgb2yuv_sharp.cc
+++ b/libheif/color-conversion/rgb2yuv_sharp.cc
@@ -248,6 +248,19 @@ Op_Any_RGB_to_YCbCr_420_Sharp::convert_colorspace(
   int input_bytes_per_pixel = (has_alpha ? 4 : 3) * input_bytes_per_sample;
   int rgb_step = planar_input ? input_bytes_per_sample : input_bytes_per_pixel;

+  // libsharpyuv uses the sample values as indices into its gamma tables, which are sized
+  // for the bit depth we pass, and it does not check the range itself. A sample above
+  // that range makes it read far outside the table, and the value found there is used
+  // as the index for a second lookup. The planes cannot guarantee the range: they are
+  // filled by the application when encoding, or by a decoder that may hand through
+  // whatever the bitstream contained. Refuse such an image here, right in front of the
+  // call that depends on it.
+  if (Error err = input->check_sample_value_ranges()) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Unspecified,
+                 err.message};
+  }
+
   int sharpyuv_ok =
       SharpYuvConvert(in_r, in_g, in_b, rgb_step, (int)in_stride,
                       input_bits, out_y, (int)out_y_stride, out_cb, (int)out_cb_stride,
diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index 3a5f43f5..35649ef9 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -23,6 +23,7 @@
 #include "common_utils.h"
 #include "security_limits.h"

+#include <bit>
 #include <cassert>
 #include <cstdlib>
 #include <cstring>
@@ -1081,6 +1082,95 @@ Error HeifPixelImage::check_plane_layout() const
 }


+// Returns whether any sample of the plane has one of the bits in 'invalid_bits' set.
+template <typename T>
+static bool plane_has_sample_with_bits(const void* mem, size_t stride, size_t samples_per_row,
+                                       uint32_t height, T invalid_bits)
+{
+  for (uint32_t y = 0; y < height; y++) {
+    const T* row = reinterpret_cast<const T*>(static_cast<const uint8_t*>(mem) + y * stride);
+    for (size_t x = 0; x < samples_per_row; x++) {
+      if (row[x] & invalid_bits) {
+        return true;
+      }
+    }
+  }
+
+  return false;
+}
+
+
+Error HeifPixelImage::check_sample_value_ranges() const
+{
+  // The interleaved RRGGBB formats store their samples with a fixed byte order.
+  const bool big_endian_samples = (m_chroma == heif_chroma_interleaved_RRGGBB_BE ||
+                                   m_chroma == heif_chroma_interleaved_RRGGBBAA_BE);
+  const bool little_endian_samples = (m_chroma == heif_chroma_interleaved_RRGGBB_LE ||
+                                      m_chroma == heif_chroma_interleaved_RRGGBBAA_LE);
+  const bool swapped_byte_order = (std::endian::native == std::endian::little) ? big_endian_samples
+                                                                               : little_endian_samples;
+
+  for (const auto& component : m_storage) {
+    if (component.m_datatype != heif_component_datatype_unsigned_integer) {
+      continue;
+    }
+
+    const int bit_depth = component.m_bit_depth;
+    const int bytes_per_sample = bytes_per_sample_for_bit_depth(bit_depth);
+    if (bit_depth == 8 * bytes_per_sample) {
+      continue; // every value of the storage word is a valid sample
+    }
+
+    const size_t samples_per_row = static_cast<size_t>(component.m_width) * component.m_num_interleaved_components;
+
+    // The largest valid value is 2^bit_depth - 1, so a sample is out of range exactly when
+    // one of the bits above the bit depth is set.
+    bool out_of_range = false;
+
+    switch (bytes_per_sample) {
+      case 1: {
+        auto invalid_bits = static_cast<uint8_t>(0xFFu << bit_depth);
+        out_of_range = plane_has_sample_with_bits(component.mem, component.stride, samples_per_row,
+                                                  component.m_height, invalid_bits);
+        break;
+      }
+      case 2: {
+        auto invalid_bits = static_cast<uint16_t>(0xFFFFu << bit_depth);
+        if (swapped_byte_order && component.m_channel == heif_channel_interleaved) {
+          invalid_bits = static_cast<uint16_t>((invalid_bits >> 8) | (invalid_bits << 8));
+        }
+        out_of_range = plane_has_sample_with_bits(component.mem, component.stride, samples_per_row,
+                                                  component.m_height, invalid_bits);
+        break;
+      }
+      case 4: {
+        uint32_t invalid_bits = 0xFFFFFFFFu << bit_depth;
+        out_of_range = plane_has_sample_with_bits(component.mem, component.stride, samples_per_row,
+                                                  component.m_height, invalid_bits);
+        break;
+      }
+      case 8: {
+        uint64_t invalid_bits = ~uint64_t{0} << bit_depth;
+        out_of_range = plane_has_sample_with_bits(component.mem, component.stride, samples_per_row,
+                                                  component.m_height, invalid_bits);
+        break;
+      }
+      default:
+        break;
+    }
+
+    if (out_of_range) {
+      std::stringstream sstr;
+      sstr << "The " << channel_name(component.m_channel) << " plane contains sample values that exceed its bit depth of "
+           << bit_depth << " bits";
+      return Error{heif_error_Usage_error, heif_suberror_Invalid_parameter_value, sstr.str()};
+    }
+  }
+
+  return Error::Ok;
+}
+
+
 std::set<heif_channel> HeifPixelImage::get_channel_set() const
 {
   std::set<heif_channel> channels;
diff --git a/libheif/image/pixelimage.h b/libheif/image/pixelimage.h
index 583ff46f..4315a4b0 100644
--- a/libheif/image/pixelimage.h
+++ b/libheif/image/pixelimage.h
@@ -163,6 +163,17 @@ public:
   // Returns a Usage_error naming the offending plane.
   Error check_plane_layout() const;

+  // Checks that no sample exceeds the value range of its plane's bit depth. A plane stores
+  // its samples in whole bytes (e.g. 10-bit samples in 16-bit words), so the memory can hold
+  // larger values than the bit depth allows, and the bit depth is only a promise of whoever
+  // filled the plane. Code that derives table sizes or bit counts from the bit depth (third
+  // party encoders, libsharpyuv) relies on that promise. Only planes with unsigned integer
+  // samples are checked.
+  // Like check_plane_layout(), this is a check for the places where pixel data enters from the
+  // outside, not something HeifPixelImage enforces by itself.
+  // Returns a Usage_error naming the offending plane.
+  Error check_sample_value_ranges() const;
+
   heif_chroma get_chroma_format() const { return m_chroma; }

   heif_colorspace get_colorspace() const { return m_colorspace; }
diff --git a/tests/conversion.cc b/tests/conversion.cc
index 63420b19..3830ce84 100644
--- a/tests/conversion.cc
+++ b/tests/conversion.cc
@@ -652,6 +652,86 @@ TEST_CASE("Sharp yuv conversion", "[heif_image]") {
 }


+#ifdef HAVE_LIBSHARPYUV
+// libsharpyuv uses the RGB sample values as indices into its gamma tables, which are sized
+// for the bit depth it is told, and it does not check the range. A 10-bit or 12-bit plane is
+// stored in 16-bit words, so nothing keeps an application (or a decoder that hands through
+// padding bits) from storing a larger value. The operator has to refuse such an image
+// instead of passing it on: the table lookup went far out of bounds otherwise.
+TEST_CASE("Sharp yuv refuses sample values above the bit depth", "[heif_image]")
+{
+  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;
+
+  nclx_profile target_nclx = nclx_profile::defaults();
+  target_nclx.set_matrix_coefficients(heif_matrix_coefficients_ITU_R_BT_601_6);
+
+  const uint16_t probe = 1;
+  const bool big_endian = (*reinterpret_cast<const uint8_t*>(&probe) == 0);
+  const heif_chroma native_rrggbb = big_endian ? heif_chroma_interleaved_RRGGBB_BE
+                                               : heif_chroma_interleaved_RRGGBB_LE;
+
+  for (bool interleaved : {false, true}) {
+    for (int bpp : {10, 12, 16}) {
+      const uint16_t max_value = static_cast<uint16_t>((1 << bpp) - 1);
+
+      // The largest valid value, the first invalid one, and the value of the original report.
+      for (uint16_t value : {max_value, static_cast<uint16_t>(max_value + 1), uint16_t{0x5a5a}}) {
+        // one sample out of range is enough, wherever it is
+        for (bool whole_plane : {true, false}) {
+          INFO("interleaved=" << interleaved << " bpp=" << bpp << " value=" << value
+                              << " whole_plane=" << whole_plane);
+
+          auto img = std::make_shared<HeifPixelImage>();
+          heif_channel last_channel;
+
+          if (interleaved) {
+            img->create(width, height, heif_colorspace_RGB, native_rrggbb);
+            REQUIRE(!img->fill_new_channel(heif_channel_interleaved, whole_plane ? value : 0,
+                                           width, height, bpp, nullptr));
+            last_channel = heif_channel_interleaved;
+          }
+          else {
+            img->create(width, height, heif_colorspace_RGB, heif_chroma_444);
+            for (heif_channel c : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+              REQUIRE(!img->fill_new_channel(c, whole_plane ? value : 0, width, height, bpp, nullptr));
+            }
+            last_channel = heif_channel_B;
+          }
+
+          if (!whole_plane) {
+            // only the very last sample of the last plane
+            size_t stride;
+            uint8_t* p = img->get_channel_memory(last_channel, &stride);
+            size_t samples_per_row = static_cast<size_t>(width) * (interleaved ? 3 : 1);
+            reinterpret_cast<uint16_t*>(p + (height - 1) * stride)[samples_per_row - 1] = value;
+          }
+
+          auto result = convert_colorspace(img, heif_colorspace_YCbCr, heif_chroma_420,
+                                           target_nclx, 8, sharp_options, nullptr,
+                                           heif_get_disabled_security_limits());
+
+          if (value <= max_value) {
+            REQUIRE(result);
+            CHECK((*result)->get_chroma_format() == heif_chroma_420);
+          }
+          else {
+            REQUIRE(!result);
+            CHECK(result.error().error_code == heif_error_Invalid_input);
+          }
+        }
+      }
+    }
+  }
+}
+#endif
+
+
 static void fill_plane(std::shared_ptr<HeifPixelImage>& img, heif_channel channel, int w, int h, const std::vector<uint16_t>& pixels, int bit_depth = 8)
 {
   auto error = img->add_channel(channel, w, h, bit_depth, nullptr);
diff --git a/tests/plane_layout.cc b/tests/plane_layout.cc
index 883d06ae..7a29f1ad 100644
--- a/tests/plane_layout.cc
+++ b/tests/plane_layout.cc
@@ -37,6 +37,7 @@
 #include "image/pixelimage.h"
 #include "color-conversion/colorconversion.h"

+#include <cstring>
 #include <initializer_list>
 #include <memory>
 #include <string>
@@ -259,3 +260,130 @@ TEST_CASE("convert_colorspace refuses images with a non-canonical plane layout")
     CHECK(!(*result)->has_channel(heif_channel_unknown));
   }
 }
+
+
+// A plane stores its samples in whole bytes, so a plane with a bit depth of, say, 10 bits can
+// hold larger values in its 16-bit words. HeifPixelImage::check_sample_value_ranges() is the
+// gate for that: the sharp-yuv operator uses it before it hands the samples to libsharpyuv,
+// which uses them as table indices.
+TEST_CASE("check_sample_value_ranges")
+{
+  auto set_sample16 = [](const std::shared_ptr<HeifPixelImage>& img, heif_channel ch, size_t idx_in_last_row, uint16_t value) {
+    size_t stride;
+    uint8_t* p = img->get_channel_memory(ch, &stride);
+    REQUIRE(p != nullptr);
+    reinterpret_cast<uint16_t*>(p + (img->get_height(ch) - 1) * stride)[idx_in_last_row] = value;
+  };
+
+  SECTION("planar planes with 9 to 15 bits") {
+    for (int bpp : {9, 10, 12, 15}) {
+      INFO("bpp=" << bpp);
+      const uint16_t max_value = static_cast<uint16_t>((1 << bpp) - 1);
+
+      auto img = make_image(heif_colorspace_YCbCr, heif_chroma_420,
+                            {heif_channel_Y, heif_channel_Cb, heif_channel_Cr}, bpp);
+      for (heif_channel ch : {heif_channel_Y, heif_channel_Cb, heif_channel_Cr}) {
+        img->fill_channel(ch, max_value);
+      }
+      CHECK(!img->check_sample_value_ranges());
+
+      // a single sample, the last one of the last plane
+      set_sample16(img, heif_channel_Cr, img->get_width(heif_channel_Cr) - 1, static_cast<uint16_t>(max_value + 1));
+      Error err = img->check_sample_value_ranges();
+      REQUIRE(err);
+      CHECK(err.error_code == heif_error_Usage_error);
+      CHECK(mentions(err, "Cr"));
+    }
+  }
+
+  SECTION("8 and 16 bit planes cannot be out of range") {
+    auto img8 = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B}, 8);
+    auto img16 = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B}, 16);
+    for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+      img8->fill_channel(ch, 0xFF);
+      img16->fill_channel(ch, 0xFFFF);
+    }
+    CHECK(!img8->check_sample_value_ranges());
+    CHECK(!img16->check_sample_value_ranges());
+  }
+
+  SECTION("planes with less than 8 bits") {
+    auto img = make_image(heif_colorspace_monochrome, heif_chroma_monochrome, {heif_channel_Y}, 4);
+    img->fill_channel(heif_channel_Y, 15);
+    CHECK(!img->check_sample_value_ranges());
+    img->fill_channel(heif_channel_Y, 16);
+    CHECK(img->check_sample_value_ranges());
+  }
+
+  SECTION("the alpha plane is checked, too") {
+    auto img = make_image(heif_colorspace_RGB, heif_chroma_444,
+                          {heif_channel_R, heif_channel_G, heif_channel_B, heif_channel_Alpha}, 10);
+    for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B, heif_channel_Alpha}) {
+      img->fill_channel(ch, 1023);
+    }
+    CHECK(!img->check_sample_value_ranges());
+    set_sample16(img, heif_channel_Alpha, 0, 1024);
+    Error err = img->check_sample_value_ranges();
+    REQUIRE(err);
+    CHECK(mentions(err, "alpha"));
+  }
+
+  SECTION("interleaved planes: all components, in the byte order of the format") {
+    for (heif_chroma chroma : {heif_chroma_interleaved_RRGGBB_LE, heif_chroma_interleaved_RRGGBB_BE,
+                               heif_chroma_interleaved_RRGGBBAA_LE, heif_chroma_interleaved_RRGGBBAA_BE}) {
+      const bool big_endian = (chroma == heif_chroma_interleaved_RRGGBB_BE ||
+                               chroma == heif_chroma_interleaved_RRGGBBAA_BE);
+      const int num_components = (chroma == heif_chroma_interleaved_RRGGBBAA_LE ||
+                                  chroma == heif_chroma_interleaved_RRGGBBAA_BE) ? 4 : 3;
+      INFO("chroma=" << chroma);
+
+      // writes one sample in the byte order of the format
+      auto set_sample = [&](const std::shared_ptr<HeifPixelImage>& img, size_t idx, uint16_t value) {
+        size_t stride;
+        uint8_t* p = img->get_channel_memory(heif_channel_interleaved, &stride) + (H - 1) * stride + 2 * idx;
+        p[big_endian ? 0 : 1] = static_cast<uint8_t>(value >> 8);
+        p[big_endian ? 1 : 0] = static_cast<uint8_t>(value & 0xFF);
+      };
+
+      auto img = make_image(heif_colorspace_RGB, chroma, {heif_channel_interleaved}, 10);
+      img->fill_channel(heif_channel_interleaved, 0);
+
+      // 0x03FF is the largest 10-bit value. Read with the wrong byte order it would be 0xFF03.
+      for (size_t idx = 0; idx < static_cast<size_t>(W) * num_components; idx++) {
+        set_sample(img, idx, 0x03FF);
+      }
+      CHECK(!img->check_sample_value_ranges());
+
+      // 0x0400 is out of range. Read with the wrong byte order it would be the valid value 4.
+      set_sample(img, static_cast<size_t>(W) * num_components - 1, 0x0400);
+      CHECK(img->check_sample_value_ranges());
+    }
+  }
+
+  SECTION("only unsigned integer planes are checked") {
+    // A signed 12-bit sample of -1 has all bits of its 16-bit word set.
+    auto img = std::make_shared<HeifPixelImage>();
+    img->create(W, H, heif_colorspace_custom, heif_chroma_planar);
+    auto id = img->add_component(W, H, heif_cmpd_component_type_monochrome,
+                                 heif_component_datatype_signed_integer, 12, nullptr);
+    REQUIRE(id);
+    size_t stride;
+    uint8_t* p = img->get_component(*id, &stride);
+    REQUIRE(p != nullptr);
+    for (uint32_t y = 0; y < H; y++) {
+      memset(p + y * stride, 0xFF, 2 * W);
+    }
+    CHECK(!img->check_sample_value_ranges());
+
+    // the same bits in an unsigned plane are out of range
+    auto id2 = img->add_component(W, H, heif_cmpd_component_type_monochrome,
+                                  heif_component_datatype_unsigned_integer, 12, nullptr);
+    REQUIRE(id2);
+    p = img->get_component(*id2, &stride);
+    REQUIRE(p != nullptr);
+    for (uint32_t y = 0; y < H; y++) {
+      memset(p + y * stride, 0xFF, 2 * W);
+    }
+    CHECK(img->check_sample_value_ranges());
+  }
+}