From e5cada535b10bd4a926851ef9e09686574298194 Mon Sep 17 00:00:00 2001 From: abhi-0203 Date: Wed, 22 Jul 2026 04:49:56 +0000 Subject: [PATCH 1/2] Fix QoiDecoder crash on truncated QOI files Check for empty reads before indexing in QoiDecoder.decode() to prevent IndexError when a truncated QOI file is opened. The decoder now raises OSError with 'image file is truncated' message, consistent with other Pillow decoders. Fixes #9812 --- src/PIL/QoiImagePlugin.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index e7a6cee16db..3b4d2b03125 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -61,12 +61,23 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: bands = Image.getmodebands(self.mode) dest_length = self.state.xsize * self.state.ysize * bands while len(data) < dest_length: - byte = self.fd.read(1)[0] + byte_data = self.fd.read(1) + if not byte_data: + msg = "image file is truncated" + raise OSError(msg) + byte = byte_data[0] value: bytes | bytearray if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB - value = bytearray(self.fd.read(3)) + self._previous_pixel[3:] + rgb_data = self.fd.read(3) + if len(rgb_data) < 3: + msg = "image file is truncated" + raise OSError(msg) + value = bytearray(rgb_data) + self._previous_pixel[3:] elif byte == 0b11111111: # QOI_OP_RGBA value = self.fd.read(4) + if len(value) < 4: + msg = "image file is truncated" + raise OSError(msg) else: op = byte >> 6 if op == 0: # QOI_OP_INDEX @@ -86,7 +97,11 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: ) ) elif op == 2 and self._previous_pixel: # QOI_OP_LUMA - second_byte = self.fd.read(1)[0] + second_byte_data = self.fd.read(1) + if not second_byte_data: + msg = "image file is truncated" + raise OSError(msg) + second_byte = second_byte_data[0] diff_green = (byte & 0b00111111) - 32 diff_red = ((second_byte & 0b11110000) >> 4) - 8 diff_blue = (second_byte & 0b00001111) - 8 From e9bdedc9116b43d5d3828ce72ef6484647ed266d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 22 Jul 2026 18:56:51 +1000 Subject: [PATCH 2/2] Break on truncated data --- Tests/test_file_qoi.py | 10 ++++++++++ src/PIL/QoiImagePlugin.py | 12 ++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py index b9becb24f77..3057dabc05e 100644 --- a/Tests/test_file_qoi.py +++ b/Tests/test_file_qoi.py @@ -1,5 +1,6 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path import pytest @@ -32,6 +33,15 @@ def test_invalid_file() -> None: QoiImagePlugin.QoiImageFile(invalid_file) +@pytest.mark.parametrize("length", (14, 44, 48, 55)) +def test_truncated(length: int) -> None: + with open("Tests/images/pil123rgba.qoi", "rb") as fp: + data = fp.read()[:length] + with Image.open(BytesIO(data)) as im: + with pytest.raises(ValueError, match="not enough image data"): + im.load() + + def test_op_index() -> None: # QOI_OP_INDEX as the first chunk with Image.open("Tests/images/op_index.qoi") as im: diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 3b4d2b03125..3226c46c509 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -63,21 +63,18 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: while len(data) < dest_length: byte_data = self.fd.read(1) if not byte_data: - msg = "image file is truncated" - raise OSError(msg) + break byte = byte_data[0] value: bytes | bytearray if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB rgb_data = self.fd.read(3) if len(rgb_data) < 3: - msg = "image file is truncated" - raise OSError(msg) + break value = bytearray(rgb_data) + self._previous_pixel[3:] elif byte == 0b11111111: # QOI_OP_RGBA value = self.fd.read(4) if len(value) < 4: - msg = "image file is truncated" - raise OSError(msg) + break else: op = byte >> 6 if op == 0: # QOI_OP_INDEX @@ -99,8 +96,7 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: elif op == 2 and self._previous_pixel: # QOI_OP_LUMA second_byte_data = self.fd.read(1) if not second_byte_data: - msg = "image file is truncated" - raise OSError(msg) + break second_byte = second_byte_data[0] diff_green = (byte & 0b00111111) - 32 diff_red = ((second_byte & 0b11110000) >> 4) - 8