From 4908edaf7f416616784c7c03d8c1a60153710ffb Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 18 Sep 2026 14:19:06 -0400 Subject: [PATCH 1/2] Bound retained TIFF tag data by stream size Stop ImageFileDirectory_v2.load() from reading out-of-line tag data past the size of the underlying stream, which prevents overlapping TIFF and EXIF entries from retaining many copies of the same bytes. Add a regression test that keeps the first aliased tag and warns while skipping the second. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Tests/test_file_tiff_metadata.py | 15 +++++++++++++++ src/PIL/TiffImagePlugin.py | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 2c41a264314..ca8408082d3 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -484,6 +484,21 @@ def test_tag_offset() -> None: ifd.load(f) +def test_tag_data_larger_than_file() -> None: + ifd = TiffImagePlugin.ImageFileDirectory_v2() + + # Two BYTE tags that both claim the whole 30 byte IFD as their value. + tag_count = struct.pack(" None: base_ifd = TiffImagePlugin.ImageFileDirectory_v2() interop_ifd = TiffImagePlugin.ImageFileDirectory_v2(group=40965) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 35b490bd4e3..58d54742dba 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -895,6 +895,9 @@ def _ensure_read(self, fp: IO[bytes], size: int) -> bytes: def load(self, fp: IO[bytes]) -> None: self.reset() self._offset = fp.tell() + fp.seek(0, io.SEEK_END) + file_size = fp.tell() + fp.seek(self._offset) try: tag_count = ( @@ -902,6 +905,7 @@ def load(self, fp: IO[bytes]) -> None: if self._bigtiff else self._unpack("H", self._ensure_read(fp, 2)) )[0] + total_size = 0 for i in range(tag_count): tag, typ, count, data = ( self._unpack("HHQ8s", self._ensure_read(fp, 20)) @@ -928,6 +932,12 @@ def load(self, fp: IO[bytes]) -> None: if offset >= 2**63: warnings.warn("Tag offset too large") continue + # Well-formed IFD values should fit within the stream they + # are read from, even before accounting for overlaps. + total_size += size + if total_size > file_size: + warnings.warn(f"Tag data larger than file. Skipping tag {tag}") + continue fp.seek(offset) data = ImageFile._safe_read(fp, size) fp.seek(here) From 4b63dffd8a6d980fa0e21b40832fa6cbf033eacf Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Sat, 10 Oct 2026 19:08:18 -0400 Subject: [PATCH 2/2] Share TIFF tag data budget across frames The per-IFD limit could be bypassed with many frames, each with a tag pointing to the same payload. Walking 30,000 such frames in a 7.5 MB file read 227 GB. Each frame's limit is now taken from the bytes left after previous frames, and fixed when the frame is first loaded so that reloading it gives the same tags. Co-Authored-By: Claude Opus 5.5 --- Tests/test_file_tiff_metadata.py | 35 ++++++++++++++++++++++++++++++++ src/PIL/TiffImagePlugin.py | 23 +++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index ca8408082d3..b42f0ec0ed5 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -499,6 +499,41 @@ def test_tag_data_larger_than_file() -> None: assert 1 not in ifd +def test_tag_data_larger_than_file_across_frames() -> None: + # Three 1x1 frames, each with a private BYTE tag that points to the same + # payload, which is more than half of the file. + payload_size = 400 + ifd_size = 2 + 10 * 12 + 4 + pixel_offset = 8 + payload_size + first_ifd = pixel_offset + 1 + + data = b"II*\x00" + struct.pack(" None: base_ifd = TiffImagePlugin.ImageFileDirectory_v2() interop_ifd = TiffImagePlugin.ImageFileDirectory_v2(group=40965) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 58d54742dba..1383c81037b 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -622,6 +622,10 @@ def __init__( self.tagtype: dict[int, int] = {} """ Dictionary of tag types """ self.reset() + # Maximum total out-of-line tag data that load() may read, as well + # as the size of the stream. Set by TiffImageFile so that + # all frames of a file share one budget. + self._tag_data_limit: int | None = None self.next = ( self._unpack("Q", ifh[8:])[0] if self._bigtiff @@ -896,8 +900,11 @@ def load(self, fp: IO[bytes]) -> None: self.reset() self._offset = fp.tell() fp.seek(0, io.SEEK_END) - file_size = fp.tell() + limit = fp.tell() fp.seek(self._offset) + if self._tag_data_limit is not None: + limit = min(limit, self._tag_data_limit) + self._tag_data_size = 0 try: tag_count = ( @@ -935,9 +942,10 @@ def load(self, fp: IO[bytes]) -> None: # Well-formed IFD values should fit within the stream they # are read from, even before accounting for overlaps. total_size += size - if total_size > file_size: + if total_size > limit: warnings.warn(f"Tag data larger than file. Skipping tag {tag}") continue + self._tag_data_size += size fp.seek(offset) data = ImageFile._safe_read(fp, size) fp.seek(here) @@ -1217,6 +1225,13 @@ def _open(self) -> None: self._fp = self.fp self._frame_pos: list[int] = [] + # Out-of-line tag data is budgeted across all frames, so that many + # frames cannot each read the same data. Each frame's limit is fixed + # when it is first loaded, so that reloading it gives the same tags. + self.fp.seek(0, io.SEEK_END) + self._tag_data_remaining = self.fp.tell() + self._tag_data_limits: list[int] = [] + logger.debug("*** TiffImageFile._open ***") logger.debug("- __first: %s", self.__first) logger.debug("- ifh: %s", repr(ifh)) # Use repr to avoid str(bytes) @@ -1269,8 +1284,11 @@ def _seek(self, frame: int) -> None: raise ValueError(msg) self.fp.seek(self.__next) self._frame_pos.append(self.__next) + self._tag_data_limits.append(self._tag_data_remaining) logger.debug("Loading tags, location: %s", self.fp.tell()) + self.tag_v2._tag_data_limit = self._tag_data_remaining self.tag_v2.load(self.fp) + self._tag_data_remaining -= self.tag_v2._tag_data_size if self.tag_v2.next in self._frame_pos: # This IFD has already been processed # Declare this to be the end of the image @@ -1283,6 +1301,7 @@ def _seek(self, frame: int) -> None: self.is_animated = self.__next != 0 self.__frame += 1 self.fp.seek(self._frame_pos[frame]) + self.tag_v2._tag_data_limit = self._tag_data_limits[frame] self.tag_v2.load(self.fp) if XMP in self.tag_v2: xmp = self.tag_v2[XMP]