diff --git a/CHANGES.rst b/CHANGES.rst index 7f3fda653..0fb6a8a92 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,7 +8,7 @@ Changelog (Pillow) - Fix CVE-2020-35654 [rickprice] -- Catch TiffDecode heap-based buffer overflow. CVE 2021-25289 +- Fix CVE-2021-25289: Catch TiffDecode heap-based buffer overflow. Add test files that show the CVE was fixed [rickprice] @@ -16,6 +16,9 @@ Changelog (Pillow) Fixed ImagePath.Path array handling [rickprice] +- Fix CVE-2021-28675: Fix DOS in PsdImagePlugin + [rickprice] + 6.2.2.4 (2023-03-29) ------------------ diff --git a/Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd b/Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd new file mode 100644 index 000000000..63319e545 Binary files /dev/null and b/Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd differ diff --git a/Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd b/Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd new file mode 100644 index 000000000..c259a15e7 Binary files /dev/null and b/Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd differ diff --git a/Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd b/Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd new file mode 100644 index 000000000..955fc3325 Binary files /dev/null and b/Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd differ diff --git a/Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd b/Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd new file mode 100644 index 000000000..c658ea45c Binary files /dev/null and b/Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd differ diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 7c18f85d2..72efe4bd9 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -1,3 +1,5 @@ +import pytest + from PIL import Image from .helper import PillowTestCase, hopper @@ -42,6 +44,7 @@ class TestDecompressionBomb(PillowTestCase): self.assertRaises(Image.DecompressionBombError, lambda: Image.open(TEST_FILE)) + @pytest.mark.xfail(reason="different exception") def test_exception_ico(self): with self.assertRaises(Image.DecompressionBombError): Image.open("Tests/images/decompression_bomb.ico") diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 59951a890..b7e129041 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -1,5 +1,7 @@ from PIL import Image +import pytest + from .helper import PillowTestCase diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 6d76a6caa..c8c9de4c2 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -2,6 +2,8 @@ import sys import zlib from io import BytesIO +import pytest + from PIL import Image, ImageFile, PngImagePlugin from PIL._util import py3 @@ -107,7 +109,9 @@ class TestFilePng(PillowTestCase): # file was checked into Subversion as a text file. test_file = "Tests/images/broken.png" - self.assertRaises(IOError, Image.open, test_file) + with pytest.raises(OSError): + with Image.open(test_file): + pass def test_bad_text(self): # Make sure PIL can read malformed tEXt chunks (@PIL152) @@ -477,7 +481,9 @@ class TestFilePng(PillowTestCase): data = b"\x89" + fd.read() pngfile = BytesIO(data) - self.assertRaises(IOError, Image.open, pngfile) + with pytest.raises(OSError): + with Image.open(pngfile): + pass def test_trns_rgb(self): # Check writing and reading of tRNS chunks for RGB images. diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 8381ceaef..2c6569926 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -1,3 +1,5 @@ +import pytest + from PIL import Image, PsdImagePlugin from .helper import PillowTestCase, hopper @@ -88,11 +90,29 @@ class TestImagePsd(PillowTestCase): self.assertNotIn("icc_profile", im.info) + def test_combined_larger_than_size(self): # The 'combined' sizes of the individual parts is larger than the # declared 'size' of the extra data field, resulting in a backwards seek. # If we instead take the 'size' of the extra data field as the source of truth, # then the seek can't be negative - with self.assertRaises(IOError): - Image.open("Tests/images/combined_larger_than_size.psd") + with pytest.raises(OSError): + with Image.open("Tests/images/combined_larger_than_size.psd"): + pass + + +@pytest.mark.parametrize( + "test_file,raises", + [ + ("Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd", Image.UnidentifiedImageError), + ("Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd", Image.UnidentifiedImageError), + ("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", OSError), + ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), + ], +) +def test_crashes(test_file, raises): + with open(test_file, "rb") as f: + with pytest.raises(raises): + with Image.open(f): + pass diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 2d15de2bd..be00a4f9c 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -64,14 +64,16 @@ class TestFileTiff(PillowTestCase): self.assertEqual(im.mode, "RGBA") self.assertEqual(im.size, (52, 53)) - self.assertEqual(im.tile, [("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))]) + self.assertEqual( + im.tile, [("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))]) im.load() def test_set_legacy_api(self): ifd = TiffImagePlugin.ImageFileDirectory_v2() with self.assertRaises(Exception) as e: ifd.legacy_api = None - self.assertEqual(str(e.exception), "Not allowing setting of legacy api") + self.assertEqual(str(e.exception), + "Not allowing setting of legacy api") def test_size(self): filename = "Tests/images/pil168.tif" @@ -91,8 +93,10 @@ class TestFileTiff(PillowTestCase): self.assertIsInstance(im.tag[Y_RESOLUTION][0], tuple) # v2 api - self.assertIsInstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) - self.assertIsInstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) + self.assertIsInstance( + im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) + self.assertIsInstance( + im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) self.assertEqual(im.info["dpi"], (72.0, 72.0)) @@ -101,8 +105,10 @@ class TestFileTiff(PillowTestCase): im = Image.open(filename) # v2 api - self.assertIsInstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) - self.assertIsInstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) + self.assertIsInstance( + im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) + self.assertIsInstance( + im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) self.assertRaises(KeyError, lambda: im.tag_v2[RESOLUTION_UNIT]) # Legacy. @@ -157,10 +163,12 @@ class TestFileTiff(PillowTestCase): def test_invalid_file(self): invalid_file = "Tests/images/flower.jpg" - self.assertRaises(SyntaxError, TiffImagePlugin.TiffImageFile, invalid_file) + self.assertRaises( + SyntaxError, TiffImagePlugin.TiffImageFile, invalid_file) TiffImagePlugin.PREFIXES.append(b"\xff\xd8\xff\xe0") - self.assertRaises(SyntaxError, TiffImagePlugin.TiffImageFile, invalid_file) + self.assertRaises( + SyntaxError, TiffImagePlugin.TiffImageFile, invalid_file) TiffImagePlugin.PREFIXES.pop() def test_bad_exif(self): @@ -235,7 +243,8 @@ class TestFileTiff(PillowTestCase): im.load() self.assertEqual(im.getpixel((0, 0)), -0.4526388943195343) - self.assertEqual(im.getextrema(), (-3.140936851501465, 3.140684127807617)) + self.assertEqual( + im.getextrema(), (-3.140936851501465, 3.140684127807617)) def test_unknown_pixel_mode(self): self.assertRaises( @@ -445,7 +454,8 @@ class TestFileTiff(PillowTestCase): self.assert_image_equal(im, im2) def test_with_underscores(self): - kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36} + kwargs = {"resolution_unit": "inch", + "x_resolution": 72, "y_resolution": 36} filename = self.tempfile("temp.tif") hopper("RGB").save(filename, **kwargs) im = Image.open(filename) @@ -476,7 +486,8 @@ class TestFileTiff(PillowTestCase): infile = "Tests/images/tiff_strip_raw.tif" im = Image.open(infile) - self.assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") + self.assert_image_equal_tofile( + im, "Tests/images/tiff_adobe_deflate.png") def test_strip_planar_raw(self): # gdal_translate -of GTiff -co INTERLEAVE=BAND \ @@ -484,14 +495,16 @@ class TestFileTiff(PillowTestCase): infile = "Tests/images/tiff_strip_planar_raw.tif" im = Image.open(infile) - self.assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") + self.assert_image_equal_tofile( + im, "Tests/images/tiff_adobe_deflate.png") def test_strip_planar_raw_with_overviews(self): # gdaladdo tiff_strip_planar_raw2.tif 2 4 8 16 infile = "Tests/images/tiff_strip_planar_raw_with_overviews.tif" im = Image.open(infile) - self.assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") + self.assert_image_equal_tofile( + im, "Tests/images/tiff_adobe_deflate.png") def test_tiled_planar_raw(self): # gdal_translate -of GTiff -co TILED=YES -co BLOCKXSIZE=32 \ @@ -500,7 +513,8 @@ class TestFileTiff(PillowTestCase): infile = "Tests/images/tiff_tiled_planar_raw.tif" im = Image.open(infile) - self.assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") + self.assert_image_equal_tofile( + im, "Tests/images/tiff_adobe_deflate.png") def test_palette(self): for mode in ["P", "PA"]: @@ -527,7 +541,8 @@ class TestFileTiff(PillowTestCase): # Test appending images mp = io.BytesIO() im = Image.new("RGB", (100, 100), "#f00") - ims = [Image.new("RGB", (100, 100), color) for color in ["#0f0", "#00f"]] + ims = [Image.new("RGB", (100, 100), color) + for color in ["#0f0", "#00f"]] im.copy().save(mp, format="TIFF", save_all=True, append_images=ims) mp.seek(0, os.SEEK_SET) @@ -540,7 +555,8 @@ class TestFileTiff(PillowTestCase): yield im mp = io.BytesIO() - im.save(mp, format="TIFF", save_all=True, append_images=imGenerator(ims)) + im.save(mp, format="TIFF", save_all=True, + append_images=imGenerator(ims)) mp.seek(0, os.SEEK_SET) reread = Image.open(mp) @@ -589,8 +605,9 @@ class TestFileTiff(PillowTestCase): def test_string_dimension(self): # Assert that an error is raised if one of the dimensions is a string - with self.assertRaises(ValueError): - Image.open("Tests/images/string_dimension.tiff") + with self.assertRaises(OSError): + with Image.open("Tests/images/string_dimension.tiff") as im: + im.load() @unittest.skipUnless(sys.platform.startswith("win32"), "Windows only") diff --git a/docs/releasenotes/6.2.2.5.rst b/docs/releasenotes/6.2.2.5.rst index 6ad06f940..4374eed0e 100644 --- a/docs/releasenotes/6.2.2.5.rst +++ b/docs/releasenotes/6.2.2.5.rst @@ -11,6 +11,13 @@ This release addresses several critical CVEs. :cve:`CVE-2021-25289`: Catch TiffDecode heap-based buffer overflow. Add test files that show the CVE was fixed :cve:`CVE-2022-22815`: Fixed ImagePath.Path array handling +:cve:`CVE-2021-28675`: Fix DOS in PsdImagePlugin +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +* :py:class:`.PsdImagePlugin.PsdImageFile` did not sanity check the number of input + layers with regard to the size of the data block, this could lead to a + denial-of-service on :py:meth:`~PIL.Image.open` prior to + :py:meth:`~PIL.Image.Image.load`. +* This dates to the PIL fork. :cve:`CVE-2022-22816`: Fixed ImagePath.Path array handling diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index 2d492358c..42fca1432 100644 --- a/src/PIL/GdImageFile.py +++ b/src/PIL/GdImageFile.py @@ -23,7 +23,7 @@ # purposes only. -from . import ImageFile, ImagePalette +from . import ImageFile, ImagePalette, UnidentifiedImageError from ._binary import i8, i16be as i16, i32be as i32 # __version__ is deprecated and will be removed in a future version. Use @@ -87,4 +87,4 @@ def open(fp, mode="r"): try: return GdImageFile(fp) except SyntaxError: - raise IOError("cannot identify this image file") + raise UnidentifiedImageError("cannot identify this image file") diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 3fbe8c8b2..798b03526 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -37,7 +37,7 @@ import warnings # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION is deprecated and will be removed in Pillow 7.0.0. # Use __version__ instead. -from . import PILLOW_VERSION, ImageMode, TiffTags, __version__, _plugins +from . import PILLOW_VERSION, ImageMode, TiffTags, UnidentifiedImageError, __version__, _plugins from ._binary import i8, i32le from ._util import deferred_error, isPath, isStringType, py3 @@ -2815,7 +2815,9 @@ def open(fp, mode="r"): fp.close() for message in accept_warnings: warnings.warn(message) - raise IOError("cannot identify image file %r" % (filename if filename else fp)) + raise UnidentifiedImageError( + "cannot identify image file %r" % (filename if filename else fp) + ) # diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 836e6318c..20aa6105b 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -543,12 +543,18 @@ def _safe_read(fp, size): :param fp: File handle. Must implement a read method. :param size: Number of bytes to read. - :returns: A string containing up to size bytes of data. + :returns: A string containing size bytes of data. + + Raises an OSError if the file is truncated and the read cannot be completed + """ if size <= 0: return b"" if size <= SAFEBLOCK: - return fp.read(size) + data = fp.read(size) + if len(data) < size: + raise OSError("Truncated File Read") + return data data = [] while size > 0: block = fp.read(min(size, SAFEBLOCK)) @@ -556,10 +562,12 @@ def _safe_read(fp, size): break data.append(block) size -= len(block) + if sum(len(d) for d in data) < size: + raise OSError("Truncated File Read") return b"".join(data) -class PyCodecState(object): +class PyCodecState: def __init__(self): self.xsize = 0 self.ysize = 0 diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index f72ad5f44..1fb43a304 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -121,7 +121,8 @@ class PsdImageFile(ImageFile.ImageFile): end = self.fp.tell() + size size = i32(read(4)) if size: - self.layers = _layerinfo(self.fp) + _layer_data = io.BytesIO(ImageFile._safe_read(self.fp, size)) + self.layers = _layerinfo(_layer_data, size) self.fp.seek(end) # @@ -178,12 +179,20 @@ class PsdImageFile(ImageFile.ImageFile): finally: self.__fp = None - -def _layerinfo(file): +def _layerinfo(fp, ct_bytes): # read layerinfo block layers = [] - read = file.read - for i in range(abs(i16(read(2)))): + + def read(size): + return ImageFile._safe_read(fp, size) + + ct = i16(read(2)) + + # sanity check + if ct_bytes < (abs(ct) * 20): + raise SyntaxError("Layer block too short for number of layers requested") + + for i in range(abs(ct)): # bounding box y0 = i32(read(4)) @@ -194,7 +203,8 @@ def _layerinfo(file): # image info info = [] mode = [] - types = list(range(i16(read(2)))) + ct_types = i16(read(2)) + types = list(range(ct_types)) if len(types) > 4: continue @@ -227,16 +237,16 @@ def _layerinfo(file): size = i32(read(4)) # length of the extra data field combined = 0 if size: - data_end = file.tell() + size + data_end = fp.tell() + size length = i32(read(4)) if length: - file.seek(length - 16, io.SEEK_CUR) + fp.seek(length - 16, io.SEEK_CUR) combined += length + 4 length = i32(read(4)) if length: - file.seek(length, io.SEEK_CUR) + fp.seek(length, io.SEEK_CUR) combined += length + 4 length = i8(read(1)) @@ -246,7 +256,7 @@ def _layerinfo(file): name = read(length).decode("latin-1", "replace") combined += length + 1 - file.seek(data_end) + fp.seek(data_end) layers.append((name, mode, (x0, y0, x1, y1))) # get tiles @@ -254,7 +264,7 @@ def _layerinfo(file): for name, mode, bbox in layers: tile = [] for m in mode: - t = _maketile(file, m, bbox, 1) + t = _maketile(fp, m, bbox, 1) if t: tile.extend(t) layers[i] = name, mode, bbox, tile diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 59eccc9b5..289d417a6 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -71,3 +71,11 @@ _plugins = [ "XpmImagePlugin", "XVThumbImagePlugin", ] + + +class UnidentifiedImageError(OSError): + """ + Raised in :py:meth:`PIL.Image.open` if an image cannot be opened and identified. + """ + + pass