From e443159da785e7d3fc214e5fe9945370f97e71b7 Mon Sep 17 00:00:00 2001 From: Meng Date: Thu, 23 Jul 2026 08:27:37 +0200 Subject: [PATCH 1/6] Raise SyntaxError instead of OverflowError for oversized JPEG 2000 box --- Tests/test_file_jpeg2k.py | 13 +++++++++++++ src/PIL/Jpeg2KImagePlugin.py | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 705dac880e5..e6ccc7a246f 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -229,6 +229,19 @@ def test_header_errors() -> None: pass +def test_oversized_box_length() -> None: + # A box declaring a 64-bit length that ends beyond the maximum seekable + # position must be rejected instead of raising OverflowError from seek() + data = ( + b"\x00\x00\x00\x0cjP \r\n\x87\n" # JP2 signature box + + struct.pack(">I4s", 1, b"\x00\x00\x00\x00") # box with 64-bit length + + struct.pack(">Q", 0xFFFFFFFFFF000004) # length beyond the seek range + ) + with pytest.raises(UnidentifiedImageError): + with Image.open(BytesIO(data)): + pass + + def test_layers_type(card: ImageFile.ImageFile, tmp_path: Path) -> None: outfile = tmp_path / "temp_layers.jp2" for quality_layers in [[100, 50, 10], (100, 50, 10), None]: diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index ca982f06aa5..d2d30567de3 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -98,6 +98,11 @@ def next_box_type(self) -> bytes: msg = "Invalid header length" raise SyntaxError(msg) + # Reject a box whose contents end beyond the maximum seekable position + if self.fp.tell() + lbox - hlen >= 2**63: + msg = "Invalid box length" + raise SyntaxError(msg) + self.remaining_in_box = lbox - hlen return tbox From 716b10043c2abc709abeee4ab87e34ed9faa8306 Mon Sep 17 00:00:00 2001 From: mengww Date: Thu, 23 Jul 2026 14:35:09 +0200 Subject: [PATCH 2/6] Update Tests/test_file_jpeg2k.py Update payload to match the one in def _accept(prefix: bytes) -> bool: Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_file_jpeg2k.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index e6ccc7a246f..ba9896c1dff 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -233,7 +233,7 @@ def test_oversized_box_length() -> None: # A box declaring a 64-bit length that ends beyond the maximum seekable # position must be rejected instead of raising OverflowError from seek() data = ( - b"\x00\x00\x00\x0cjP \r\n\x87\n" # JP2 signature box + b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" # JP2 signature box + struct.pack(">I4s", 1, b"\x00\x00\x00\x00") # box with 64-bit length + struct.pack(">Q", 0xFFFFFFFFFF000004) # length beyond the seek range ) From 884460acb4fc89a00898fe51a5471bef802d99f3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 23 Jul 2026 22:58:48 +1000 Subject: [PATCH 3/6] Raise error when seeking, rather than when starting box --- Tests/test_file_jpeg2k.py | 12 +++++------- src/PIL/Jpeg2KImagePlugin.py | 8 +++----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index ba9896c1dff..0f045fba1ba 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -230,16 +230,14 @@ def test_header_errors() -> None: def test_oversized_box_length() -> None: - # A box declaring a 64-bit length that ends beyond the maximum seekable - # position must be rejected instead of raising OverflowError from seek() + # Do not raise an OverflowError() when seeking to the end of a box data = ( b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" # JP2 signature box - + struct.pack(">I4s", 1, b"\x00\x00\x00\x00") # box with 64-bit length - + struct.pack(">Q", 0xFFFFFFFFFF000004) # length beyond the seek range + + struct.pack(">I4s", 1, b"") # box with 64-bit length + + struct.pack(">Q", 16 + 2**63) # length beyond the seek range ) - with pytest.raises(UnidentifiedImageError): - with Image.open(BytesIO(data)): - pass + with pytest.raises(SyntaxError, match="Box length too large"): + Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) def test_layers_type(card: ImageFile.ImageFile, tmp_path: Path) -> None: diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index d2d30567de3..e2f70d11fdd 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -83,6 +83,9 @@ def has_next_box(self) -> bool: def next_box_type(self) -> bytes: # Skip the rest of the box if it has not been read if self.remaining_in_box > 0: + if self.remaining_in_box >= 2**63: + msg = "Box length too large" + raise SyntaxError(msg) self.fp.seek(self.remaining_in_box, os.SEEK_CUR) self.remaining_in_box = -1 @@ -98,11 +101,6 @@ def next_box_type(self) -> bytes: msg = "Invalid header length" raise SyntaxError(msg) - # Reject a box whose contents end beyond the maximum seekable position - if self.fp.tell() + lbox - hlen >= 2**63: - msg = "Invalid box length" - raise SyntaxError(msg) - self.remaining_in_box = lbox - hlen return tbox From 80bdd470c917f5d419a881a725677e3dcf5f40a5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 23 Jul 2026 23:20:58 +1000 Subject: [PATCH 4/6] Consider new position, not just offset --- Tests/test_file_jpeg2k.py | 2 +- src/PIL/Jpeg2KImagePlugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 0f045fba1ba..6cfbcd269f7 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -234,7 +234,7 @@ def test_oversized_box_length() -> None: data = ( b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" # JP2 signature box + struct.pack(">I4s", 1, b"") # box with 64-bit length - + struct.pack(">Q", 16 + 2**63) # length beyond the seek range + + struct.pack(">Q", 2**63 - 12) # length beyond the seek range ) with pytest.raises(SyntaxError, match="Box length too large"): Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index e2f70d11fdd..9941e6deb9c 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -83,7 +83,7 @@ def has_next_box(self) -> bool: def next_box_type(self) -> bytes: # Skip the rest of the box if it has not been read if self.remaining_in_box > 0: - if self.remaining_in_box >= 2**63: + if self.fp.tell() + self.remaining_in_box >= 2**63: msg = "Box length too large" raise SyntaxError(msg) self.fp.seek(self.remaining_in_box, os.SEEK_CUR) From 196974ef39cd1ecc3f8dacda01d6bb03e3086ddc Mon Sep 17 00:00:00 2001 From: Meng Date: Thu, 23 Jul 2026 16:00:46 +0200 Subject: [PATCH 5/6] Validate JPEG 2000 box length when stored, covering the read path The previous guard only rejected an oversized box length on the seek path in next_box_type(). A box that is descended into via read_boxes() (e.g. the jp2h header box) still passed the huge length to BytesIO.read(), leaking OverflowError out of Image.open(). Move the check to the point where the length is stored, so it protects both consumers of remaining_in_box: the seek in next_box_type() and the read in read_boxes(). Add regression tests that independently trigger the seek path and the read path. --- Tests/test_file_jpeg2k.py | 11 +++++++++++ src/PIL/Jpeg2KImagePlugin.py | 9 ++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 6cfbcd269f7..5cd4f14c45e 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -240,6 +240,17 @@ def test_oversized_box_length() -> None: Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) +def test_oversized_box_length_when_reading() -> None: + # Do not raise an OverflowError() when reading the contents of a box + data = ( + b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" # JP2 signature box + + struct.pack(">I4s", 1, b"jp2h") # box read into with a 64-bit length + + struct.pack(">Q", 16 + 2**63) # length whose contents overflow fp.read() + ) + with pytest.raises(SyntaxError, match="Box length too large"): + Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) + + def test_layers_type(card: ImageFile.ImageFile, tmp_path: Path) -> None: outfile = tmp_path / "temp_layers.jp2" for quality_layers in [[100, 50, 10], (100, 50, 10), None]: diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 9941e6deb9c..dbacb72f350 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -83,9 +83,6 @@ def has_next_box(self) -> bool: def next_box_type(self) -> bytes: # Skip the rest of the box if it has not been read if self.remaining_in_box > 0: - if self.fp.tell() + self.remaining_in_box >= 2**63: - msg = "Box length too large" - raise SyntaxError(msg) self.fp.seek(self.remaining_in_box, os.SEEK_CUR) self.remaining_in_box = -1 @@ -101,6 +98,12 @@ def next_box_type(self) -> bytes: msg = "Invalid header length" raise SyntaxError(msg) + # Reject a box whose contents would extend beyond the maximum position + # that can be sought or read, before it is stored and later consumed + if self.fp.tell() + lbox - hlen >= 2**63: + msg = "Box length too large" + raise SyntaxError(msg) + self.remaining_in_box = lbox - hlen return tbox From e73ccd59ccc759327fb7e2a2c730c1f55f58f574 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 24 Jul 2026 22:51:07 +1000 Subject: [PATCH 6/6] Only raise the error at the last possible operation --- Tests/test_file_jpeg2k.py | 21 ++++++--------------- src/PIL/Jpeg2KImagePlugin.py | 12 ++++++------ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 5cd4f14c45e..6b58ec9a8a2 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -229,23 +229,14 @@ def test_header_errors() -> None: pass -def test_oversized_box_length() -> None: - # Do not raise an OverflowError() when seeking to the end of a box +@pytest.mark.parametrize("tbox", (b"", b"jp2h")) +def test_oversized_box_length(tbox: bytes) -> None: + # Do not raise an OverflowError() when seeking to the end of a box, + # or when reading the contents of a box data = ( b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" # JP2 signature box - + struct.pack(">I4s", 1, b"") # box with 64-bit length - + struct.pack(">Q", 2**63 - 12) # length beyond the seek range - ) - with pytest.raises(SyntaxError, match="Box length too large"): - Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) - - -def test_oversized_box_length_when_reading() -> None: - # Do not raise an OverflowError() when reading the contents of a box - data = ( - b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" # JP2 signature box - + struct.pack(">I4s", 1, b"jp2h") # box read into with a 64-bit length - + struct.pack(">Q", 16 + 2**63) # length whose contents overflow fp.read() + + struct.pack(">I4s", 1, tbox) # box with 64-bit length + + struct.pack(">Q", 2**63 - 12) # oversized length ) with pytest.raises(SyntaxError, match="Box length too large"): Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index dbacb72f350..7d27fcc8fff 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -54,6 +54,9 @@ def _read_bytes(self, num_bytes: int) -> bytes: if not self._can_read(num_bytes): msg = "Not enough data in header" raise SyntaxError(msg) + if self.fp.tell() + num_bytes >= 2**63: + msg = "Box length too large" + raise SyntaxError(msg) data = self.fp.read(num_bytes) if len(data) < num_bytes: @@ -83,6 +86,9 @@ def has_next_box(self) -> bool: def next_box_type(self) -> bytes: # Skip the rest of the box if it has not been read if self.remaining_in_box > 0: + if self.fp.tell() + self.remaining_in_box >= 2**63: + msg = "Box length too large" + raise SyntaxError(msg) self.fp.seek(self.remaining_in_box, os.SEEK_CUR) self.remaining_in_box = -1 @@ -98,12 +104,6 @@ def next_box_type(self) -> bytes: msg = "Invalid header length" raise SyntaxError(msg) - # Reject a box whose contents would extend beyond the maximum position - # that can be sought or read, before it is stored and later consumed - if self.fp.tell() + lbox - hlen >= 2**63: - msg = "Box length too large" - raise SyntaxError(msg) - self.remaining_in_box = lbox - hlen return tbox