Commit 2b87e150 for libheif

commit 2b87e150b3fce9d0b29c7d97423b38ce3d8239fc
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Oct 4 22:40:39 2026 +0200

    Reject image sequence tracks with a non-visual sample entry in a chunk (GHSA-8p2c-9wv4-rx4p)

    A 'pict'/'vide'/'auxv' track whose 'stsd' carries a non-visual sample
    entry (for example an 'mp4a' audio entry) and maps a chunk to it via
    'stsc' produced a Chunk with no decoder. The decode loop guarded that
    pointer only with assert(), so on an NDEBUG build decoding the file
    dereferenced a null pointer through a virtual call (GHSA-8p2c-9wv4-rx4p).

    Reject such a track in Track_Visual::load(), before any decoding, and
    turn the assert in the decode loop into a runtime error as a backstop.
    The check is scoped to visual tracks, so metadata tracks, which
    legitimately carry non-visual sample entries, are unaffected.

diff --git a/libheif/sequences/track_visual.cc b/libheif/sequences/track_visual.cc
index 7b407ba1..4efda475 100644
--- a/libheif/sequences/track_visual.cc
+++ b/libheif/sequences/track_visual.cc
@@ -53,6 +53,25 @@ Error Track_Visual::load(const std::shared_ptr<Box_trak>& trak)
     return parentLoadError;
   }

+  // Every chunk of a visual track must have a decoder attached. Track::load()
+  // attaches one only when the chunk's sample description is a VisualSampleEntry.
+  // A 'pict'/'vide'/'auxv' track whose 'stsd' also carries a non-visual entry (for
+  // example an 'mp4a' audio entry) and maps a chunk to it via 'stsc' produces a
+  // decoder-less chunk. The decode loop (decode_next_image_sample) guarded that
+  // pointer only with assert(), so on an NDEBUG build it was a NULL-pointer
+  // dereference through a virtual call (GHSA-8p2c-9wv4-rx4p). Reject the file here,
+  // before any decoding. This is scoped to the visual track and does not affect
+  // metadata tracks, which legitimately have non-visual sample entries.
+  for (const auto& chunk : m_chunks) {
+    if (!chunk->get_decoder()) {
+      return {
+        heif_error_Invalid_input,
+        heif_suberror_Unspecified,
+        "Image sequence track has a chunk that references a non-visual sample description."
+      };
+    }
+  }
+
   const std::vector<uint32_t>& chunk_offsets = m_stco->get_offsets();

   // Find sequence resolution
@@ -205,7 +224,17 @@ Result<std::shared_ptr<HeifPixelImage> > Track_Visual::decode_next_image_sample(
     const std::shared_ptr<Chunk>& chunk = m_chunks[chunk_idx];

     auto decoder = chunk->get_decoder();
-    assert(decoder);
+    if (!decoder) {
+      // Should not happen: Track_Visual::load() rejects any visual track that has a
+      // decoder-less chunk. Guard anyway so that a future regression surfaces as a
+      // clean error instead of a NULL-pointer dereference through the virtual call
+      // below (GHSA-8p2c-9wv4-rx4p).
+      return Error{
+        heif_error_Invalid_input,
+        heif_suberror_Unspecified,
+        "Sequence chunk has no decoder."
+      };
+    }

     // avoid calling get_decoded_frame() before starting the decoder.
     if (m_next_sample_to_be_decoded != 0) {
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index e97c1910..a3bbf413 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -124,6 +124,7 @@ add_libheif_test(sequence_timing_overflow)
 add_libheif_test(sequence_vfr_durations)
 add_libheif_test(track_reference_duplicates)
 add_libheif_test(sequence_raw_sample_errors)
+add_libheif_test(sequence_nonvisual_sample_entry)
 add_libheif_test(tai)
 add_libheif_test(text)
 add_libheif_test(cxx_wrapper)
diff --git a/tests/sequence_nonvisual_sample_entry.cc b/tests/sequence_nonvisual_sample_entry.cc
new file mode 100644
index 00000000..d3f3c6fd
--- /dev/null
+++ b/tests/sequence_nonvisual_sample_entry.cc
@@ -0,0 +1,272 @@
+/*
+  libheif regression test for GHSA-8p2c-9wv4-rx4p: a visual sequence track whose
+  'stsd' carries a non-visual sample entry (e.g. an 'mp4a' audio entry) that a
+  chunk is mapped to via 'stsc'. Such a chunk gets no decoder in Track::load().
+  The decode loop guarded the decoder pointer only with assert(), so on an NDEBUG
+  build this was a NULL-pointer dereference through a virtual call. The file must
+  now be rejected with heif_error_Invalid_input at read time, not crash.
+
+  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.
+*/
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_sequences.h"
+#include "sequence_file_builder.h"
+
+#include <cstdint>
+#include <vector>
+
+using namespace seqfile;
+
+namespace {
+
+// A minimal but structurally valid 'hvc1' VisualSampleEntry. The 78-byte visual
+// header is followed by a minimal 'hvcC' configuration record (no NAL arrays),
+// which is enough for Decoder::alloc_for_sequence_sample_description_box() to
+// construct an HEVC decoder object. No codec plugin is needed: the file is
+// rejected during Track::load(), before any decoding is attempted.
+std::vector<uint8_t> make_hvc1_entry()
+{
+  std::vector<uint8_t> vse;
+  for (int i = 0; i < 6; i++) vse.push_back(0); // SampleEntry reserved
+  put16(vse, 1);                                // data_reference_index
+  put16(vse, 0);                                // pre_defined
+  put16(vse, 0);                                // reserved
+  for (int i = 0; i < 3; i++) put32(vse, 0);    // pre_defined
+  put16(vse, 4);                                // width
+  put16(vse, 4);                                // height
+  put32(vse, 0x00480000);                       // horizresolution 72 dpi
+  put32(vse, 0x00480000);                       // vertresolution 72 dpi
+  put32(vse, 0);                                // reserved
+  put16(vse, 1);                                // frame_count
+  for (int i = 0; i < 32; i++) vse.push_back(0);// compressorname (length-prefixed, empty)
+  put16(vse, 0x0018);                           // depth
+  put16(vse, 0xFFFF);                           // pre_defined (-1)
+
+  // HEVCDecoderConfigurationRecord, 23 bytes, zero NAL arrays.
+  std::vector<uint8_t> hvcc;
+  hvcc.push_back(1);        // configuration_version
+  hvcc.push_back(0);        // profile_space / tier / profile_idc
+  put32(hvcc, 0);           // general_profile_compatibility_flags
+  for (int i = 0; i < 6; i++) hvcc.push_back(0); // general_constraint_indicator_flags
+  hvcc.push_back(0);        // general_level_idc
+  put16(hvcc, 0);           // min_spatial_segmentation_idc
+  hvcc.push_back(0);        // parallelism_type
+  hvcc.push_back(1);        // chroma_format (4:2:0)
+  hvcc.push_back(0);        // bit_depth_luma_minus8
+  hvcc.push_back(0);        // bit_depth_chroma_minus8
+  put16(hvcc, 0);           // avg_frame_rate
+  hvcc.push_back(0);        // constant_frame_rate / temporal layers / length_size
+  hvcc.push_back(0);        // num_of_arrays
+
+  append(vse, box("hvcC", hvcc));
+  return box("hvc1", vse);
+}
+
+// A non-visual sample entry of the given 4cc (a bare SampleEntry: 6 reserved
+// bytes + data_reference_index). Any 4cc the box factory does not know becomes a
+// generic Box, which does not cast to Box_VisualSampleEntry.
+std::vector<uint8_t> make_nonvisual_entry(const char* type)
+{
+  std::vector<uint8_t> payload;
+  for (int i = 0; i < 6; i++) payload.push_back(0);
+  put16(payload, 1); // data_reference_index
+  return box(type, payload);
+}
+
+// Build a 'pict' sequence with two samples in two chunks. The 'stsd' holds the
+// hvc1 entry (index 1) plus 'second_entry' (index 2). Chunk 1 maps to sample
+// description index 1; chunk 2 maps to 'chunk2_sdi'.
+std::vector<uint8_t> build_two_entry_visual_sequence(const std::vector<uint8_t>& second_entry,
+                                                     uint32_t chunk2_sdi)
+{
+  std::vector<uint8_t> ftyp_payload;
+  put_fourcc(ftyp_payload, "msf1");
+  put32(ftyp_payload, 0);
+  put_fourcc(ftyp_payload, "msf1");
+  put_fourcc(ftyp_payload, "iso8");
+  std::vector<uint8_t> ftyp = box("ftyp", ftyp_payload);
+
+  // mvhd (version 0)
+  std::vector<uint8_t> mvhd_payload;
+  put32(mvhd_payload, 0);        // version 0, flags 0
+  put32(mvhd_payload, 0);        // creation_time
+  put32(mvhd_payload, 0);        // modification_time
+  put32(mvhd_payload, 1000);     // timescale
+  put32(mvhd_payload, 2000);     // duration
+  put32(mvhd_payload, 0x00010000);
+  put16(mvhd_payload, 0x0100);
+  put16(mvhd_payload, 0);
+  put32(mvhd_payload, 0);
+  put32(mvhd_payload, 0);
+  for (int i = 0; i < 9; i++) put32(mvhd_payload, 0);
+  for (int i = 0; i < 6; i++) put32(mvhd_payload, 0);
+  put32(mvhd_payload, 2);        // next_track_ID
+  std::vector<uint8_t> mvhd = box("mvhd", mvhd_payload);
+
+  // tkhd (version 0)
+  std::vector<uint8_t> tkhd_payload;
+  put32(tkhd_payload, 0x00000007);
+  put32(tkhd_payload, 0);
+  put32(tkhd_payload, 0);
+  put32(tkhd_payload, 1);        // track_ID
+  put32(tkhd_payload, 0);
+  put32(tkhd_payload, 2000);     // duration
+  put32(tkhd_payload, 0);
+  put32(tkhd_payload, 0);
+  put16(tkhd_payload, 0);
+  put16(tkhd_payload, 0);
+  put16(tkhd_payload, 0);
+  put16(tkhd_payload, 0);
+  for (int i = 0; i < 9; i++) put32(tkhd_payload, 0);
+  put32(tkhd_payload, 0x00040000); // width 4.0
+  put32(tkhd_payload, 0x00040000); // height 4.0
+  std::vector<uint8_t> tkhd = box("tkhd", tkhd_payload);
+
+  // mdhd (version 0)
+  std::vector<uint8_t> mdhd_payload;
+  put32(mdhd_payload, 0);
+  put32(mdhd_payload, 0);
+  put32(mdhd_payload, 0);
+  put32(mdhd_payload, 1000);     // timescale
+  put32(mdhd_payload, 2000);     // duration
+  put16(mdhd_payload, 0x55c4);   // language 'und'
+  put16(mdhd_payload, 0);
+  std::vector<uint8_t> mdhd = box("mdhd", mdhd_payload);
+
+  // hdlr 'pict'
+  std::vector<uint8_t> hdlr_payload;
+  put32(hdlr_payload, 0);
+  put32(hdlr_payload, 0);
+  put_fourcc(hdlr_payload, "pict");
+  put32(hdlr_payload, 0);
+  put32(hdlr_payload, 0);
+  put32(hdlr_payload, 0);
+  hdlr_payload.push_back(0);
+  std::vector<uint8_t> hdlr = box("hdlr", hdlr_payload);
+
+  // vmhd
+  std::vector<uint8_t> vmhd_payload;
+  put32(vmhd_payload, 0x00000001);
+  put16(vmhd_payload, 0);
+  for (int i = 0; i < 3; i++) put16(vmhd_payload, 0);
+  std::vector<uint8_t> vmhd = box("vmhd", vmhd_payload);
+
+  // stsd with two entries
+  std::vector<uint8_t> stsd_payload;
+  put32(stsd_payload, 0);
+  put32(stsd_payload, 2); // entry_count
+  append(stsd_payload, make_hvc1_entry());
+  append(stsd_payload, second_entry);
+  std::vector<uint8_t> stsd = box("stsd", stsd_payload);
+
+  // stts: two samples
+  std::vector<uint8_t> stts_payload;
+  put32(stts_payload, 0);
+  put32(stts_payload, 1);
+  put32(stts_payload, 2);     // sample_count
+  put32(stts_payload, 1000);  // sample_delta
+  std::vector<uint8_t> stts = box("stts", stts_payload);
+
+  // stsc: chunk 1 -> sdi 1, chunk 2 -> chunk2_sdi
+  std::vector<uint8_t> stsc_payload;
+  put32(stsc_payload, 0);
+  put32(stsc_payload, 2);
+  put32(stsc_payload, 1); put32(stsc_payload, 1); put32(stsc_payload, 1);
+  put32(stsc_payload, 2); put32(stsc_payload, 1); put32(stsc_payload, chunk2_sdi);
+  std::vector<uint8_t> stsc = box("stsc", stsc_payload);
+
+  // stsz: fixed size, two samples
+  std::vector<uint8_t> stsz_payload;
+  put32(stsz_payload, 0);
+  put32(stsz_payload, 16);  // fixed sample size
+  put32(stsz_payload, 2);   // sample_count
+  std::vector<uint8_t> stsz = box("stsz", stsz_payload);
+
+  // Assemble, then patch the two chunk offsets to point into the mdat payload.
+  auto make_stco = [](uint32_t off0, uint32_t off1) {
+    std::vector<uint8_t> p;
+    put32(p, 0);
+    put32(p, 2);
+    put32(p, off0);
+    put32(p, off1);
+    return box("stco", p);
+  };
+  auto assemble = [&](const std::vector<uint8_t>& stco) {
+    std::vector<uint8_t> stbl = box("stbl", concat({stsd, stts, stsc, stsz, stco}));
+    std::vector<uint8_t> minf = box("minf", concat({vmhd, stbl}));
+    std::vector<uint8_t> mdia = box("mdia", concat({mdhd, hdlr, minf}));
+    std::vector<uint8_t> trak = box("trak", concat({tkhd, mdia}));
+    return box("moov", concat({mvhd, trak}));
+  };
+
+  std::vector<uint8_t> moov0 = assemble(make_stco(0, 0));
+  uint32_t mdat_payload_offset = static_cast<uint32_t>(ftyp.size() + moov0.size() + 8);
+  std::vector<uint8_t> moov = assemble(make_stco(mdat_payload_offset, mdat_payload_offset + 16));
+
+  std::vector<uint8_t> mdat = box("mdat", std::vector<uint8_t>(32, 0xA5));
+
+  return concat({ftyp, moov, mdat});
+}
+
+heif_error read_sequence(const std::vector<uint8_t>& file)
+{
+  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);
+  heif_context_free(ctx);
+  return err;
+}
+
+} // namespace
+
+TEST_CASE("sequence with non-visual sample entry in chunk is rejected")
+{
+  // Chunk 2 maps to the second 'stsd' entry, which is not a VisualSampleEntry.
+  // Before the fix this reached a decoder-less chunk and crashed (NULL deref on
+  // an NDEBUG build, assert on a debug build). It must now be a clean error.
+  SECTION("mp4a audio entry")
+  {
+    heif_error err = read_sequence(build_two_entry_visual_sequence(make_nonvisual_entry("mp4a"), 2));
+    REQUIRE(err.code == heif_error_Invalid_input);
+  }
+
+  // The defect is codec-independent: any non-visual second entry triggers it.
+  SECTION("unknown four-cc entry")
+  {
+    heif_error err = read_sequence(build_two_entry_visual_sequence(make_nonvisual_entry("zzzz"), 2));
+    REQUIRE(err.code == heif_error_Invalid_input);
+  }
+}
+
+TEST_CASE("sequence that maps every chunk to the visual entry still loads")
+{
+  // Same file, but chunk 2 maps to the visual 'hvc1' entry (index 1) instead of
+  // the non-visual one. This is the matched control: it isolates the non-visual
+  // entry as the cause. Both chunks get a decoder, so the track loads without
+  // error (no frame is decoded here, so no codec plugin is required).
+  heif_error err = read_sequence(build_two_entry_visual_sequence(make_nonvisual_entry("mp4a"), 1));
+  REQUIRE(err.code == heif_error_Ok);
+}