Commit 30b52a4e for libheif

commit 30b52a4e829efe0ee6d7208e8500b99f664af5e9
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Tue Aug 25 01:54:44 2026 +0200

    Bound derived-image reference amplification (GHSA-x8xm-cm2c-cfc8)

    Derived images (grid/iovl/iden) can reference a shared base image through
    indirection. The cycle-detection set `processed_ids` is passed by value, so
    it forks at every recursion branch and acts only as a per-path cycle guard,
    never as a visited-memo. A shared subtree is therefore re-decoded once per
    path that reaches it, and nested references make the number of decodes grow
    as branch^depth. A tiny file can thus request an effectively unbounded amount
    of decoding work.

    Bound this with a per-top-level-decode traversal state (DecodeTraversalState):

    - A shared decode-operation budget (shared_ptr<atomic>, so it is shared across
      all branches and the parallel tile-decode threads), checked at the single
      choke point ImageItem::decode_image and capped at a small multiple of
      max_items. This is the structure-agnostic backstop against the exponential
      blow-up.
    - An overlay-nesting depth guard (per-path), for fast rejection of the nested
      'iovl' gadget.
    - A per-overlay fan-out limit in read_overlay_spec (hardcoded stopgap until a
      configurable heif_security_limits field can be added in the next major
      release).

    The budget is seeded once in HeifContext::decode_image and disabled when the
    security limits are disabled. Only internal C++ signatures change; the public
    C API and ABI are unaffected.

    Also fix HeifFile::check_for_ref_cycle_recursion, which ran at file-open on
    the primary item and had the same defect: it tracked the current DFS path with
    backtracking but never memoized finished nodes, making it O(number of paths)
    and exponential for shared/nested references. A ~5 KB nested-overlay file could
    hang heif_context_read_from_memory before any decode. Fixed with a standard
    white/gray/black DFS (finished_items set).

    'tili' (V3, experimental, off by default): only a TODO added at
    TiledHeader::set_parameters for routing the offset-table allocation through
    MemoryHandle.

    Add tests/overlay_amplification.cc: a deeply nested overlay gadget is now
    rejected in milliseconds instead of hanging, an over-wide overlay is rejected,
    and a shallow legitimate overlay still decodes.

diff --git a/libheif/context.cc b/libheif/context.cc
index e4ce85a7..110a5588 100644
--- a/libheif/context.cc
+++ b/libheif/context.cc
@@ -1448,8 +1448,26 @@ Result<std::shared_ptr<HeifPixelImage>> HeifContext::decode_image(heif_item_id I
     return Error(heif_error_Invalid_input, heif_suberror_Nonexisting_item_referenced);
   }

-
-  auto decodingResult = imgitem->decode_image(options, decode_only_tile, tx, ty, processed_ids);
+  // Seed the traversal state for this top-level decode. The cycle-detection set
+  // is carried over from the caller; the amplification budget (shared across all
+  // recursion branches and parallel tile-decode threads) is created here, once.
+  // (GHSA-x8xm-cm2c-cfc8)
+  DecodeTraversalState decode_state;
+  decode_state.processed_ids = std::move(processed_ids);
+
+  const heif_security_limits* limits = get_security_limits();
+  if (limits && limits->max_items != 0) {
+    // Bound total sub-image decodes at a modest multiple of max_items. A
+    // well-formed file decodes each of its (<= max_items) items a small number
+    // of times, so this only trips on reference-amplification. Computed in 64
+    // bit and clamped to avoid overflow when max_items is configured very high.
+    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.decode_count = std::make_shared<std::atomic<uint32_t>>(0);
+  }
+
+  auto decodingResult = imgitem->decode_image(options, decode_only_tile, tx, ty, decode_state);
   if (!decodingResult) {
     return decodingResult.error();
   }
diff --git a/libheif/context.h b/libheif/context.h
index 498e0130..27f3a3c1 100644
--- a/libheif/context.h
+++ b/libheif/context.h
@@ -29,6 +29,7 @@
 #include <utility>

 #include "error.h"
+#include "security_limits.h"

 #include "libheif/heif.h"
 #include "libheif/heif_experimental.h"
diff --git a/libheif/file.cc b/libheif/file.cc
index 68eecfd6..194f6a3c 100644
--- a/libheif/file.cc
+++ b/libheif/file.cc
@@ -636,30 +636,44 @@ Error HeifFile::parse_heif_sequences()
 Error HeifFile::check_for_ref_cycle(heif_item_id ID,
                                     const std::shared_ptr<Box_iref>& iref_box) const
 {
-  std::unordered_set<heif_item_id> parent_items;
-  return check_for_ref_cycle_recursion(ID, iref_box, parent_items);
+  std::unordered_set<heif_item_id> parent_items;    // items on the current DFS path
+  std::unordered_set<heif_item_id> finished_items;  // items whose subtree is known acyclic
+  return check_for_ref_cycle_recursion(ID, iref_box, parent_items, finished_items);
 }


 Error HeifFile::check_for_ref_cycle_recursion(heif_item_id ID,
                                     const std::shared_ptr<Box_iref>& iref_box,
-                                    std::unordered_set<heif_item_id>& parent_items) const {
+                                    std::unordered_set<heif_item_id>& parent_items,
+                                    std::unordered_set<heif_item_id>& finished_items) const {
   if (parent_items.find(ID) != parent_items.end()) {
     return Error(heif_error_Invalid_input,
                  heif_suberror_Item_reference_cycle,
                  "Image reference cycle");
   }
+
+  // An item whose subtree we have already fully verified as acyclic cannot be
+  // part of a cycle when reached again through a different path. Without this
+  // memo the DFS visits every distinct root-to-item path, which is exponential
+  // for shared/nested derived-image references (e.g. many 'iden' items pointing
+  // at a common base), turning a tiny file into a file-open CPU DoS.
+  // (GHSA-x8xm-cm2c-cfc8)
+  if (finished_items.find(ID) != finished_items.end()) {
+    return Error::Ok;
+  }
+
   parent_items.insert(ID);

   std::vector<heif_item_id> image_references = iref_box->get_references(ID, fourcc("dimg"));
   for (heif_item_id reference_idx : image_references) {
-    Error error = check_for_ref_cycle_recursion(reference_idx, iref_box, parent_items);
+    Error error = check_for_ref_cycle_recursion(reference_idx, iref_box, parent_items, finished_items);
     if (error) {
       return error;
     }
   }

   parent_items.erase(ID);
+  finished_items.insert(ID);
   return Error::Ok;
 }

diff --git a/libheif/file.h b/libheif/file.h
index 34dc2502..2f9134e6 100644
--- a/libheif/file.h
+++ b/libheif/file.h
@@ -313,7 +313,8 @@ private:

   Error check_for_ref_cycle_recursion(heif_item_id ID,
                                       const std::shared_ptr<Box_iref>& iref_box,
-                                      std::unordered_set<heif_item_id>& parent_items) const;
+                                      std::unordered_set<heif_item_id>& parent_items,
+                                      std::unordered_set<heif_item_id>& finished_items) const;
 };

 #endif
diff --git a/libheif/image-items/grid.cc b/libheif/image-items/grid.cc
index 048b7687..989f28b5 100644
--- a/libheif/image-items/grid.cc
+++ b/libheif/image-items/grid.cc
@@ -209,22 +209,22 @@ Error ImageItem_Grid::read_grid_spec()

 Result<std::shared_ptr<HeifPixelImage>> ImageItem_Grid::decode_compressed_image(const heif_decoding_options& options,
                                                                                 bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                                std::set<heif_item_id> processed_ids) const
+                                                                                DecodeTraversalState decode_state) const
 {
-  if (processed_ids.contains(get_id())) {
+  if (decode_state.processed_ids.contains(get_id())) {
     return Error{heif_error_Invalid_input,
                  heif_suberror_Unspecified,
                  "'iref' has cyclic references"};
   }

-  processed_ids.insert(get_id());
+  decode_state.processed_ids.insert(get_id());


   if (decode_tile_only) {
-    return decode_grid_tile(options, tile_x0, tile_y0, processed_ids);
+    return decode_grid_tile(options, tile_x0, tile_y0, decode_state);
   }
   else {
-    return decode_full_grid_image(options, processed_ids);
+    return decode_full_grid_image(options, decode_state);
   }
 }

@@ -247,7 +247,7 @@ static void wait_for_jobs(std::deque<std::future<Error> >* jobs) {
 }
 #endif

-Result<std::shared_ptr<HeifPixelImage>> ImageItem_Grid::decode_full_grid_image(const heif_decoding_options& options, std::set<heif_item_id> processed_ids) const
+Result<std::shared_ptr<HeifPixelImage>> ImageItem_Grid::decode_full_grid_image(const heif_decoding_options& options, DecodeTraversalState decode_state) const
 {
   std::shared_ptr<HeifPixelImage> img; // the decoded image

@@ -388,7 +388,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_Grid::decode_full_grid_image(c
           }
         }

-        err = decode_and_paste_tile_image(tileID, x0, y0, img, options, progress_counter, warnings, processed_ids);
+        err = decode_and_paste_tile_image(tileID, x0, y0, img, options, progress_counter, warnings, decode_state);
         if (err) {
           return err;
         }
@@ -436,7 +436,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_Grid::decode_full_grid_image(c
       errs.push_back(std::async(std::launch::async,
                                 &ImageItem_Grid::decode_and_paste_tile_image, this,
                                 data.tileID, data.x_origin, data.y_origin, std::ref(img), options,
-                                std::ref(progress_counter), warnings, processed_ids));
+                                std::ref(progress_counter), warnings, decode_state));
     }

     // check for decoding errors in remaining tiles
@@ -484,7 +484,7 @@ Error ImageItem_Grid::decode_and_paste_tile_image(heif_item_id tileID, uint32_t
                                                   const heif_decoding_options& options,
                                                   int& progress_counter,
                                                   std::shared_ptr<std::vector<Error> > warnings,
-                                                  std::set<heif_item_id> processed_ids) const
+                                                  DecodeTraversalState decode_state) const
 {
   std::shared_ptr<HeifPixelImage> tile_img;
 #if ENABLE_PARALLEL_TILE_DECODING
@@ -510,7 +510,7 @@ Error ImageItem_Grid::decode_and_paste_tile_image(heif_item_id tileID, uint32_t
     return error;
   }

-  auto decodeResult = tileItem->decode_image(options, false, 0, 0, processed_ids);
+  auto decodeResult = tileItem->decode_image(options, false, 0, 0, decode_state);
   if (!decodeResult) {
     if (!options.strict_decoding) {
       // We ignore broken tiles. The un-pasted canvas region stays zero from calloc().
@@ -578,7 +578,7 @@ Error ImageItem_Grid::decode_and_paste_tile_image(heif_item_id tileID, uint32_t


 Result<std::shared_ptr<HeifPixelImage>> ImageItem_Grid::decode_grid_tile(const heif_decoding_options& options, uint32_t tx, uint32_t ty,
-                                                                         std::set<heif_item_id> processed_ids) const
+                                                                         DecodeTraversalState decode_state) const
 {
   uint32_t idx = ty * m_grid_spec.get_columns() + tx;

@@ -599,7 +599,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_Grid::decode_grid_tile(const h
     return error;
   }

-  return tile_item->decode_compressed_image(options, false, 0, 0, processed_ids);
+  return tile_item->decode_compressed_image(options, false, 0, 0, decode_state);
 }


diff --git a/libheif/image-items/grid.h b/libheif/image-items/grid.h
index 3e6f9052..7cd6a27e 100644
--- a/libheif/image-items/grid.h
+++ b/libheif/image-items/grid.h
@@ -134,7 +134,7 @@ public:

   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                  std::set<heif_item_id> processed_ids) const override;
+                                                                  DecodeTraversalState decode_state) const override;

   heif_brand2 get_compatible_brand() const override;

@@ -166,15 +166,15 @@ private:

   Error read_grid_spec();

-  Result<std::shared_ptr<HeifPixelImage>> decode_full_grid_image(const heif_decoding_options& options, std::set<heif_item_id> processed_ids) const;
+  Result<std::shared_ptr<HeifPixelImage>> decode_full_grid_image(const heif_decoding_options& options, DecodeTraversalState decode_state) const;

-  Result<std::shared_ptr<HeifPixelImage>> decode_grid_tile(const heif_decoding_options& options, uint32_t tx, uint32_t ty, std::set<heif_item_id> processed_ids) const;
+  Result<std::shared_ptr<HeifPixelImage>> decode_grid_tile(const heif_decoding_options& options, uint32_t tx, uint32_t ty, DecodeTraversalState decode_state) const;

   Error decode_and_paste_tile_image(heif_item_id tileID, uint32_t x0, uint32_t y0,
                                     std::shared_ptr<HeifPixelImage>& inout_image,
                                     const heif_decoding_options& options, int& progress_counter,
                                     std::shared_ptr<std::vector<Error> > warnings,
-                                    std::set<heif_item_id> processed_ids) const;
+                                    DecodeTraversalState decode_state) const;
 };


diff --git a/libheif/image-items/iden.cc b/libheif/image-items/iden.cc
index 8050af27..a031a6bf 100644
--- a/libheif/image-items/iden.cc
+++ b/libheif/image-items/iden.cc
@@ -37,15 +37,15 @@ ImageItem_iden::ImageItem_iden(HeifContext* ctx, heif_item_id id)

 Result<std::shared_ptr<HeifPixelImage>> ImageItem_iden::decode_compressed_image(const heif_decoding_options& options,
                                                                                 bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                                std::set<heif_item_id> processed_ids) const
+                                                                                DecodeTraversalState decode_state) const
 {
-  if (processed_ids.contains(get_id())) {
+  if (decode_state.processed_ids.contains(get_id())) {
     return Error{heif_error_Invalid_input,
                  heif_suberror_Unspecified,
                  "'iref' has cyclic references"};
   }

-  processed_ids.insert(get_id());
+  decode_state.processed_ids.insert(get_id());


   std::shared_ptr<HeifPixelImage> img;
@@ -93,7 +93,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_iden::decode_compressed_image(
     return error;
   }

-  return imgitem->decode_image(options, decode_tile_only, tile_x0, tile_y0, processed_ids);
+  return imgitem->decode_image(options, decode_tile_only, tile_x0, tile_y0, decode_state);
 }


diff --git a/libheif/image-items/iden.h b/libheif/image-items/iden.h
index 2eba9697..51e75bec 100644
--- a/libheif/image-items/iden.h
+++ b/libheif/image-items/iden.h
@@ -66,7 +66,7 @@ public:

   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                  std::set<heif_item_id> processed_ids) const override;
+                                                                  DecodeTraversalState decode_state) const override;

   heif_brand2 get_compatible_brand() const override;

diff --git a/libheif/image-items/image_item.cc b/libheif/image-items/image_item.cc
index 276034bc..b49ea391 100644
--- a/libheif/image-items/image_item.cc
+++ b/libheif/image-items/image_item.cc
@@ -881,20 +881,32 @@ void ImageItem::set_omaf_image_projection(heif_omaf_image_projection projection)

 Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decoding_options& options,
                                                                 bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                std::set<heif_item_id> processed_ids) const
+                                                                DecodeTraversalState decode_state) const
 {
   // Check for cycles before taking m_decode_mutex: a derived item that
   // (transitively) references itself would otherwise re-enter decode_image()
   // on the same ImageItem and self-deadlock on the non-recursive mutex.
   // The matching insert lives inside decode_compressed_image() of derived
-  // items (grid/overlay/iden), so the current item is in processed_ids only
+  // items (grid/overlay/iden), so the current item is in decode_state only
   // when called from one of its own descendants.
-  if (processed_ids.contains(m_id)) {
+  if (decode_state.processed_ids.contains(m_id)) {
     return Error{heif_error_Invalid_input,
                  heif_suberror_Unspecified,
                  "'iref' has cyclic references"};
   }

+  // Bound the total number of sub-image decodes for this top-level decode.
+  // Derived images (grid/iovl/iden) can reference the same base image through
+  // indirection, and because the cycle-detection set is per-path, a shared
+  // subtree is otherwise re-decoded once per path that reaches it, which grows
+  // as branch^depth for nested references. This is the single choke point that
+  // every item decode passes through. (GHSA-x8xm-cm2c-cfc8)
+  if (!decode_state.count_decode()) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Security_limit_exceeded,
+                 "Too many derived-image decode operations (possible reference amplification)"};
+  }
+
   if (m_item_error) {
     return m_item_error;
   }
@@ -924,7 +936,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod

   // --- decode image

-  Result<std::shared_ptr<HeifPixelImage>> decodingResult = decode_compressed_image(options, decode_tile_only, tile_x0, tile_y0, processed_ids);
+  Result<std::shared_ptr<HeifPixelImage>> decodingResult = decode_compressed_image(options, decode_tile_only, tile_x0, tile_y0, decode_state);
   if (!decodingResult) {
     return decodingResult.error();
   }
@@ -1033,7 +1045,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod
       return alpha_image->get_item_error();
     }

-    auto alphaDecodingResult = alpha_image->decode_image(options, decode_tile_only, tile_x0, tile_y0, processed_ids);
+    auto alphaDecodingResult = alpha_image->decode_image(options, decode_tile_only, tile_x0, tile_y0, decode_state);
     if (!alphaDecodingResult) {
       return alphaDecodingResult.error();
     }
@@ -1244,15 +1256,15 @@ Result<std::vector<uint8_t>> ImageItem::read_bitstream_configuration_data_overri

 Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_compressed_image(const heif_decoding_options& options,
                                                                            bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                           std::set<heif_item_id> processed_ids) const
+                                                                           DecodeTraversalState decode_state) const
 {
-  if (processed_ids.contains(m_id)) {
+  if (decode_state.processed_ids.contains(m_id)) {
     return Error{heif_error_Invalid_input,
                  heif_suberror_Unspecified,
                  "'iref' has cyclic references"};
   }

-  processed_ids.insert(m_id);
+  decode_state.processed_ids.insert(m_id);


   DataExtent extent;
diff --git a/libheif/image-items/image_item.h b/libheif/image-items/image_item.h
index be1c6431..960bebf4 100644
--- a/libheif/image-items/image_item.h
+++ b/libheif/image-items/image_item.h
@@ -23,6 +23,7 @@

 #include "api/libheif/heif.h"
 #include "error.h"
+#include "security_limits.h"
 #include "nclx.h"
 #include <string>
 #include <vector>
@@ -368,12 +369,12 @@ public:
   virtual Result<std::shared_ptr<HeifPixelImage>> decode_image(const heif_decoding_options& options,
                                                                bool decode_tile_only, uint32_t tile_x0,
                                                                uint32_t tile_y0,
-                                                               std::set<heif_item_id> processed_ids) const;
+                                                               DecodeTraversalState decode_state) const;

   virtual Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                           bool decode_tile_only, uint32_t tile_x0,
                                                                           uint32_t tile_y0,
-                                                                          std::set<heif_item_id> processed_ids) const;
+                                                                          DecodeTraversalState decode_state) const;

   // Validate the just-decoded pixel image against the size signaled for this item.
   // Called by decode_image() right after decode_compressed_image(), BEFORE transforms,
@@ -543,14 +544,14 @@ public:
   Result<std::shared_ptr<HeifPixelImage>> decode_image(const heif_decoding_options& options,
                                                        bool decode_tile_only, uint32_t tile_x0,
                                                        uint32_t tile_y0,
-                                                       std::set<heif_item_id> processed_ids) const override
+                                                       DecodeTraversalState decode_state) const override
   {
     return m_item_error;
   }

   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0,
-                                                                  uint32_t tile_y0, std::set<heif_item_id> processed_ids) const override
+                                                                  uint32_t tile_y0, DecodeTraversalState decode_state) const override
   {
     return m_item_error;
   }
diff --git a/libheif/image-items/mask_image.cc b/libheif/image-items/mask_image.cc
index a3cb1d35..532bfdea 100644
--- a/libheif/image-items/mask_image.cc
+++ b/libheif/image-items/mask_image.cc
@@ -126,7 +126,7 @@ Error MaskImageCodec::decode_mask_image(const HeifContext* context,

 Result<std::shared_ptr<HeifPixelImage>> ImageItem_mask::decode_compressed_image(const heif_decoding_options& options,
                                                                                 bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                                std::set<heif_item_id> processed_ids) const
+                                                                                DecodeTraversalState decode_state) const
 {
   std::shared_ptr<HeifPixelImage> img;

diff --git a/libheif/image-items/mask_image.h b/libheif/image-items/mask_image.h
index 5e14ceec..33e97c97 100644
--- a/libheif/image-items/mask_image.h
+++ b/libheif/image-items/mask_image.h
@@ -100,7 +100,7 @@ public:

   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                  std::set<heif_item_id> processed_ids) const override;
+                                                                  DecodeTraversalState decode_state) const override;

   Result<Encoder::CodedImageData> encode(const std::shared_ptr<HeifPixelImage>& image,
                                          heif_encoder* encoder,
diff --git a/libheif/image-items/overlay.cc b/libheif/image-items/overlay.cc
index 0368ecbf..5ab6ab20 100644
--- a/libheif/image-items/overlay.cc
+++ b/libheif/image-items/overlay.cc
@@ -252,6 +252,17 @@ Error ImageItem_Overlay::read_overlay_spec()
                  "'iovl' image has no referenced input images");
   }

+  // Limit the number of images composited into a single overlay. This is a
+  // hardcoded stopgap (see MAX_OVERLAY_IMAGES) until a configurable limit can be
+  // added to heif_security_limits in the next major release. Skipped when the
+  // security limits are disabled. (GHSA-x8xm-cm2c-cfc8)
+  if (get_context()->get_security_limits()->max_items != 0 &&
+      m_overlay_image_ids.size() > MAX_OVERLAY_IMAGES) {
+    return Error(heif_error_Invalid_input,
+                 heif_suberror_Security_limit_exceeded,
+                 "'iovl' image composites more input images than allowed");
+  }
+

   auto overlayDataResult = heif_file->get_uncompressed_item_data(get_id());
   if (!overlayDataResult) {
@@ -275,9 +286,9 @@ Error ImageItem_Overlay::read_overlay_spec()

 Result<std::shared_ptr<HeifPixelImage>> ImageItem_Overlay::decode_compressed_image(const heif_decoding_options& options,
                                                                                    bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                                   std::set<heif_item_id> processed_ids) const
+                                                                                   DecodeTraversalState decode_state) const
 {
-  return decode_overlay_image(options, processed_ids);
+  return decode_overlay_image(options, decode_state);
 }

 // Note: ImageItem_Overlay does not override check_decoded_image_size(). The overlay
@@ -288,15 +299,28 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_Overlay::decode_compressed_ima


 Result<std::shared_ptr<HeifPixelImage>> ImageItem_Overlay::decode_overlay_image(const heif_decoding_options& options,
-                                                                                std::set<heif_item_id> processed_ids) const
+                                                                                DecodeTraversalState decode_state) const
 {
-  if (processed_ids.contains(get_id())) {
+  if (decode_state.processed_ids.contains(get_id())) {
     return Error{heif_error_Invalid_input,
                  heif_suberror_Unspecified,
                  "'iref' has cyclic references"};
   }

-  processed_ids.insert(get_id());
+  decode_state.processed_ids.insert(get_id());
+
+  // Bound the depth of overlays nested inside one another. Real files place at
+  // most one overlay in a decode chain; deep nesting is a fast-rejection path
+  // for the reference-amplification gadget. decode_state (and thus this counter)
+  // is copied by value at each hop, so overlay_nesting measures depth along the
+  // current path only. (GHSA-x8xm-cm2c-cfc8)
+  decode_state.overlay_nesting++;
+  if (decode_state.max_overlay_nesting != 0 &&
+      decode_state.overlay_nesting > decode_state.max_overlay_nesting) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Security_limit_exceeded,
+                 "'iovl' overlay images nested too deeply"};
+  }


   std::shared_ptr<HeifPixelImage> img;
@@ -350,7 +374,7 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_Overlay::decode_overlay_image(
       return error;
     }

-    auto decodeResult = imgItem->decode_image(options, false, 0,0, processed_ids);
+    auto decodeResult = imgItem->decode_image(options, false, 0,0, decode_state);
     if (!decodeResult) {
       return decodeResult.error();
     }
diff --git a/libheif/image-items/overlay.h b/libheif/image-items/overlay.h
index efa53675..b48d7920 100644
--- a/libheif/image-items/overlay.h
+++ b/libheif/image-items/overlay.h
@@ -125,7 +125,7 @@ public:

   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                  std::set<heif_item_id> processed_ids) const override;
+                                                                  DecodeTraversalState decode_state) const override;


   // --- iovl specific
@@ -139,7 +139,7 @@ private:
   Error read_overlay_spec();

   Result<std::shared_ptr<HeifPixelImage>> decode_overlay_image(const heif_decoding_options& options,
-                                                               std::set<heif_item_id> processed_ids) const;
+                                                               DecodeTraversalState decode_state) const;
 };


diff --git a/libheif/image-items/tiled.cc b/libheif/image-items/tiled.cc
index ec081fe3..72bf7b0c 100644
--- a/libheif/image-items/tiled.cc
+++ b/libheif/image-items/tiled.cc
@@ -350,6 +350,13 @@ Error TiledHeader::set_parameters(const heif_tiled_image_parameters& params)
     return err;
   }

+  // TODO(security): this offset table is bounded only by max_number_of_tiles
+  // (default 4096*4096 => ~256 MB for the TileOffset vector), and the allocation
+  // is neither counted against max_total_memory via MemoryHandle nor deferred to
+  // decode time. It also uses the global security limits instead of the context
+  // limits. Route this allocation through MemoryHandle and use the context limits
+  // so a small 'tili' file cannot pre-allocate hundreds of MB at file-open time.
+  // (Reported in GHSA-x8xm-cm2c-cfc8, variant V3. 'tili' is experimental.)
   m_offsets.resize(*num_tiles_result);

   for (auto& tile: m_offsets) {
@@ -958,7 +965,7 @@ Error ImageItem_Tiled::process_before_write()
 Result<std::shared_ptr<HeifPixelImage>>
 ImageItem_Tiled::decode_compressed_image(const heif_decoding_options& options,
                                          bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                         std::set<heif_item_id> processed_ids) const
+                                         DecodeTraversalState decode_state) const
 {
   if (decode_tile_only) {
     return decode_grid_tile(options, tile_x0, tile_y0);
diff --git a/libheif/image-items/tiled.h b/libheif/image-items/tiled.h
index 92c64d29..561aad9c 100644
--- a/libheif/image-items/tiled.h
+++ b/libheif/image-items/tiled.h
@@ -197,7 +197,7 @@ public:

   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                  std::set<heif_item_id> processed_ids) const override;
+                                                                  DecodeTraversalState decode_state) const override;

   heif_brand2 get_compatible_brand() const override;

diff --git a/libheif/image-items/unc_image.cc b/libheif/image-items/unc_image.cc
index ad85cb70..ff490252 100644
--- a/libheif/image-items/unc_image.cc
+++ b/libheif/image-items/unc_image.cc
@@ -143,7 +143,7 @@ ImageItem_uncompressed::ImageItem_uncompressed(HeifContext* ctx)

 Result<std::shared_ptr<HeifPixelImage>> ImageItem_uncompressed::decode_compressed_image(const heif_decoding_options& options,
                                                                                 bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                                std::set<heif_item_id> processed_ids) const
+                                                                                DecodeTraversalState decode_state) const
 {
   std::shared_ptr<HeifPixelImage> img;

diff --git a/libheif/image-items/unc_image.h b/libheif/image-items/unc_image.h
index a5a14723..97c892a9 100644
--- a/libheif/image-items/unc_image.h
+++ b/libheif/image-items/unc_image.h
@@ -70,7 +70,7 @@ public:

   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
-                                                                  std::set<heif_item_id> processed_ids) const override;
+                                                                  DecodeTraversalState decode_state) const override;

   heif_image_tiling get_heif_image_tiling() const override;

diff --git a/libheif/security_limits.h b/libheif/security_limits.h
index 2db08e83..1c5bf138 100644
--- a/libheif/security_limits.h
+++ b/libheif/security_limits.h
@@ -23,6 +23,9 @@
 #include "libheif/heif.h"
 #include <cinttypes>
 #include <cstddef>
+#include <set>
+#include <atomic>
+#include <memory>
 #include "error.h"


@@ -38,6 +41,65 @@ static const int64_t MAX_LARGE_BOX_SIZE = 0x0FFFFFFFFFFFFFFF;
 static const int64_t MAX_FILE_POS = 0x007FFFFFFFFFFFFFLL; // maximum file position
 static const int MAX_FRACTION_VALUE = 0x10000;

+// Maximum number of 'iovl' overlay images that may be nested inside one another
+// along a single decode path. Real files use at most one overlay per decode
+// chain, so this is a structural sanity limit, not a common-case constraint.
+static const uint32_t MAX_OVERLAY_NESTING_LEVEL = 3;
+
+// Maximum number of input images that a single 'iovl' overlay may composite.
+// This is a hardcoded stopgap until a configurable security-limit field can be
+// added to heif_security_limits in the next major release (that is an API/ABI
+// change and cannot go into a point release).
+static const uint32_t MAX_OVERLAY_IMAGES = 5;
+
+// Factor applied to max_items to bound the total number of sub-image decode
+// operations triggered by a single top-level decode. Derived images (grid,
+// iovl, iden) can reference the same base image through indirection, and the
+// cycle-detection set is per-path (it forks at every branch), so a shared
+// subtree can be re-decoded once per path that reaches it. Nested sharing makes
+// the number of decode operations grow as branch^depth, which is bounded only
+// by the recursion depth (<= number of items). Because a well-formed file
+// decodes each of its (<= max_items) items a small number of times, capping the
+// total at a modest multiple of max_items stops the exponential blow-up while
+// leaving every realistic file untouched.  (GHSA-x8xm-cm2c-cfc8, variants V1/V2.)
+static const uint32_t MAX_DERIVED_IMAGE_DECODE_FACTOR = 2;
+
+
+// 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.
+//
+//   - 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
+//     that bounds the total number of decode operations.
+//
+// A default-constructed DecodeTraversalState (max_decodes == 0) imposes no limit; the
+// budget is seeded once at the top level in HeifContext::decode_image().
+struct DecodeTraversalState
+{
+  std::set<heif_item_id> processed_ids;
+
+  uint32_t overlay_nesting = 0;         // number of overlays on the path to here
+  uint32_t max_overlay_nesting = 0;     // 0 == unlimited
+
+  std::shared_ptr<std::atomic<uint32_t>> decode_count;
+  uint32_t max_decodes = 0;             // 0 == unlimited
+
+  // Count one sub-image decode against the shared budget.
+  // Returns false when the budget has been exhausted.
+  bool count_decode()
+  {
+    if (max_decodes == 0 || !decode_count) {
+      return true;
+    }
+    return decode_count->fetch_add(1, std::memory_order_relaxed) < max_decodes;
+  }
+};
+

 Error check_for_valid_image_size(const heif_security_limits* limits, uint32_t width, uint32_t height);

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 0e162dfa..144a6cae 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -76,6 +76,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(overlay_amplification)
 add_libheif_test(region)
 add_libheif_test(sequence_no_track)
 add_libheif_test(sequence_timing_overflow)
diff --git a/tests/overlay_amplification.cc b/tests/overlay_amplification.cc
new file mode 100644
index 00000000..c85e4502
--- /dev/null
+++ b/tests/overlay_amplification.cc
@@ -0,0 +1,356 @@
+/*
+  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 tests for GHSA-x8xm-cm2c-cfc8: derived-image (grid/iovl/iden)
+// reference chains could decode a shared base image an unbounded number of
+// times. Because the cycle-detection set is copied per recursion path, a shared
+// subtree is re-decoded once per path that reaches it, and nested references
+// make the number of decodes grow as branch^depth. A tiny file could thus pin a
+// CPU for minutes (linear amplification) or effectively forever (exponential,
+// via nested overlays). These tests build the amplification structures out of
+// 'iovl' overlays over an 'mski' base (which needs no codec plugin) and check
+// that decoding is rejected quickly instead of blowing up.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace {
+
+void put_u32_be(std::vector<uint8_t>& out, uint32_t v) {
+  out.push_back(static_cast<uint8_t>(v >> 24));
+  out.push_back(static_cast<uint8_t>(v >> 16));
+  out.push_back(static_cast<uint8_t>(v >> 8));
+  out.push_back(static_cast<uint8_t>(v));
+}
+
+void put_u16_be(std::vector<uint8_t>& out, uint16_t v) {
+  out.push_back(static_cast<uint8_t>(v >> 8));
+  out.push_back(static_cast<uint8_t>(v));
+}
+
+void append_fourcc(std::vector<uint8_t>& out, const char fourcc[4]) {
+  out.insert(out.end(), fourcc, fourcc + 4);
+}
+
+void append(std::vector<uint8_t>& out, const std::vector<uint8_t>& v) {
+  out.insert(out.end(), v.begin(), v.end());
+}
+
+std::vector<uint8_t> make_box(const char fourcc[4],
+                              const std::vector<uint8_t>& payload,
+                              bool is_full_box = false,
+                              uint8_t version = 0,
+                              uint32_t flags = 0) {
+  std::vector<uint8_t> body;
+  if (is_full_box) {
+    body.push_back(version);
+    body.push_back(static_cast<uint8_t>(flags >> 16));
+    body.push_back(static_cast<uint8_t>(flags >> 8));
+    body.push_back(static_cast<uint8_t>(flags));
+  }
+  body.insert(body.end(), payload.begin(), payload.end());
+
+  std::vector<uint8_t> box;
+  put_u32_be(box, static_cast<uint32_t>(8 + body.size()));
+  append_fourcc(box, fourcc);
+  box.insert(box.end(), body.begin(), body.end());
+  return box;
+}
+
+// ImageOverlay payload (ISO/IEC 23008-12): version, flags, 4x background color,
+// canvas width/height, and one (x,y) offset per composited image. flags=0 uses
+// 16-bit fields. All composited images are placed at (0,0) here.
+std::vector<uint8_t> make_overlay_spec(uint16_t canvas_w, uint16_t canvas_h, size_t num_images) {
+  std::vector<uint8_t> s;
+  s.push_back(0);           // version
+  s.push_back(0);           // flags (16-bit fields)
+  for (int i = 0; i < 4; i++) { put_u16_be(s, 0); }  // background color RGBA
+  put_u16_be(s, canvas_w);
+  put_u16_be(s, canvas_h);
+  for (size_t i = 0; i < num_images; i++) {
+    put_u16_be(s, 0);       // x offset
+    put_u16_be(s, 0);       // y offset
+  }
+  return s;
+}
+
+struct Item {
+  uint16_t id = 0;
+  std::string type;                 // "mski", "iovl", "iden"
+  std::vector<uint16_t> dimg;       // 'dimg' references (iovl/iden)
+  std::vector<uint8_t> data;        // idat payload (mski pixels / iovl spec); empty for iden
+};
+
+// Assemble a minimal, self-contained HEIF file from an explicit item list. Every
+// item carries an 8x8 'ispe'; the single 'mski' base additionally carries a
+// 'mskC'. Items with data store it in 'idat' (construction_method 1).
+std::vector<uint8_t> build_file(const std::vector<Item>& items, uint16_t primary_id) {
+  const uint32_t W = 8, H = 8;
+
+  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);
+
+  std::vector<uint8_t> pitm_payload;
+  put_u16_be(pitm_payload, primary_id);
+  auto pitm = make_box("pitm", pitm_payload, /*full=*/true);
+
+  // iinf
+  std::vector<uint8_t> iinf_payload;
+  put_u16_be(iinf_payload, static_cast<uint16_t>(items.size()));
+  for (const auto& it : items) {
+    std::vector<uint8_t> infe_payload;
+    put_u16_be(infe_payload, it.id);
+    put_u16_be(infe_payload, 0);
+    append_fourcc(infe_payload, it.type.c_str());
+    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: one ispe(8x8) per item, plus one shared mskC for the base.
+  // Property indices are 1-based in the order boxes appear in ipco.
+  std::vector<uint8_t> ispe_payload;
+  put_u32_be(ispe_payload, W);
+  put_u32_be(ispe_payload, H);
+  auto ispe = make_box("ispe", ispe_payload, /*full=*/true);
+
+  std::vector<uint8_t> mskC_payload;
+  mskC_payload.push_back(8);  // bits_per_pixel
+  auto mskC = make_box("mskC", mskC_payload, /*full=*/true);
+
+  std::vector<uint8_t> ipco_payload;
+  append(ipco_payload, ispe);   // property 1: ispe(8x8), shared by all items
+  append(ipco_payload, mskC);   // property 2: mskC, for the base
+  auto ipco = make_box("ipco", ipco_payload);
+
+  std::vector<uint8_t> ipma_payload;
+  put_u32_be(ipma_payload, static_cast<uint32_t>(items.size()));  // entry_count
+  for (const auto& it : items) {
+    put_u16_be(ipma_payload, it.id);
+    if (it.type == "mski") {
+      ipma_payload.push_back(2);          // association_count
+      ipma_payload.push_back(0x80 | 1);   // essential, ispe
+      ipma_payload.push_back(0x80 | 2);   // essential, mskC
+    }
+    else {
+      ipma_payload.push_back(1);
+      ipma_payload.push_back(0x80 | 1);   // essential, ispe
+    }
+  }
+  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);
+
+  // idat + iloc: concatenate the data of all items that have any.
+  std::vector<uint8_t> idat_payload;
+  struct Extent { uint16_t id; uint32_t off; uint32_t len; };
+  std::vector<Extent> extents;
+  for (const auto& it : items) {
+    if (!it.data.empty()) {
+      extents.push_back({it.id, static_cast<uint32_t>(idat_payload.size()),
+                         static_cast<uint32_t>(it.data.size())});
+      append(idat_payload, it.data);
+    }
+  }
+  auto idat = make_box("idat", idat_payload);
+
+  std::vector<uint8_t> iloc_payload;
+  iloc_payload.push_back((4 << 4) | 4);   // offset_size=4, length_size=4
+  iloc_payload.push_back((0 << 4) | 0);   // base_offset_size=0, index_size=0
+  put_u16_be(iloc_payload, static_cast<uint16_t>(extents.size()));  // item_count
+  for (const auto& e : extents) {
+    put_u16_be(iloc_payload, e.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, e.off);      // extent_offset (within idat)
+    put_u32_be(iloc_payload, e.len);      // extent_length
+  }
+  auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+  // iref: one 'dimg' entry per item that references others.
+  std::vector<uint8_t> iref_children;
+  for (const auto& it : items) {
+    if (!it.dimg.empty()) {
+      std::vector<uint8_t> dimg_payload;
+      put_u16_be(dimg_payload, it.id);
+      put_u16_be(dimg_payload, static_cast<uint16_t>(it.dimg.size()));
+      for (uint16_t to : it.dimg) { put_u16_be(dimg_payload, to); }
+      append(iref_children, make_box("dimg", dimg_payload));
+    }
+  }
+  auto iref = make_box("iref", iref_children, /*full=*/true);
+
+  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);
+  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;
+}
+
+// Build the exponential amplification gadget: a chain of `depth` overlays where
+// each overlay composites the next one twice, through two distinct 'iden' items
+// (a direct double reference would be rejected by Box_iref). Without the fix,
+// decoding the primary overlay decodes the base 2^depth times.
+//
+// Item layout: id 1 = base ('mski'); then per level k = 0..depth-1:
+//   iovl_k  = 2 + 3*k
+//   iden_ak = 3 + 3*k, iden_bk = 4 + 3*k   (both reference the next level)
+std::vector<uint8_t> build_exponential_overlays(int depth) {
+  std::vector<Item> items;
+  items.push_back({1, "mski", {}, std::vector<uint8_t>(64, 0x7F)});
+
+  for (int k = 0; k < depth; k++) {
+    uint16_t iovl = static_cast<uint16_t>(2 + 3 * k);
+    uint16_t iden_a = static_cast<uint16_t>(3 + 3 * k);
+    uint16_t iden_b = static_cast<uint16_t>(4 + 3 * k);
+    uint16_t next = (k + 1 < depth) ? static_cast<uint16_t>(2 + 3 * (k + 1)) : 1;
+
+    items.push_back({iovl, "iovl", {iden_a, iden_b}, make_overlay_spec(8, 8, 2)});
+    items.push_back({iden_a, "iden", {next}, {}});
+    items.push_back({iden_b, "iden", {next}, {}});
+  }
+
+  return build_file(items, /*primary=*/2);
+}
+
+heif_error decode_primary(const std::vector<uint8_t>& data, bool& read_ok) {
+  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);
+  read_ok = (err.code == heif_error_Ok);
+  if (!read_ok) {
+    heif_context_free(ctx);
+    return err;
+  }
+
+  heif_image_handle* handle = nullptr;
+  err = heif_context_get_primary_image_handle(ctx, &handle);
+  if (err.code != heif_error_Ok) {
+    heif_context_free(ctx);
+    return err;
+  }
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+
+  if (img) { heif_image_release(img); }
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+  return err;
+}
+
+} // namespace
+
+
+// The core DoS: without the fix this decodes the base 2^30 times and never
+// returns. With the fix the overlay-nesting guard rejects it after a few levels,
+// so decoding completes essentially instantly. The test therefore also proves
+// (by completing at all) that the amplification is bounded.
+TEST_CASE("overlay amplification: deeply nested overlays are rejected, not decoded") {
+  auto data = build_exponential_overlays(/*depth=*/30);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok);
+
+  REQUIRE(read_ok);            // the file parses; the blow-up is only at decode
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+}
+
+// The per-overlay fan-out limit (MAX_OVERLAY_IMAGES): an overlay compositing
+// more than the allowed number of input images is rejected while reading the
+// overlay spec, so the item becomes undecodable.
+TEST_CASE("overlay amplification: overlay with too many input images is rejected") {
+  std::vector<Item> items;
+  items.push_back({1, "mski", {}, std::vector<uint8_t>(64, 0x7F)});
+
+  // 6 iden items (> MAX_OVERLAY_IMAGES == 5), all pointing at the base.
+  std::vector<uint16_t> refs;
+  for (uint16_t k = 0; k < 6; k++) {
+    uint16_t iden = static_cast<uint16_t>(3 + k);
+    items.push_back({iden, "iden", {1}, {}});
+    refs.push_back(iden);
+  }
+  items.push_back({2, "iovl", refs, make_overlay_spec(8, 8, refs.size())});
+
+  auto data = build_file(items, /*primary=*/2);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok);
+
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+}
+
+// Control: a legitimate two-level overlay (well within all limits) still decodes
+// normally, proving the guards do not reject ordinary derived images.
+TEST_CASE("overlay amplification: a shallow legitimate overlay still decodes") {
+  std::vector<Item> items;
+  items.push_back({1, "mski", {}, std::vector<uint8_t>(64, 0x7F)});
+  // iovl_2 (primary) -> iovl_3 -> base. Overlay nesting depth 2 (<= 3), fan-out 1.
+  items.push_back({2, "iovl", {3}, make_overlay_spec(8, 8, 1)});
+  items.push_back({3, "iovl", {1}, make_overlay_spec(8, 8, 1)});
+
+  auto data = build_file(items, /*primary=*/2);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code == heif_error_Ok);
+}