---
id: "GHSA-xcvg-w56q-r89m"
page: "https://infrastack.ai/live/GHSA-xcvg-w56q-r89m"
kind: "vulnerability"
aliases: ["GHSA-xcvg-w56q-r89m"]
title: "Heap buffer overflow saving TIFF with libtiff when tags override image size"
severity: "high"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": "<= 12.3.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-787", "image-upload", "package", "pillow", "python"]
not_affected_if: ["no-user-uploads", "pillow-13-or-later", "raw-compression-only"]
published_at: "2026-11-01T23:27:59+00:00"
first_seen_at: "2026-11-09T22:53:22+00:00"
updated_at: "2026-11-09T22:58:51+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-xcvg-w56q-r89m", "first_seen_at": "2026-11-09T22:53:22+00:00"}]
exploit_sources: []
---

# Heap buffer overflow saving TIFF with libtiff when tags override image size

Link for people: https://infrastack.ai/live/GHSA-xcvg-w56q-r89m (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:Pillow affected <= 12.3.0 fixed 13.0.0

## How to check

- Check the installed Pillow version with `python -c "import PIL; print(PIL.__version__)"`, and treat anything at or below `12.3.0` as affected.
- Look for `.save(` calls with `"TIFF"` and a `compression=` other than raw, or `exif=`/`tiffinfo=` arguments, in code that handles uploaded images.

## Fix

- Pillow: move to `13.0.0` or later.

## Timeline

- 2026-11-09T22:53:22+00:00 ghsa-repo https://github.com/python-pillow/Pillow/security/advisories/GHSA-xcvg-w56q-r89m

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/python-pillow/Pillow/security/advisories/GHSA-xcvg-w56q-r89m

## From the sources

```
============================================================================
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

```python
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):

```python
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:

```python
# 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

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
