Live › vulnerability › CVE-2026-112404
Go to dashboardPillow 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 written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 19:11 UTC, 3h before NVD. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| Pillow | pypi | >= 8.1.0, < 13.0.0 | 13.0.0 |
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
13.0.0 or later (affected range >= 8.1.0, < 13.0.0)Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
============================================================================
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.
_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, , , …); (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 , against the 256-byte region allocated at . The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in 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).
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
_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
This page as .md · .json · in your agent: get_signal("CVE-2026-112404")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.
I;32F;32FRGBA;16Nsrc/libImaging/TiffDecode.c:483TiffDecode.c:448copy4 ← _decodeTile