From e7ad3ab39442b535626693508dd7ec6080adb2b1 Mon Sep 17 00:00:00 2001 From: Kesavamoorthi G Date: Wed, 7 Oct 2026 12:23:43 +0530 Subject: [PATCH 1/4] Fix unbounded memory allocation reading PSD packbits bytecounts --- Tests/images/psd-huge-bytecounts.psd | Bin 0 -> 40 bytes Tests/test_file_psd.py | 8 ++++++++ src/PIL/PsdImagePlugin.py | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 Tests/images/psd-huge-bytecounts.psd diff --git a/Tests/images/psd-huge-bytecounts.psd b/Tests/images/psd-huge-bytecounts.psd new file mode 100644 index 0000000000000000000000000000000000000000..6e71809936c98180d11ae200c9df1ddb69d702a8 GIT binary patch literal 40 ecmcC;3J7LkWPkwX|3Cm_GBR*5Fhj*46e9qQumvjs literal 0 HcmV?d00001 diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index f03a1f94601..96b5fda43a7 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -192,6 +192,14 @@ def test_unknown_channel_id() -> None: assert im.layers[0][1] == "" +def test_huge_bytecounts() -> None: + # The declared height requires a packbits bytecounts table of ~26 GB. + # Reading it must fail cleanly instead of attempting a huge allocation. + with pytest.raises(OSError): + with Image.open("Tests/images/psd-huge-bytecounts.psd"): + pass + + def test_combined_larger_than_size() -> None: # The combined size of the individual parts is larger than the # declared 'size' of the extra data field, resulting in a backwards seek. diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index dd0afa46973..759657ff020 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -310,8 +310,11 @@ def _maketile( elif compression == 1: # # packbits compression + if ysize < 0: + msg = "negative image height" + raise ValueError(msg) i = 0 - bytecount = read(channels * ysize * 2) + bytecount = ImageFile._safe_read(file, channels * ysize * 2) offset = file.tell() for channel in range(channels): layer = mode[channel] From 5c1623a9bb5794250e4a2c282b15c9afca3f5ab3 Mon Sep 17 00:00:00 2001 From: Kesavamoorthig06 Date: Wed, 7 Oct 2026 04:23:30 -0400 Subject: [PATCH 2/4] Test PSD packbits negative-height guard --- Tests/test_file_psd.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 96b5fda43a7..34fc213cfbf 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -192,6 +192,11 @@ def test_unknown_channel_id() -> None: assert im.layers[0][1] == "" +def test_negative_height() -> None: + with pytest.raises(ValueError, match="negative image height"): + PsdImagePlugin._maketile(io.BytesIO(b"\x00\x01"), "RGB", (0, 1, 1, 0), 3) + + def test_huge_bytecounts() -> None: # The declared height requires a packbits bytecounts table of ~26 GB. # Reading it must fail cleanly instead of attempting a huge allocation. From c571c0a3e41503b839660909ddbcd31aa96aebd5 Mon Sep 17 00:00:00 2001 From: Kesavamoorthig06 Date: Wed, 7 Oct 2026 07:23:59 -0400 Subject: [PATCH 3/4] Expect explicit PSD bounds error on 32-bit Python --- Tests/test_file_psd.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 34fc213cfbf..d050a18152f 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -1,7 +1,6 @@ from __future__ import annotations import io -import sys import warnings import pytest @@ -264,5 +263,5 @@ def test_bounds_crash_overflow() -> None: with Image.open("Tests/images/psd-oob-write-overflow.psd") as im: assert isinstance(im, PsdImagePlugin.PsdImageFile) im.load() - with pytest.raises(OverflowError if sys.maxsize <= 2**32 else ValueError): + with pytest.raises(ValueError, match="negative image height"): im.seek(im.n_frames) From e4340eb619b3ae9823ed88b5210a98f156c9b26d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 9 Oct 2026 22:56:58 +1100 Subject: [PATCH 4/4] Raise error on negative tile width --- ...ytecounts.psd => psd-large-bytecounts.psd} | Bin Tests/test_file_psd.py | 17 ++++++++------- src/PIL/PsdImagePlugin.py | 20 +++++++----------- 3 files changed, 17 insertions(+), 20 deletions(-) rename Tests/images/{psd-huge-bytecounts.psd => psd-large-bytecounts.psd} (100%) diff --git a/Tests/images/psd-huge-bytecounts.psd b/Tests/images/psd-large-bytecounts.psd similarity index 100% rename from Tests/images/psd-huge-bytecounts.psd rename to Tests/images/psd-large-bytecounts.psd diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index d050a18152f..ff0ed37dc68 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -191,17 +191,18 @@ def test_unknown_channel_id() -> None: assert im.layers[0][1] == "" -def test_negative_height() -> None: - with pytest.raises(ValueError, match="negative image height"): - PsdImagePlugin._maketile(io.BytesIO(b"\x00\x01"), "RGB", (0, 1, 1, 0), 3) +@pytest.mark.parametrize("bbox", ((1, 0, 0, 1), (0, 1, 1, 0))) +def test_negative_tile_size(bbox: tuple[int, int, int, int]) -> None: + fp = io.BytesIO(b"\x00\x00") + with pytest.raises(ValueError, match="negative tile size"): + PsdImagePlugin._maketile(fp, "RGB", bbox, 3) -def test_huge_bytecounts() -> None: +def test_large_bytecounts() -> None: # The declared height requires a packbits bytecounts table of ~26 GB. # Reading it must fail cleanly instead of attempting a huge allocation. - with pytest.raises(OSError): - with Image.open("Tests/images/psd-huge-bytecounts.psd"): - pass + with pytest.raises(OSError, match="Truncated File Read"): + Image.open("Tests/images/psd-large-bytecounts.psd") def test_combined_larger_than_size() -> None: @@ -263,5 +264,5 @@ def test_bounds_crash_overflow() -> None: with Image.open("Tests/images/psd-oob-write-overflow.psd") as im: assert isinstance(im, PsdImagePlugin.PsdImageFile) im.load() - with pytest.raises(ValueError, match="negative image height"): + with pytest.raises(ValueError, match="negative tile size"): im.seek(im.n_frames) diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 759657ff020..55395b307de 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -287,16 +287,15 @@ def read(size: int) -> bytes: def _maketile( file: IO[bytes], mode: str, bbox: tuple[int, int, int, int], channels: int ) -> list[ImageFile._Tile]: - tiles = [] - read = file.read - - compression = i16(read(2)) - xsize = bbox[2] - bbox[0] ysize = bbox[3] - bbox[1] + if xsize < 0 or ysize < 0: + msg = "negative tile size" + raise ValueError(msg) + tiles = [] + compression = i16(file.read(2)) offset = file.tell() - if compression == 0: # # raw compression @@ -305,14 +304,11 @@ def _maketile( if mode == "CMYK": layer += ";I" tiles.append(ImageFile._Tile("raw", bbox, offset, layer)) - offset = offset + xsize * ysize + offset += xsize * ysize elif compression == 1: # # packbits compression - if ysize < 0: - msg = "negative image height" - raise ValueError(msg) i = 0 bytecount = ImageFile._safe_read(file, channels * ysize * 2) offset = file.tell() @@ -322,13 +318,13 @@ def _maketile( layer += ";I" tiles.append(ImageFile._Tile("packbits", bbox, offset, layer)) for y in range(ysize): - offset = offset + i16(bytecount, i) + offset += i16(bytecount, i) i += 2 file.seek(offset) if offset & 1: - read(1) # padding + file.read(1) # padding return tiles