Commit a44ad895 for libheif
commit a44ad895c9bba6df99c06385d51a83db257f0a33
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sat Sep 19 00:53:16 2026 +0200
openjpeg: bound the JPEG 2000 reference-grid area, not just the window span (GHSA-q492-cfcm-895h)
The OpenJPEG decoder plugin's pre-decode resource-limit gate checked
(x1-x0) * (y1-y0), the JPEG 2000 window span, against
max_image_size_pixels before calling opj_decode(). It did not bound
the absolute SIZ coordinates.
A crafted codestream can declare a tiny window (e.g. 17 pixels) while
the reference grid (Xsiz=x1, Ysiz=y1) sits near the 32-bit coordinate
boundary. OpenJPEG's tile and coefficient arithmetic operates over the
full reference grid, not just the window, so such a file passed the
span-based check and reached opj_decode() with pathological geometry.
Against OpenJPEG 2.3.1 this produced a heap-buffer-overflow write
(the sink class of CVE-2020-6851); against 2.5.4, opj_decode() merely
errors out, but the libheif-side gate still wrongly accepted the input
in both cases.
Add a second check bounding the reference-grid area (x1 * y1) against
max_image_size_pixels. For any file with x0 = y0 = 0 (the common case),
this is identical to the existing span check, so it introduces no new
false positives for normal files.
Add a regression test that rebuilds the advisory's PoC bytes at
runtime and asserts the file is now rejected with
heif_suberror_Security_limit_exceeded before opj_decode() runs.
diff --git a/libheif/plugins/decoder_openjpeg.cc b/libheif/plugins/decoder_openjpeg.cc
index 63a021f0..783cc56e 100644
--- a/libheif/plugins/decoder_openjpeg.cc
+++ b/libheif/plugins/decoder_openjpeg.cc
@@ -365,6 +365,19 @@ heif_error openjpeg_decode_next_image2(void* decoder_raw, heif_image** out_img,
"JPEG 2000 image exceeds maximum allowed image size"};
}
+ // The visible image is only the window (x1-x0, y1-y0), but OpenJPEG performs
+ // its tile and coefficient arithmetic over the full JPEG 2000 reference grid
+ // (Xsiz=x1, Ysiz=y1). A tiny window placed at very large absolute coordinates
+ // therefore reaches internal decode paths with pathological geometry, which
+ // has caused out-of-bounds writes inside OpenJPEG (see GHSA-q492-cfcm-895h,
+ // CVE-2020-6851). Bound the reference-grid area, not just the window span.
+ // (x1,y1 are uint32, so the product fits in uint64 without overflow.)
+ uint64_t grid_pixels = uint64_t(image->x1) * uint64_t(image->y1);
+ if (limits->max_image_size_pixels > 0 && grid_pixels > limits->max_image_size_pixels) {
+ return {heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+ "JPEG 2000 reference grid exceeds maximum allowed image size"};
+ }
+
uint64_t estimated_memory = openjpeg_estimate_decode_memory_bytes(image.get());
if (limits->max_memory_block_size > 0 && estimated_memory > limits->max_memory_block_size) {
return {heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 6f9d9913..433a3355 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -86,6 +86,7 @@ add_libheif_test(entity_groups)
add_libheif_test(extended_type)
add_libheif_test(grid_tile_missing)
add_libheif_test(iden_declared_size)
+add_libheif_test(jpeg2000_openjpeg_grid_limit)
add_libheif_test(item_properties)
add_libheif_test(item_writing)
add_libheif_test(overlay_amplification)
diff --git a/tests/jpeg2000_openjpeg_grid_limit.cc b/tests/jpeg2000_openjpeg_grid_limit.cc
new file mode 100644
index 00000000..d52bb34c
--- /dev/null
+++ b/tests/jpeg2000_openjpeg_grid_limit.cc
@@ -0,0 +1,185 @@
+/*
+ libheif unit 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.
+*/
+
+// Regression test for GHSA-q492-cfcm-895h: the OpenJPEG decoder plugin's
+// pre-decode resource-limit gate only bounded the JPEG 2000 *window* span
+// (x1-x0) * (y1-y0), not the absolute SIZ coordinates. A crafted codestream
+// can declare a tiny window (e.g. 17 pixels) anchored at coordinates close to
+// the 32-bit boundary; OpenJPEG's tile/coefficient arithmetic operates over
+// the full reference grid (Xsiz=x1, Ysiz=y1), not just the window, so such a
+// file passed libheif's span-based checks and reached opj_decode() with
+// pathological geometry (observed as a heap-buffer-overflow write inside
+// OpenJPEG 2.3.1; corresponds to the sink class of CVE-2020-6851).
+//
+// The fix additionally bounds the reference-grid area x1*y1 against
+// max_image_size_pixels, so the file below must now be rejected with a
+// Security_limit_exceeded error *before* opj_decode() is ever called.
+//
+// The test file bytes below are exactly the trigger.heif PoC from the
+// advisory (ISO base media container bytes + a real JPEG 2000 codestream
+// with SIZ: x0=2147483631, x1=2147483648, y0=0, y1=1 -> window span = 17
+// pixels, reference grid area = 2147483648 pixels).
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace {
+
+// Minimal base64 decoder (no external dependency needed for this test).
+std::vector<uint8_t> base64_decode(const std::string& in) {
+ auto val = [](char c) -> int {
+ if (c >= 'A' && c <= 'Z') return c - 'A';
+ if (c >= 'a' && c <= 'z') return c - 'a' + 26;
+ if (c >= '0' && c <= '9') return c - '0' + 52;
+ if (c == '+') return 62;
+ if (c == '/') return 63;
+ return -1;
+ };
+
+ std::vector<uint8_t> out;
+ int bits = 0;
+ uint32_t acc = 0;
+ for (char c : in) {
+ if (c == '=' || c == '\n' || c == '\r') {
+ continue;
+ }
+ int v = val(c);
+ if (v < 0) {
+ continue;
+ }
+ acc = (acc << 6) | uint32_t(v);
+ bits += 6;
+ if (bits >= 8) {
+ bits -= 8;
+ out.push_back(uint8_t((acc >> bits) & 0xFF));
+ }
+ }
+ return out;
+}
+
+
+// ftyp + meta for a single j2k1 item; iloc extent starts right after this
+// header, at file offset 284. Taken verbatim from the advisory's PoC.
+const char* kHeaderB64 =
+ "AAAAHGZ0eXBqMmtpAAAAAG1pZjFqMmtpbWlhZgAAAQBtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABw"
+ "aWN0AAAAAAAAAAAAAAAAAAAAACJpbG9jAAAAAERAAAEAAQAAAAABJAABAAAAAAAABCkAAAAjaWlu"
+ "ZgAAAAAAAQAAABVpbmZlAgAAAAABAABqMmsxAAAAAA5waXRtAAAAAAABAAAAgGlwcnAAAABhaXBj"
+ "bwAAACRqMmtIAAAAHGNkZWYAAwAAAAAAAQABAAAAAgACAAAAAwAAABNjb2xybmNseAABAA0ABoAA"
+ "AAAUaXNwZQAAAAAAAAAgAAAAIAAAAA5waXhpAAAAAAEIAAAAF2lwbWEAAAAAAAAAAQABBIECAwQ=";
+
+// Raw JPEG 2000 codestream (1065 bytes). SIZ: huge absolute offsets, window
+// span == 17 pixels, reference grid area == 2147483648 pixels.
+const char* kJ2kB64 =
+ "/0//UQApAACAAAAAAAAAAX///+8AAAAAAAAAEAAAAAF////vAAAAAAABBwEB/1IADAAAAAEAAAQE"
+ "AAH/XAAEQED/ZAAlAAFDcmVhdGVkIGJ5IE9wZW5KUEVHIHZlcnNpb24gMi4zLjH/kAAKAAAAAAAf"
+ "AAH/k9+AcAc2AD6Ey0mHIVs2Icqr/5AACgABAAAAHgAB/5PPtDQJXfai5z0KYbomZ1SF/5AACgAC"
+ "AAAAHgAB/5PPtDQJWaoxlpMOQrZsQ5VX/5AACgADAAAAHgAB/5PPtDQJY0vTznoUw3RMzqkL/5AA"
+ "CgAEAAAAHwAB/5PPtDgHNgA+enwM/2bxxDlVf/+QAAoABQAAAB0AAf+Tx9QYCVmqHPgZ/03jiHKq"
+ "/5AACgAGAAAAHAAB/5PH1BYHNgA6dOTCJ3lUzP+QAAoABwAAABoAAf+Tw+cSBzXV2/Un/O3v/5AA"
+ "CgAIAAAAGQAB/5PB8ggZze1KnSkB0f+QAAoACQAAABwAAf+Tw+cWAqnKfq5737yqZj//kAAKAAoA"
+ "AAAdAAH/k8fUGAKq6UAZ/03jiHKqf/+QAAoACwAAAB0AAf+Tx9QYAqWeshn/TeOIcqp//5AACgAM"
+ "AAAAHQAB/5PPtDACqwf7AF0LWbEOVU//kAAKAA0AAAAeAAH/k8+0NAKrjMbgakU4qm2dUhP/kAAK"
+ "AA4AAAAdAAH/k8+0MAKlvW0AXQtZsQ5VT/+QAAoADwAAAB4AAf+Tz7Q0AqY5a42auO6JmdUhP/+Q"
+ "AAoAEAAAAB8AAf+T34BwBzYAPoTLSYchWzYhyqv/kAAKABEAAAAeAAH/k8+0NAld9qLnPQphuiZn"
+ "VIX/kAAKABIAAAAeAAH/k8+0NAlZqjGWkw5CtmxDlVf/kAAKABMAAAAeAAH/k8+0NAljS9POehTD"
+ "dEzOqQv/kAAKABQAAAAfAAH/k8+0OAc2AD56fAz/ZvHEOVV//5AACgAVAAAAHQAB/5PH1BgJWaoc"
+ "+Bn/TeOIcqr/kAAKABYAAAAcAAH/k8fUFgc2ADp05MIneVTM/5AACgAXAAAAGgAB/5PD5xIHNdXb"
+ "9Sf87e//kAAKABgAAAAZAAH/k8HyCBnN7UqdKQHR/5AACgAZAAAAHAAB/5PD5xYCqcp+rnvfvKpm"
+ "P/+QAAoAGgAAAB0AAf+Tx9QYAqrpQBn/TeOIcqp//5AACgAbAAAAHQAB/5PH1BgCpZ6yGf9N44hy"
+ "qn//kAAKABwAAAAdAAH/k8+0MAKrB/sAXQtZsQ5VT/+QAAoAHQAAAB4AAf+Tz7Q0AquMxuBqRTiq"
+ "bZ1SE/+QAAoAHgAAAB0AAf+Tz7QwAqW9bQBdC1mxDlVP/5AACgAfAAAAHgAB/5PPtDQCpjlrjZq4"
+ "7omZ1SE//5AACgAgAAAAHwAB/5PfgHAHPcWfV5c9In/nmcSVV//Z";
+
+
+std::vector<uint8_t> build_trigger_heif() {
+ std::vector<uint8_t> header = base64_decode(kHeaderB64);
+ std::vector<uint8_t> j2k = base64_decode(kJ2kB64);
+ REQUIRE(header.size() == 284);
+ REQUIRE(j2k.size() == 1065);
+
+ std::vector<uint8_t> file = header;
+
+ uint32_t mdat_size = uint32_t(8 + j2k.size());
+ file.push_back(uint8_t((mdat_size >> 24) & 0xFF));
+ file.push_back(uint8_t((mdat_size >> 16) & 0xFF));
+ file.push_back(uint8_t((mdat_size >> 8) & 0xFF));
+ file.push_back(uint8_t(mdat_size & 0xFF));
+ file.push_back('m');
+ file.push_back('d');
+ file.push_back('a');
+ file.push_back('t');
+ file.insert(file.end(), j2k.begin(), j2k.end());
+
+ return file;
+}
+
+} // namespace
+
+
+TEST_CASE("jpeg2000: OpenJPEG plugin rejects huge reference-grid coordinates with a tiny window")
+{
+ if (!heif_have_decoder_for_format(heif_compression_JPEG2000)) {
+ SKIP("JPEG 2000 (OpenJPEG) decoder not available, skipping test");
+ }
+
+ std::vector<uint8_t> data = build_trigger_heif();
+
+ 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);
+
+ // Before the fix, this pathological geometry passed the span-based
+ // pre-decode gate (window span = 17 pixels) and reached opj_decode(),
+ // which either crashes the underlying codec (OpenJPEG <= 2.3.x) or fails
+ // with a generic decoder error (OpenJPEG >= 2.4, observed as
+ // heif_error_Decoder_plugin_error / "opj_decode()" on this build). Either
+ // way, opj_decode() was called on the pathological input.
+ //
+ // After the fix, the reference-grid-area check must reject the input
+ // before opj_decode() is invoked, surfacing as a security-limit error.
+ REQUIRE(err.code == heif_error_Memory_allocation_error);
+ REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+
+ if (img) {
+ heif_image_release(img);
+ }
+ heif_image_handle_release(handle);
+ heif_context_free(ctx);
+}