Repository: python-pillow/Pillow

Issue:
[PNG] struct.error is raised when there is the broken IHDR chunk after the IDAT chunk
Hi, I am recently fuzzing this library and found the test case which raises struct.error.

### What did you do?

```python
from PIL import Image
import io

# assumes the current directory is the root of this repository.
with open('Tests/images/imagedraw_polygon_1px_high.png', 'rb') as f:
    data = bytearray(f.read())

# insert the IHDR chunk after the IDAT chunk. Its length, chunk type and crc are valid, but the IHDR chunk should contain more data.
data[61:61] = b"\x00\x00\x00\x00IHDR\xa8\xa1\xae\x0a"

# struct.error is raised
with Image.open(io.BytesIO(data)) as img:
    img.load()
```

### What did you expect to happen?

Though the inserted IHDR chunk is broken, `struct.error` is a little confusing.

Maybe `ValueError` is better? Or the chunk may be ignored because other parts are still valid.
Another idea is to have `verify()` check the number of IHDR chunks. It should be 1 [according to the png spec](http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.Critical-chunks).

### What actually happened?

```
Traceback (most recent call last):
  File "issue2.py", line 14, in <module>
    img.load()
  File "/usr/local/lib/python3.8/site-packages/PIL/ImageFile.py", line 256, in load
    self.load_end()
  File "/usr/local/lib/python3.8/site-packages/PIL/PngImagePlugin.py", line 942, in load_end
    self.png.call(cid, pos, length)
  File "/usr/local/lib/python3.8/site-packages/PIL/PngImagePlugin.py", line 187, in call
    return getattr(self, "chunk_" + cid.decode("ascii"))(pos, length)
  File "/usr/local/lib/python3.8/site-packages/PIL/PngImagePlugin.py", line 412, in chunk_IHDR
    self.im_size = i32(s, 0), i32(s, 4)
  File "/usr/local/lib/python3.8/site-packages/PIL/_binary.py", line 85, in i32be
    return unpack_from(">I", c, o)[0]
struct.error: unpack_from requires a buffer of at least 4 bytes for unpacking 4 bytes at offset 0 (actual buffer size is 0)
```

### What are your OS, Python and Pillow versions?

* OS: Debian GNU/Linux 11 (bullseye)
* Python: 3.8.13
* Pillow: 9.1.0



Discussion:

Resolving patch:
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -424,6 +424,10 @@ def chunk_IHDR(self, pos, length):
 
         # image header
         s = ImageFile._safe_read(self.fp, length)
+        if length < 13:
+            if ImageFile.LOAD_TRUNCATED_IMAGES:
+                return s
+            raise ValueError("Truncated IHDR chunk")
         self.im_size = i32(s, 0), i32(s, 4)
         try:
             self.im_mode, self.im_rawmode = _MODES[(s[8], s[9])]
@@ -512,6 +516,10 @@ def chunk_pHYs(self, pos, length):
 
         # pixels per unit
         s = ImageFile._safe_read(self.fp, length)
+        if length < 9:
+            if ImageFile.LOAD_TRUNCATED_IMAGES:
+                return s
+            raise ValueError("Truncated pHYs chunk")
         px, py = i32(s, 0), i32(s, 4)
         unit = s[8]
         if unit == 1:  # meter
@@ -624,6 +632,10 @@ def chunk_eXIf(self, pos, length):
     # APNG chunks
     def chunk_acTL(self, pos, length):
         s = ImageFile._safe_read(self.fp, length)
+        if length < 8:
+            if ImageFile.LOAD_TRUNCATED_IMAGES:
+                return s
+            raise ValueError("APNG contains truncated acTL chunk")
         if self.im_n_frames is not None:
             self.im_n_frames = None
             warnings.warn("Invalid APNG, will use default PNG image if possible")
@@ -639,6 +651,10 @@ def chunk_acTL(self, pos, length):
 
     def chunk_fcTL(self, pos, length):
         s = ImageFile._safe_read(self.fp, length)
+        if length < 26:
+            if ImageFile.LOAD_TRUNCATED_IMAGES:
+                return s
+            raise ValueError("APNG contains truncated fcTL chunk")
         seq = i32(s)
         if (self._seq_num is None and seq != 0) or (
             self._seq_num is not None and self._seq_num != seq - 1
@@ -660,6 +676,11 @@ def chunk_fcTL(self, pos, length):
         return s
 
     def chunk_fdAT(self, pos, length):
+        if length < 4:
+            if ImageFile.LOAD_TRUNCATED_IMAGES:
+                s = ImageFile._safe_read(self.fp, length)
+                return s
+            raise ValueError("APNG contains truncated fDAT chunk")
         s = ImageFile._safe_read(self.fp, 4)
         seq = i32(s)
         if self._seq_num != seq - 1:


Test patch:
diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py
--- a/Tests/test_file_png.py
+++ b/Tests/test_file_png.py
@@ -635,6 +635,17 @@ def test_padded_idat(self):
 
             assert_image_equal_tofile(im, "Tests/images/bw_gradient.png")
 
+    @pytest.mark.parametrize("cid", (b"IHDR", b"pHYs", b"acTL", b"fcTL", b"fdAT"))
+    def test_truncated_chunks(self, cid):
+        fp = BytesIO()
+        with PngImagePlugin.PngStream(fp) as png:
+            with pytest.raises(ValueError):
+                png.call(cid, 0, 0)
+
+            ImageFile.LOAD_TRUNCATED_IMAGES = True
+            png.call(cid, 0, 0)
+            ImageFile.LOAD_TRUNCATED_IMAGES = False
+
     def test_specify_bits(self, tmp_path):
         im = hopper("P")