Commit b1e4d72a for libheif
commit b1e4d72a492a2d7be6d1c29153660cb91e570556
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sat Sep 5 21:46:38 2026 +0200
Convert the decode-graph cycle check to an explicit worklist (GHSA-xrp2-63fq-jm8q)
ImageItem::verify_decodable() detects cycles in the decode reference graph before decoding by walking the 'dimg' and alpha 'auxl' edges. The walk was a recursive depth-first traversal, so its native-stack depth equalled the derived-image chain length. A long enough chain of derived items overflows the stack and crashes the process, and with the item-count security limit disabled the depth is unbounded. This is the decode-time relative of the parse-time reference-cycle recursion reported in GHSA-xrp2-63fq-jm8q; the parse-time walk was already removed in 4be53862.
Convert the traversal to an explicit heap worklist. The DFS order (all 'dimg' inputs, then the alpha auxiliary), the on-path cycle set and the verified-subtree memo are preserved, so the behaviour is unchanged; only the recursion is gone, and depth is now bounded by available memory instead of the stack.
Also bind the alpha auxiliary as a const reference instead of copying the shared_ptr: get_alpha_channel() returns a const reference, so the copy tripped clang-tidy's performance-unnecessary-copy-initialization (warnings-as-errors) in the 'clang' CI job.
diff --git a/libheif/image-items/image_item.cc b/libheif/image-items/image_item.cc
index 1e61f39a..d0b75292 100644
--- a/libheif/image-items/image_item.cc
+++ b/libheif/image-items/image_item.cc
@@ -891,53 +891,91 @@ void ImageItem::set_omaf_image_projection(heif_omaf_image_projection projection)
namespace {
-// Depth-first walk of the decode reference graph, detecting cycles. `on_path`
-// 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
+// Detect cycles in the decode reference graph reached from `root`, following the
+// same edges the decode recursion follows. This uses an explicit heap worklist
+// rather than recursion on purpose: the graph depth is influenced by the input
+// (a chain of derived items, and unbounded when the item-count limit is
+// disabled), so a recursive walk could exhaust the native stack and crash the
+// process before any decode, on the read path of an untrusted file. The worklist
+// grows on the heap instead, so depth is bounded only by available memory.
+//
+// It is a depth-first walk. `on_path` holds the items on the current
+// root-to-node path; reaching one that is already on the path is a cycle.
+// `verified` memoizes items whose subtree is already proven acyclic, so a shared
// sub-image reached through several paths is visited once and the walk stays
// 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)
+Error check_decode_reference_cycles(const ImageItem* root)
{
- heif_item_id id = item->get_id();
+ std::set<heif_item_id> on_path; // items on the current DFS path
+ std::set<heif_item_id> verified; // items whose subtree is proven acyclic
+
+ // Collect the decode-input children of an item, in the exact order the decode
+ // recursion follows them: the derived-image ('dimg') inputs (grid tiles,
+ // overlay inputs, the 'iden' base) first, then the alpha ('auxl') auxiliary.
+ // The returned shared_ptrs keep the child ImageItems alive for as long as the
+ // frame that holds them stays on the worklist.
+ auto collect_children = [](const ImageItem* item) {
+ std::vector<std::shared_ptr<const ImageItem>> children;
+ auto file = item->get_file();
+ auto iref = file ? file->get_iref_box() : nullptr;
+ if (iref) {
+ for (heif_item_id child_id : iref->get_references(item->get_id(), fourcc("dimg"))) {
+ if (auto child = item->get_context()->get_image(child_id, true)) {
+ children.push_back(std::move(child));
+ }
+ }
+ }
+ if (const auto& alpha = item->get_alpha_channel()) {
+ children.push_back(alpha);
+ }
+ return children;
+ };
- if (on_path.find(id) != on_path.end()) {
- return {heif_error_Invalid_input,
- heif_suberror_Item_reference_cycle,
- "Image reference cycle"};
- }
- if (verified.find(id) != verified.end()) {
- return Error::Ok;
- }
+ // One worklist frame per item currently on the DFS path. `next` is the index
+ // of the child to descend into next; when it reaches the end, the item's whole
+ // subtree has been proven acyclic and the item leaves the path.
+ struct Frame {
+ const ImageItem* item;
+ std::vector<std::shared_ptr<const ImageItem>> children;
+ size_t next = 0;
+ };
- on_path.insert(id);
+ std::vector<Frame> stack;
+ on_path.insert(root->get_id());
+ stack.push_back(Frame{root, collect_children(root), 0});
- // Follow exactly the edges the decode recursion follows: the derived-image
- // ('dimg') inputs (grid tiles, overlay inputs, the 'iden' base) and the alpha
- // ('auxl') auxiliary image.
- auto file = item->get_file();
- auto iref = file ? file->get_iref_box() : nullptr;
- if (iref) {
- for (heif_item_id child_id : iref->get_references(id, fourcc("dimg"))) {
- auto child = item->get_context()->get_image(child_id, true);
- if (child) {
- if (Error err = check_decode_reference_cycles(child.get(), on_path, verified)) {
- return err;
- }
- }
+ while (!stack.empty()) {
+ Frame& top = stack.back();
+
+ if (top.next >= top.children.size()) {
+ // All children proven acyclic: leave the DFS path and memoize the subtree.
+ heif_item_id done_id = top.item->get_id();
+ on_path.erase(done_id);
+ verified.insert(done_id);
+ stack.pop_back();
+ continue;
}
- }
- if (auto alpha = item->get_alpha_channel()) {
- if (Error err = check_decode_reference_cycles(alpha.get(), on_path, verified)) {
- return err;
+ const ImageItem* child = top.children[top.next++].get();
+ // From here on `top` must not be used: the push_back below may reallocate
+ // `stack` and invalidate the reference.
+
+ heif_item_id child_id = child->get_id();
+ if (on_path.find(child_id) != on_path.end()) {
+ return {heif_error_Invalid_input,
+ heif_suberror_Item_reference_cycle,
+ "Image reference cycle"};
+ }
+ if (verified.find(child_id) != verified.end()) {
+ continue; // subtree already proven acyclic; do not descend into it again
}
+
+ on_path.insert(child_id);
+ auto grandchildren = collect_children(child);
+ stack.push_back(Frame{child, std::move(grandchildren), 0});
}
- on_path.erase(id);
- verified.insert(id);
return Error::Ok;
}
@@ -1050,12 +1088,8 @@ Error ImageItem::verify_decodable() const
{
// Always: reject a cyclic decode reference graph (both 'dimg' and 'auxl'
// edges) before decoding. See the declaration in image_item.h.
- {
- std::set<heif_item_id> on_path;
- std::set<heif_item_id> verified;
- if (Error err = check_decode_reference_cycles(this, on_path, verified)) {
- return err;
- }
+ if (Error err = check_decode_reference_cycles(this)) {
+ return err;
}
// Optionally: enforce MIAF's restricted derived-image dependencies