Repository: python-pillow/Pillow

Issue:
Indefinite loop while counting frames in truncated tiff image
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you do?
I tried to count number of frames in a tiff image. However, the image is corrupted.

### What did you expect to happen?
I expected some kind of exception (OSError).

### What actually happened?
Warning is logged
`.../lib/python3.9/site-packages/PIL/TiffImagePlugin.py:793: UserWarning: Truncated File Read`
and code is trapped inside indefinite for loop.

### What are your OS, Python and Pillow versions?

* OS: MacOS Big Sur
* Python: 3.9.4
* Pillow: 8.2.0

also occurs in docker container with
* OS: Ubuntu focal
* Python: 3.9.0
* Pillow: 8.2.0

<!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

The best reproductions are self-contained scripts with minimal dependencies. If you are using a framework such as Plone, Django, or Buildout, try to replicate the issue just using Pillow.
-->

```python
from pathlib import Path
from PIL import Image

# path to the attached image
file_path = Path("truncated_img.tiff")

Image.open(file_path).n_frames
```
[truncated_img.tiff.zip](https://github.com/python-pillow/Pillow/files/6440092/truncated_img.tiff.zip)




Discussion:
Thanks for reporting. Your file is telling Pillow that the next frame is in the same position as the current frame, leading to an infinite loop.

I've created PR #5473 to resolve this - although my change doesn't raise an error like you are expecting, it just concludes that your image only has 1 frame.
Thank you for such a quick solution. This seems to work when the last frame is corrupted. But when the image contains e.g. 4 frames and the third one is corrupted, `n_frames` returns `2` which is incorrect.
Would you be able to attach that image? The information that is incorrect is the information telling us where the next image is, so I'm not sure if there is a way to figure that out without it. The [specification](https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFF6.pdf) states that "This file offset may
point anywhere in the file, even after the image data."
If I create a multiframe image like you're describing and check to see if commercial software can handle it, I can't open it in Preview or GIMP.
Unfortunately I cannot shared this file, it is confidential. As I tried to create another one, I also wasn't able to open it in such a software. That leads me to outcome that `n_frames` should really raise an exception rather than return an invalid number. It should be sufficient to add `raise` in here https://github.com/python-pillow/Pillow/blob/master/src/PIL/TiffImagePlugin.py#L794 (this change would cause 2 tests to fail)

Resolving patch:
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -1067,7 +1067,12 @@ def _seek(self, frame):
             self._frame_pos.append(self.__next)
             logger.debug("Loading tags, location: %s" % self.fp.tell())
             self.tag_v2.load(self.fp)
-            self.__next = self.tag_v2.next
+            if self.tag_v2.next in self._frame_pos:
+                # This IFD has already been processed
+                # Declare this to be the end of the image
+                self.__next = 0
+            else:
+                self.__next = self.tag_v2.next
             if self.__next == 0:
                 self._n_frames = frame + 1
             if len(self._frame_pos) == 1:


Test patch:
diff --git a/Tests/images/multipage_multiple_frame_loop.tiff b/Tests/images/multipage_multiple_frame_loop.tiff
new file mode 100644
Binary files /dev/null and b/Tests/images/multipage_multiple_frame_loop.tiff differ
diff --git a/Tests/images/multipage_out_of_order.tiff b/Tests/images/multipage_out_of_order.tiff
new file mode 100644
Binary files /dev/null and b/Tests/images/multipage_out_of_order.tiff differ
diff --git a/Tests/images/multipage_single_frame_loop.tiff b/Tests/images/multipage_single_frame_loop.tiff
new file mode 100644
Binary files /dev/null and b/Tests/images/multipage_single_frame_loop.tiff differ
diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py
--- a/Tests/test_file_tiff.py
+++ b/Tests/test_file_tiff.py
@@ -301,6 +301,19 @@ def test_multipage_last_frame(self):
             assert im.size == (20, 20)
             assert im.convert("RGB").getpixel((0, 0)) == (0, 0, 255)
 
+    def test_frame_order(self):
+        # A frame can't progress to itself after reading
+        with Image.open("Tests/images/multipage_single_frame_loop.tiff") as im:
+            assert im.n_frames == 1
+
+        # A frame can't progress to a frame that has already been read
+        with Image.open("Tests/images/multipage_multiple_frame_loop.tiff") as im:
+            assert im.n_frames == 2
+
+        # Frames don't have to be in sequence
+        with Image.open("Tests/images/multipage_out_of_order.tiff") as im:
+            assert im.n_frames == 3
+
     def test___str__(self):
         filename = "Tests/images/pil136.tiff"
         with Image.open(filename) as im: