Commit 3a7a69ae for libheif
commit 3a7a69ae325f652e48c026b8241ab25bedf44d9b
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Tue Aug 25 00:29:57 2026 +0200
fix sequence timing-table DoS and unbounded memory (GHSA-xw34-mjcp-jqh8)
A crafted HEIF sequence with an edit list in repeat mode plus the movie
header duration could amplify a single physical sample into an
astronomical logical output count, and several per-track structures were
built without going through the memory limits.
- Clamp the repeat-amplified output count. init_sample_timing_table()
set m_num_output_samples = multiplier * timeline_size (uint64), but the
decode/raw-output loops and end_of_sequence_reached() count with a
uint32 counter, so a value above UINT32_MAX could never be reached and
decoding never terminated. Compute the product saturating and clamp to
max_sequence_frames (or UINT32_MAX when the limit is disabled).
m_num_repetitions still reports UINT32_MAX ("infinite") so a caller can
loop the media itself via ignore_sequence_editlist.
- Make Box_stts::get_sample_duration() O(log entries). It scanned the
run-length table from the start on every call, once per sample while
building the timing table (O(entries*samples), a file-open CPU DoS) and
once per output sample on the decode/raw paths. Add a
cumulative_sample_count prefix sum to TimeToSample (maintained by
parse() and append_sample_duration()) and binary-search it.
- Account Track::m_presentation_timeline and Chunk::m_sample_ranges
against max_total_memory via MemoryHandle. A small track can declare
millions of samples, so these ~48 B/sample and ~16 B/sample tables were
~1 GB/track of untracked memory. The media timeline is now moved (not
copied) into m_presentation_timeline, removing the transient double
allocation.
- Give the raw-sample path a way to honor decoding options. The raw C API
heif_track_get_next_raw_sequence_sample() could not set
ignore_sequence_editlist. Move the implementation into an internal
static heif_track_get_next_raw_sequence_sample2() taking
heif_decoding_options; the public function forwards with null options.
Promotion to public API is left for the next major version (TODO).
Add tests/sequence_timing_overflow.cc: builds minimal in-memory
metadata-track sequence files and checks that repeat/indefinite edit
lists terminate, that stts durations resolve correctly across entries,
and that the per-track tables are bounded by max_total_memory.
diff --git a/libheif/api/libheif/heif_sequences.cc b/libheif/api/libheif/heif_sequences.cc
index 9b822242..bc2373e9 100644
--- a/libheif/api/libheif/heif_sequences.cc
+++ b/libheif/api/libheif/heif_sequences.cc
@@ -258,21 +258,48 @@ heif_error heif_track_get_urim_sample_entry_uri_of_first_cluster(const heif_trac
}
-heif_error heif_track_get_next_raw_sequence_sample(heif_track* track_ptr,
- heif_raw_sequence_sample** out_sample)
-{
- auto track = track_ptr->track;
-
- // --- reached end of sequence ?
-
- if (track->end_of_sequence_reached()) {
- return {heif_error_End_of_sequence, heif_suberror_Unspecified, "End of sequence"};
+// TODO(next major API version): promote this to public API — remove 'static', add
+// the LIBHEIF_API attribute, and add its declaration to
+// libheif/api/libheif/heif_sequences.h. It is the options-taking counterpart of
+// heif_track_get_next_raw_sequence_sample() and the fix for GHSA-xw34-mjcp-jqh8
+// variant V7 (the raw-sample path otherwise cannot honor
+// heif_decoding_options::ignore_sequence_editlist). It is kept 'static' for now
+// because adding a new exported symbol / public declaration is not permitted in a
+// stable-API bugfix release. (The non-terminating-loop aspect of V7 is already
+// fixed by the clamp in Track::init_sample_timing_table(); this only closes the
+// remaining functional gap.)
+//
+// Proposed header declaration:
+//
+// /**
+// * Like heif_track_get_next_raw_sequence_sample(), but takes decoding options.
+// * Set heif_decoding_options::ignore_sequence_editlist to iterate the raw media
+// * timeline once, ignoring edit-list repetition. `options` may be NULL, which
+// * behaves like heif_track_get_next_raw_sequence_sample().
+// */
+// LIBHEIF_API
+// heif_error heif_track_get_next_raw_sequence_sample2(heif_track*,
+// heif_raw_sequence_sample** out_sample,
+// const heif_decoding_options* options);
+static heif_error heif_track_get_next_raw_sequence_sample2(heif_track* track_ptr,
+ heif_raw_sequence_sample** out_sample,
+ const heif_decoding_options* options)
+{
+ if (out_sample == nullptr) {
+ return heif_error_null_pointer_argument;
}
- // --- get next raw sample
+ auto track = track_ptr->track;
- // TODO: pass decoding options. We currently have no way to ignore the edit-list.
- auto decodingResult = track->get_next_sample_raw_data(nullptr);
+ // `options` may be null (the no-options public wrapper below passes nullptr).
+ // get_next_sample_raw_data() treats null as "apply the edit list" and only reads
+ // options->ignore_sequence_editlist when options is non-null.
+ //
+ // We intentionally do not pre-check end_of_sequence_reached() here: that helper
+ // compares against the edit-list-applied output count, whereas
+ // get_next_sample_raw_data() applies ignore_sequence_editlist itself and signals
+ // heif_error_End_of_sequence at the correct (option-dependent) boundary.
+ auto decodingResult = track->get_next_sample_raw_data(options);
if (!decodingResult) {
return decodingResult.error_struct(track_ptr->context.get());
}
@@ -283,6 +310,16 @@ heif_error heif_track_get_next_raw_sequence_sample(heif_track* track_ptr,
}
+heif_error heif_track_get_next_raw_sequence_sample(heif_track* track_ptr,
+ heif_raw_sequence_sample** out_sample)
+{
+ // Thin wrapper over the (currently internal) options-taking implementation, with
+ // no decoding options. Once heif_track_get_next_raw_sequence_sample2() is promoted
+ // to public API (see the TODO above), this stays as a convenience wrapper.
+ return heif_track_get_next_raw_sequence_sample2(track_ptr, out_sample, nullptr);
+}
+
+
void heif_raw_sequence_sample_release(heif_raw_sequence_sample* sample)
{
delete sample;
diff --git a/libheif/sequences/chunk.cc b/libheif/sequences/chunk.cc
index 48d3c05e..a4683e95 100644
--- a/libheif/sequences/chunk.cc
+++ b/libheif/sequences/chunk.cc
@@ -102,6 +102,12 @@ Chunk::Chunk(HeifContext* ctx, uint32_t track_id,
m_next_sample_to_be_decoded = first_sample;
+ // Reserve the exact final size so the running offset check below is the only
+ // growth concern (no geometric reallocation). The aggregate over all chunks is
+ // accounted against max_total_memory by the owning Track before any chunk is
+ // built (GHSA-xw34-mjcp-jqh8, variants V2/V3), so no per-chunk limit check here.
+ m_sample_ranges.reserve(num_samples);
+
for (uint32_t i=0;i<num_samples;i++) {
SampleFileRange range;
range.offset = file_offset;
@@ -130,6 +136,12 @@ Chunk::Chunk(HeifContext* ctx, uint32_t track_id,
}
+size_t Chunk::sample_range_entry_size()
+{
+ return sizeof(SampleFileRange);
+}
+
+
DataExtent Chunk::get_data_extent_for_sample(uint32_t n) const
{
assert(n>= m_first_sample);
diff --git a/libheif/sequences/chunk.h b/libheif/sequences/chunk.h
index 66f472f2..b788d861 100644
--- a/libheif/sequences/chunk.h
+++ b/libheif/sequences/chunk.h
@@ -45,6 +45,11 @@ public:
virtual ~Chunk() = default;
+ // Size in bytes of one entry in the internal sample-range table. Used by the
+ // owning Track to reserve the aggregate memory for all chunks up front
+ // (GHSA-xw34-mjcp-jqh8, variants V2/V3).
+ static size_t sample_range_entry_size();
+
heif_compression_format get_compression_format() const { return m_compression_format; }
virtual std::shared_ptr<class Decoder> get_decoder() const { return m_decoder; }
diff --git a/libheif/sequences/seq_boxes.cc b/libheif/sequences/seq_boxes.cc
index 6dc8cf21..8861a00a 100644
--- a/libheif/sequences/seq_boxes.cc
+++ b/libheif/sequences/seq_boxes.cc
@@ -610,6 +610,17 @@ Error Box_stts::parse(BitstreamRange& range, const heif_security_limits* limits)
}
}
+ // Precompute the prefix sum of sample_count for O(log entries) lookups in
+ // get_sample_duration(). Safe as uint32 because the total was just checked to
+ // be <= max_samples <= 0xFFFFFFFF.
+ {
+ uint32_t running = 0;
+ for (auto& entry : m_entries) {
+ running += entry.sample_count;
+ entry.cumulative_sample_count = running;
+ }
+ }
+
return range.get_error();
}
@@ -644,14 +655,22 @@ Error Box_stts::write(StreamWriter& writer) const
uint32_t Box_stts::get_sample_duration(uint32_t sample_idx)
{
- for (const auto& entry : m_entries) {
- if (sample_idx < entry.sample_count) {
- return entry.sample_delta;
- }
- sample_idx -= entry.sample_count;
+ // The entries are contiguous and cumulative_sample_count is the non-decreasing
+ // index one past the last sample of each entry, so the entry covering sample_idx
+ // is the first one whose cumulative_sample_count is strictly greater than
+ // sample_idx. Binary search keeps this O(log entries); a linear scan would make
+ // the decode/raw output paths O(entries) per sample (GHSA-xw34-mjcp-jqh8, V1).
+ auto it = std::upper_bound(m_entries.begin(), m_entries.end(), sample_idx,
+ [](uint32_t idx, const TimeToSample& entry) {
+ return idx < entry.cumulative_sample_count;
+ });
+
+ if (it == m_entries.end()) {
+ // sample_idx is not covered by any entry.
+ return 0;
}
- return 0;
+ return it->sample_delta;
}
@@ -661,11 +680,13 @@ void Box_stts::append_sample_duration(uint32_t duration)
TimeToSample entry{};
entry.sample_delta = duration;
entry.sample_count = 1;
+ entry.cumulative_sample_count = (m_entries.empty() ? 0 : m_entries.back().cumulative_sample_count) + 1;
m_entries.push_back(entry);
return;
}
m_entries.back().sample_count++;
+ m_entries.back().cumulative_sample_count++;
}
diff --git a/libheif/sequences/seq_boxes.h b/libheif/sequences/seq_boxes.h
index 2da1cec5..bd593c92 100644
--- a/libheif/sequences/seq_boxes.h
+++ b/libheif/sequences/seq_boxes.h
@@ -356,8 +356,18 @@ public:
struct TimeToSample {
uint32_t sample_count;
uint32_t sample_delta;
+
+ // Index one past the last sample described by this entry, i.e. the prefix sum
+ // of sample_count over the entries up to and including this one. Derived (not
+ // stored in the file); kept in sync by parse() and append_sample_duration() so
+ // get_sample_duration() can binary-search instead of scanning linearly.
+ uint32_t cumulative_sample_count = 0;
};
+ // O(log entries) lookup of the sample duration (delta) for a given sample index.
+ // Called once per output sample on the decode/raw paths, so a linear scan would
+ // be O(entries) per sample, i.e. O(entries * samples) overall (GHSA-xw34-mjcp-jqh8,
+ // variant V1).
uint32_t get_sample_duration(uint32_t sample_idx);
void append_sample_duration(uint32_t duration);
diff --git a/libheif/sequences/track.cc b/libheif/sequences/track.cc
index 9a7aabc1..97755659 100644
--- a/libheif/sequences/track.cc
+++ b/libheif/sequences/track.cc
@@ -26,6 +26,7 @@
#include "sequences/track_visual.h"
#include "sequences/track_metadata.h"
#include "api_structs.h"
+#include <algorithm>
#include <limits>
@@ -357,6 +358,17 @@ Error Track::load(const std::shared_ptr<Box_trak>& trak_box)
const std::vector<uint32_t>& chunk_offsets = m_stco->get_offsets();
assert(chunk_offsets.size() <= (size_t) std::numeric_limits<uint32_t>::max()); // There cannot be more than uint32_t chunks.
+ // Account the per-chunk sample-range tables (Chunk::m_sample_ranges) against
+ // max_total_memory before building any chunk. Their combined size across all
+ // chunks is num_samples * sizeof(SampleFileRange); a small track can declare
+ // millions of samples in one chunk, so without this the tables are untracked
+ // and multiple tracks can exhaust memory undetected (GHSA-xw34-mjcp-jqh8, V2/V3).
+ if (auto err = m_chunk_sample_ranges_memory.alloc(m_stsz->num_samples(),
+ Chunk::sample_range_entry_size(),
+ limits, "the sequence chunk sample-range tables")) {
+ return err;
+ }
+
uint32_t current_sample_idx = 0;
int32_t previous_sample_description_index = -1;
@@ -1045,9 +1057,23 @@ Error Track::init_sample_timing_table()
{
m_num_samples = m_stsz->num_samples();
+ const auto* limits = m_heif_context->get_security_limits();
+
+ // Account the presentation timeline against max_total_memory before allocating
+ // it. m_num_samples is bounded by max_sequence_frames, but a single small track
+ // can still declare millions of samples, so this ~48-bytes/sample vector must be
+ // tracked to bound multi-track accumulation (GHSA-xw34-mjcp-jqh8, V2/V3). The
+ // media timeline built below is moved into m_presentation_timeline (not copied),
+ // so only one such buffer ever exists and this single reservation covers it.
+ if (auto err = m_presentation_timeline_memory.alloc(m_num_samples, sizeof(SampleTiming),
+ limits, "the sequence presentation timeline")) {
+ return err;
+ }
+
// --- build media timeline
std::vector<SampleTiming> media_timeline;
+ media_timeline.reserve(m_num_samples);
uint64_t current_decoding_time = 0;
uint32_t current_chunk = 0;
@@ -1058,6 +1084,9 @@ Error Track::init_sample_timing_table()
timing.sampleIdx = i;
timing.sampleInChunkIdx = current_sample_in_chunk_idx;
timing.media_decoding_time = current_decoding_time;
+ // O(log entries) per lookup (Box_stts uses a prefix-sum binary search), so
+ // building the whole table is O(samples * log entries) rather than the former
+ // O(samples * entries) file-open CPU-DoS (GHSA-xw34-mjcp-jqh8, variant V1).
timing.sample_duration_media_time = m_stts->get_sample_duration(i);
current_decoding_time += timing.sample_duration_media_time;
current_sample_in_chunk_idx++;
@@ -1093,7 +1122,6 @@ Error Track::init_sample_timing_table()
m_elst->get_entry(0).media_time == 0 &&
m_elst->get_entry(0).segment_duration == m_mdhd->get_duration() &&
m_elst->is_repeat_mode()) {
- m_presentation_timeline = media_timeline;
uint64_t duration_media_units = get_duration_in_media_units();
if (duration_media_units == 0) {
@@ -1104,8 +1132,38 @@ Error Track::init_sample_timing_table()
};
}
- uint64_t multiplier = m_heif_context->get_sequence_duration() / get_duration_in_media_units();
- m_num_output_samples = multiplier * media_timeline.size();
+ uint64_t multiplier = m_heif_context->get_sequence_duration() / duration_media_units;
+
+ // Logical number of output samples = physical samples x repeat multiplier.
+ // Compute this saturating instead of wrapping: with the indefinite-duration
+ // sentinel (mvhd.duration = UINT64_MAX) the multiplier is enormous, and a plain
+ // multiply could overflow uint64_t and wrap to a small, misleading value.
+ uint64_t timeline_size = media_timeline.size();
+ uint64_t total_output_samples;
+ if (timeline_size != 0 && multiplier > std::numeric_limits<uint64_t>::max() / timeline_size) {
+ total_output_samples = std::numeric_limits<uint64_t>::max();
+ }
+ else {
+ total_output_samples = multiplier * timeline_size;
+ }
+
+ // The decode loop (Track_Visual::decode_next_image_sample), the raw-data path
+ // (get_next_sample_raw_data) and end_of_sequence_reached() all count emitted
+ // samples with a uint32_t (m_next_sample_to_be_output). If m_num_output_samples
+ // exceeds UINT32_MAX, that counter can never reach it and decoding never
+ // terminates (GHSA-xw34-mjcp-jqh8, variants V4/V5). The physical-sample limit
+ // (max_sequence_frames) is also never applied to this repeat-amplified logical
+ // count (variant V6). Bound the number of samples the loop will emit by the
+ // sequence-frame limit (or UINT32_MAX when the limit is disabled), so a
+ // repeat/indefinite edit list cannot inflate a single physical sample into an
+ // effectively infinite decode. The reported repetition count below still signals
+ // "infinite", so a caller wanting true unbounded playback can loop the media
+ // itself via heif_decoding_options::ignore_sequence_editlist.
+ uint64_t output_sample_cap = (limits->max_sequence_frames > 0)
+ ? limits->max_sequence_frames
+ : std::numeric_limits<uint32_t>::max();
+
+ m_num_output_samples = std::min(total_output_samples, output_sample_cap);
if (m_heif_context->is_sequence_duration_indefinite()) {
// mvhd carries the all-1s sentinel -> editlist repeats forever.
@@ -1125,7 +1183,6 @@ Error Track::init_sample_timing_table()
// Fallback: just play the media timeline
if (fallback) {
- m_presentation_timeline = media_timeline;
m_num_output_samples = media_timeline.size();
// No editlist box at all: the media plays exactly once.
// Editlist box present but its pattern isn't one libheif interprets: report
@@ -1133,6 +1190,11 @@ Error Track::init_sample_timing_table()
m_num_repetitions = m_elst ? 0 : 1;
}
+ // Both branches above use media_timeline unchanged as the presentation timeline.
+ // Move (do not copy) it in so only one such buffer is ever allocated, matching
+ // the single reservation made at the top of this function.
+ m_presentation_timeline = std::move(media_timeline);
+
return {};
}
diff --git a/libheif/sequences/track.h b/libheif/sequences/track.h
index e7ec2beb..5da1fb94 100644
--- a/libheif/sequences/track.h
+++ b/libheif/sequences/track.h
@@ -23,6 +23,7 @@
#include "error.h"
#include "api_structs.h"
+#include "security_limits.h"
#include "libheif/heif_plugin.h"
#include "libheif/heif_sequences.h"
#include <string>
@@ -220,6 +221,10 @@ protected:
uint32_t sample_duration_presentation_time = 0; // TODO
};
std::vector<SampleTiming> m_presentation_timeline;
+ // Accounts m_presentation_timeline against max_total_memory. A small track can
+ // declare millions of samples, so this vector (~48 bytes/sample) must be tracked
+ // to bound multi-track accumulation (GHSA-xw34-mjcp-jqh8, variants V2/V3).
+ MemoryHandle m_presentation_timeline_memory;
uint64_t m_num_output_samples = 0; // Can be larger than the vector. It then repeats the playback.
// How many times the media timeline is repeated.
@@ -241,6 +246,10 @@ protected:
Error init_sample_timing_table();
std::vector<std::shared_ptr<Chunk>> m_chunks;
+ // Accounts the per-chunk Chunk::m_sample_ranges tables against max_total_memory.
+ // Their combined size over all chunks is num_samples * sizeof(SampleFileRange),
+ // so a single reservation here bounds them all (GHSA-xw34-mjcp-jqh8, V2/V3).
+ MemoryHandle m_chunk_sample_ranges_memory;
std::vector<uint8_t> m_chunk_data;
std::shared_ptr<Box_moov> m_moov;
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 7d6c596f..0e162dfa 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -78,6 +78,7 @@ add_libheif_test(grid_tile_missing)
add_libheif_test(iden_declared_size)
add_libheif_test(region)
add_libheif_test(sequence_no_track)
+add_libheif_test(sequence_timing_overflow)
add_libheif_test(tai)
add_libheif_test(text)
add_libheif_test(cxx_wrapper)
diff --git a/tests/sequence_timing_overflow.cc b/tests/sequence_timing_overflow.cc
new file mode 100644
index 00000000..ddcbaecc
--- /dev/null
+++ b/tests/sequence_timing_overflow.cc
@@ -0,0 +1,497 @@
+/*
+ libheif integration tests for sequence sample-timing overflow.
+
+ 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.
+*/
+
+// Regression tests for GHSA-xw34-mjcp-jqh8: an ISOBMFF edit list in "repeat"
+// mode combined with the movie-header duration amplifies a single physical
+// sample into an astronomically large *logical* output count. Before the fix,
+// Track::init_sample_timing_table() left m_num_output_samples (uint64_t) at that
+// value while the decode/raw-output loops count with a uint32_t, so
+// end_of_sequence_reached() could never become true and decoding never
+// terminated (variants V4/V5). The physical-sample security limit
+// (max_sequence_frames) was also never applied to the amplified logical count
+// (variant V6).
+//
+// These tests build minimal HEIF sequence files by hand. A 'urim' metadata
+// track is used so the raw-sample API path exercises the timing table without
+// needing any codec plugin.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_sequences.h"
+
+#include <cstdint>
+#include <limits>
+#include <vector>
+
+namespace {
+
+void put16(std::vector<uint8_t>& v, uint16_t x)
+{
+ v.push_back(static_cast<uint8_t>(x >> 8));
+ v.push_back(static_cast<uint8_t>(x));
+}
+
+void put32(std::vector<uint8_t>& v, uint32_t x)
+{
+ v.push_back(static_cast<uint8_t>(x >> 24));
+ v.push_back(static_cast<uint8_t>(x >> 16));
+ v.push_back(static_cast<uint8_t>(x >> 8));
+ v.push_back(static_cast<uint8_t>(x));
+}
+
+void put64(std::vector<uint8_t>& v, uint64_t x)
+{
+ for (int i = 7; i >= 0; i--) {
+ v.push_back(static_cast<uint8_t>(x >> (i * 8)));
+ }
+}
+
+void put_fourcc(std::vector<uint8_t>& v, const char* s)
+{
+ v.push_back(static_cast<uint8_t>(s[0]));
+ v.push_back(static_cast<uint8_t>(s[1]));
+ v.push_back(static_cast<uint8_t>(s[2]));
+ v.push_back(static_cast<uint8_t>(s[3]));
+}
+
+// Wrap a payload in a box: [uint32 size][fourcc type][payload].
+std::vector<uint8_t> box(const char* type, const std::vector<uint8_t>& payload)
+{
+ std::vector<uint8_t> b;
+ put32(b, static_cast<uint32_t>(8 + payload.size()));
+ put_fourcc(b, type);
+ b.insert(b.end(), payload.begin(), payload.end());
+ return b;
+}
+
+std::vector<uint8_t> concat(std::initializer_list<std::vector<uint8_t>> parts)
+{
+ std::vector<uint8_t> out;
+ for (const auto& p : parts) {
+ out.insert(out.end(), p.begin(), p.end());
+ }
+ return out;
+}
+
+struct Stts_entry
+{
+ uint32_t sample_count;
+ uint32_t sample_delta;
+};
+
+struct SequenceFileParams
+{
+ bool mvhd_v1 = true;
+ uint64_t mvhd_duration = std::numeric_limits<uint64_t>::max(); // indefinite sentinel
+ uint32_t timescale = 1000;
+
+ uint64_t mdhd_duration = 1;
+
+ bool with_editlist = true;
+ uint64_t elst_segment_duration = 1; // must equal mdhd_duration to match the repeat pattern
+
+ std::vector<Stts_entry> stts = {{1, 1}};
+
+ uint32_t num_samples = 1;
+ uint32_t sample_size = 1;
+};
+
+// Build a minimal, structurally valid HEIF sequence file containing a single
+// 'urim' metadata track, following the box tree that Track::load() requires.
+std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
+{
+ // --- ftyp
+ std::vector<uint8_t> ftyp_payload;
+ put_fourcc(ftyp_payload, "msf1"); // major brand: HEIF image sequence
+ put32(ftyp_payload, 0); // minor version
+ put_fourcc(ftyp_payload, "msf1");
+ put_fourcc(ftyp_payload, "isom");
+ std::vector<uint8_t> ftyp = box("ftyp", ftyp_payload);
+
+ // --- mvhd
+ std::vector<uint8_t> mvhd_payload;
+ if (p.mvhd_v1) {
+ put32(mvhd_payload, 0x01000000); // version 1, flags 0
+ put64(mvhd_payload, 0); // creation_time
+ put64(mvhd_payload, 0); // modification_time
+ put32(mvhd_payload, p.timescale);
+ put64(mvhd_payload, p.mvhd_duration);
+ }
+ else {
+ put32(mvhd_payload, 0x00000000); // version 0, flags 0
+ put32(mvhd_payload, 0); // creation_time
+ put32(mvhd_payload, 0); // modification_time
+ put32(mvhd_payload, p.timescale);
+ put32(mvhd_payload, static_cast<uint32_t>(p.mvhd_duration));
+ }
+ put32(mvhd_payload, 0x00010000); // rate 1.0
+ put16(mvhd_payload, 0x0100); // volume
+ put16(mvhd_payload, 0); // reserved
+ put32(mvhd_payload, 0); // reserved
+ put32(mvhd_payload, 0); // reserved
+ for (int i = 0; i < 9; i++) put32(mvhd_payload, 0); // matrix
+ for (int i = 0; i < 6; i++) put32(mvhd_payload, 0); // pre_defined
+ put32(mvhd_payload, 2); // next_track_ID
+ std::vector<uint8_t> mvhd = box("mvhd", mvhd_payload);
+
+ // --- tkhd (version 1)
+ std::vector<uint8_t> tkhd_payload;
+ put32(tkhd_payload, 0x01000007); // version 1, flags = enabled|in_movie|in_preview
+ put64(tkhd_payload, 0); // creation_time
+ put64(tkhd_payload, 0); // modification_time
+ put32(tkhd_payload, 1); // track_ID
+ put32(tkhd_payload, 0); // reserved
+ put64(tkhd_payload, 0); // duration
+ put64(tkhd_payload, 0); // reserved
+ put16(tkhd_payload, 0); // layer
+ put16(tkhd_payload, 0); // alternate_group
+ put16(tkhd_payload, 0); // volume
+ put16(tkhd_payload, 0); // reserved
+ for (int i = 0; i < 9; i++) put32(tkhd_payload, 0); // matrix
+ put32(tkhd_payload, 0); // width
+ put32(tkhd_payload, 0); // height
+ std::vector<uint8_t> tkhd = box("tkhd", tkhd_payload);
+
+ // --- edts / elst (version 1, flags = repeat)
+ std::vector<uint8_t> edts;
+ if (p.with_editlist) {
+ std::vector<uint8_t> elst_payload;
+ put32(elst_payload, 0x01000001); // version 1, flags = Repeat_EditList
+ put32(elst_payload, 1); // entry_count
+ put64(elst_payload, p.elst_segment_duration);
+ put64(elst_payload, 0); // media_time
+ put16(elst_payload, 1); // media_rate_integer
+ put16(elst_payload, 0); // media_rate_fraction
+ edts = box("edts", box("elst", elst_payload));
+ }
+
+ // --- mdhd (version 0)
+ std::vector<uint8_t> mdhd_payload;
+ put32(mdhd_payload, 0x00000000); // version 0, flags 0
+ put32(mdhd_payload, 0); // creation_time
+ put32(mdhd_payload, 0); // modification_time
+ put32(mdhd_payload, p.timescale);
+ put32(mdhd_payload, static_cast<uint32_t>(p.mdhd_duration));
+ put16(mdhd_payload, 0x55c4); // language ('und')
+ put16(mdhd_payload, 0); // pre_defined
+ std::vector<uint8_t> mdhd = box("mdhd", mdhd_payload);
+
+ // --- hdlr (handler type 'meta')
+ std::vector<uint8_t> hdlr_payload;
+ put32(hdlr_payload, 0x00000000); // version 0, flags 0
+ put32(hdlr_payload, 0); // pre_defined
+ put_fourcc(hdlr_payload, "meta");
+ put32(hdlr_payload, 0); // reserved
+ put32(hdlr_payload, 0); // reserved
+ put32(hdlr_payload, 0); // reserved
+ hdlr_payload.push_back(0); // name (empty, null-terminated)
+ std::vector<uint8_t> hdlr = box("hdlr", hdlr_payload);
+
+ // --- nmhd (null media header for metadata tracks)
+ std::vector<uint8_t> nmhd_payload;
+ put32(nmhd_payload, 0x00000000); // version 0, flags 0
+ std::vector<uint8_t> nmhd = box("nmhd", nmhd_payload);
+
+ // --- stsd with a single 'urim' (URI meta) sample entry
+ std::vector<uint8_t> urim_payload;
+ for (int i = 0; i < 6; i++) urim_payload.push_back(0); // SampleEntry reserved
+ put16(urim_payload, 1); // data_reference_index
+ std::vector<uint8_t> urim = box("urim", urim_payload);
+
+ std::vector<uint8_t> stsd_payload;
+ put32(stsd_payload, 0x00000000); // version 0, flags 0
+ put32(stsd_payload, 1); // entry_count
+ stsd_payload.insert(stsd_payload.end(), urim.begin(), urim.end());
+ std::vector<uint8_t> stsd = box("stsd", stsd_payload);
+
+ // --- stts
+ std::vector<uint8_t> stts_payload;
+ put32(stts_payload, 0x00000000); // version 0, flags 0
+ put32(stts_payload, static_cast<uint32_t>(p.stts.size()));
+ for (const auto& e : p.stts) {
+ put32(stts_payload, e.sample_count);
+ put32(stts_payload, e.sample_delta);
+ }
+ std::vector<uint8_t> stts = box("stts", stts_payload);
+
+ // --- stsc: all samples in a single chunk
+ std::vector<uint8_t> stsc_payload;
+ put32(stsc_payload, 0x00000000); // version 0, flags 0
+ put32(stsc_payload, 1); // entry_count
+ put32(stsc_payload, 1); // first_chunk
+ put32(stsc_payload, p.num_samples); // samples_per_chunk
+ put32(stsc_payload, 1); // sample_description_index
+ std::vector<uint8_t> stsc = box("stsc", stsc_payload);
+
+ // --- stsz: fixed sample size
+ std::vector<uint8_t> stsz_payload;
+ put32(stsz_payload, 0x00000000); // version 0, flags 0
+ put32(stsz_payload, p.sample_size); // fixed sample size (non-zero -> no per-sample array)
+ put32(stsz_payload, p.num_samples); // sample_count
+ std::vector<uint8_t> stsz = box("stsz", stsz_payload);
+
+ // --- stco: single chunk offset, patched below to point at the mdat payload
+ auto make_stco = [](uint32_t offset) {
+ std::vector<uint8_t> stco_payload;
+ put32(stco_payload, 0x00000000); // version 0, flags 0
+ put32(stco_payload, 1); // entry_count
+ put32(stco_payload, offset); // chunk offset
+ return box("stco", stco_payload);
+ };
+
+ 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({nmhd, stbl}));
+ std::vector<uint8_t> mdia = box("mdia", concat({mdhd, hdlr, minf}));
+ std::vector<uint8_t> trak = box("trak", concat({tkhd, edts, mdia}));
+ std::vector<uint8_t> moov = box("moov", concat({mvhd, trak}));
+ return moov;
+ };
+
+ // Box sizes are independent of the offset value, so we can size the file with a
+ // placeholder, then patch the real offset in one pass.
+ std::vector<uint8_t> moov0 = assemble(make_stco(0));
+ uint32_t mdat_payload_offset = static_cast<uint32_t>(ftyp.size() + moov0.size() + 8 /* mdat header */);
+ std::vector<uint8_t> moov = assemble(make_stco(mdat_payload_offset));
+
+ // --- mdat: num_samples * sample_size bytes of arbitrary content
+ std::vector<uint8_t> mdat_payload(static_cast<size_t>(p.num_samples) * p.sample_size);
+ for (size_t i = 0; i < mdat_payload.size(); i++) {
+ mdat_payload[i] = static_cast<uint8_t>(0xA0 + (i & 0x0f));
+ }
+ std::vector<uint8_t> mdat = box("mdat", mdat_payload);
+
+ return concat({ftyp, moov, mdat});
+}
+
+} // namespace
+
+
+TEST_CASE("repeat edit list with indefinite duration terminates")
+{
+ // A single physical sample plus an indefinite-duration repeat edit list.
+ // Before the fix this inflated m_num_output_samples to ~UINT64_MAX, and the
+ // uint32_t output counter could never reach it -> non-terminating loop.
+ SequenceFileParams p;
+ p.mvhd_v1 = true;
+ p.mvhd_duration = std::numeric_limits<uint64_t>::max(); // indefinite sentinel
+ p.mdhd_duration = 1;
+ p.with_editlist = true;
+ p.elst_segment_duration = 1;
+ p.stts = {{1, 1}};
+ p.num_samples = 1;
+
+ std::vector<uint8_t> file = build_sequence_file(p);
+
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ // Lower the frame limit so the (now bounded) output count is small and the test
+ // runs quickly. This is exactly the limit that must cap the repeat-amplified
+ // logical sample count.
+ heif_security_limits* limits = heif_context_get_security_limits(ctx);
+ const uint32_t kFrameLimit = 100;
+ limits->max_sequence_frames = kFrameLimit;
+
+ heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(heif_context_has_sequence(ctx) == 1);
+
+ heif_track* track = heif_context_get_track(ctx, 0);
+ REQUIRE(track != nullptr);
+
+ // The file still advertises "infinite" repetition to the caller...
+ REQUIRE(heif_track_get_number_of_repetitions(track) ==
+ heif_sequence_track_number_of_repetitions_infinite);
+
+ // ...but iterating the raw samples must terminate. It is bounded by the
+ // frame limit. Cap the loop well above that so a regression fails (rather than
+ // hanging): with the bug, End_of_sequence is never reached.
+ const int kIterationCap = 100000;
+ int count = 0;
+ bool reached_end = false;
+ for (; count < kIterationCap; count++) {
+ heif_raw_sequence_sample* sample = nullptr;
+ heif_error serr = heif_track_get_next_raw_sequence_sample(track, &sample);
+ if (serr.code == heif_error_End_of_sequence) {
+ reached_end = true;
+ break;
+ }
+ REQUIRE(serr.code == heif_error_Ok);
+ REQUIRE(sample != nullptr);
+ heif_raw_sequence_sample_release(sample);
+ }
+
+ REQUIRE(reached_end);
+ REQUIRE(count == static_cast<int>(kFrameLimit));
+
+ heif_track_release(track);
+ heif_context_free(ctx);
+}
+
+
+TEST_CASE("repeat edit list with finite over-uint32 multiplier terminates")
+{
+ // Non-sentinel duration whose multiplier exceeds UINT32_MAX (variant V5): the
+ // repeat is finite but the logical count overflows the uint32_t output counter.
+ SequenceFileParams p;
+ p.mvhd_v1 = true;
+ p.mvhd_duration = (uint64_t{1} << 32) + 1; // 0x100000001, > UINT32_MAX, not the sentinel
+ p.mdhd_duration = 1;
+ p.with_editlist = true;
+ p.elst_segment_duration = 1;
+ p.stts = {{1, 1}};
+ p.num_samples = 1;
+
+ std::vector<uint8_t> file = build_sequence_file(p);
+
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ heif_security_limits* limits = heif_context_get_security_limits(ctx);
+ const uint32_t kFrameLimit = 50;
+ limits->max_sequence_frames = kFrameLimit;
+
+ heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_track* track = heif_context_get_track(ctx, 0);
+ REQUIRE(track != nullptr);
+
+ const int kIterationCap = 100000;
+ int count = 0;
+ bool reached_end = false;
+ for (; count < kIterationCap; count++) {
+ heif_raw_sequence_sample* sample = nullptr;
+ heif_error serr = heif_track_get_next_raw_sequence_sample(track, &sample);
+ if (serr.code == heif_error_End_of_sequence) {
+ reached_end = true;
+ break;
+ }
+ REQUIRE(serr.code == heif_error_Ok);
+ heif_raw_sequence_sample_release(sample);
+ }
+
+ REQUIRE(reached_end);
+ REQUIRE(count == static_cast<int>(kFrameLimit));
+
+ heif_track_release(track);
+ heif_context_free(ctx);
+}
+
+
+TEST_CASE("multi-entry stts yields correct per-sample durations")
+{
+ // Guards the prefix-sum + binary-search rewrite of Box_stts::get_sample_duration():
+ // the run-length coded 'stts' must resolve to the same per-sample durations as the
+ // original linear scan, including across entry boundaries. Uses a plain track (no
+ // edit list -> single playback).
+ SequenceFileParams p;
+ p.mvhd_v1 = false;
+ p.mvhd_duration = 6;
+ p.mdhd_duration = 6;
+ p.with_editlist = false;
+ p.stts = {{2, 10}, {1, 20}, {3, 30}}; // durations: 10, 10, 20, 30, 30, 30
+ p.num_samples = 6;
+
+ std::vector<uint8_t> file = build_sequence_file(p);
+
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_track* track = heif_context_get_track(ctx, 0);
+ REQUIRE(track != nullptr);
+
+ std::vector<uint32_t> expected_durations = {10, 10, 20, 30, 30, 30};
+ std::vector<uint32_t> got_durations;
+
+ for (int i = 0; i < 20; i++) {
+ heif_raw_sequence_sample* sample = nullptr;
+ heif_error serr = heif_track_get_next_raw_sequence_sample(track, &sample);
+ if (serr.code == heif_error_End_of_sequence) {
+ break;
+ }
+ REQUIRE(serr.code == heif_error_Ok);
+ got_durations.push_back(heif_raw_sequence_sample_get_duration(sample));
+ heif_raw_sequence_sample_release(sample);
+ }
+
+ REQUIRE(got_durations == expected_durations);
+
+ heif_track_release(track);
+ heif_context_free(ctx);
+}
+
+
+TEST_CASE("per-track timeline/chunk memory is bounded by max_total_memory")
+{
+ // A tiny file that declares a large sample count via a single run-length 'stts'
+ // entry plus a fixed_sample_size 'stsz'. The per-track presentation timeline
+ // (~48 B/sample) and the chunk sample-range table (~16 B/sample) must be accounted
+ // against max_total_memory. Before the fix they bypassed MemoryHandle, so a small
+ // file could force hundreds of MB per track undetected (GHSA-xw34-mjcp-jqh8, V2/V3).
+ const uint32_t N = 500000; // ~24 MB timeline + ~8 MB chunk ranges; file stays tiny
+
+ SequenceFileParams p;
+ p.mvhd_v1 = false;
+ p.mvhd_duration = N;
+ p.mdhd_duration = N;
+ p.with_editlist = false;
+ p.stts = {{N, 1}}; // one run-length entry describes all N samples
+ p.num_samples = N;
+ p.sample_size = 1;
+
+ std::vector<uint8_t> file = build_sequence_file(p);
+
+ // With ample memory the file is structurally valid and reads fine.
+ {
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+ heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+ REQUIRE(err.code == heif_error_Ok);
+ heif_context_free(ctx);
+ }
+
+ // With a small max_total_memory the per-track tables must trip the limit. The
+ // 'stts'/'stsz'/'stsc'/'stco' tables are all tiny here, so the only allocation
+ // large enough to exceed 4 MB is one of the newly-tracked per-track tables.
+ {
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ heif_security_limits* limits = heif_context_get_security_limits(ctx);
+ limits->max_total_memory = 4 * 1024 * 1024; // 4 MB, far below the ~32 MB needed
+
+ heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+ REQUIRE(err.code == heif_error_Memory_allocation_error);
+ REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+
+ heif_context_free(ctx);
+ }
+}