Commit 72a2684e for libheif

commit 72a2684e19b530aca56029def250db31516b8c34
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Sep 21 18:14:02 2026 +0200

    Never pass a NULL pointer to memcpy(), even for zero-length copies

    An empty std::vector has a NULL data() pointer. Reading a box with an
    empty payload, an item whose iloc extent has length zero, or copying an
    empty ICC profile or mask therefore called memcpy() with a NULL argument
    and a size of zero. This is undefined behaviour in C17 and C++ and is
    reported by UBSan's nonnull-attribute check on glibc's memcpy()
    declaration. C2y (WG14 N3322) makes zero-length operations on NULL
    pointers well-defined, but we cannot rely on that for years.

    Guard the memcpy() calls at the memory-backed StreamReader (the sink
    for all box parsers), the StreamWriter, the C-API reader, and the API
    entry points that copy possibly-empty vectors. The HEVC Annex-B splitter
    now also skips empty NAL units, which additionally indexed an empty
    vector.

    Reported by @iceray00 (iceray-Li) in GHSA-2764-mqj2-c458
    (StreamReader_memory::read) and GHSA-x8qp-vqp7-mm4r
    (heif_item_get_item_data).

diff --git a/libheif/api/libheif/heif_color.cc b/libheif/api/libheif/heif_color.cc
index 37ad2854..684da4b1 100644
--- a/libheif/api/libheif/heif_color.cc
+++ b/libheif/api/libheif/heif_color.cc
@@ -139,9 +139,10 @@ heif_error heif_image_handle_get_raw_color_profile(const heif_image_handle* hand

   auto raw_profile = handle->image->get_color_profile_icc();
   if (raw_profile) {
-    memcpy(out_data,
-           raw_profile->get_data().data(),
-           raw_profile->get_data().size());
+    const auto& profile_data = raw_profile->get_data();
+    if (!profile_data.empty()) { // memcpy() from a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+      memcpy(out_data, profile_data.data(), profile_data.size());
+    }
   }
   else {
     Error err(heif_error_Color_profile_does_not_exist,
@@ -347,9 +348,10 @@ heif_error heif_image_get_raw_color_profile(const heif_image* image,

   auto raw_profile = image->image->get_color_profile_icc();
   if (raw_profile) {
-    memcpy(out_data,
-           raw_profile->get_data().data(),
-           raw_profile->get_data().size());
+    const auto& profile_data = raw_profile->get_data();
+    if (!profile_data.empty()) { // memcpy() from a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+      memcpy(out_data, profile_data.data(), profile_data.size());
+    }
   }
   else {
     Error err(heif_error_Color_profile_does_not_exist,
diff --git a/libheif/api/libheif/heif_items.cc b/libheif/api/libheif/heif_items.cc
index 113d8d72..1ae03ce6 100644
--- a/libheif/api/libheif/heif_items.cc
+++ b/libheif/api/libheif/heif_items.cc
@@ -167,7 +167,9 @@ heif_error heif_item_get_item_data(const heif_context* ctx,

   if (out_data) {
     *out_data = new uint8_t[dataResult->size()];
-    memcpy(*out_data, dataResult->data(), dataResult->size());
+    if (!dataResult->empty()) { // memcpy() from a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+      memcpy(*out_data, dataResult->data(), dataResult->size());
+    }
   }

   return heif_error_success;
diff --git a/libheif/api/libheif/heif_regions.cc b/libheif/api/libheif/heif_regions.cc
index ee550504..54d582c1 100644
--- a/libheif/api/libheif/heif_regions.cc
+++ b/libheif/api/libheif/heif_regions.cc
@@ -748,7 +748,9 @@ heif_error heif_region_get_inline_mask_data(const heif_region* region,
     *y = mask->y;
     *width = mask->width;
     *height = mask->height;
-    memcpy(data, mask->mask_data.data(), mask->mask_data.size());
+    if (!mask->mask_data.empty()) { // memcpy() from a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+      memcpy(data, mask->mask_data.data(), mask->mask_data.size());
+    }
     return heif_error_success;
   }
   return heif_error_invalid_parameter_value;
diff --git a/libheif/bitstream.cc b/libheif/bitstream.cc
index 57d617af..ec6a02ea 100644
--- a/libheif/bitstream.cc
+++ b/libheif/bitstream.cc
@@ -79,7 +79,9 @@ StreamReader_memory::StreamReader_memory(const uint8_t* data, size_t size, bool
 {
   if (copy) {
     m_owned_data = new uint8_t[m_length];
-    memcpy(m_owned_data, data, size);
+    if (size > 0) { // memcpy() from a NULL pointer is UB even for size 0 (until C2y/N3322), see read()
+      memcpy(m_owned_data, data, size);
+    }

     m_data = m_owned_data;
   }
@@ -112,7 +114,14 @@ bool StreamReader_memory::read(void* data, size_t size)
     return false;
   }

-  memcpy(data, &m_data[m_position], size);
+  // Do not call memcpy() with a NULL pointer, even when size == 0. 'data' is NULL when the
+  // caller reads into an empty std::vector (e.g. a box with an empty payload), and passing
+  // NULL to memcpy() is undefined behaviour in C17 and C++. It trips UBSan's nonnull-attribute
+  // check on glibc's memcpy() declaration. C2y (WG14 N3322) makes zero-length operations on
+  // NULL pointers well-defined, but we cannot rely on that for many years.
+  if (size > 0) {
+    memcpy(data, &m_data[m_position], size);
+  }
   m_position += size;

   return true;
@@ -1053,7 +1062,9 @@ void StreamWriter::write(const std::vector<uint8_t>& vec)
     m_data.resize(required_size);
   }

-  memcpy(m_data.data() + m_position, vec.data(), vec.size());
+  if (!vec.empty()) { // memcpy() with a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+    memcpy(m_data.data() + m_position, vec.data(), vec.size());
+  }
   m_position += vec.size();
 }

@@ -1068,7 +1079,9 @@ void StreamWriter::write(const StreamWriter& writer)

   const auto& data = writer.get_data();

-  memcpy(m_data.data() + m_position, data.data(), data.size());
+  if (!data.empty()) { // memcpy() with a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+    memcpy(m_data.data() + m_position, data.data(), data.size());
+  }

   m_position += data.size();
 }
diff --git a/libheif/bitstream.h b/libheif/bitstream.h
index a56ebf9d..d0a12fe2 100644
--- a/libheif/bitstream.h
+++ b/libheif/bitstream.h
@@ -159,7 +159,16 @@ public:

   StreamReader::grow_status wait_for_file_size(uint64_t target_size) override;

-  bool read(void* data, size_t size) override { return !m_func_table->read(data, size, m_userdata); }
+  bool read(void* data, size_t size) override
+  {
+    if (size == 0) {
+      // Do not hand a NULL buffer (an empty std::vector) to the user's read callback; it may pass
+      // it on to memcpy(), which is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read().
+      return true;
+    }
+
+    return !m_func_table->read(data, size, m_userdata);
+  }

   bool seek(uint64_t position) override { return !m_func_table->seek(position, m_userdata); }

diff --git a/libheif/codecs/hevc_boxes.cc b/libheif/codecs/hevc_boxes.cc
index 2af42e7a..1658ac15 100644
--- a/libheif/codecs/hevc_boxes.cc
+++ b/libheif/codecs/hevc_boxes.cc
@@ -312,7 +312,9 @@ void Box_hvcC::append_nal_data(const uint8_t* data, size_t size)
 {
   std::vector<uint8_t> nal;
   nal.resize(size);
-  memcpy(nal.data(), data, size);
+  if (size > 0) { // memcpy() with a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+    memcpy(nal.data(), data, size);
+  }

   append_nal_data(nal);
 }
diff --git a/libheif/codecs/jpeg_enc.cc b/libheif/codecs/jpeg_enc.cc
index 98741ec3..ea397d97 100644
--- a/libheif/codecs/jpeg_enc.cc
+++ b/libheif/codecs/jpeg_enc.cc
@@ -79,9 +79,11 @@ Result<Encoder::CodedImageData> Encoder_JPEG::encode(const std::shared_ptr<HeifP
       break;
     }

-    size_t oldsize = vec.size();
-    vec.resize(oldsize + size);
-    memcpy(vec.data() + oldsize, data, size);
+    if (size > 0) { // memcpy() with a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+      size_t oldsize = vec.size();
+      vec.resize(oldsize + size);
+      memcpy(vec.data() + oldsize, data, size);
+    }
   }

 #if 0
diff --git a/libheif/codecs/vvc_boxes.cc b/libheif/codecs/vvc_boxes.cc
index 96f16871..5c042c20 100644
--- a/libheif/codecs/vvc_boxes.cc
+++ b/libheif/codecs/vvc_boxes.cc
@@ -230,7 +230,9 @@ void Box_vvcC::append_nal_data(const uint8_t* data, size_t size)
 {
   std::vector<uint8_t> nal;
   nal.resize(size);
-  memcpy(nal.data(), data, size);
+  if (size > 0) { // memcpy() with a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+    memcpy(nal.data(), data, size);
+  }

   append_nal_data(nal);
 }
diff --git a/libheif/context.cc b/libheif/context.cc
index f433606a..04e6566b 100644
--- a/libheif/context.cc
+++ b/libheif/context.cc
@@ -1982,8 +1982,10 @@ Error HeifContext::add_generic_metadata(const std::shared_ptr<ImageItem>& master
   else {
     // uncompressed data, plain copy

-    data_array.resize(size);
-    memcpy(data_array.data(), data, size);
+    if (size > 0) { // memcpy() with a NULL pointer is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read()
+      data_array.resize(size);
+      memcpy(data_array.data(), data, size);
+    }
   }

   // copy the data into the file, store the pointer to it in an iloc box entry
diff --git a/libheif/image-items/hevc.cc b/libheif/image-items/hevc.cc
index 104020bd..606df83c 100644
--- a/libheif/image-items/hevc.cc
+++ b/libheif/image-items/hevc.cc
@@ -169,36 +169,41 @@ void ImageItem_HEVC::set_preencoded_hevc_image(const std::vector<uint8_t>& data)
         first = false;
       }
       else {
-        std::vector<uint8_t> nal_data;
-        size_t length = start_code_start - (prev_start_code_start + 3);
-
-        nal_data.resize(length);
-
         assert(prev_start_code_start >= 0);
-        memcpy(nal_data.data(), data.data() + prev_start_code_start + 3, length);
-
-        int nal_type = (nal_data[0] >> 1);
-
-        switch (nal_type) {
-          case 0x20:
-          case 0x21:
-          case 0x22:
-            hvcC->append_nal_data(nal_data);
-            break;
-
-          default: {
-            std::vector<uint8_t> nal_data_with_size;
-            nal_data_with_size.resize(nal_data.size() + 4);
-
-            memcpy(nal_data_with_size.data() + 4, nal_data.data(), nal_data.size());
-            nal_data_with_size[0] = ((nal_data.size() >> 24) & 0xFF);
-            nal_data_with_size[1] = ((nal_data.size() >> 16) & 0xFF);
-            nal_data_with_size[2] = ((nal_data.size() >> 8) & 0xFF);
-            nal_data_with_size[3] = ((nal_data.size() >> 0) & 0xFF);
+        size_t length = start_code_start - (prev_start_code_start + 3);

-            get_file()->append_iloc_data(get_id(), nal_data_with_size, 0);
+        // Skip empty NAL units (two consecutive start codes). Besides being invalid, an empty
+        // NAL unit would read nal_data[0] of an empty vector and pass NULL to memcpy(), which
+        // is UB even for size 0 (until C2y/N3322), see StreamReader_memory::read().
+        if (length > 0) {
+          std::vector<uint8_t> nal_data;
+          nal_data.resize(length);
+
+          memcpy(nal_data.data(), data.data() + prev_start_code_start + 3, length);
+
+          int nal_type = (nal_data[0] >> 1);
+
+          switch (nal_type) {
+            case 0x20:
+            case 0x21:
+            case 0x22:
+              hvcC->append_nal_data(nal_data);
+              break;
+
+            default: {
+              std::vector<uint8_t> nal_data_with_size;
+              nal_data_with_size.resize(nal_data.size() + 4);
+
+              memcpy(nal_data_with_size.data() + 4, nal_data.data(), nal_data.size());
+              nal_data_with_size[0] = ((nal_data.size() >> 24) & 0xFF);
+              nal_data_with_size[1] = ((nal_data.size() >> 16) & 0xFF);
+              nal_data_with_size[2] = ((nal_data.size() >> 8) & 0xFF);
+              nal_data_with_size[3] = ((nal_data.size() >> 0) & 0xFF);
+
+              get_file()->append_iloc_data(get_id(), nal_data_with_size, 0);
+            }
+              break;
           }
-            break;
         }
       }

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index ed7941f5..fdfa748f 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -95,6 +95,7 @@ add_libheif_test(encode)
 add_libheif_test(encode_grid)
 add_libheif_test(entity_groups)
 add_libheif_test(extended_type)
+add_libheif_test(zero_length_memcpy)
 add_libheif_test(grid_tile_missing)
 add_libheif_test(iden_declared_size)
 add_libheif_test(inband_coded_size_limit)
diff --git a/tests/zero_length_memcpy.cc b/tests/zero_length_memcpy.cc
new file mode 100644
index 00000000..ad21c257
--- /dev/null
+++ b/tests/zero_length_memcpy.cc
@@ -0,0 +1,144 @@
+/*
+  libheif regression tests for zero-length reads and copies into empty buffers
+  (GHSA-2764-mqj2-c458).
+
+  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.
+*/
+
+// A box with an empty payload is read into an empty std::vector, whose data() pointer is
+// NULL. The same happens for an item whose iloc extent has length zero. Passing that NULL
+// pointer to memcpy() is undefined behaviour (until C2y, WG14 N3322) and is reported by
+// UBSan's nonnull-attribute check. These tests only detect a regression when they are run
+// in a build with -fsanitize=undefined; in a plain build they merely exercise the paths.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_items.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <vector>
+
+namespace {
+
+// ftyp + meta, where meta holds an unknown box with an empty payload (parsed as Box_other)
+// and one item whose single iloc extent has length zero.
+std::vector<uint8_t> build_heif_with_empty_box_and_empty_item()
+{
+  std::vector<uint8_t> ftyp_payload;
+  append_fourcc(ftyp_payload, "mif1");
+  put_u32_be(ftyp_payload, 0);
+  append_fourcc(ftyp_payload, "mif1");
+  auto ftyp = make_box("ftyp", ftyp_payload);
+
+  // hdlr: handler type 'null' so that no image items are required.
+  std::vector<uint8_t> hdlr_payload;
+  put_u32_be(hdlr_payload, 0);        // pre_defined
+  append_fourcc(hdlr_payload, "null"); // handler_type
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  hdlr_payload.push_back(0);          // name
+  auto hdlr = make_box("hdlr", hdlr_payload, /*full=*/true);
+
+  // infe (version 2): item 1, unprotected, item type 'test', empty name
+  std::vector<uint8_t> infe_payload;
+  put_u16_be(infe_payload, 1);
+  put_u16_be(infe_payload, 0);
+  append_fourcc(infe_payload, "test");
+  infe_payload.push_back(0);
+  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);
+
+  // iloc (version 0): offset_size=4, length_size=4, base_offset_size=0,
+  // one item with a single extent of length 0
+  std::vector<uint8_t> iloc_payload;
+  iloc_payload.push_back(0x44);
+  iloc_payload.push_back(0x00);
+  put_u16_be(iloc_payload, 1); // item_count
+  put_u16_be(iloc_payload, 1); // item_ID
+  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
+  put_u32_be(iloc_payload, 0); // extent_length
+  auto iloc = make_box("iloc", iloc_payload, /*full=*/true);
+
+  // Unknown box type with a header only. Box_other::parse() reads its zero-length payload.
+  auto empty_box = make_box("zzzz", {});
+
+  std::vector<uint8_t> meta_payload;
+  append(meta_payload, hdlr);
+  append(meta_payload, iinf);
+  append(meta_payload, iloc);
+  append(meta_payload, empty_box);
+  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("zero-length box payload and item data do not pass NULL to memcpy") {
+  auto data = build_heif_with_empty_box_and_empty_item();
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  // Read with a copy so that the memory-backed StreamReader is used for both the
+  // input copy and every box payload read.
+  heif_error err = heif_context_read_from_memory(ctx, data.data(), data.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  REQUIRE(heif_context_get_number_of_items(ctx) == 1);
+
+  uint8_t* item_data = nullptr;
+  size_t item_data_size = 123;
+  err = heif_item_get_item_data(ctx, 1, nullptr, &item_data, &item_data_size);
+  REQUIRE(err.code == heif_error_Ok);
+  CHECK(item_data_size == 0);
+  CHECK(item_data != nullptr);
+  heif_release_item_data(ctx, &item_data);
+
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("reading an empty memory buffer does not pass NULL to memcpy") {
+  std::vector<uint8_t> empty;
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_error err = heif_context_read_from_memory(ctx, empty.data(), 0, nullptr);
+  CHECK(err.code != heif_error_Ok);
+
+  heif_context_free(ctx);
+}