PILLOW · GHSA-xcvg-w56q-r89m · Package
FIX → 13.0.0
Pillow's TIFF save through libtiff can read or write past a heap buffer when EXIF or tiffinfo tags override the image size or samples per pixel. An attacker who uploads a crafted image can get heap memory copied into the saved file, or crash the process.
first public Nov 1, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Applications that save images as TIFF through Pillow's libtiff path (any compression other than raw) and either copy metadata with exif=im.getexif() or tiffinfo=, or re-save uploaded TIFF files with a plain save. Code that only writes raw TIFF or never handles user uploads is much less exposed.
Not affected if no-user-uploads, pillow-13-or-later, raw-compression-only
Who cares image-upload, pillow, python
python -c "import PIL; print(PIL.__version__)", and treat anything at or below 12.3.0 as affected..save( calls with "TIFF" and a compression= other than raw, or exif=/tiffinfo= arguments, in code that handles uploaded images.============================================================================
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 , which inherits its compression and predictor from the upload.
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
Upgrade to 13.0.0
13.0.0 or later.Image.open(f).save(out, "TIFF")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).
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:
{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.{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).Invalid read of size 1 … TIFFWriteScanline … by ImagingLibTiffEncode … Address is 0 bytes after a block of size 64 alloc'd at calloc by _setimage.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.
_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