---
id: "CVE-2026-112404"
kind: "vulnerability"
aliases: ["CVE-2026-112404", "GHSA-c6hw-5grf-ff96"]
title: "Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels"
severity: "high"
cvss: 8.8
epss: 0.0029100000392645597
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 8.1.0, < 13.0.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-125", "cwe-126", "image-upload", "package", "pillow", "python", "thumbnail-service"]
not_affected_if: ["no-untrusted-tiff-input", "pillow-13.0.0-or-later", "pillow-below-8.1.0"]
published_at: "2026-10-19T22:17:07+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:19+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-c6hw-5grf-ff96", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112404", "first_seen_at": "2026-10-23T21:57:19+00:00"}]
---

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

Pillow reads past the end of a heap buffer when decoding malformed compressed TIFF files with duplicated or conflicting photometric or bits-per-sample tags, and copies those bytes into the returned image's pixels. An attacker who can get an application to open an untrusted TIFF (for example via a thumbnail or conversion upload) can read fragments of that process's heap, including pointer values, or crash it.

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

## Affected

- pypi:Pillow affected >= 8.1.0, < 13.0.0 fixed 13.0.0

## How to check

pip show pillow or python -c "import PIL; print(PIL.__version__)"
Check whether any service calls Image.open()/load()/thumbnail() on user-supplied TIFF files

## Fix

Upgrade Pillow to 13.0.0 or later (affected range >= 8.1.0, < 13.0.0).
Until upgraded, reject or convert untrusted TIFF uploads before decoding them with Pillow.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-c6hw-5grf-ff96
- 2026-10-23T21:57:19+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112404

## From the sources

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

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

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

```c
// 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:

```c
// 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

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
