Commit bbf8e3ab for libheif
commit bbf8e3abd60038e8867bf2673d694313f97c6c3a
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sat Oct 24 21:48:39 2026 +0200
Normalize unsized AV1 sample OBUs [GHSA-jx54-6rm6-2cgj]
diff --git a/libheif/codecs/avif_boxes.cc b/libheif/codecs/avif_boxes.cc
index 859b0957..3e4a241c 100644
--- a/libheif/codecs/avif_boxes.cc
+++ b/libheif/codecs/avif_boxes.cc
@@ -269,25 +269,31 @@ Error Box_a1lx::write(StreamWriter& writer) const
}
-static uint64_t leb128(BitReader& reader)
+static bool leb128(BitReader& reader, uint64_t* out_value)
{
uint64_t val = 0;
for (int i = 0; i < 8; i++) {
+ if (reader.get_bits_remaining() < 8) {
+ return false;
+ }
+
int64_t v = reader.get_bits(8);
val |= (v & 0x7F) << (i * 7);
if (!(v & 0x80)) {
- break;
+ *out_value = val;
+ return true;
}
}
- return val;
+ return false;
}
struct obu_header_info
{
- int type;
- bool has_size;
+ int type = 0;
+ bool has_size = false;
+ bool valid = false;
uint64_t size = 0;
};
@@ -295,6 +301,10 @@ static obu_header_info read_obu_header_type(BitReader& reader)
{
obu_header_info info;
+ if (reader.get_bits_remaining() < 8) {
+ return info;
+ }
+
reader.skip_bits(1);
info.type = reader.get_bits(4);
bool has_extension = reader.get_bits(1);
@@ -302,13 +312,19 @@ static obu_header_info read_obu_header_type(BitReader& reader)
reader.skip_bits(1);
if (has_extension) {
+ if (reader.get_bits_remaining() < 8) {
+ return info;
+ }
reader.skip_bits(8);
}
if (info.has_size) {
- info.size = leb128(reader);
+ if (!leb128(reader, &info.size)) {
+ return info;
+ }
}
+ info.valid = true;
return info;
}
@@ -336,6 +352,9 @@ bool fill_av1C_configuration_from_stream(Box_av1C::configuration* out_config, co
while (reader.get_bits_remaining() > 0) {
obu_header_info header_info = read_obu_header_type(reader);
+ if (!header_info.valid) {
+ return false;
+ }
if (header_info.type == HEIF_OBU_SEQUENCE_HEADER) {
seq_header_found = true;
break;
@@ -651,20 +670,95 @@ static bool parse_av1_seq_header_max_frame_size(BitReader& reader,
}
-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)
+Error normalize_av1_sample_data(std::vector<uint8_t>& data, size_t sample_offset)
{
- // The combined AV1 bitstream that libheif pushes to a decoder plugin consists
- // of the av1C configOBUs followed by the item/sample data. A conforming AVIF
- // carries exactly one OBU_SEQUENCE_HEADER in the item data (and may duplicate
- // it in configOBUs). We 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 (data == nullptr || dataSize == 0 ||
- dataSize > (size_t) std::numeric_limits<int>::max()) {
+ if (sample_offset > data.size()) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because its sample-data offset is invalid."};
+ }
+
+ size_t pos = sample_offset;
+ while (pos < data.size()) {
+ size_t header_pos = pos++;
+ uint8_t header = data[header_pos];
+ bool has_extension = (header & 0x04) != 0;
+ bool has_size = (header & 0x02) != 0;
+
+ if (has_extension) {
+ if (pos == data.size()) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because an OBU header is truncated."};
+ }
+ pos++;
+ }
+
+ if (!has_size) {
+ // AV1 ISOBMFF permits only the last OBU in a sample to omit its size. Its
+ // payload is therefore the complete sample remainder. Make that framing
+ // explicit before either the security scanner or decoder sees the bytes.
+ uint64_t payload_size = data.size() - pos;
+ std::vector<uint8_t> encoded_size;
+ do {
+ uint8_t byte = payload_size & 0x7f;
+ payload_size >>= 7;
+ if (payload_size != 0) {
+ byte |= 0x80;
+ }
+ encoded_size.push_back(byte);
+ } while (payload_size != 0);
+
+ data[header_pos] |= 0x02;
+ data.insert(data.begin() + static_cast<std::ptrdiff_t>(pos),
+ encoded_size.begin(), encoded_size.end());
+ return Error::Ok;
+ }
+
+ uint64_t payload_size = 0;
+ bool size_complete = false;
+ for (int i = 0; i < 8 && pos < data.size(); i++) {
+ uint8_t byte = data[pos++];
+ payload_size |= static_cast<uint64_t>(byte & 0x7f) << (i * 7);
+ if ((byte & 0x80) == 0) {
+ size_complete = true;
+ break;
+ }
+ }
+
+ if (!size_complete) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because an OBU size is truncated."};
+ }
+ if (payload_size > data.size() - pos) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because an OBU size exceeds the remaining data."};
+ }
+
+ pos += static_cast<size_t>(payload_size);
+ }
+
+ return Error::Ok;
+}
+
+
+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)
+{
+ // 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) {
return false;
}
+ if (data == nullptr || dataSize > (size_t) std::numeric_limits<int>::max()) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because it cannot be scanned completely."};
+ }
BitReader reader(data, (int) dataSize);
@@ -675,9 +769,16 @@ bool find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
while (reader.get_bits_remaining() >= 8) {
obu_header_info header = read_obu_header_type(reader);
- // read_obu_header_type() may read past the end on truncated input.
- if (reader.get_bits_remaining() < 0) {
- break;
+ if (!header.valid) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because an OBU header or size is truncated."};
+ }
+
+ if (!header.has_size) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because of an unknown OBU header size."};
}
// The reader is byte-aligned after the OBU header; this is where the payload
@@ -685,41 +786,35 @@ bool find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
// payload so that neither the seq-header parse nor a following OBU can read
// across the declared OBU boundary.
size_t payload_start = reader.get_current_byte_index();
- if (payload_start >= dataSize) {
- break;
+ if (payload_start > dataSize || header.size > dataSize - payload_start) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because an OBU size exceeds the remaining data."};
}
- if (header.type == HEIF_OBU_SEQUENCE_HEADER) {
- size_t avail = dataSize - payload_start;
- size_t payload_len = avail;
- if (header.has_size && header.size < payload_len) {
- payload_len = (size_t) header.size;
- }
+ size_t payload_len = (size_t) header.size;
+ if (header.type == HEIF_OBU_SEQUENCE_HEADER) {
BitReader seq_reader(data + payload_start, (int) payload_len);
uint32_t w = 0, h = 0;
- if (parse_av1_seq_header_max_frame_size(seq_reader, &w, &h)) {
- found = true;
- // Track the largest width and height independently. A conforming file
- // has a single sequence header (the AVIF spec even requires identical
- // headers when several are present), so this equals that header's size.
- // For a non-conforming file with differing headers it over-approximates
- // rather than under-approximates the buffer any frame could demand,
- // which is the safe direction for a security gate.
- max_width = std::max(max_width, w);
- max_height = std::max(max_height, h);
+ if (!parse_av1_seq_header_max_frame_size(seq_reader, &w, &h)) {
+ return Error{heif_error_Invalid_input,
+ heif_suberror_Unspecified,
+ "AV1 stream was rejected because a sequence header cannot be scanned completely."};
}
- }
- // Advance to the next OBU. Without an explicit size we cannot locate it.
- if (!header.has_size) {
- break;
- }
- if (header.size > (uint64_t) std::numeric_limits<int>::max()) {
- break;
+ found = true;
+ // Track the largest width and height independently. A conforming file
+ // has a single sequence header (the AVIF spec even requires identical
+ // headers when several are present), so this equals that header's size.
+ // For a non-conforming file with differing headers it over-approximates
+ // rather than under-approximates the buffer any frame could demand,
+ // which is the safe direction for a security gate.
+ max_width = std::max(max_width, w);
+ max_height = std::max(max_height, h);
}
- reader.skip_bytes((uint32_t) header.size);
+ reader.skip_bytes((uint32_t) payload_len);
}
if (found) {
diff --git a/libheif/codecs/avif_boxes.h b/libheif/codecs/avif_boxes.h
index f59f51d4..5691df92 100644
--- a/libheif/codecs/avif_boxes.h
+++ b/libheif/codecs/avif_boxes.h
@@ -168,12 +168,19 @@ Error fill_av1C_configuration(Box_av1C::configuration* inout_config, const std::
bool fill_av1C_configuration_from_stream(Box_av1C::configuration* out_config, const uint8_t* data, int dataSize);
-// Scan a combined AV1 bitstream (av1C configOBUs + item/sample data) for every
-// OBU_SEQUENCE_HEADER and return the largest coded frame size (in luma samples)
-// declared by any of them. Returns false when no parseable sequence header was
-// found, in which case the size outputs are left untouched. Used to enforce the
-// security limits before the bitstream reaches any AV1 decoder plugin.
-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);
+// In an AV1 ISOBMFF sample, make a terminal OBU without a size field explicit:
+// set obu_has_size_field and insert a LEB128 size covering the sample remainder.
+// This makes the framing passed to a decoder unambiguous. All preceding OBUs
+// must carry valid sizes. `sample_offset` skips any prepended av1C configOBUs.
+Error normalize_av1_sample_data(std::vector<uint8_t>& data, size_t sample_offset);
+
+// Scan an explicitly-sized AV1 OBU stream for every OBU_SEQUENCE_HEADER and
+// return the largest coded frame size (in luma samples) declared by any of them.
+// The bool result is false when a complete scan found no sequence header, in
+// which case the size outputs are left untouched. Malformed input that prevents
+// 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);
#endif
diff --git a/libheif/codecs/avif_dec.cc b/libheif/codecs/avif_dec.cc
index 64ccc8b7..fba2a3f8 100644
--- a/libheif/codecs/avif_dec.cc
+++ b/libheif/codecs/avif_dec.cc
@@ -23,6 +23,7 @@
#include "error.h"
#include "context.h"
+#include <algorithm>
#include <string>
@@ -38,7 +39,15 @@ Result<std::vector<uint8_t>> Decoder_AVIF::read_bitstream_configuration_data() c
}
-Result<std::optional<ImageSize>> Decoder_AVIF::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const
+Error Decoder_AVIF::prepare_compressed_data_for_decoding(std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const
+{
+ return normalize_av1_sample_data(compressed_data, configuration_data_size);
+}
+
+
+Result<std::optional<ImageSize>> Decoder_AVIF::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const
{
// The AV1 sequence header carries the coded frame size
// (max_frame_width_minus_1 / max_frame_height_minus_1). Per the AVIF
@@ -47,14 +56,55 @@ Result<std::optional<ImageSize>> Decoder_AVIF::get_max_coded_image_size(const st
// coded size, which can be far larger than the (possibly deliberately small)
// 'ispe' dimensions on which the container-level size checks are based.
//
- // `compressed_data` is the combined configOBUs + item data buffer that is about
- // to be pushed to the decoder plugin. Scan it for the largest sequence-header
- // frame size so the shared decode path can reject over-limit inputs before ANY
- // AV1 plugin (aom, dav1d, ffmpeg, ...) allocates a frame buffer.
+ // `compressed_data` is the combined configOBUs + item/sample data buffer that
+ // is about to be pushed to the decoder plugin. Scan both regions for the
+ // largest sequence-header frame size so the shared decode path can reject
+ // over-limit inputs before ANY AV1 plugin allocates a frame buffer. They are
+ // scanned separately because the sample boundary must not depend on the
+ // contents of configOBUs. prepare_compressed_data_for_decoding() has already
+ // made any terminal unsized sample OBU explicit, so every OBU is now sized.
+ 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."};
+ }
+
+ bool found = false;
uint32_t max_width = 0;
uint32_t max_height = 0;
- if (!find_max_av1_frame_size_in_stream(compressed_data.data(), compressed_data.size(),
- &max_width, &max_height)) {
+
+ auto scan_region = [&](size_t offset, size_t size) -> Error {
+ if (size == 0) {
+ return Error::Ok;
+ }
+
+ uint32_t width = 0;
+ uint32_t height = 0;
+ auto result = find_max_av1_frame_size_in_stream(compressed_data.data() + offset, size,
+ &width, &height);
+ if (!result) {
+ return result.error();
+ }
+ if (*result) {
+ found = true;
+ max_width = std::max(max_width, width);
+ max_height = std::max(max_height, height);
+ }
+ 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;
+ }
+
+ if (!found) {
// No parseable sequence header (e.g. a non-sync frame in a sequence).
return std::optional<ImageSize>{};
}
diff --git a/libheif/codecs/avif_dec.h b/libheif/codecs/avif_dec.h
index 3478906d..55790183 100644
--- a/libheif/codecs/avif_dec.h
+++ b/libheif/codecs/avif_dec.h
@@ -46,7 +46,11 @@ public:
Result<std::vector<uint8_t>> read_bitstream_configuration_data() const override;
- Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;
+ Error prepare_compressed_data_for_decoding(std::vector<uint8_t>& compressed_data,
+ size_t configuration_data_size) const override;
+
+ Result<std::optional<ImageSize>> get_max_coded_image_size(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 76dda8ee..a938030a 100644
--- a/libheif/codecs/decoder.cc
+++ b/libheif/codecs/decoder.cc
@@ -282,8 +282,13 @@ std::shared_ptr<Decoder> Decoder::alloc_for_sequence_sample_description_box(cons
}
-Result<std::vector<uint8_t>> Decoder::get_compressed_data(bool with_configuration_NALs) const
+Result<std::vector<uint8_t>> Decoder::get_compressed_data(bool with_configuration_NALs,
+ size_t* out_configuration_data_size) const
{
+ if (out_configuration_data_size) {
+ *out_configuration_data_size = 0;
+ }
+
// --- get the compressed image data
if (with_configuration_NALs) {
@@ -295,6 +300,9 @@ Result<std::vector<uint8_t>> Decoder::get_compressed_data(bool with_configuratio
}
std::vector<uint8_t> data = *confData;
+ if (out_configuration_data_size) {
+ *out_configuration_data_size = data.size();
+ }
// append image data
@@ -470,7 +478,8 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
// Fetch the compressed data once. The same buffer is used both to enforce the
// security limits below and to feed the decoder, so we neither re-read the
// iloc extents nor rebuild the combined config+bitstream buffer twice.
- auto dataResult = get_compressed_data(upload_configuration_NALs);
+ size_t configuration_data_size = 0;
+ auto dataResult = get_compressed_data(upload_configuration_NALs, &configuration_data_size);
if (!dataResult) {
return dataResult.error();
}
@@ -486,6 +495,11 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
};
}
+ Error prepareErr = prepare_compressed_data_for_decoding(*dataResult, configuration_data_size);
+ if (prepareErr) {
+ return prepareErr;
+ }
+
// Reject memory-bomb inputs whose coded picture size exceeds libheif's
// security limits, before handing any bytes to the decoder plugin. The coded
// (pre-crop) size the decoder will allocate lives in the bitstream: the SPS for
@@ -494,7 +508,7 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
// an SPS may sit in the item data rather than only in the config record, so we
// scan the whole buffer that is about to be pushed. Codecs that expose no coded
// size return nullopt and skip the check.
- auto codedSize = get_max_coded_image_size(*dataResult);
+ auto codedSize = get_max_coded_image_size(*dataResult, configuration_data_size);
if (codedSize.is_error()) {
return codedSize.error();
}
diff --git a/libheif/codecs/decoder.h b/libheif/codecs/decoder.h
index 1a353d59..309d272f 100644
--- a/libheif/codecs/decoder.h
+++ b/libheif/codecs/decoder.h
@@ -142,7 +142,25 @@ public:
return std::optional<ImageSize>{};
}
- Result<std::vector<uint8_t>> get_compressed_data(bool with_configuration_NALs) const;
+ // As above, with the length of the configuration-data prefix supplied for
+ // codecs whose framing rules differ between configuration and sample data.
+ [[nodiscard]] virtual Result<std::optional<ImageSize>>
+ get_max_coded_image_size(const std::vector<uint8_t>& compressed_data,
+ size_t /*configuration_data_size*/) const
+ {
+ return get_max_coded_image_size(compressed_data);
+ }
+
+ // 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*/,
+ size_t /*configuration_data_size*/) const
+ {
+ return Error::Ok;
+ }
+
+ Result<std::vector<uint8_t>> get_compressed_data(bool with_configuration_NALs,
+ size_t* out_configuration_data_size = nullptr) const;
// --- decoding
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index da4194c3..1513b6ac 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -71,6 +71,7 @@ add_libheif_test(sdtp_sample_limit)
add_libheif_test(add_channel_checks)
add_libheif_test(jpeg2000)
add_libheif_test(avc_box)
+ add_libheif_test(av1_coded_size)
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
new file mode 100644
index 00000000..15013dbd
--- /dev/null
+++ b/tests/av1_coded_size.cc
@@ -0,0 +1,268 @@
+/*
+ libheif AV1 coded-size scan 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/avif_boxes.h"
+#include "bitstream.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+
+static void append_sequence_header(std::vector<uint8_t>& data, uint32_t width, uint32_t height)
+{
+ BitWriter writer;
+ writer.write_bits(0, 3); // seq_profile
+ writer.write_bits(1, 1); // still_picture
+ writer.write_bits(1, 1); // reduced_still_picture_header
+ writer.write_bits(0, 5); // seq_level_idx[0]
+ writer.write_bits(15, 4); // frame_width_bits_minus_1
+ writer.write_bits(15, 4); // frame_height_bits_minus_1
+ writer.write_bits(width - 1, 16);
+ writer.write_bits(height - 1, 16);
+ std::vector<uint8_t> payload = writer.get_data();
+
+ data.push_back(0x0A); // obu_type = 1, obu_has_size_field = 1
+ data.push_back(static_cast<uint8_t>(payload.size()));
+ data.insert(data.end(), payload.begin(), payload.end());
+}
+
+
+TEST_CASE("largest of several AV1 sequence headers is found")
+{
+ std::vector<uint8_t> data;
+ append_sequence_header(data, 64, 64);
+ append_sequence_header(data, 8192, 4096);
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result);
+ REQUIRE(*result);
+ CHECK(width == 8192);
+ CHECK(height == 4096);
+}
+
+
+TEST_CASE("zero-sized AV1 OBU completes the scan")
+{
+ std::vector<uint8_t> data;
+ append_sequence_header(data, 64, 48);
+ data.push_back(0x12); // temporal-delimiter OBU with a size field
+ data.push_back(0x00); // zero-sized payload
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result);
+ REQUIRE(*result);
+ CHECK(width == 64);
+ CHECK(height == 48);
+}
+
+
+TEST_CASE("AV1 configuration OBU without a size field is rejected")
+{
+ uint8_t unsized_obu_header = GENERATE(as<uint8_t>{}, 0x00, 0x10, 0x78);
+
+ std::vector<uint8_t> data;
+ append_sequence_header(data, 64, 64);
+ data.push_back(unsized_obu_header);
+ append_sequence_header(data, 8192, 4096);
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result.is_error());
+ CHECK(result.error().error_code == heif_error_Invalid_input);
+ CHECK(result.error().message.find("unknown OBU header size") != std::string::npos);
+}
+
+
+TEST_CASE("terminal AV1 sample OBU gets an explicit size")
+{
+ uint8_t unsized_obu_header = GENERATE(as<uint8_t>{}, 0x10, 0x78);
+
+ std::vector<uint8_t> data;
+ append_sequence_header(data, 64, 48);
+ size_t header_pos = data.size();
+ data.push_back(unsized_obu_header);
+ data.push_back(0xaa);
+ data.push_back(0xbb);
+
+ Error err = normalize_av1_sample_data(data, 0);
+ REQUIRE_FALSE(err);
+ CHECK(data[header_pos] == (unsized_obu_header | 0x02));
+ CHECK(data[header_pos + 1] == 2);
+ CHECK(data[header_pos + 2] == 0xaa);
+ CHECK(data[header_pos + 3] == 0xbb);
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result);
+ REQUIRE(*result);
+ CHECK(width == 64);
+ CHECK(height == 48);
+}
+
+
+TEST_CASE("terminal unsized AV1 sequence header remains scannable")
+{
+ std::vector<uint8_t> data;
+ append_sequence_header(data, 320, 240);
+ REQUIRE(data[0] == 0x0a);
+ REQUIRE(data[1] < 128);
+ data[0] = 0x08;
+ data.erase(data.begin() + 1);
+
+ Error err = normalize_av1_sample_data(data, 0);
+ REQUIRE_FALSE(err);
+ CHECK(data[0] == 0x0a);
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result);
+ REQUIRE(*result);
+ CHECK(width == 320);
+ CHECK(height == 240);
+}
+
+
+TEST_CASE("bytes after an unsized AV1 sample OBU remain in its payload")
+{
+ std::vector<uint8_t> data;
+ append_sequence_header(data, 64, 64);
+ size_t header_pos = data.size();
+ data.push_back(0x00); // unsized reserved OBU
+ append_sequence_header(data, 8192, 4096);
+
+ size_t payload_size = data.size() - header_pos - 1;
+ REQUIRE(payload_size < 128);
+ Error err = normalize_av1_sample_data(data, 0);
+ REQUIRE_FALSE(err);
+ CHECK(data[header_pos] == 0x02);
+ CHECK(data[header_pos + 1] == payload_size);
+
+ // The apparent second sequence header is part of the terminal OBU payload,
+ // both for this scan and for the decoder that receives the same bytes.
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result);
+ REQUIRE(*result);
+ CHECK(width == 64);
+ CHECK(height == 64);
+}
+
+
+TEST_CASE("extended terminal AV1 sample OBU gets its size after the extension")
+{
+ std::vector<uint8_t> data{0x14, 0xab, 0xca, 0xfe};
+
+ Error err = normalize_av1_sample_data(data, 0);
+ REQUIRE_FALSE(err);
+ REQUIRE(data.size() == 5);
+ CHECK(data[0] == 0x16);
+ CHECK(data[1] == 0xab);
+ CHECK(data[2] == 2);
+ CHECK(data[3] == 0xca);
+ CHECK(data[4] == 0xfe);
+}
+
+
+TEST_CASE("terminal AV1 sample OBU gets a multi-byte size")
+{
+ std::vector<uint8_t> data(301, 0xaa);
+ data[0] = 0x10;
+
+ Error err = normalize_av1_sample_data(data, 0);
+ REQUIRE_FALSE(err);
+ REQUIRE(data.size() == 303);
+ CHECK(data[0] == 0x12);
+ CHECK(data[1] == 0xac);
+ CHECK(data[2] == 0x02);
+}
+
+
+TEST_CASE("AV1 sample normalization preserves the configuration prefix")
+{
+ std::vector<uint8_t> data{0x00, 0x10, 0xaa};
+
+ Error err = normalize_av1_sample_data(data, 1);
+ REQUIRE_FALSE(err);
+ REQUIRE(data.size() == 4);
+ CHECK(data[0] == 0x00);
+ CHECK(data[1] == 0x12);
+ CHECK(data[2] == 0x01);
+ CHECK(data[3] == 0xaa);
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), 1, &width, &height);
+ REQUIRE(result.is_error());
+ CHECK(result.error().message.find("unknown OBU header size") != std::string::npos);
+}
+
+
+TEST_CASE("malformed sized OBU prevents AV1 sample normalization")
+{
+ std::vector<uint8_t> data{0x12, 0x08};
+
+ Error err = normalize_av1_sample_data(data, 0);
+ REQUIRE(err);
+ CHECK(err.message.find("exceeds the remaining data") != std::string::npos);
+}
+
+
+TEST_CASE("truncated AV1 OBU size is rejected")
+{
+ // Sequence-header OBU followed by an unterminated LEB128 size.
+ const std::vector<uint8_t> data{0x0A, 0x80};
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result.is_error());
+ CHECK(result.error().message.find("truncated") != std::string::npos);
+}
+
+
+TEST_CASE("unparseable AV1 sequence header is rejected")
+{
+ const std::vector<uint8_t> data{0x0A, 0x00};
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result.is_error());
+ CHECK(result.error().message.find("sequence header cannot be scanned completely") != std::string::npos);
+}
+
+
+TEST_CASE("AV1 OBU extending past the input is rejected")
+{
+ // Temporal-delimiter OBU declares an eight-byte payload but supplies none.
+ const std::vector<uint8_t> data{0x12, 0x08};
+
+ uint32_t width = 0, height = 0;
+ auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+ REQUIRE(result.is_error());
+ CHECK(result.error().message.find("exceeds the remaining data") != std::string::npos);
+}
diff --git a/tests/inband_coded_size_limit.cc b/tests/inband_coded_size_limit.cc
index d3e73234..9f5fd358 100644
--- a/tests/inband_coded_size_limit.cc
+++ b/tests/inband_coded_size_limit.cc
@@ -34,8 +34,10 @@
// combined config+bitstream buffer for the largest coded size and rejects it
// against the (ispe-tightened) security limits before any decoder plugin runs.
//
-// Each file below carries a 64x64 (HEVC: 320x240) 'ispe' but an oversized coded
-// size in the bitstream, and must be rejected with a security-limit error.
+// Most files below carry a 64x64 (HEVC: 320x240) 'ispe' but an oversized coded
+// size in the bitstream, and must be rejected with a security-limit error. The
+// unsized-OBU AVIF instead verifies that its apparent second picture remains
+// payload of the terminal OBU and is never decoded.
#include "catch_amalgamated.hpp"
#include "libheif/heif.h"
@@ -100,6 +102,37 @@ void expect_security_reject(heif_compression_format format, const char* b64) {
heif_context_free(ctx);
}
+// Assert that normalizing a terminal unsized OBU prevents a decoder from
+// resynchronizing on bytes inside that OBU's payload.
+void expect_safe_av1_decode(const char* b64) {
+ if (!heif_have_decoder_for_format(heif_compression_AV1)) {
+ SKIP("no AV1 decoder built, skipping");
+ }
+
+ std::vector<uint8_t> data = base64_decode(b64);
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ heif_error err = heif_context_read_from_memory_without_copy(ctx, data.data(), data.size(), nullptr);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_image_handle* handle = nullptr;
+ err = heif_context_get_primary_image_handle(ctx, &handle);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(handle != nullptr);
+
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+ REQUIRE(err.code == heif_error_Ok);
+ REQUIRE(img != nullptr);
+ CHECK(heif_image_get_primary_width(img) == 64);
+ CHECK(heif_image_get_primary_height(img) == 64);
+
+ heif_image_release(img);
+ heif_image_handle_release(handle);
+ heif_context_free(ctx);
+}
+
// HEVC item, 320x240 ispe, with an extra SPS (declaring 2000x2000) injected into
// the item data. The hvcC SPS (320x240) passes the tightened limit, so only the
// in-band 2000x2000 SPS can trip the gate.
@@ -148,6 +181,22 @@ const char* kAvifPocB64 =
"mLGOwbZyMlmA0easG5s7ljg1k8Hjl6lwngAo89cMZZgd5Q0mv6aYsY7BtnIyWYDR5qwbmzuWODWTweOXqXCe"
;
+// AVIF item with a 64x64 ispe and picture, followed by a zero byte and bytes
+// encoding a complete 8192x8192 picture. The zero byte is a terminal OBU header
+// without a size field, so all remaining bytes are its payload. Unnormalized,
+// some decoders skip that header and continue with the apparent second picture.
+const char* kAvifUnknownObuSizeB64 =
+ "AAAAHGZ0eXBhdmlmAAAAAG1pZjFhdmlmbWlhZgAAANBtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAAAAAA"
+ "AA5waXRtAAAAAAABAAAAHmlsb2MAAAAARAAAAQABAAAAAQAAAPQAAAHaAAAAI2lpbmYAAAAAAAEAAAAVaW5mZQIAAAAAAQAAYXYw"
+ "MQAAAABUaXBycAAAADZpcGNvAAAADGF2MUOBABwAAAAAFGlzcGUAAAAAAAAAQAAAAEAAAAAOcGl4aQAAAAABCAAAABZpcG1hAAAA"
+ "AAAAAAEAAQOBAgMAAAHibWRhdBIACgYYFX/9hUAyCRgAKKEAvQSfQAASAAoHH/M////+FTK2AxgIACihAAA09cchURGzwfjl07r5"
+ "Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7O"
+ "bKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw0"
+ "9cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpY"
+ "j1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL"
+ "9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZz1xyFREbPB+OXT"
+ "uvkhzzwjimqGGliPVgjTtVDQEolj7s5srB+9NIqH4kv38HkPSav9v/dFnA==";
+
} // namespace
@@ -155,6 +204,10 @@ TEST_CASE("avif: reject in-band AV1 sequence header exceeding the security limit
expect_security_reject(heif_compression_AV1, kAvifPocB64);
}
+TEST_CASE("avif: terminal unsized OBU prevents decoder resynchronization") {
+ expect_safe_av1_decode(kAvifUnknownObuSizeB64);
+}
+
TEST_CASE("hevc: reject in-band SPS exceeding the security limit") {
expect_security_reject(heif_compression_HEVC, kHevcInbandB64);
}