Commit c91d16e9 for libheif
commit c91d16e905c42cff179871834c71201a467408cd
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Mon Oct 5 13:09:49 2026 +0200
Check the number of tiles and components before OpenJPEG reads the header (GHSA-h4h8-qgvc-m7r2)
OpenJPEG allocates its coding parameters for each tile (about 8 kB) and
for each component of each tile (1080 bytes) while it reads the SIZ
marker segment in opj_read_header(), and it has no means to limit this.
The plugin applied all its checks to the image header that
opj_read_header() returns, when the memory had been allocated already.
A SIZ marker segment can declare 65535 tiles of one pixel with up to
16384 components. A file of 1357 bytes made OpenJPEG allocate 1.8 GB
with 24 components and 4.7 GB with 121 components, whatever the
security limits were (GHSA-h4h8-qgvc-m7r2). The check of the reference
grid does not help, because 65535 tiles of one pixel are a grid of
255x257 only. OpenJPEG has behaved like this in all its releases
(uclouvain/openjpeg#1111, open since 2018).
The plugin now reads the SIZ marker segment itself before it calls
OpenJPEG. It refuses a codestream
- that does not start with an SOC marker and an SIZ marker segment.
OpenJPEG skips other data until it finds an SIZ marker, so we could
not know which SIZ marker segment it is going to use,
- with a number of components other than one or three (this was
checked only after the header had been read), or with more than
max_components,
- with more tiles than max_number_of_tiles,
- that is too short for its number of tiles. Each tile needs at least
an SOT marker segment and an SOD marker, which are 14 bytes. FFmpeg
has the same check. It ties the allocation to the size of the input,
- with tiles that need more memory than max_memory_block_size,
- with a reference grid of more than INT32_MAX in one direction.
OpenJPEG before 2.5.1 computes the number of tiles with signed
integers and gets to 65535 tiles where there is only one.
OSS-Fuzz found this with the sequence fuzzer. Its file is added to the
seed corpus. With OpenJPEG from git, decoding it took 1.9 GB and takes
27 MB now. The OpenJPEG of Ubuntu 24.04 (2.5.0) rejects this particular
file because of its signed tile computation, but allocates the same
amount when the tile height in the file is below 2^31.
Tiled codestreams written by opj_compress decode as before, down to
tiles of one pixel (3072 tiles with 28 bytes each).
diff --git a/fuzzing/data/sequence_corpus/j2ki-siz-tile-alloc-oom.heif b/fuzzing/data/sequence_corpus/j2ki-siz-tile-alloc-oom.heif
new file mode 100644
index 00000000..04487294
Binary files /dev/null and b/fuzzing/data/sequence_corpus/j2ki-siz-tile-alloc-oom.heif differ
diff --git a/libheif/plugins/decoder_openjpeg.cc b/libheif/plugins/decoder_openjpeg.cc
index a6afe0f3..f56b6b0a 100644
--- a/libheif/plugins/decoder_openjpeg.cc
+++ b/libheif/plugins/decoder_openjpeg.cc
@@ -279,6 +279,105 @@ opj_stream_t* opj_stream_create_default_memory_stream(openjpeg_decoder* p_decode
//**************************************************************************
+// Memory that OpenJPEG allocates while it reads the SIZ marker segment: the coding
+// parameters (opj_tcp_t) and the codestream index of each tile, and the coding parameters
+// of each component of each tile (opj_tccp_t). These are the sizes of OpenJPEG 2.5 on a
+// 64-bit platform, rounded up.
+static const uint64_t OPENJPEG_HEADER_BYTES_PER_TILE = 8192;
+static const uint64_t OPENJPEG_HEADER_BYTES_PER_TILE_COMPONENT = 1088;
+
+// Each tile has at least one tile-part, which consists at least of an SOT marker segment
+// (12 bytes) and an SOD marker (2 bytes).
+static const uint64_t JPEG2000_MIN_BYTES_PER_TILE = 14;
+
+
+static uint32_t read_uint32_be(const uint8_t* p)
+{
+ return (uint32_t{p[0]} << 24) | (uint32_t{p[1]} << 16) | (uint32_t{p[2]} << 8) | uint32_t{p[3]};
+}
+
+
+// OpenJPEG allocates memory for every tile and for every component of every tile already
+// when it reads the SIZ marker segment in opj_read_header(), and there is no way to limit
+// that. A SIZ marker segment of less than 100 bytes can declare 65535 tiles of one pixel
+// with up to 16384 components each, for which OpenJPEG allocates many gigabytes before we
+// get to see the image header. Hence, we read the SIZ marker segment ourselves and check
+// the number of tiles and components before we call OpenJPEG (GHSA-h4h8-qgvc-m7r2).
+static heif_error openjpeg_check_siz_marker_segment(const std::vector<uint8_t>& data,
+ const heif_security_limits* limits)
+{
+ // SOC marker, SIZ marker, Lsiz, Rsiz, eight 32-bit sizes and offsets, Csiz
+ const size_t fixed_part_size = 2 + 2 + 2 + 2 + 8 * 4 + 2;
+
+ // The SIZ marker segment has to follow the SOC marker directly. OpenJPEG does not insist
+ // on this, it skips over other data until it finds an SIZ marker. We do insist, because
+ // this is the only way to know which SIZ marker segment OpenJPEG is going to use.
+ if (data.size() < fixed_part_size ||
+ data[0] != 0xFF || data[1] != 0x4F ||
+ data[2] != 0xFF || data[3] != 0x51) {
+ return {heif_error_Invalid_input, heif_suberror_Invalid_J2K_codestream,
+ "JPEG 2000 codestream does not start with an SOC marker and an SIZ marker segment"};
+ }
+
+ const uint64_t xsiz = read_uint32_be(&data[8]);
+ const uint64_t ysiz = read_uint32_be(&data[12]);
+ const uint64_t xtsiz = read_uint32_be(&data[24]);
+ const uint64_t ytsiz = read_uint32_be(&data[28]);
+ const uint64_t xtosiz = read_uint32_be(&data[32]);
+ const uint64_t ytosiz = read_uint32_be(&data[36]);
+ const uint32_t csiz = (uint32_t{data[40]} << 8) | uint32_t{data[41]};
+
+ if (xtsiz == 0 || ytsiz == 0 || xtosiz >= xsiz || ytosiz >= ysiz) {
+ return {heif_error_Invalid_input, heif_suberror_Invalid_J2K_codestream,
+ "Invalid tile geometry in JPEG 2000 codestream"};
+ }
+
+ // This plugin handles the image size as 'int'. Moreover, OpenJPEG versions before 2.5.1
+ // compute the number of tiles with signed 32-bit integers. With a reference grid of
+ // more than INT32_MAX in one direction, they can get to 65535 tiles where we compute
+ // a single one here.
+ if (xsiz > INT32_MAX || ysiz > INT32_MAX) {
+ return {heif_error_Unsupported_feature, heif_suberror_Unsupported_data_version,
+ "JPEG 2000 reference grid is too large"};
+ }
+
+ if (limits->max_components > 0 && csiz > limits->max_components) {
+ return {heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+ "JPEG 2000 image exceeds the maximum number of components"};
+ }
+
+ if (csiz != 3 && csiz != 1) {
+ //TODO - Handle other numbers of components
+ return {heif_error_Unsupported_feature, heif_suberror_Unsupported_data_version, "Number of components must be 3 or 1"};
+ }
+
+ // Both factors are below 2^31, thus there is no overflow in the product.
+ const uint64_t num_tiles = ((xsiz - xtosiz + xtsiz - 1) / xtsiz) * ((ysiz - ytosiz + ytsiz - 1) / ytsiz);
+
+ if (limits->max_number_of_tiles > 0 && num_tiles > limits->max_number_of_tiles) {
+ return {heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+ "JPEG 2000 image exceeds the maximum number of tiles"};
+ }
+
+ // A codestream that is shorter than the minimum size of its tiles cannot be complete.
+ // This check ties the memory that OpenJPEG allocates for the tiles to the input size.
+ if (num_tiles > data.size() / JPEG2000_MIN_BYTES_PER_TILE) {
+ return {heif_error_Invalid_input, heif_suberror_Invalid_J2K_codestream,
+ "JPEG 2000 codestream is too short for its number of tiles"};
+ }
+
+ // num_tiles is bounded by the input size here and csiz is at most 3, no overflow.
+ const uint64_t header_memory = num_tiles * (OPENJPEG_HEADER_BYTES_PER_TILE +
+ csiz * OPENJPEG_HEADER_BYTES_PER_TILE_COMPONENT);
+ if (limits->max_memory_block_size > 0 && header_memory > limits->max_memory_block_size) {
+ return {heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+ "JPEG 2000 image would require too much memory for its tiles"};
+ }
+
+ return heif_error_ok;
+}
+
+
// Conservative upper bound on bytes OpenJPEG will allocate to decode this
// codestream. Saturates to UINT64_MAX on overflow. OpenJPEG stores each sample
// internally as OPJ_INT32 regardless of the codestream bit depth; the 3x
@@ -321,6 +420,15 @@ heif_error openjpeg_decode_next_image2(void* decoder_raw, heif_image** out_img,
}
+ heif_error siz_error = openjpeg_check_siz_marker_segment(decoder->encoded_data, limits);
+ if (siz_error.code) {
+ return siz_error;
+ }
+
+ // OpenJPEG has to read the data from its start, as we did in the check above.
+ decoder->read_position = 0;
+
+
OPJ_BOOL success;
opj_dparameters_t decompression_parameters;
std::unique_ptr<opj_codec_t, void (OPJ_CALLCONV *)(opj_codec_t*)> l_codec(opj_create_decompress(OPJ_CODEC_J2K),
@@ -386,9 +494,6 @@ heif_error openjpeg_decode_next_image2(void* decoder_raw, heif_image** out_img,
"JPEG 2000 image would require too much memory to decode"};
}
- // TODO: also enforce limits->max_components against image->numcomps, and
- // limits->max_number_of_tiles against opj_get_cstr_info()->tw * th.
-
if (image->numcomps != 3 && image->numcomps != 1) {
//TODO - Handle other numbers of components
return {heif_error_Unsupported_feature, heif_suberror_Unsupported_data_version, "Number of components must be 3 or 1"};
diff --git a/tests/jpeg2000_openjpeg_grid_limit.cc b/tests/jpeg2000_openjpeg_grid_limit.cc
index d52bb34c..c9e5e316 100644
--- a/tests/jpeg2000_openjpeg_grid_limit.cc
+++ b/tests/jpeg2000_openjpeg_grid_limit.cc
@@ -118,9 +118,10 @@ const char* kJ2kB64 =
"7omZ1SE//5AACgAgAAAAHwAB/5PfgHAHPcWfV5c9In/nmcSVV//Z";
-std::vector<uint8_t> build_trigger_heif() {
+// Appends the codestream as 'mdat' to the header above. The item location in the header
+// is fixed, hence the codestream has to have the size of the advisory's PoC.
+std::vector<uint8_t> wrap_codestream_in_heif(const std::vector<uint8_t>& j2k) {
std::vector<uint8_t> header = base64_decode(kHeaderB64);
- std::vector<uint8_t> j2k = base64_decode(kJ2kB64);
REQUIRE(header.size() == 284);
REQUIRE(j2k.size() == 1065);
@@ -140,6 +141,109 @@ std::vector<uint8_t> build_trigger_heif() {
return file;
}
+
+std::vector<uint8_t> build_trigger_heif() {
+ return wrap_codestream_in_heif(base64_decode(kJ2kB64));
+}
+
+
+void append_be(std::vector<uint8_t>& data, uint32_t value, int num_bytes) {
+ for (int i = num_bytes - 1; i >= 0; i--) {
+ data.push_back(uint8_t((value >> (i * 8)) & 0xFF));
+ }
+}
+
+
+// A codestream that consists of the SOC marker and an SIZ marker segment for three 8-bit
+// components. It is padded with zeros to the size of the item. Nothing more is needed,
+// because the codestream has to be rejected based on its SIZ marker segment.
+std::vector<uint8_t> build_codestream(uint32_t width, uint32_t height,
+ uint32_t tile_width, uint32_t tile_height) {
+ const uint16_t num_components = 3;
+
+ std::vector<uint8_t> j2k = {0xFF, 0x4F}; // SOC
+
+ j2k.insert(j2k.end(), {0xFF, 0x51}); // SIZ
+ append_be(j2k, 38 + 3 * num_components, 2); // Lsiz
+ append_be(j2k, 0, 2); // Rsiz
+ append_be(j2k, width, 4); // Xsiz
+ append_be(j2k, height, 4); // Ysiz
+ append_be(j2k, 0, 4); // XOsiz
+ append_be(j2k, 0, 4); // YOsiz
+ append_be(j2k, tile_width, 4); // XTsiz
+ append_be(j2k, tile_height, 4); // YTsiz
+ append_be(j2k, 0, 4); // XTOsiz
+ append_be(j2k, 0, 4); // YTOsiz
+ append_be(j2k, num_components, 2); // Csiz
+ for (int c = 0; c < num_components; c++) {
+ j2k.insert(j2k.end(), {0x07, 0x01, 0x01}); // Ssiz, XRsiz, YRsiz
+ }
+
+ j2k.resize(1065);
+ return j2k;
+}
+
+
+bool have_openjpeg_decoder() {
+ const heif_decoder_descriptor* descriptors[10];
+ int n = heif_get_decoder_descriptors(heif_compression_JPEG2000, descriptors, 10);
+ for (int i = 0; i < n && i < 10; i++) {
+ if (std::string(heif_decoder_descriptor_get_id_name(descriptors[i])) == "openjpeg") {
+ return true;
+ }
+ }
+ return false;
+}
+
+
+struct DecodeResult {
+ heif_error_code code;
+ heif_suberror_code subcode;
+};
+
+// Decodes the codestream as the primary image of a HEIF file. When 'decoder_id' is not
+// NULL, this decoder is used. The limits are the defaults, optionally with a lower
+// maximum number of tiles and maximum memory block size.
+DecodeResult decode_codestream(const std::vector<uint8_t>& j2k, const char* decoder_id,
+ uint64_t max_number_of_tiles = 0, uint64_t max_memory_block_size = 0) {
+ std::vector<uint8_t> data = wrap_codestream_in_heif(j2k);
+
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ heif_security_limits* limits = heif_context_get_security_limits(ctx);
+ if (max_number_of_tiles) {
+ limits->max_number_of_tiles = max_number_of_tiles;
+ }
+ if (max_memory_block_size) {
+ limits->max_memory_block_size = max_memory_block_size;
+ }
+
+ 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_decoding_options* options = heif_decoding_options_alloc();
+ options->decoder_id = decoder_id;
+
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, options);
+ DecodeResult result{err.code, err.subcode};
+
+ if (img) {
+ heif_image_release(img);
+ }
+ heif_decoding_options_free(options);
+ heif_image_handle_release(handle);
+ heif_context_free(ctx);
+
+ return result;
+}
+
} // namespace
@@ -183,3 +287,51 @@ TEST_CASE("jpeg2000: OpenJPEG plugin rejects huge reference-grid coordinates wit
heif_image_handle_release(handle);
heif_context_free(ctx);
}
+
+
+// Regression test for GHSA-h4h8-qgvc-m7r2 (found by OSS-Fuzz as an out-of-memory error
+// of the sequence_fuzzer in opj_j2k_read_siz()).
+//
+// OpenJPEG allocates its coding parameters for each tile and for each component of each
+// tile while it reads the SIZ marker segment, i.e. within opj_read_header(), before the
+// plugin could check anything. A codestream of some hundred bytes made it allocate 2 GB
+// (65388 tiles with 24 components) and it could have been much more. The plugin now reads
+// the SIZ marker segment itself and checks it before it calls OpenJPEG.
+
+TEST_CASE("jpeg2000: OpenJPEG plugin checks the number of tiles before it reads the header")
+{
+ if (!have_openjpeg_decoder()) {
+ SKIP("OpenJPEG decoder not available, skipping test");
+ }
+
+ // In all cases, we got heif_error_Decoder_plugin_error before the fix, because
+ // opj_read_header() had read the SIZ marker segment and failed on the data after it.
+
+ SECTION("more tiles than the codestream can hold") {
+ // 65025 tiles of one pixel. The reference grid is small enough to pass the image size
+ // limit. OpenJPEG allocated 500 MB for it (2 GB with 24 components).
+ DecodeResult result = decode_codestream(build_codestream(255, 255, 1, 1), "openjpeg");
+ REQUIRE(result.code == heif_error_Invalid_input);
+ REQUIRE(result.subcode == heif_suberror_Invalid_J2K_codestream);
+ }
+
+ SECTION("more tiles than the security limit allows") {
+ DecodeResult result = decode_codestream(build_codestream(4, 2, 1, 1), "openjpeg", 4);
+ REQUIRE(result.code == heif_error_Memory_allocation_error);
+ REQUIRE(result.subcode == heif_suberror_Security_limit_exceeded);
+ }
+
+ SECTION("tiles need more memory than the security limit allows") {
+ // OpenJPEG allocates about 11 kB for each tile with three components.
+ DecodeResult result = decode_codestream(build_codestream(4, 2, 1, 1), "openjpeg", 0, 64 * 1024);
+ REQUIRE(result.code == heif_error_Memory_allocation_error);
+ REQUIRE(result.subcode == heif_suberror_Security_limit_exceeded);
+ }
+
+ SECTION("the limits do not get in the way of the same tiles with the default limits") {
+ // The 8 tiles pass our checks. OpenJPEG fails later, as the codestream ends after
+ // the SIZ marker segment.
+ DecodeResult result = decode_codestream(build_codestream(4, 2, 1, 1), "openjpeg");
+ REQUIRE(result.code == heif_error_Decoder_plugin_error);
+ }
+}