Commit 6fb37974 for libheif

commit 6fb37974726abde6de7fe2206386d4a6d9ce1c39
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Oct 25 01:33:01 2026 +0200

    Bound Exif IFD traversal by input size [GHSA-pfcm-ffqg-84hx]

diff --git a/heifio/exif.cc b/heifio/exif.cc
index ec5725c2..fd1be5f0 100644
--- a/heifio/exif.cc
+++ b/heifio/exif.cc
@@ -20,6 +20,7 @@

 #include <cassert>
 #include <cstdio>
+#include <vector>
 #include "exif.h"

 #include "common_utils.h"
@@ -109,61 +110,73 @@ static uint32_t find_exif_tag_in_ifd(const uint8_t* exif, uint32_t size,
                                      uint32_t ifd_offset,
                                      uint16_t query_tag,
                                      bool littleEndian,
-                                     int recursion_depth)
+                                     uint32_t remaining_bytes)
 {
-  const int MAX_IFD_TABLE_RECURSION_DEPTH = 5;
-
-  if (recursion_depth > MAX_IFD_TABLE_RECURSION_DEPTH) {
-    return 0;
-  }
-
-  uint32_t offset = ifd_offset;
-
-  // is offset valid (i.e. can we read at least the 'size' field and the pointer to the next IFD ?)
-  if (offset == 0) {
-    return 0;
-  }
+  struct IFDFrame {
+    uint32_t offset;
+    uint16_t count = 0;
+    uint16_t next_entry = 0;
+    bool initialized = false;
+  };
+
+  std::vector<IFDFrame> stack;
+  stack.push_back({ifd_offset});
+
+  while (!stack.empty()) {
+    IFDFrame& frame = stack.back();
+    uint32_t offset = frame.offset;
+
+    if (!frame.initialized) {
+      if (offset == 0 || size < 6 || offset > size - 6) {
+        stack.pop_back();
+        continue;
+      }

-  if (size < 6 || size - 2 - 4 < offset) {
-    return 0;
-  }
+      uint16_t count = read16(exif, size, offset, littleEndian);
+      uint32_t table_size = 2U + count * 12U + 4U;
+      if (table_size > size - offset) {
+        stack.pop_back();
+        continue;
+      }

-  uint16_t cnt = read16(exif, size, offset, littleEndian);
+      // Charge the whole IFD, including the next-IFD pointer. Repeated visits
+      // consume the budget even when an IFD has no entries.
+      if (table_size > remaining_bytes) {
+        stack.pop_back();
+        continue;
+      }
+      remaining_bytes -= table_size;

-  // Does the IFD table fit into our memory range? We need this check to prevent an underflow in the following statement.
-  uint32_t IFD_table_size = 2U + cnt * 12U + 4U;
-  if (IFD_table_size > size) {
-    return 0;
-  }
+      frame.count = count;
+      frame.initialized = true;
+    }

-  // end of IFD table would exceed the end of the EXIF data
-  // offset + IFD_table_size > size ?
-  if (size - IFD_table_size < offset) {
-    return 0;
-  }
+    if (frame.next_entry < frame.count) {
+      uint32_t entry_offset = offset + 2U + frame.next_entry * 12U;
+      frame.next_entry++;
+      uint16_t tag = read16(exif, size, entry_offset, littleEndian);
+      if (tag == query_tag) {
+        return entry_offset;
+      }

-  for (int i = 0; i < cnt; i++) {
-    int tag = read16(exif, size, offset + 2 + i * 12, littleEndian);
-    if (tag == query_tag) {
-      return offset + 2 + i * 12;
+      if (tag == EXIF_TAG_EXIF_IFD_POINTER) {
+        uint32_t exif_ifd_offset = read32(exif, size, entry_offset + 8, littleEndian);
+        if (exif_ifd_offset != 0) {
+          stack.push_back({exif_ifd_offset});
+        }
+      }
     }
-
-    if (tag == EXIF_TAG_EXIF_IFD_POINTER) {
-      uint32_t exifIFD_offset = read32(exif, size, offset + 2 + i * 12 + 8, littleEndian);
-      uint32_t tag_position = find_exif_tag_in_ifd(exif, size, exifIFD_offset, query_tag, littleEndian,
-                                                   recursion_depth + 1);
-      if (tag_position) {
-        return tag_position;
+    else {
+      uint32_t next_offset_pos = offset + 2U + frame.count * 12U;
+      uint32_t next_ifd_offset = read32(exif, size, next_offset_pos, littleEndian);
+      stack.pop_back();
+      if (next_ifd_offset != 0) {
+        stack.push_back({next_ifd_offset});
       }
     }
   }

-  // continue with next IFD table
-
-  uint32_t pos = offset + 2 + cnt * 12;
-  uint32_t next_ifd_offset = read32(exif, size, pos, littleEndian);
-
-  return find_exif_tag_in_ifd(exif, size, next_ifd_offset, query_tag, littleEndian, recursion_depth + 1);
+  return 0;
 }


@@ -192,7 +205,7 @@ static uint32_t find_exif_tag(const uint8_t* exif, uint32_t size, uint16_t query
   uint32_t offset;
   offset = read32(exif, size, 4, littleEndian);

-  uint32_t tag_position = find_exif_tag_in_ifd(exif, size, offset, query_tag, littleEndian, 1);
+  uint32_t tag_position = find_exif_tag_in_ifd(exif, size, offset, query_tag, littleEndian, size);
   return tag_position;
 }

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 52780432..df2555c3 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -216,6 +216,9 @@ else()
     message(WARNING "Tests of the 'uncompressed codec' are not compiled because the uncompressed codec is not enabled (WITH_UNCOMPRESSED_CODEC==OFF)")
 endif ()

+add_heifio_test(exif_ifd_loop)
+set_tests_properties(exif_ifd_loop PROPERTIES TIMEOUT 10)
+
 # find_package(TIFF) is called in the heifio subdirectory, but its result
 # variables do not propagate to this sibling directory scope. Query again
 # (the result is cached) so we only build the TIFF test when libtiff is present.
diff --git a/tests/exif_ifd_loop.cc b/tests/exif_ifd_loop.cc
new file mode 100644
index 00000000..cb049807
--- /dev/null
+++ b/tests/exif_ifd_loop.cc
@@ -0,0 +1,172 @@
+/*
+  libheifio Exif IFD loop 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.
+*/
+
+// The Exif tag lookup follows ExifIFD pointer entries (tag 0x8769) recursively.
+// An IFD whose entries all point back to itself must not cost N^depth work.
+
+#include "catch_amalgamated.hpp"
+#include "heifio/exif.h"
+
+#include <cstdint>
+#include <vector>
+
+static void put16(std::vector<uint8_t>& v, uint16_t x)
+{
+  v.push_back((uint8_t) (x >> 8));
+  v.push_back((uint8_t) (x & 0xFF));
+}
+
+static void put32(std::vector<uint8_t>& v, uint32_t x)
+{
+  put16(v, (uint16_t) (x >> 16));
+  put16(v, (uint16_t) (x & 0xFFFF));
+}
+
+// One IFD entry with a 4-byte inline value.
+static void put_entry(std::vector<uint8_t>& v, uint16_t tag, uint16_t type, uint32_t count, uint32_t value)
+{
+  put16(v, tag);
+  put16(v, type);
+  put32(v, count);
+  put32(v, value);
+}
+
+static std::vector<uint8_t> tiff_header()
+{
+  std::vector<uint8_t> v;
+  v.push_back('M');
+  v.push_back('M');
+  put16(v, 42);
+  put32(v, 8); // IFD0 at offset 8
+  return v;
+}
+
+
+TEST_CASE("exif IFD entries that point back to their own IFD")
+{
+  const int entry_count = 2000;
+
+  std::vector<uint8_t> exif = tiff_header();
+  put16(exif, entry_count);
+  for (int i = 0; i < entry_count; i++) {
+    put_entry(exif, 0x8769 /* ExifIFD pointer */, 4 /* LONG */, 1, 8 /* IFD0 itself */);
+  }
+  put32(exif, 0); // no next IFD
+
+  // Without a bound on the total number of scanned entries, this would take entry_count^5 steps.
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 1);
+
+  modify_exif_orientation_tag_if_it_exists(exif.data(), (uint32_t) exif.size(), 3);
+  overwrite_exif_image_size_if_it_exists(exif.data(), (uint32_t) exif.size(), 100, 200);
+}
+
+
+TEST_CASE("empty exif IFD whose next pointer points to itself")
+{
+  std::vector<uint8_t> exif = tiff_header();
+  put16(exif, 0);
+  put32(exif, 8); // next IFD is this IFD
+
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 1);
+}
+
+
+TEST_CASE("exif orientation after a cyclic IFD pointer is still found")
+{
+  std::vector<uint8_t> exif = tiff_header();
+  put16(exif, 2);
+  put_entry(exif, 0x8769 /* ExifIFD pointer */, 4 /* LONG */, 1, 8 /* IFD0 itself */);
+  put_entry(exif, 0x0112 /* Orientation */, 3 /* SHORT */, 1, 6u << 16);
+  put32(exif, 0);
+
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 6);
+}
+
+
+TEST_CASE("exif orientation beyond five IFDs is found")
+{
+  std::vector<uint8_t> exif = tiff_header();
+  for (uint32_t i = 0; i < 7; i++) {
+    put16(exif, 0);
+    put32(exif, 8 + (i + 1) * 6);
+  }
+  put16(exif, 1);
+  put_entry(exif, 0x0112 /* Orientation */, 3 /* SHORT */, 1, 7u << 16);
+  put32(exif, 0);
+
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 7);
+}
+
+
+TEST_CASE("deep cyclic exif IFD pointer does not exhaust the call stack")
+{
+  std::vector<uint8_t> exif = tiff_header();
+  put16(exif, 1);
+  put_entry(exif, 0x8769 /* ExifIFD pointer */, 4 /* LONG */, 1, 8 /* IFD0 itself */);
+  put32(exif, 0);
+  exif.resize(18 * 10000);
+
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 1);
+}
+
+
+TEST_CASE("exif orientation in IFD0 is found")
+{
+  std::vector<uint8_t> exif = tiff_header();
+  put16(exif, 2);
+  put_entry(exif, 0x8769 /* ExifIFD pointer */, 4 /* LONG */, 1, 8 + 2 + 2 * 12 + 4);
+  put_entry(exif, 0x0112 /* Orientation */, 3 /* SHORT */, 1, 6u << 16);
+  put32(exif, 0); // no next IFD
+
+  // Exif IFD
+  put16(exif, 1);
+  put_entry(exif, 0xA002 /* PixelXDimension */, 4 /* LONG */, 1, 10);
+  put32(exif, 0);
+
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 6);
+}
+
+
+TEST_CASE("exif tag in the Exif IFD is found")
+{
+  std::vector<uint8_t> exif = tiff_header();
+  put16(exif, 1);
+  put_entry(exif, 0x8769 /* ExifIFD pointer */, 4 /* LONG */, 1, 8 + 2 + 1 * 12 + 4);
+  put32(exif, 0); // no next IFD
+
+  // Exif IFD with the orientation tag (not where it belongs, but it has to be reachable through the pointer)
+  const size_t exifIFD_pos = exif.size();
+  put16(exif, 1);
+  put_entry(exif, 0x0112 /* Orientation */, 3 /* SHORT */, 1, 8u << 16);
+  put32(exif, 0);
+
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 8);
+
+  modify_exif_orientation_tag_if_it_exists(exif.data(), (uint32_t) exif.size(), 1);
+  REQUIRE(exif[exifIFD_pos + 2 + 8] == 0);
+  REQUIRE(exif[exifIFD_pos + 2 + 9] == 1);
+  REQUIRE(read_exif_orientation_tag(exif.data(), (uint32_t) exif.size()) == 1);
+}