From 39b01f277e9d0323c7149ef3181426aa509f42e8 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:46:00 -0400 Subject: [PATCH 1/5] Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile()/_decodeAsRGBA() _decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c size their decode buffer from libtiff's own view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), or img.width * 4), but unpack each row with the shuffler that TiffImagePlugin._setup() picked from Pillow's separate parse of the IFD. Neither function checks that the unpacker's input row fits inside the row libtiff actually produced. _decodeStrip() has exactly that check (added for OSS-Fuzz crash-0da0 in 8.2.0); the tile and RGBA paths never got it. The two parsers can be made to disagree with a duplicated or miscounted PhotometricInterpretation/BitsPerSample tag: libtiff keeps the first occurrence (or ignores a badly-typed tag), Pillow's ImageFileDirectory_v2.load() keeps the last. Pillow then picks a wider rawmode (e.g. RGBX, RGB;16N) than libtiff actually decoded, so the unpacker reads past the end of the reused tile/RGBA buffer -- the last rows of every tile, scaling with the attacker-chosen TileWidth. A wide enough tile turns the over-read into a segfault instead of disclosure. Confirmed live: opening the report's 16x16 tiled TIFF (single 8-bit sample per pixel, but PhotometricInterpretation written as SHORT with duplicate value so Pillow selects rawmode RGBX/4 bytes-per-pixel while libtiff decodes 1 byte-per-pixel) succeeds with no error on the unfixed baseline, and rows 13-15 of the decoded image contain bytes that are not derived from the 256-byte tile buffer's real content (trailing zero/pattern runs inconsistent with the deflate-decoded image data). The fixed build rejects the same file cleanly with `OSError: decoder error -2`, the same failure mode _decodeStrip() already gives for the equivalent strip-based file. Fix (reporter's patch, applied as-is): - _decodeTile(): compute unpacker_row_byte_size in a tsize_t-widened product (avoiding the 32-bit overflow the report also flags for very wide tiles) and reuse it for the existing TIFFTileSize() sanity check; fail with IMAGING_CODEC_BROKEN when it exceeds TIFFTileRowSize(), mirroring _decodeStrip()'s guard. - _decodeAsRGBA(): fail with IMAGING_CODEC_BROKEN when state->bits > 32, since TIFFRGBAImage rows are always 32 bits/pixel, before any allocation or TIFFRGBAImageBegin() call. Adds test_unpacker_row_wider_than_libtiff_row to Tests/test_file_libtiff.py, embedding the report's count-2 tile and duplicated-tag YCbCr strip reproducers as base64 and asserting both now fail load() with "decoder error -2". Reported-by: carlini Fixes: GHSA-c6hw-5grf-ff96 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Tests/test_file_libtiff.py | 44 +++++++++++++++++++++++++++++++++++++ src/libImaging/TiffDecode.c | 21 +++++++++++++++--- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 068cac85bcd..61d0372c9b7 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1281,6 +1281,50 @@ def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None: with pytest.raises(OSError, match="decoder error -9"): im.load() + @pytest.mark.parametrize( + "data", + ( + # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that + # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel) + # while libtiff ignores it and decodes 1 byte/pixel. + ( + b"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA" + b"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA" + b"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4" + b"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm" + b"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT" + b"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A" + b"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt" + b"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna" + b"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo=" + ), + # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation + # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA) + # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel). + ( + b"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA" + b"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA" + b"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB" + b"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS" + b"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3" + b"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J" + b"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x" + b"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w" + b"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x" + b"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA" + b"EAAQAA==" + ), + ), + ) + @pytest.mark.filterwarnings("ignore:Metadata Warning") + def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -> None: + # The unpacker chosen by Pillow reads more bytes per row than libtiff + # decoded, so the decoder must reject the image rather than read past + # the end of its buffer + with Image.open(io.BytesIO(base64.b64decode(data))) as im: + with pytest.raises(OSError, match="decoder error -2"): + im.load() + @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB").resize((256, 256)) diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 20f6c8c3720..864a3661672 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -239,6 +239,14 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) { rows_per_block = state->ysize; } + // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel + // than that, (at least) the last row of the block will have a read buffer + // overflow. + if (state->bits > 32) { + state->errcode = IMAGING_CODEC_BROKEN; + return -1; + } + if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) { state->errcode = IMAGING_CODEC_BROKEN; // nothing to clean up, just return @@ -320,7 +328,7 @@ _decodeTile( ) { INT32 x, y, tile_y, current_tile_length, current_tile_width; UINT32 tile_width, tile_length; - tsize_t tile_bytes_size, row_byte_size; + tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size; UINT8 *new_data; tile_bytes_size = TIFFTileSize(tiff); @@ -352,8 +360,8 @@ _decodeTile( return -1; } - if (tile_bytes_size != - ((tile_length * state->bits / planes + 7) / 8) * tile_width) { + unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8; + if (tile_bytes_size != (unpacker_row_byte_size * tile_length)) { // If the tile size as expected by LibTiff isn't what we're expecting, abort. // man: TIFFTileSize returns the equivalent size for a tile of data as it // would be returned in a call to TIFFReadTile ... @@ -361,6 +369,13 @@ _decodeTile( return -1; } + // if the unpacker calculated row size is > row byte size, (at least) the last + // row of the tile will have a read buffer overflow. + if (unpacker_row_byte_size > row_byte_size) { + state->errcode = IMAGING_CODEC_BROKEN; + return -1; + } + state->bytes = tile_bytes_size; /* realloc to fit whole tile */ From 85101c7d7626faceb3ec161246d551471742f997 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Sat, 10 Oct 2026 07:19:36 -0400 Subject: [PATCH 2/5] Update src/libImaging/TiffDecode.c Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/libImaging/TiffDecode.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 864a3661672..9915a596feb 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -242,7 +242,7 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) { // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel // than that, (at least) the last row of the block will have a read buffer // overflow. - if (state->bits > 32) { + if (state->bits != 32) { state->errcode = IMAGING_CODEC_BROKEN; return -1; } From 4b82d6ff16c12d738ffa110784098245acadc07e Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Sat, 10 Oct 2026 19:34:22 -0400 Subject: [PATCH 3/5] Test RGBA decoding with a 6 channel rawmode Covers the PackBits, RGBAXX case reported in GHSA-c98p-g9r2-r642, where the unpacker reads 6 bytes per pixel from libtiff's 4 byte RGBA rows. Co-Authored-By: Claude Opus 5.5 --- Tests/test_file_libtiff.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 61d0372c9b7..47b7f40933b 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1314,6 +1314,18 @@ def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None: b"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA" b"EAAQAA==" ), + # 4x2 PackBits strip with duplicated BitsPerSample, Photometric + # Interpretation and SamplesPerPixel tags. libtiff keeps the first + # (8-bit YCbCr, read as 4 bytes/pixel RGBA) while Pillow keeps the last + # (6 channel RGB, rawmode RGBAXX, 6 bytes/pixel). + ( + b"SUkqAAgAAAAPAAABBAABAAAABAAAAAEBBAABAAAAAgAAAAIBAwADAAAAwgAA" + b"AAIBAwAGAAAAyAAAAAMBAwABAAAABYAAAAYBAwABAAAABgAAAAYBAwABAAAA" + b"AgAAABEBBAABAAAA2gAAABUBAwABAAAAAwAAABUBAwABAAAABgAAABYBBAAB" + b"AAAAAgAAABcBBAABAAAAGQAAABwBAwABAAAAAQAAAFIBAwADAAAA1AAAABIC" + b"AwACAAAAAQABAAAAAAAIAAgACAAIAAgACAAIAAgACAACAAAAAAAXAAECAwQF" + b"BgcICQoLDA0ODxAREhMUFRYX" + ), ), ) @pytest.mark.filterwarnings("ignore:Metadata Warning") From ba4b6eb0331c5648b2846881e5cc43a51ef1b469 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:36:22 +1100 Subject: [PATCH 4/5] Remove brackets --- src/libImaging/TiffDecode.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 9915a596feb..f0db363a002 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -361,7 +361,7 @@ _decodeTile( } unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8; - if (tile_bytes_size != (unpacker_row_byte_size * tile_length)) { + if (tile_bytes_size != unpacker_row_byte_size * tile_length) { // If the tile size as expected by LibTiff isn't what we're expecting, abort. // man: TIFFTileSize returns the equivalent size for a tile of data as it // would be returned in a call to TIFFReadTile ... From eb4a81d32f8d2ab506b1477b5013154ccab61785 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:36:42 +1100 Subject: [PATCH 5/5] Remove brackets