Commit fa748765 for libheif
commit fa7487659c1c2d36941c00aa9c99b3c813f44904
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sun Oct 25 01:52:31 2026 +0200
Bound HEIF transform amplification [GHSA-7qm8-hj35-269r]
diff --git a/libheif/context.cc b/libheif/context.cc
index 2acf2e8b..5150f3c7 100644
--- a/libheif/context.cc
+++ b/libheif/context.cc
@@ -1531,6 +1531,7 @@ Result<std::shared_ptr<HeifPixelImage>> HeifContext::decode_image(heif_item_id I
uint64_t budget = static_cast<uint64_t>(limits->max_items) * MAX_DERIVED_IMAGE_DECODE_FACTOR;
decode_state.max_decodes = (budget > UINT32_MAX) ? UINT32_MAX : static_cast<uint32_t>(budget);
decode_state.max_overlay_nesting = MAX_OVERLAY_NESTING_LEVEL;
+ decode_state.max_transformations = MAX_TRANSFORMATIONS_PER_DECODE_PATH;
decode_state.decode_count = std::make_shared<std::atomic<uint32_t>>(0);
}
diff --git a/libheif/image-items/image_item.cc b/libheif/image-items/image_item.cc
index f8fb92ff..c4d80331 100644
--- a/libheif/image-items/image_item.cc
+++ b/libheif/image-items/image_item.cc
@@ -1254,6 +1254,52 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod
}
}
+ // --- validate and limit image transformations before decoding derived inputs
+
+ Result<std::vector<std::shared_ptr<Box>>> properties_result = get_properties();
+ if (!properties_result) {
+ return properties_result.error();
+ }
+
+ bool has_rotation = false;
+ bool has_mirror = false;
+ bool has_crop = false;
+ for (const auto& property : *properties_result) {
+ bool* seen = nullptr;
+ bool applied = true;
+ if (std::dynamic_pointer_cast<Box_irot>(property)) {
+ seen = &has_rotation;
+ }
+ else if (std::dynamic_pointer_cast<Box_imir>(property)) {
+ seen = &has_mirror;
+ }
+ else if (std::dynamic_pointer_cast<Box_clap>(property)) {
+ seen = &has_crop;
+ applied = !decode_tile_only; // A tile uses a shifted grid instead of a crop.
+ }
+
+ if (seen) {
+ // HEIF permits at most one association of each type per item, even when
+ // the same property index appears repeatedly in 'ipma'.
+ if (*seen) {
+ return Error{heif_error_Invalid_input, heif_suberror_Invalid_property,
+ "Multiple irot, imir, or clap properties associated with one item"};
+ }
+ *seen = true;
+ if (applied && !options.ignore_transformations) {
+ ++decode_state.num_transformations;
+ }
+ }
+ }
+
+ // A derived-image chain can apply one full-image pass at each item. The state
+ // is copied by value at each hop, so this bounds each decode path separately.
+ if (decode_state.max_transformations != 0 &&
+ decode_state.num_transformations > decode_state.max_transformations) {
+ return Error{heif_error_Invalid_input, heif_suberror_Security_limit_exceeded,
+ "Too many image transformations along the decode path"};
+ }
+
// --- decode image
Result<std::shared_ptr<HeifPixelImage>> decodingResult = decode_compressed_image(options, decode_coded_tile_only, tile_x0, tile_y0, decode_state);
@@ -1286,12 +1332,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod
// --- apply image transformations
if (options.ignore_transformations == false) {
- Result<std::vector<std::shared_ptr<Box>>> propertiesResult = get_properties();
- if (!propertiesResult) {
- return propertiesResult.error();
- }
-
- const std::vector<std::shared_ptr<Box>>& properties = *propertiesResult;
+ const std::vector<std::shared_ptr<Box>>& properties = *properties_result;
for (const auto& property : properties) {
if (auto rot = std::dynamic_pointer_cast<Box_irot>(property)) {
diff --git a/libheif/security_limits.h b/libheif/security_limits.h
index 866c9195..611153d5 100644
--- a/libheif/security_limits.h
+++ b/libheif/security_limits.h
@@ -64,14 +64,25 @@ static const uint32_t MAX_OVERLAY_IMAGES = 5;
// leaving every realistic file untouched. (GHSA-x8xm-cm2c-cfc8, variants V1/V2.)
static const uint32_t MAX_DERIVED_IMAGE_DECODE_FACTOR = 2;
+// Maximum number of transformative properties ('irot', 'imir', 'clap') that may be
+// applied along a single decode path, i.e. by an item and all the derived images
+// (grid, iovl, iden) through which it is reached. Each one is a pass over the whole
+// image, 'ipma' can associate up to 255 properties with an item (also the same one
+// again and again), and each item of an 'iden' chain applies its own list to the same
+// image. HEIF allows one property of each type per item, but chains can add many
+// items, so this bounds the total amount of repeated pixel processing.
+static const uint32_t MAX_TRANSFORMATIONS_PER_DECODE_PATH = 32;
+
// Traversal state threaded through the derived-image decode recursion for one
// top-level decode. It is copied by value at every hop, which gives the two
// members opposite (and intended) sharing semantics:
//
-// - processed_ids and overlay_nesting are plain values, so each recursion
-// branch gets its own copy. processed_ids is a per-path cycle guard;
-// overlay_nesting counts the overlays nested along the current path.
+// - processed_ids, overlay_nesting and num_transformations are plain values,
+// so each recursion branch gets its own copy. processed_ids is a per-path
+// cycle guard; overlay_nesting counts the overlays nested along the current
+// path; num_transformations counts the transformative properties of the
+// items along the current path.
//
// - decode_count is a shared_ptr to an atomic, so every copy (including the
// copies handed to parallel tile-decode threads) shares one global counter
@@ -86,6 +97,9 @@ struct DecodeTraversalState
uint32_t overlay_nesting = 0; // number of overlays on the path to here
uint32_t max_overlay_nesting = 0; // 0 == unlimited
+ uint32_t num_transformations = 0; // number of transformations on the path to here
+ uint32_t max_transformations = 0; // 0 == unlimited
+
std::shared_ptr<std::atomic<uint32_t>> decode_count;
uint32_t max_decodes = 0; // 0 == unlimited
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index df2555c3..3812510f 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -121,6 +121,7 @@ add_libheif_test(item_properties)
add_libheif_test(item_writing)
add_libheif_test(overlay_amplification)
add_libheif_test(overlay_offsets)
+add_libheif_test(transformation_amplification)
add_libheif_test(derived_images_shared_input_threads)
add_libheif_test(error_item_decode)
add_libheif_test(alpha_cycle_deadlock)
diff --git a/tests/transformation_amplification.cc b/tests/transformation_amplification.cc
new file mode 100644
index 00000000..ec64e79e
--- /dev/null
+++ b/tests/transformation_amplification.cc
@@ -0,0 +1,281 @@
+/*
+ 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.
+*/
+
+// Check the per-item HEIF cardinality rule and the cumulative decode-path limit.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace {
+
+// Build a minimal HEIF file with
+// item 1 ('mski', 8x8, 8bpp): real, decodable content (the mask codec needs
+// no bitstream -- its "compressed" data is just the raw pixel bytes).
+// items 2 ... 1+num_iden ('iden'): each one 'dimg'-references the item before it.
+// The last one is the primary item.
+// The single 'imir' property of the file is associated `mirrors_on_coded_item` times
+// with item 1 and `mirrors_per_iden` times with each 'iden' item.
+// No codec plugin is required for any item.
+std::vector<uint8_t> build_heif_with_mirrors(uint16_t num_iden, uint8_t mirrors_per_iden,
+ uint8_t mirrors_on_coded_item,
+ const char* transform_type = "imir") {
+ const uint16_t num_items = static_cast<uint16_t>(1 + num_iden);
+
+ std::vector<uint8_t> ftyp_payload;
+ append_fourcc(ftyp_payload, "heic");
+ put_u32_be(ftyp_payload, 0);
+ append_fourcc(ftyp_payload, "mif1");
+ append_fourcc(ftyp_payload, "heic");
+ auto ftyp = make_box("ftyp", ftyp_payload);
+
+ std::vector<uint8_t> hdlr_payload;
+ put_u32_be(hdlr_payload, 0);
+ append_fourcc(hdlr_payload, "pict");
+ put_u32_be(hdlr_payload, 0);
+ put_u32_be(hdlr_payload, 0);
+ put_u32_be(hdlr_payload, 0);
+ hdlr_payload.push_back(0);
+ auto hdlr = make_box("hdlr", hdlr_payload, /*full=*/true);
+
+ // pitm: primary item = top of the chain.
+ std::vector<uint8_t> pitm_payload;
+ put_u16_be(pitm_payload, num_items);
+ auto pitm = make_box("pitm", pitm_payload, /*full=*/true);
+
+ // iinf: item 1 = 'mski', all others = 'iden'.
+ std::vector<uint8_t> iinf_payload;
+ put_u16_be(iinf_payload, num_items);
+ for (uint16_t id = 1; id <= num_items; id++) {
+ std::vector<uint8_t> infe_payload;
+ put_u16_be(infe_payload, id);
+ put_u16_be(infe_payload, 0);
+ append_fourcc(infe_payload, id == 1 ? "mski" : "iden");
+ infe_payload.push_back(0);
+ append(iinf_payload, make_box("infe", infe_payload, /*full=*/true, /*version=*/2));
+ }
+ auto iinf = make_box("iinf", iinf_payload, /*full=*/true);
+
+ // iprp / ipco: prop 1 = ispe(8,8); prop 2 = mskC(8bpp); prop 3 = imir.
+ std::vector<uint8_t> ispe_payload;
+ put_u32_be(ispe_payload, 8);
+ put_u32_be(ispe_payload, 8);
+
+ std::vector<uint8_t> mskC_payload;
+ mskC_payload.push_back(8); // bits_per_pixel
+
+ std::vector<uint8_t> transform_payload;
+ if (std::string(transform_type) == "clap") {
+ // Full 8x8 crop with zero offsets, each rational having denominator 1.
+ for (uint32_t value : {8u, 1u, 8u, 1u, 0u, 1u, 0u, 1u}) {
+ put_u32_be(transform_payload, value);
+ }
+ }
+ else {
+ transform_payload.push_back(0); // imir axis or irot angle
+ }
+
+ std::vector<uint8_t> ipco_payload;
+ append(ipco_payload, make_box("ispe", ispe_payload, /*full=*/true));
+ append(ipco_payload, make_box("mskC", mskC_payload, /*full=*/true));
+ append(ipco_payload, make_box(transform_type, transform_payload));
+ auto ipco = make_box("ipco", ipco_payload);
+
+ std::vector<uint8_t> ipma_payload;
+ put_u32_be(ipma_payload, num_items); // entry_count
+ for (uint16_t id = 1; id <= num_items; id++) {
+ const uint8_t num_descriptive = (id == 1 ? 2 : 1);
+ const uint8_t num_mirrors = (id == 1 ? mirrors_on_coded_item : mirrors_per_iden);
+
+ put_u16_be(ipma_payload, id);
+ ipma_payload.push_back(static_cast<uint8_t>(num_descriptive + num_mirrors)); // association_count
+ ipma_payload.push_back(0x80 | 1); // essential, ispe
+ if (id == 1) {
+ ipma_payload.push_back(0x80 | 2); // essential, mskC
+ }
+ for (uint8_t i = 0; i < num_mirrors; i++) {
+ ipma_payload.push_back(0x80 | 3); // essential, imir
+ }
+ }
+ auto ipma = make_box("ipma", ipma_payload, /*full=*/true);
+
+ std::vector<uint8_t> iprp_payload;
+ append(iprp_payload, ipco);
+ append(iprp_payload, ipma);
+ auto iprp = make_box("iprp", iprp_payload);
+
+ // iloc: only item 1 has data (raw 8x8 8bpp mask, in idat).
+ std::vector<uint8_t> iloc_payload;
+ put_u16_be(iloc_payload, (4 << 12) | (4 << 8) | (0 << 4) | 0);
+ put_u16_be(iloc_payload, 1); // item_count
+ put_u16_be(iloc_payload, 1); // item_ID
+ put_u16_be(iloc_payload, 0x0001); // reserved(12) + construction_method=1 (idat)
+ put_u16_be(iloc_payload, 0); // data_reference_index
+ put_u16_be(iloc_payload, 1); // extent_count
+ put_u32_be(iloc_payload, 0); // extent_offset (within idat)
+ put_u32_be(iloc_payload, 64); // extent_length (8x8x1 byte)
+ auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+ // iref: each 'iden' item 'dimg'-references the item before it.
+ std::vector<uint8_t> iref_payload;
+ for (uint16_t id = 2; id <= num_items; id++) {
+ std::vector<uint8_t> dimg_payload;
+ put_u16_be(dimg_payload, id); // from_item_ID
+ put_u16_be(dimg_payload, 1); // reference_count
+ put_u16_be(dimg_payload, static_cast<uint16_t>(id - 1)); // to_item_ID
+ append(iref_payload, make_box("dimg", dimg_payload));
+ }
+ auto iref = make_box("iref", iref_payload, /*full=*/true);
+
+ // idat: 8x8 = 64 bytes of raw 8bpp mask pixel data.
+ std::vector<uint8_t> idat_payload(64, 0x7F);
+ auto idat = make_box("idat", idat_payload);
+
+ std::vector<uint8_t> meta_payload;
+ append(meta_payload, hdlr);
+ append(meta_payload, pitm);
+ append(meta_payload, iinf);
+ append(meta_payload, iprp);
+ append(meta_payload, iloc);
+ if (num_iden > 0) {
+ append(meta_payload, iref);
+ }
+ append(meta_payload, idat);
+ auto meta = make_box("meta", meta_payload, /*full=*/true);
+
+ std::vector<uint8_t> file;
+ append(file, ftyp);
+ append(file, meta);
+ return file;
+}
+
+
+heif_error decode_primary_image(const std::vector<uint8_t>& data,
+ bool ignore_transformations = false,
+ const heif_security_limits* limits = nullptr) {
+ heif_context* ctx = heif_context_alloc();
+ REQUIRE(ctx != nullptr);
+
+ if (limits) {
+ REQUIRE(heif_context_set_security_limits(ctx, limits).code == heif_error_Ok);
+ }
+
+ 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->ignore_transformations = ignore_transformations;
+
+ heif_image* img = nullptr;
+ err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, options);
+ REQUIRE((img != nullptr) == (err.code == heif_error_Ok));
+
+ // The message belongs to the context, which is released below.
+ err.message = nullptr;
+
+ heif_image_release(img);
+ heif_decoding_options_free(options);
+ heif_image_handle_release(handle);
+ heif_context_free(ctx);
+
+ return err;
+}
+
+} // namespace
+
+
+TEST_CASE("one transformation per item decodes normally") {
+ REQUIRE(decode_primary_image(build_heif_with_mirrors(0, 0, 1)).code == heif_error_Ok);
+ REQUIRE(decode_primary_image(build_heif_with_mirrors(1, 1, 1)).code == heif_error_Ok);
+ REQUIRE(decode_primary_image(build_heif_with_mirrors(4, 1, 1)).code == heif_error_Ok);
+}
+
+
+TEST_CASE("a long 'iden' chain without transformations decodes normally") {
+ REQUIRE(decode_primary_image(build_heif_with_mirrors(100, 0, 1)).code == heif_error_Ok);
+}
+
+
+TEST_CASE("a transformative property associated twice with one item is rejected") {
+ SECTION("coded item") {
+ heif_error err = decode_primary_image(build_heif_with_mirrors(0, 0, 2));
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Invalid_property);
+ }
+
+ SECTION("'iden' item") {
+ heif_error err = decode_primary_image(build_heif_with_mirrors(1, 2, 0));
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Invalid_property);
+ }
+
+ SECTION("rotation") {
+ heif_error err = decode_primary_image(build_heif_with_mirrors(0, 0, 2, "irot"));
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Invalid_property);
+ }
+
+ SECTION("crop") {
+ heif_error err = decode_primary_image(build_heif_with_mirrors(0, 0, 2, "clap"));
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Invalid_property);
+ }
+
+ SECTION("disabled security limits still enforce the format rule") {
+ heif_error err = decode_primary_image(build_heif_with_mirrors(0, 0, 2), false,
+ heif_get_disabled_security_limits());
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Invalid_property);
+ }
+}
+
+
+TEST_CASE("the transformations of the items on an 'iden' chain add up") {
+ REQUIRE(decode_primary_image(build_heif_with_mirrors(31, 1, 1)).code == heif_error_Ok);
+ heif_error err = decode_primary_image(build_heif_with_mirrors(32, 1, 1));
+ REQUIRE(err.code == heif_error_Invalid_input);
+ REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+}
+
+
+TEST_CASE("transformations that are not applied are not counted") {
+ REQUIRE(decode_primary_image(build_heif_with_mirrors(100, 1, 1), true).code == heif_error_Ok);
+}
+
+
+TEST_CASE("no transformation limit when the security limits are disabled") {
+ REQUIRE(decode_primary_image(build_heif_with_mirrors(100, 1, 1), false,
+ heif_get_disabled_security_limits()).code == heif_error_Ok);
+}