Commit 517dd138 for libheif
commit 517dd138e8ab983e8d0c4b8ba507f40f616660fd
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Mon Oct 26 11:31:39 2026 +0100
Look up 'stsc' entries by binary search [GHSA-74mj-m37q-h3q9]
Track::load() looks up the 'stsc' entry of every 'stco' chunk, and
Box_stsc::get_chunk() scanned the table linearly. Opening a file therefore
took chunks x 'stsc' entries steps, i.e. time quadratic in the file size
(a 1 MB file took 12 s in a Debug build). Any file with a 'moov' box was
affected, also when the application only reads the still image.
get_chunk() now uses a binary search. It relies on 'first_chunk' values that
do not decrease, which parse() now checks: each entry starts a run of chunks
(ISO/IEC 14496-12, 8.7.4), so a decreasing table has no meaning. The former
linear scan accepted such tables and mapped some chunks to the wrong entry.
Equal values (empty runs) are still accepted and map as before.
Fixes GHSA-74mj-m37q-h3q9 (also reported as GHSA-xf84-wgwm-8vhx and
ANT-2026-ZNJZAJWY).
diff --git a/libheif/sequences/seq_boxes.cc b/libheif/sequences/seq_boxes.cc
index e6cc03f2..8c39aef0 100644
--- a/libheif/sequences/seq_boxes.cc
+++ b/libheif/sequences/seq_boxes.cc
@@ -975,6 +975,15 @@ Error Box_stsc::parse(BitstreamRange& range, const heif_security_limits* limits)
"Number of chunk samples in `stsc` box exceeds security limits of maximum number of frames."};
}
+ // Each entry starts a run of chunks, so decreasing values have no meaning (ISO/IEC 14496-12, 8.7.4).
+ // get_chunk() relies on this order for its binary search. Equal values (empty runs) are tolerated.
+ if (i > 0 && entry.first_chunk < m_entries[i - 1].first_chunk) {
+ return {
+ heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "'first_chunk' values in 'stsc' box must not decrease."};
+ }
+
m_entries[i] = entry;
}
@@ -1017,13 +1026,20 @@ Error Box_stsc::write(StreamWriter& writer) const
const Box_stsc::SampleToChunk* Box_stsc::get_chunk(uint32_t idx) const
{
assert(idx>=1);
- for (size_t i = 0 ; i < m_entries.size();i++) {
- if (idx >= m_entries[i].first_chunk && (i==m_entries.size()-1 || idx < m_entries[i+1].first_chunk)) {
- return &m_entries[i];
- }
+
+ // The entries are ordered by first_chunk (checked in parse()). The entry covering chunk 'idx'
+ // is the last one that does not start after it. A linear scan would make Track::load(), which
+ // looks up every chunk, quadratic in the file size.
+ auto it = std::upper_bound(m_entries.begin(), m_entries.end(), idx,
+ [](uint32_t chunk_idx, const SampleToChunk& entry) {
+ return chunk_idx < entry.first_chunk;
+ });
+
+ if (it == m_entries.begin()) {
+ return nullptr;
}
- return nullptr;
+ return &*(it - 1);
}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index e921296f..2913928f 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -149,6 +149,7 @@ add_libheif_test(sequence_short_aux_info)
add_libheif_test(sequence_nonvisual_sample_entry)
add_libheif_test(sequence_no_decoder_output)
add_libheif_test(sequence_urim_missing_uri)
+add_libheif_test(sequence_stsc_lookup)
add_libheif_test(tai)
add_libheif_test(text)
if (NOT WITH_REDUCED_VISIBILITY)
diff --git a/tests/sequence_file_builder.h b/tests/sequence_file_builder.h
index 89ee734e..fb140da5 100644
--- a/tests/sequence_file_builder.h
+++ b/tests/sequence_file_builder.h
@@ -114,6 +114,14 @@ struct SampleAuxInfo
// to make 'saiz' cover fewer samples than the track has.
};
+// One entry of the 'stsc' table.
+struct Stsc_entry
+{
+ uint32_t first_chunk;
+ uint32_t samples_per_chunk;
+ uint32_t sample_description_index;
+};
+
struct SequenceFileParams
{
bool mvhd_v1 = true;
@@ -138,6 +146,11 @@ struct SequenceFileParams
std::vector<uint8_t> second_sample_entry;
std::vector<uint8_t> second_sample_bytes; // same length as sample_bytes
+ // Nonempty -> write this 'stsc' table and 'num_chunks' chunks, all pointing to the start of
+ // the sample data. The samples per chunk must add up to num_samples.
+ std::vector<Stsc_entry> stsc_entries;
+ uint32_t num_chunks = 0;
+
std::vector<SampleAuxInfo> aux_infos;
};
@@ -260,17 +273,27 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
}
std::vector<uint8_t> stts = box("stts", stts_payload);
- // --- stsc: one or two chunks, each with its own sample description
+ // --- stsc: one or two chunks, each with its own sample description, or the given table
std::vector<uint8_t> stsc_payload;
put32(stsc_payload, 0x00000000); // version 0, flags 0
- put32(stsc_payload, two_chunks ? 2 : 1); // entry_count
- put32(stsc_payload, 1); // first_chunk
- put32(stsc_payload, two_chunks ? p.first_chunk_num_samples : p.num_samples); // samples_per_chunk
- put32(stsc_payload, 1); // sample_description_index
- if (two_chunks) {
- put32(stsc_payload, 2); // first_chunk
- put32(stsc_payload, p.num_samples - p.first_chunk_num_samples);
- put32(stsc_payload, 2); // sample_description_index
+ if (!p.stsc_entries.empty()) {
+ put32(stsc_payload, static_cast<uint32_t>(p.stsc_entries.size())); // entry_count
+ for (const auto& entry : p.stsc_entries) {
+ put32(stsc_payload, entry.first_chunk);
+ put32(stsc_payload, entry.samples_per_chunk);
+ put32(stsc_payload, entry.sample_description_index);
+ }
+ }
+ else {
+ put32(stsc_payload, two_chunks ? 2 : 1); // entry_count
+ put32(stsc_payload, 1); // first_chunk
+ put32(stsc_payload, two_chunks ? p.first_chunk_num_samples : p.num_samples); // samples_per_chunk
+ put32(stsc_payload, 1); // sample_description_index
+ if (two_chunks) {
+ put32(stsc_payload, 2); // first_chunk
+ put32(stsc_payload, p.num_samples - p.first_chunk_num_samples);
+ put32(stsc_payload, 2); // sample_description_index
+ }
}
std::vector<uint8_t> stsc = box("stsc", stsc_payload);
@@ -353,8 +376,8 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
for (const auto& aux : p.aux_infos) {
saio_placeholders.push_back(make_saio(aux, 0));
}
- std::vector<uint8_t> moov0 = assemble(make_stco(two_chunks ? std::vector<uint32_t>{0, 0}
- : std::vector<uint32_t>{0}), saio_placeholders);
+ const size_t num_chunks = !p.stsc_entries.empty() ? p.num_chunks : (two_chunks ? 2 : 1);
+ std::vector<uint8_t> moov0 = assemble(make_stco(std::vector<uint32_t>(num_chunks, 0)), saio_placeholders);
uint32_t mdat_payload_offset = static_cast<uint32_t>(ftyp.size() + moov0.size() + 8 /* mdat header */);
uint32_t file_size = static_cast<uint32_t>(mdat_payload_offset + mdat_payload.size());
@@ -366,7 +389,8 @@ inline std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
saio_boxes.push_back(make_saio(p.aux_infos[i], offset));
}
std::vector<uint32_t> chunk_offsets = {mdat_payload_offset};
- if (two_chunks) chunk_offsets.push_back(mdat_payload_offset + p.first_chunk_num_samples * sample_size);
+ if (!p.stsc_entries.empty()) chunk_offsets.assign(num_chunks, mdat_payload_offset);
+ else if (two_chunks) chunk_offsets.push_back(mdat_payload_offset + p.first_chunk_num_samples * sample_size);
std::vector<uint8_t> moov = assemble(make_stco(chunk_offsets), saio_boxes);
std::vector<uint8_t> mdat = box("mdat", mdat_payload);
diff --git a/tests/sequence_stsc_lookup.cc b/tests/sequence_stsc_lookup.cc
new file mode 100644
index 00000000..d7fef9ee
--- /dev/null
+++ b/tests/sequence_stsc_lookup.cc
@@ -0,0 +1,154 @@
+/*
+ libheif integration tests for the 'stsc' chunk lookup.
+
+ 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-74mj-m37q-h3q9.
+//
+// Track::load() looks up the 'stsc' entry of every chunk. Box_stsc::get_chunk() scanned
+// the table linearly, which made opening a file quadratic in its size. It is now a binary
+// search, which requires 'first_chunk' values that do not decrease; parse() checks this.
+//
+// All chunks of the test files point to the start of the sample data, and sample i of
+// the mdat payload holds the byte 0xA0 + (i & 0x0f). So the first byte of each raw sample
+// is 0xA0 + its index within its chunk, which shows how the chunks were mapped to entries.
+
+#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 {
+
+SequenceFileParams params_for(const std::vector<Stsc_entry>& entries, uint32_t num_chunks, uint32_t num_samples)
+{
+ SequenceFileParams p;
+ p.mvhd_v1 = false;
+ p.mvhd_duration = num_samples;
+ p.mdhd_duration = num_samples;
+ p.with_editlist = false;
+ p.stts = {{num_samples, 1}};
+ p.num_samples = num_samples;
+ p.sample_size = 1;
+ p.stsc_entries = entries;
+ p.num_chunks = num_chunks;
+ return p;
+}
+
+struct Context
+{
+ heif_context* ctx = heif_context_alloc();
+
+ Context() = default;
+ Context(const Context&) = delete;
+ Context& operator=(const Context&) = delete;
+
+ ~Context() { heif_context_free(ctx); }
+};
+
+heif_error read(Context& c, const std::vector<uint8_t>& file)
+{
+ REQUIRE(c.ctx != nullptr);
+ return heif_context_read_from_memory(c.ctx, file.data(), file.size(), nullptr);
+}
+
+// Index of each sample within its chunk, in sample order.
+std::vector<int> positions_in_chunks(heif_context* ctx)
+{
+ heif_track* track = heif_context_get_track(ctx, 0);
+ REQUIRE(track != nullptr);
+
+ std::vector<int> positions;
+ for (;;) {
+ heif_raw_sequence_sample* sample = nullptr;
+ heif_error err = heif_track_get_next_raw_sequence_sample(track, &sample);
+ if (err.code == heif_error_End_of_sequence) {
+ break;
+ }
+ REQUIRE(err.code == heif_error_Ok);
+
+ size_t size = 0;
+ const uint8_t* data = heif_raw_sequence_sample_get_data(sample, &size);
+ REQUIRE(size == 1);
+ positions.push_back(data[0] - 0xA0);
+ heif_raw_sequence_sample_release(sample);
+ }
+
+ heif_track_release(track);
+ return positions;
+}
+
+} // namespace
+
+
+TEST_CASE("'stsc' runs map chunks to the entry they start in")
+{
+ // chunks 1-2: 1 sample each, chunks 3-5: 2 samples each
+ Context c;
+ REQUIRE(read(c, build_sequence_file(params_for({{1, 1, 1}, {3, 2, 1}}, 5, 8))).code == heif_error_Ok);
+ REQUIRE(positions_in_chunks(c.ctx) == std::vector<int>{0, 0, 0, 1, 0, 1, 0, 1});
+}
+
+
+TEST_CASE("'stsc' with one entry per chunk")
+{
+ // chunk k has (k % 3) + 1 samples
+ std::vector<Stsc_entry> entries;
+ std::vector<int> expected;
+ uint32_t num_samples = 0;
+ for (uint32_t k = 1; k <= 1000; k++) {
+ uint32_t n = (k % 3) + 1;
+ entries.push_back({k, n, 1});
+ for (uint32_t j = 0; j < n; j++) expected.push_back(static_cast<int>(j));
+ num_samples += n;
+ }
+
+ Context c;
+ REQUIRE(read(c, build_sequence_file(params_for(entries, 1000, num_samples))).code == heif_error_Ok);
+ REQUIRE(positions_in_chunks(c.ctx) == expected);
+}
+
+
+TEST_CASE("'stsc' entries with equal first_chunk: the last one of the run applies")
+{
+ // the entry with 5 samples per chunk describes an empty run
+ Context c;
+ REQUIRE(read(c, build_sequence_file(params_for({{1, 1, 1}, {3, 5, 1}, {3, 2, 1}}, 5, 8))).code == heif_error_Ok);
+ REQUIRE(positions_in_chunks(c.ctx) == std::vector<int>{0, 0, 0, 1, 0, 1, 0, 1});
+}
+
+
+TEST_CASE("'stsc' with decreasing first_chunk is rejected")
+{
+ // The former linear scan accepted this table: it mapped chunks 1-3 to the first entry and
+ // chunks 4-5 to the last one (7 samples), and never used the entry that starts at chunk 4.
+ Context c;
+ heif_error err = read(c, build_sequence_file(params_for({{1, 1, 1}, {4, 2, 1}, {3, 2, 1}}, 5, 7)));
+ REQUIRE(err.code == heif_error_Invalid_input);
+}