Commit faae2e29 for libheif
commit faae2e29b01beb414fef8b02aae4ab717f136d0c
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sun Oct 25 02:56:53 2026 +0200
Bound coded pictures per decode [GHSA-2g6j-vhr6-6wrj]
Reject more than eight coded pictures in the configuration and item/sample data sent to one codec decode. Count picture starts for NAL codecs, frame OBUs for AV1, and reject embedded AVC start codes that OpenH264 would interpret as extra NAL units.
diff --git a/libheif/codecs/avc_dec.cc b/libheif/codecs/avc_dec.cc
index 3fe1ac75..f08bf7f8 100644
--- a/libheif/codecs/avc_dec.cc
+++ b/libheif/codecs/avc_dec.cc
@@ -102,6 +102,13 @@ Result<std::optional<ImageSize>> Decoder_AVC::get_max_coded_image_size(const std
}
+Result<uint32_t> Decoder_AVC::get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t) const
+{
+ return count_nal_coded_pictures(compressed_data, heif_compression_AVC);
+}
+
+
Error Decoder_AVC::get_coded_image_colorspace(heif_colorspace* out_colorspace, heif_chroma* out_chroma) const
{
*out_chroma = m_avcC->get_configuration().chroma_format;
diff --git a/libheif/codecs/avc_dec.h b/libheif/codecs/avc_dec.h
index f5609b88..0bfae68e 100644
--- a/libheif/codecs/avc_dec.h
+++ b/libheif/codecs/avc_dec.h
@@ -48,6 +48,9 @@ public:
Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;
+ Result<uint32_t> get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const override;
+
private:
const std::shared_ptr<const Box_avcC> m_avcC;
};
diff --git a/libheif/codecs/avif_boxes.cc b/libheif/codecs/avif_boxes.cc
index 48bb176a..3ea74f3e 100644
--- a/libheif/codecs/avif_boxes.cc
+++ b/libheif/codecs/avif_boxes.cc
@@ -785,13 +785,15 @@ Error normalize_av1_sample_data(std::vector<uint8_t>& data, size_t sample_offset
Result<bool> find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
- uint32_t* out_max_width, uint32_t* out_max_height)
+ uint32_t* out_max_width, uint32_t* out_max_height,
+ uint32_t* out_frame_count)
{
// Walk every OBU and keep the largest frame size found in any sequence header,
// because that is the buffer a decoder will allocate, independent of the
// (possibly much smaller) 'ispe' dimensions.
if (dataSize == 0) {
+ if (out_frame_count) { *out_frame_count = 0; }
return false;
}
if (data == nullptr || dataSize > (size_t) std::numeric_limits<int>::max()) {
@@ -805,6 +807,7 @@ Result<bool> find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataS
bool found = false;
uint32_t max_width = 0;
uint32_t max_height = 0;
+ uint32_t frame_count = 0;
while (reader.get_bits_remaining() >= 8) {
obu_header_info header = read_obu_header_type(reader);
@@ -834,6 +837,13 @@ Result<bool> find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataS
size_t payload_len = (size_t) header.size;
+ // FRAME contains its own header; FRAME_HEADER starts a frame whose tiles
+ // follow in separate OBUs. Redundant headers are conservatively counted as
+ // well: malformed streams may be interpreted differently by plugins.
+ if (header.type == 3 || header.type == 6 || header.type == 7) {
+ ++frame_count;
+ }
+
if (header.type == HEIF_OBU_SEQUENCE_HEADER) {
BitReader seq_reader(data + payload_start, (int) payload_len);
uint32_t w = 0, h = 0;
@@ -862,5 +872,7 @@ Result<bool> find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataS
*out_max_height = max_height;
}
+ if (out_frame_count) { *out_frame_count = frame_count; }
+
return found;
}
diff --git a/libheif/codecs/avif_boxes.h b/libheif/codecs/avif_boxes.h
index 5691df92..6a23e81e 100644
--- a/libheif/codecs/avif_boxes.h
+++ b/libheif/codecs/avif_boxes.h
@@ -181,6 +181,7 @@ Error normalize_av1_sample_data(std::vector<uint8_t>& data, size_t sample_offset
// a complete scan is returned as an error so that a partial result cannot be
// used as a security bound.
Result<bool> find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
- uint32_t* out_max_width, uint32_t* out_max_height);
+ uint32_t* out_max_width, uint32_t* out_max_height,
+ uint32_t* out_frame_count = nullptr);
#endif
diff --git a/libheif/codecs/avif_dec.cc b/libheif/codecs/avif_dec.cc
index fba2a3f8..0bc70bd0 100644
--- a/libheif/codecs/avif_dec.cc
+++ b/libheif/codecs/avif_dec.cc
@@ -113,6 +113,31 @@ Result<std::optional<ImageSize>> Decoder_AVIF::get_max_coded_image_size(const st
}
+Result<uint32_t> Decoder_AVIF::get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const
+{
+ if (configuration_data_size > compressed_data.size()) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "AV1 stream was rejected because its configuration-data size is invalid."};
+ }
+ uint32_t total = 0;
+ auto scan_region = [&](size_t offset, size_t size) -> Error {
+ uint32_t width = 0, height = 0, count = 0;
+ auto result = find_max_av1_frame_size_in_stream(compressed_data.data() + offset, size,
+ &width, &height, &count);
+ if (!result) { return result.error(); }
+ total += count;
+ return Error::Ok;
+ };
+ Error err = scan_region(0, configuration_data_size);
+ if (err) { return err; }
+ err = scan_region(configuration_data_size,
+ compressed_data.size() - configuration_data_size);
+ if (err) { return err; }
+ return total;
+}
+
+
int Decoder_AVIF::get_luma_bits_per_pixel() const
{
Box_av1C::configuration config = m_av1C->get_configuration();
diff --git a/libheif/codecs/avif_dec.h b/libheif/codecs/avif_dec.h
index 55790183..32c2f428 100644
--- a/libheif/codecs/avif_dec.h
+++ b/libheif/codecs/avif_dec.h
@@ -52,6 +52,9 @@ public:
Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data,
size_t configuration_data_size) const override;
+ Result<uint32_t> get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const override;
+
private:
const std::shared_ptr<const Box_av1C> m_av1C;
};
diff --git a/libheif/codecs/decoder.cc b/libheif/codecs/decoder.cc
index a938030a..ad908050 100644
--- a/libheif/codecs/decoder.cc
+++ b/libheif/codecs/decoder.cc
@@ -360,6 +360,88 @@ split_nal_units_4byte_length_prefixed(const uint8_t* data, size_t size)
}
+Result<uint32_t> count_nal_coded_pictures(const std::vector<uint8_t>& data,
+ heif_compression_format codec)
+{
+ size_t pos = 0;
+ uint32_t pictures = 0;
+ while (pos < data.size()) {
+ if (data.size() - pos < 4) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "NAL stream was rejected because a length field is truncated."};
+ }
+ uint32_t length = (uint32_t(data[pos]) << 24) | (uint32_t(data[pos + 1]) << 16) |
+ (uint32_t(data[pos + 2]) << 8) | data[pos + 3];
+ pos += 4;
+ if (length == 0 || length > data.size() - pos) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "NAL stream was rejected because a NAL unit size is invalid."};
+ }
+ const uint8_t* nal = data.data() + pos;
+
+ // OpenH264 interprets these bytes as additional NAL boundaries even when
+ // they occur inside a length-prefixed unit. Reject them before any plugin
+ // sees the buffer, or the picture count could be bypassed.
+ if (codec == heif_compression_AVC) {
+ for (size_t i = 2; i < length; ++i) {
+ if (nal[i - 2] == 0 && nal[i - 1] == 0 && nal[i] <= 2) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "AVC stream was rejected because a NAL unit contains a start-code prefix."};
+ }
+ }
+ }
+
+ if (codec == heif_compression_HEVC) {
+ if (length < 2) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "HEVC stream was rejected because a NAL header is truncated."};
+ }
+ // Every layer counts. A first slice begins a coded picture; subsequent
+ // slices of that picture must not consume the picture budget again.
+ if (((nal[0] >> 1) & 0x3f) <= 31) {
+ if (length < 3) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "HEVC stream was rejected because a slice header is truncated."};
+ }
+ pictures += (nal[2] & 0x80) != 0;
+ }
+ }
+ else if (codec == heif_compression_AVC) {
+ const int type = nal[0] & 0x1f;
+ const size_t header_size = type == 20 ? 4 : 1;
+ if (type == 1 || type == 2 || type == 5 || type == 20) {
+ if (length <= header_size) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "AVC stream was rejected because a slice header is truncated."};
+ }
+ // first_mb_in_slice is ue(v); its value is zero iff its first bit is 1.
+ pictures += (nal[header_size] & 0x80) != 0;
+ }
+ }
+ else if (codec == heif_compression_VVC) {
+ if (length < 2) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "VVC stream was rejected because a NAL header is truncated."};
+ }
+ const int type = (nal[1] >> 3) & 0x1f;
+ if (type == 19) { // PH_NUT
+ ++pictures;
+ }
+ else if (type <= 11) { // VCL; picture header may be in its slice header
+ if (length < 3) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "VVC stream was rejected because a slice header is truncated."};
+ }
+ pictures += (nal[2] & 0x80) != 0;
+ }
+ }
+
+ pos += length;
+ }
+ return pictures;
+}
+
+
void Decoder::release_decoder()
{
@@ -520,6 +602,18 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
}
}
+ auto pictureCount = get_number_of_coded_pictures(*dataResult, configuration_data_size);
+ if (!pictureCount) {
+ return pictureCount.error();
+ }
+ // v1.23.x cannot extend the public security-limits struct. Keep the bound
+ // local to this decode call, so tiles and sequence samples have independent
+ // budgets, rather than charging all of a grid against one global counter.
+ if (*pictureCount > 8) {
+ return Error{heif_error_Invalid_input, heif_suberror_Unspecified,
+ "Image data was rejected because it contains more than 8 coded pictures in one decode."};
+ }
+
// --- decode image with the plugin
heif_error err;
diff --git a/libheif/codecs/decoder.h b/libheif/codecs/decoder.h
index 309d272f..9a8da345 100644
--- a/libheif/codecs/decoder.h
+++ b/libheif/codecs/decoder.h
@@ -57,6 +57,11 @@ struct ImageSize
std::vector<std::pair<const uint8_t*, size_t>>
split_nal_units_4byte_length_prefixed(const uint8_t* data, size_t size);
+// Scan the complete length-prefixed buffer that will be submitted to a NAL
+// decoder. Invalid framing is an error, not a partial picture count.
+Result<uint32_t> count_nal_coded_pictures(const std::vector<uint8_t>& data,
+ heif_compression_format codec);
+
// Specifies the input data for decoding.
// For images, this points to the iloc extents.
@@ -151,6 +156,16 @@ public:
return get_max_coded_image_size(compressed_data);
}
+ // Count coded pictures in the exact buffer sent to the plugin, including
+ // configuration data. One call covers one image item (or grid tile) or one
+ // sequence sample. Codecs with no multi-picture bitstream return zero.
+ [[nodiscard]] virtual Result<uint32_t>
+ get_number_of_coded_pictures(const std::vector<uint8_t>& /*compressed_data*/,
+ size_t /*configuration_data_size*/) const
+ {
+ return uint32_t{0};
+ }
+
// Normalize codec framing in the exact buffer that will be checked and sent
// to the decoder. Most codecs need no transformation.
virtual Error prepare_compressed_data_for_decoding(std::vector<uint8_t>& /*compressed_data*/,
diff --git a/libheif/codecs/hevc_dec.cc b/libheif/codecs/hevc_dec.cc
index ca56bd98..cd313a12 100644
--- a/libheif/codecs/hevc_dec.cc
+++ b/libheif/codecs/hevc_dec.cc
@@ -102,6 +102,13 @@ Result<std::optional<ImageSize>> Decoder_HEVC::get_max_coded_image_size(const st
}
+Result<uint32_t> Decoder_HEVC::get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t) const
+{
+ return count_nal_coded_pictures(compressed_data, heif_compression_HEVC);
+}
+
+
Error Decoder_HEVC::get_coded_image_colorspace(heif_colorspace* out_colorspace, heif_chroma* out_chroma) const
{
*out_chroma = (heif_chroma) (m_hvcC->get_configuration().chroma_format);
diff --git a/libheif/codecs/hevc_dec.h b/libheif/codecs/hevc_dec.h
index 27bc324a..c4d8b210 100644
--- a/libheif/codecs/hevc_dec.h
+++ b/libheif/codecs/hevc_dec.h
@@ -49,6 +49,9 @@ public:
Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;
+ Result<uint32_t> get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const override;
+
private:
const std::shared_ptr<const Box_hvcC> m_hvcC;
};
diff --git a/libheif/codecs/vvc_dec.cc b/libheif/codecs/vvc_dec.cc
index 8e0d3e6c..fb4d5651 100644
--- a/libheif/codecs/vvc_dec.cc
+++ b/libheif/codecs/vvc_dec.cc
@@ -109,6 +109,13 @@ Result<std::optional<ImageSize>> Decoder_VVC::get_max_coded_image_size(const std
}
+Result<uint32_t> Decoder_VVC::get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t) const
+{
+ return count_nal_coded_pictures(compressed_data, heif_compression_VVC);
+}
+
+
Error Decoder_VVC::get_coded_image_colorspace(heif_colorspace* out_colorspace, heif_chroma* out_chroma) const
{
*out_chroma = (heif_chroma) (m_vvcC->get_configuration().chroma_format_idc);
diff --git a/libheif/codecs/vvc_dec.h b/libheif/codecs/vvc_dec.h
index 7c45dab8..159e59d3 100644
--- a/libheif/codecs/vvc_dec.h
+++ b/libheif/codecs/vvc_dec.h
@@ -49,6 +49,9 @@ public:
Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;
+ Result<uint32_t> get_number_of_coded_pictures(const std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const override;
+
private:
const std::shared_ptr<const Box_vvcC> m_vvcC;
};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 3812510f..7a52053e 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -74,6 +74,7 @@ add_libheif_test(sdtp_sample_limit)
add_libheif_test(avc_box)
add_libheif_test(vvc_sps)
add_libheif_test(av1_coded_size)
+ add_libheif_test(coded_picture_count)
add_libheif_test(hevc_sps)
add_libheif_test(hevc_brands)
add_libheif_test(file_layout)
diff --git a/tests/av1_coded_size.cc b/tests/av1_coded_size.cc
index c167f12a..65b3f311 100644
--- a/tests/av1_coded_size.cc
+++ b/tests/av1_coded_size.cc
@@ -228,6 +228,36 @@ TEST_CASE("terminal unsized AV1 sequence header remains scannable")
}
+TEST_CASE("AV1 frame budget counts frame OBUs in both configuration and sample data")
+{
+ std::vector<uint8_t> config;
+ std::vector<uint8_t> sample;
+ for (int i = 0; i < 3; ++i) {
+ config.push_back(0x32); // OBU_FRAME
+ config.push_back(0x01);
+ config.push_back(0x00);
+ }
+ for (int i = 0; i < 6; ++i) {
+ sample.push_back(0x1a); // OBU_FRAME_HEADER
+ sample.push_back(0x01);
+ sample.push_back(0x00);
+ sample.push_back(0x22); // OBU_TILE_GROUP (not a frame)
+ sample.push_back(0x01);
+ sample.push_back(0x00);
+ }
+
+ uint32_t width = 0, height = 0, count = 0;
+ auto result = find_max_av1_frame_size_in_stream(config.data(), config.size(),
+ &width, &height, &count);
+ REQUIRE(result);
+ CHECK(count == 3);
+ result = find_max_av1_frame_size_in_stream(sample.data(), sample.size(),
+ &width, &height, &count);
+ REQUIRE(result);
+ CHECK(count == 6); // nine total, exceeding the per-decode limit of eight
+}
+
+
TEST_CASE("bytes after an unsized AV1 sample OBU remain in its payload")
{
std::vector<uint8_t> data;
diff --git a/tests/coded_picture_count.cc b/tests/coded_picture_count.cc
new file mode 100644
index 00000000..29ad5720
--- /dev/null
+++ b/tests/coded_picture_count.cc
@@ -0,0 +1,76 @@
+/*
+ libheif coded-picture budget tests
+
+ 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 "codecs/decoder.h"
+
+#include <initializer_list>
+#include <vector>
+
+
+static std::vector<uint8_t> nal_stream(std::initializer_list<std::vector<uint8_t>> units)
+{
+ std::vector<uint8_t> stream;
+ for (const auto& unit : units) {
+ stream.insert(stream.end(), {0, 0, 0, static_cast<uint8_t>(unit.size())});
+ stream.insert(stream.end(), unit.begin(), unit.end());
+ }
+ return stream;
+}
+
+
+TEST_CASE("NAL picture count distinguishes slices from pictures")
+{
+ auto hevc = count_nal_coded_pictures(nal_stream({{0x28, 0x01, 0x80},
+ {0x28, 0x01, 0x00},
+ {0x28, 0x01, 0x80}}),
+ heif_compression_HEVC);
+ REQUIRE(hevc);
+ CHECK(*hevc == 2);
+
+ auto avc = count_nal_coded_pictures(nal_stream({{0x65, 0x80}, {0x65, 0x40}, {0x65, 0x80}}),
+ heif_compression_AVC);
+ REQUIRE(avc);
+ CHECK(*avc == 2);
+
+ auto vvc = count_nal_coded_pictures(nal_stream({{0x00, 0x99, 0x00},
+ {0x00, 0x39, 0x00},
+ {0x00, 0x39, 0x80}}),
+ heif_compression_VVC);
+ REQUIRE(vvc);
+ CHECK(*vvc == 2);
+}
+
+
+TEST_CASE("AVC picture count rejects embedded start codes and incomplete framing")
+{
+ auto embedded = count_nal_coded_pictures(nal_stream({{0x65, 0x80, 0x00, 0x00, 0x01, 0x65, 0x80}}),
+ heif_compression_AVC);
+ CHECK(embedded.is_error());
+
+ auto truncated = count_nal_coded_pictures({0, 0, 0, 12, 0x65, 0x80},
+ heif_compression_AVC);
+ CHECK(truncated.is_error());
+}