From 4bada07dc6c24319edd1eb76f1dd28d968d58207 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Feb 2026 22:24:03 +1100 Subject: [PATCH 1/5] Avoid overflow by not adding extents together --- Tests/images/psd-oob-write-overflow.psd | Bin 0 -> 496 bytes Tests/test_file_psd.py | 2 ++ src/decode.c | 13 ++++++------- src/encode.c | 12 +++++------- 4 files changed, 13 insertions(+), 14 deletions(-) create mode 100644 Tests/images/psd-oob-write-overflow.psd diff --git a/Tests/images/psd-oob-write-overflow.psd b/Tests/images/psd-oob-write-overflow.psd new file mode 100644 index 0000000000000000000000000000000000000000..c2bb10d614ed8a2130a28338f474b74f6e67d486 GIT binary patch literal 496 zcmcC;3J7LkWPkt=%>~9Ba4{g4F$IVd7?>c6z$8Q!L|>YPlc#T9eo^j!gaWWk1BA~7 bHETJhx&}G`21d@^a4>k8z_6l2U^D;#tMlDs literal 0 HcmV?d00001 diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 3b145b13983..9964a68e18e 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -195,11 +195,13 @@ def test_layer_crashes(test_file: str) -> None: "Tests/images/psd-oob-write.psd", "Tests/images/psd-oob-write-x.psd", "Tests/images/psd-oob-write-y.psd", + "Tests/images/psd-oob-write-overflow.psd", ], ) def test_bounds_crash(test_file: str) -> None: with Image.open(test_file) as im: assert isinstance(im, PsdImagePlugin.PsdImageFile) + im.load() im.seek(im.n_frames) with pytest.raises(ValueError): diff --git a/src/decode.c b/src/decode.c index cda4ce7027f..2268b353306 100644 --- a/src/decode.c +++ b/src/decode.c @@ -171,6 +171,12 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) { return NULL; } + if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0 || x1 > (int)im->xsize || + y1 > (int)im->ysize) { + PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image"); + return NULL; + } + decoder->im = im; state = &decoder->state; @@ -181,13 +187,6 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) { state->xsize = x1 - x0; state->ysize = y1 - y0; - if (state->xoff < 0 || state->xsize <= 0 || - state->xsize + state->xoff > (int)im->xsize || state->yoff < 0 || - state->ysize <= 0 || state->ysize + state->yoff > (int)im->ysize) { - PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image"); - return NULL; - } - /* Allocate memory buffer (if bits field is set) */ if (state->bits > 0) { if (!state->bytes) { diff --git a/src/encode.c b/src/encode.c index 1fc31404d9e..02356d5648b 100644 --- a/src/encode.c +++ b/src/encode.c @@ -244,6 +244,11 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) { return NULL; } + if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0 || x1 > im->xsize || y1 > im->ysize) { + PyErr_SetString(PyExc_SystemError, "tile cannot extend outside image"); + return NULL; + } + encoder->im = im; state = &encoder->state; @@ -253,13 +258,6 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) { state->xsize = x1 - x0; state->ysize = y1 - y0; - if (state->xoff < 0 || state->xsize <= 0 || - state->xsize + state->xoff > im->xsize || state->yoff < 0 || - state->ysize <= 0 || state->ysize + state->yoff > im->ysize) { - PyErr_SetString(PyExc_SystemError, "tile cannot extend outside image"); - return NULL; - } - /* Allocate memory buffer (if bits field is set) */ if (state->bits > 0) { if (state->xsize > ((INT_MAX / state->bits) - 7)) { From 591ce38ca56fa7516df4f4ee0525730dee049144 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Feb 2026 23:24:05 +1100 Subject: [PATCH 2/5] Skip OverflowError on Windows Python 3.10 --- Tests/test_file_psd.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 9964a68e18e..a5223cacea7 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -1,12 +1,19 @@ from __future__ import annotations +import sys import warnings import pytest from PIL import Image, PsdImagePlugin -from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy +from .helper import ( + assert_image_equal_tofile, + assert_image_similar, + hopper, + is_pypy, + is_win32, +) test_file = "Tests/images/hopper.psd" @@ -195,11 +202,23 @@ def test_layer_crashes(test_file: str) -> None: "Tests/images/psd-oob-write.psd", "Tests/images/psd-oob-write-x.psd", "Tests/images/psd-oob-write-y.psd", - "Tests/images/psd-oob-write-overflow.psd", ], ) def test_bounds_crash(test_file: str) -> None: with Image.open(test_file) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) + im.seek(im.n_frames) + + with pytest.raises(ValueError): + im.load() + + +@pytest.mark.skipif( + is_win32() and sys.version_info < (3, 11), + reason="OverflowError on Windows Python 3.10", +) +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() im.seek(im.n_frames) From b2a16f0dbe80d4add20294b3dca0618cfe2c1660 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 23:45:57 +1100 Subject: [PATCH 3/5] Copy offset check from C into Python --- Tests/test_imagefile.py | 49 +++++++++++++++++++++++++++++++++++++++-- src/PIL/ImageFile.py | 11 ++++----- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 6656ee506ca..2dcebc4b15f 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -308,7 +308,20 @@ def test_extents_none(self) -> None: assert MockPyDecoder.last.state.xsize == 200 assert MockPyDecoder.last.state.ysize == 200 - def test_negsize(self) -> None: + def test_negative_offset(self) -> None: + buf = BytesIO(b"\x00" * 255) + + im = MockImageFile(buf) + im.tile = [ImageFile._Tile("MOCK", (-10, yoff, xsize, ysize), 32, None)] + + with pytest.raises(ValueError): + im.load() + + im.tile = [ImageFile._Tile("MOCK", (xoff, -10, xsize, ysize), 32, None)] + with pytest.raises(ValueError): + im.load() + + def test_negative_size(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -386,7 +399,39 @@ def test_extents_none(self) -> None: assert MockPyEncoder.last.state.xsize == 200 assert MockPyEncoder.last.state.ysize == 200 - def test_negsize(self) -> None: + def test_negative_offset(self) -> None: + buf = BytesIO(b"\x00" * 255) + + im = MockImageFile(buf) + + fp = BytesIO() + MockPyEncoder.last = None + with pytest.raises(ValueError): + ImageFile._save( + im, + fp, + [ + ImageFile._Tile( + "MOCK", (-10, yoff, xoff + xsize, yoff + ysize), 0, "RGB" + ) + ], + ) + last: MockPyEncoder | None = MockPyEncoder.last + assert last + assert last.cleanup_called + + with pytest.raises(ValueError): + ImageFile._save( + im, + fp, + [ + ImageFile._Tile( + "MOCK", (xoff, -10, xoff + xsize, yoff + ysize), 0, "RGB" + ) + ], + ) + + def test_negative_size(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index b79f23f0d64..dd1116ab986 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -805,6 +805,10 @@ def setimage( if extents: x0, y0, x1, y1 = extents + + if x0 < 0 or y0 < 0 or x1 > self.im.size[0] or y1 > self.im.size[1]: + msg = "Tile cannot extend outside image" + raise ValueError(msg) else: x0, y0, x1, y1 = (0, 0, 0, 0) @@ -820,13 +824,6 @@ def setimage( msg = "Size must be positive" raise ValueError(msg) - if ( - self.state.xsize + self.state.xoff > self.im.size[0] - or self.state.ysize + self.state.yoff > self.im.size[1] - ): - msg = "Tile cannot extend outside image" - raise ValueError(msg) - class PyDecoder(PyCodec): """ From cc22efda7a296c3e6ca9b40a4a4eb4d1af1741ac Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2026 23:54:09 +1100 Subject: [PATCH 4/5] Parametrize tests --- Tests/test_imagefile.py | 168 ++++++++++------------------------------ 1 file changed, 42 insertions(+), 126 deletions(-) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 2dcebc4b15f..5f4ed2eb082 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -295,65 +295,38 @@ def test_setimage(self) -> None: with pytest.raises(ValueError): MockPyDecoder.last.set_as_raw(b"\x00") - def test_extents_none(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - im.tile = [ImageFile._Tile("MOCK", None, 32, None)] - - im.load() - - assert MockPyDecoder.last.state.xoff == 0 - assert MockPyDecoder.last.state.yoff == 0 - assert MockPyDecoder.last.state.xsize == 200 - assert MockPyDecoder.last.state.ysize == 200 - - def test_negative_offset(self) -> None: + @pytest.mark.parametrize( + "extents", + ( + (-10, yoff, xoff + xsize, yoff + ysize), + (xoff, -10, xoff + xsize, yoff + ysize), + (xoff, yoff, -10, yoff + ysize), + (xoff, yoff, xoff + xsize, -10), + (xoff, yoff, xoff + xsize + 100, yoff + ysize), + (xoff, yoff, xoff + xsize, yoff + ysize + 100), + ), + ) + def test_extents(self, extents: tuple[int, int, int, int]) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [ImageFile._Tile("MOCK", (-10, yoff, xsize, ysize), 32, None)] - - with pytest.raises(ValueError): - im.load() + im.tile = [ImageFile._Tile("MOCK", extents, 32, None)] - im.tile = [ImageFile._Tile("MOCK", (xoff, -10, xsize, ysize), 32, None)] with pytest.raises(ValueError): im.load() - def test_negative_size(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] - - with pytest.raises(ValueError): - im.load() - - im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] - with pytest.raises(ValueError): - im.load() - - def test_oversize(self) -> None: + def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None - ) - ] + im.tile = [ImageFile._Tile("MOCK", None, 32, None)] - with pytest.raises(ValueError): - im.load() + im.load() - im.tile = [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None - ) - ] - with pytest.raises(ValueError): - im.load() + assert MockPyDecoder.last.state.xoff == 0 + assert MockPyDecoder.last.state.yoff == 0 + assert MockPyDecoder.last.state.xsize == 200 + assert MockPyDecoder.last.state.ysize == 200 def test_decode(self) -> None: decoder = ImageFile.PyDecoder("") @@ -384,22 +357,18 @@ def test_setimage(self) -> None: assert MockPyEncoder.last.state.xsize == xsize assert MockPyEncoder.last.state.ysize == ysize - def test_extents_none(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) - im.tile = [ImageFile._Tile("MOCK", None, 32, None)] - - fp = BytesIO() - ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")]) - - assert MockPyEncoder.last - assert MockPyEncoder.last.state.xoff == 0 - assert MockPyEncoder.last.state.yoff == 0 - assert MockPyEncoder.last.state.xsize == 200 - assert MockPyEncoder.last.state.ysize == 200 - - def test_negative_offset(self) -> None: + @pytest.mark.parametrize( + "extents", + ( + (-10, yoff, xoff + xsize, yoff + ysize), + (xoff, -10, xoff + xsize, yoff + ysize), + (xoff, yoff, -10, yoff + ysize), + (xoff, yoff, xoff + xsize, -10), + (xoff, yoff, xoff + xsize + 100, yoff + ysize), + (xoff, yoff, xoff + xsize, yoff + ysize + 100), + ), + ) + def test_extents(self, extents: tuple[int, int, int, int]) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -407,81 +376,28 @@ def test_negative_offset(self) -> None: fp = BytesIO() MockPyEncoder.last = None with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (-10, yoff, xoff + xsize, yoff + ysize), 0, "RGB" - ) - ], - ) + ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")]) last: MockPyEncoder | None = MockPyEncoder.last assert last assert last.cleanup_called with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (xoff, -10, xoff + xsize, yoff + ysize), 0, "RGB" - ) - ], - ) - - def test_negative_size(self) -> None: - buf = BytesIO(b"\x00" * 255) - - im = MockImageFile(buf) + ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")]) - fp = BytesIO() - MockPyEncoder.last = None - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")], - ) - last: MockPyEncoder | None = MockPyEncoder.last - assert last - assert last.cleanup_called - - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")], - ) - - def test_oversize(self) -> None: + def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) + im.tile = [ImageFile._Tile("MOCK", None, 32, None)] fp = BytesIO() - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB" - ) - ], - ) + ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")]) - with pytest.raises(ValueError): - ImageFile._save( - im, - fp, - [ - ImageFile._Tile( - "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB" - ) - ], - ) + assert MockPyEncoder.last + assert MockPyEncoder.last.state.xoff == 0 + assert MockPyEncoder.last.state.yoff == 0 + assert MockPyEncoder.last.state.xsize == 200 + assert MockPyEncoder.last.state.ysize == 200 def test_encode(self) -> None: encoder = ImageFile.PyEncoder("") From f5e893e46e869a9e275298207c70cf915173a072 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 Apr 2026 09:46:09 +1100 Subject: [PATCH 5/5] Seek raises OverFlowError on 32-bit --- Tests/test_file_psd.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index a5223cacea7..538b1406b36 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -12,7 +12,6 @@ assert_image_similar, hopper, is_pypy, - is_win32, ) test_file = "Tests/images/hopper.psd" @@ -213,15 +212,15 @@ def test_bounds_crash(test_file: str) -> None: im.load() -@pytest.mark.skipif( - is_win32() and sys.version_info < (3, 11), - reason="OverflowError on Windows Python 3.10", -) 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() - im.seek(im.n_frames) - - with pytest.raises(ValueError): - im.load() + if sys.maxsize <= 2**32: + with pytest.raises(OverflowError): + im.seek(im.n_frames) + else: + im.seek(im.n_frames) + + with pytest.raises(ValueError): + im.load()