Commit 4be53862 for libheif
commit 4be53862a558726a132c1ac4a17ead273115bf19
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sat Sep 5 14:42:09 2026 +0200
Do not reject reference cycles at file load; catch them per item at decode
A reference cycle only matters when the cyclic item is decoded, and it is now
caught there by ImageItem::verify_decodable() (GHSA-prgh-72vc-3xmc), per item
and following both the 'dimg' and 'auxl' edges. The parse-time
HeifFile::check_for_ref_cycle rejected the whole file at load whenever the
primary item's 'dimg' subtree contained a cycle, which also made the file's
other, independently valid items undecodable.
Remove that load-time check. A file with a cyclic reference now opens, its valid
items decode, and only the cyclic item fails, at decode. This also makes the
behavior uniform: a cyclic subtree under a non-primary item already loaded and
failed only at decode, and a primary-rooted cycle now behaves the same way. No
other load-time traversal recurses over the derived-image graph (the remaining
'dimg' lookups are single-level), so nothing else relied on this rejection.
Adds a regression test: a file whose primary is a cyclic grid still loads and
its unrelated valid mask item decodes, while decoding the cyclic item returns
the reference-cycle error.
diff --git a/libheif/file.cc b/libheif/file.cc
index 5263d8fc..725aef20 100644
--- a/libheif/file.cc
+++ b/libheif/file.cc
@@ -624,12 +624,11 @@ Error HeifFile::parse_heif_images()
m_idat_box = m_meta_box->get_child_box<Box_idat>();
m_iref_box = m_meta_box->get_child_box<Box_iref>();
- if (m_iref_box && m_pitm_box) {
- Error error = check_for_ref_cycle(get_primary_image_ID(), m_iref_box);
- if (error) {
- return error;
- }
- }
+
+ // Note: reference cycles are not rejected at load. A cycle only matters when
+ // it is decoded, and it is caught there per item by ImageItem::verify_decodable()
+ // (following both 'dimg' and 'auxl' edges). Rejecting the whole file at load
+ // would also make its unaffected, independently valid items undecodable.
m_grpl_box = m_meta_box->get_child_box<Box_grpl>();
@@ -651,51 +650,6 @@ 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; // 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,
- 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, finished_items);
- if (error) {
- return error;
- }
- }
-
- parent_items.erase(ID);
- finished_items.insert(ID);
- return Error::Ok;
-}
-
-
bool HeifFile::item_exists(heif_item_id ID) const
{
auto image_iter = m_infe_boxes.find(ID);
diff --git a/libheif/file.h b/libheif/file.h
index b7e0c966..247778c2 100644
--- a/libheif/file.h
+++ b/libheif/file.h
@@ -318,14 +318,6 @@ private:
Error parse_heif_images();
Error parse_heif_sequences();
-
- Error check_for_ref_cycle(heif_item_id ID,
- const std::shared_ptr<Box_iref>& iref_box) const;
-
- 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,
- std::unordered_set<heif_item_id>& finished_items) const;
};
#endif
diff --git a/libheif/image-items/image_item.cc b/libheif/image-items/image_item.cc
index 4ede1466..34590736 100644
--- a/libheif/image-items/image_item.cc
+++ b/libheif/image-items/image_item.cc
@@ -895,7 +895,8 @@ namespace {
// holds the items on the current root-to-node path (a repeat is a cycle);
// `verified` memoizes items whose subtree is already known acyclic, so a shared
// sub-image reached through several paths is visited once and the walk stays
-// linear (cf. HeifFile::check_for_ref_cycle, GHSA-x8xm-cm2c-cfc8).
+// linear rather than exponential in the number of root-to-item paths
+// (cf. the decode amplification bound, GHSA-x8xm-cm2c-cfc8).
Error check_decode_reference_cycles(const ImageItem* item,
std::set<heif_item_id>& on_path,
std::set<heif_item_id>& verified)
@@ -961,6 +962,14 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod
// The matching insert lives inside decode_compressed_image() of derived
// items (grid/overlay/iden), so the current item is in decode_state only
// when called from one of its own descendants.
+ //
+ // Second-layer hardening, not required for correctness: the top-level decode
+ // already ran ImageItem::verify_decodable() (HeifContext::decode_image), which
+ // proves the whole reachable decode graph is acyclic before any recursion, so
+ // this per-path check can never fire on a graph that reached here. It is kept
+ // as a cheap in-decode backstop, and the same applies to the equivalent checks
+ // in decode_compressed_image() and in grid/overlay/iden. We may remove them in
+ // the future once verify_decodable() is the sole cycle guard.
if (decode_state.processed_ids.contains(m_id)) {
return Error{heif_error_Invalid_input,
heif_suberror_Unspecified,
diff --git a/libheif/image-items/image_item.h b/libheif/image-items/image_item.h
index 533f5a31..9fd58550 100644
--- a/libheif/image-items/image_item.h
+++ b/libheif/image-items/image_item.h
@@ -376,10 +376,11 @@ public:
// the graph of items reached by the decode recursion (derived-image 'dimg'
// inputs and the alpha 'auxl' auxiliary) must be acyclic. A reference cycle
// would otherwise let two parallel grid-tile workers take two item mutexes in
- // opposite order and deadlock (GHSA-prgh-72vc-3xmc); the parse-time check only
- // follows 'dimg' from the primary item and so misses the alpha edge and any
- // non-primary subtree. Called once per top-level decode in
- // HeifContext::decode_image(). This is the single place to add further
+ // opposite order and deadlock (GHSA-prgh-72vc-3xmc). Cycles are not rejected
+ // at file load, so that a file's independently valid items stay decodable;
+ // this per-item decode-time check is what makes a cyclic item safe. Called
+ // once per top-level decode in HeifContext::decode_image(). This is the single
+ // place to add further
// decodability constraints (e.g. no alpha auxiliary on an alpha image, MIAF
// derivation-chain limits).
Error verify_decodable() const;
diff --git a/tests/parallel_grid_deadlock.cc b/tests/parallel_grid_deadlock.cc
index dd37566d..cf78397c 100644
--- a/tests/parallel_grid_deadlock.cc
+++ b/tests/parallel_grid_deadlock.cc
@@ -394,3 +394,33 @@ TEST_CASE("parallel grid: a normal two-tile grid still decodes") {
REQUIRE(completed);
REQUIRE(err.code == heif_error_Ok);
}
+
+// A reference cycle is not rejected at file load: the file still opens and its
+// independently valid items still decode. Only the cyclic item itself fails,
+// at decode time. Here the primary (item 1) is a grid whose two sub-grids form
+// a 'dimg' cycle, while item 4 is an unrelated valid mask.
+TEST_CASE("parallel grid: a cyclic primary does not make valid sibling items undecodable") {
+ const std::vector<uint8_t> big(32 * 64, 0x7F);
+ const std::vector<uint8_t> small(32 * 32, 0x7F);
+ std::vector<Item> items;
+ // id type w h alpha dimg auxl data
+ items.push_back({1, "grid", 64, 64, false, {2, 3}, {}, image_grid(1, 2, 64, 64)}); // cyclic primary
+ items.push_back({2, "grid", 32, 64, false, {3}, {}, image_grid(1, 1, 32, 64)}); // G1 -> G2
+ items.push_back({3, "grid", 32, 64, false, {2}, {}, image_grid(1, 1, 32, 64)}); // G2 -> G1
+ items.push_back({4, "mski", 32, 32, false, {}, {}, small}); // valid, unrelated
+
+ auto data = build_file(items, /*primary=*/1);
+
+ // The valid sibling decodes. Because decode reads the file first, this also
+ // proves the file loaded despite the cyclic primary.
+ heif_error err_valid{};
+ bool completed_valid = decode_item_with_timeout(data, /*item=*/4, std::chrono::seconds(20), err_valid);
+ REQUIRE(completed_valid);
+ REQUIRE(err_valid.code == heif_error_Ok);
+
+ // The cyclic item is still rejected, at decode.
+ heif_error err_cyclic{};
+ bool completed_cyclic = decode_item_with_timeout(data, /*item=*/1, std::chrono::seconds(20), err_cyclic);
+ REQUIRE(completed_cyclic);
+ REQUIRE(err_cyclic.code == heif_error_Invalid_input);
+}