Live › vulnerability › CVE-2026-112393
Go to dashboardPillow (Python imaging library) has a heap buffer overflow in its TIFF writer when saving to an in-memory target such as io.BytesIO, triggered by re-encoding an attacker-supplied TIFF with default settings. The overflow writes attacker-chosen tag data past a heap allocation and can leak stale heap bytes into the output file; the advisory says code execution is not demonstrated.
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 | >= 2.0.0, < 13.0.0 | 13.0.0 |
pip show pillow (affected if version >= 2.0.0 and < 13.0.0)
grep -rn "save(.BytesIO|save(."TIFF"" over code that re-encodes uploaded images as TIFF
Upgrade Pillow to 13.0.0 or later: pip install -U pillow>=13.0.0
Until upgraded, avoid saving TIFF to BytesIO or other non-file targets for untrusted input.
Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
============================================================================
Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO
Location: src/libImaging/TiffDecode.c:94 in _tiffWriteProc()
Commit: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
Pillow can write attacker-chosen data past the end of a heap buffer when it saves a TIFF image to an in-memory destination such as io.BytesIO rather than to a file. The same code also copies leftover heap memory from the process into the saved file. Anyone who can supply an image to a service that opens it and re-saves it as TIFF this way can trigger this with default settings. The attached reproducer crashes the process, and the overflow could potentially be developed further, but code execution is not demonstrated here.
The bug is in _tiffWriteProc in src/libImaging/TiffDecode.c. This is the write callback for the in-memory sink that Pillow gives libtiff whenever the save target has no fileno(). While writing the directory, libtiff seeks past the current end of data and writes tag values there. _tiffWriteProc sizes its realloc() from state->size + size instead of from the write position state->loc. As a result, the first out-of-line tag value written after that seek can run past the allocation by up to the size of the directory: 12 bytes per entry, 126 bytes in the minimal case, and 2384 bytes observed with a 331-entry directory. The overflowing bytes are the tail of a tag value taken from the uploaded file.
No save options are needed, because compression and tags are inherited from the source image. The overflow does require an attacker-sized out-of-line tag to reach libtiff. That happens when the opened TIFF is saved directly (ImageDescription, DocumentName or XMP), or through an embedded ICC profile, which survives copy() in im.info. The bytes libtiff skips for alignment are never initialised, so any compressed TIFF over 64 KiB written this way also leaks one stale heap byte per padding hole. That leak needs no crafted tag.
The attached reproducer builds a 131 KB PackBits TIFF and runs Image.open(...).save(io.BytesIO(), "TIFF"). Saving the same image to a file path succeeds, but the BytesIO save aborts with glibc's malloc(): corrupted top size. A second part of the script shows uninitialised heap bytes at the padding offsets of a BytesIO save that are zero in the file save. Saves to real files are unaffected. The defect dates to e782fe721e01 (2013) and has been present since Pillow 2.0.0.
When im.save() writes a TIFF through libtiff to a target that has no usable fileno() (io.BytesIO, an HTTP response object, a storage backend stream), libtiff is opened with TIFFClientOpen() on Pillow's own in-memory sink, _tiffWriteProc / _tiffSeekProc in src/libImaging/TiffDecode.c. libtiff treats that sink like a file: when it writes the directory it seeks beyond the current end of data (to eof + IFD size, where the out-of-line tag values go, and one byte further after every odd-length tag value to keep offsets even) and writes there. On a real file the kernel grows the file and zero-fills the gap. The memory sink does neither correctly:
_tiffWriteProc sizes its realloc() from state->size + size instead of state->loc + size, so the first write after such a seek can land past the end of the new allocation — a heap buffer overflow whose content is tag data taken from the image being saved.realloc(), so they reach the output TIFF with whatever the heap held before.Both are reachable with default settings by the ordinary "re-encode the upload" sequence Image.open(upload); im.save(io.BytesIO(), "TIFF") with no save options: compression defaults to the source file's (src/PIL/TiffImagePlugin.py:1734), any compression other than raw selects the libtiff writer (:1746), the source's tag_v2 entries are passed through to libtiff (:1959), and a target without fileno() leaves _fp = 0 (:1928-1935), i.e. the memory sink. The overflow half additionally needs an attacker-controlled out-of-line tag value of the right length to reach libtiff. With the opened TiffImageFile saved directly (tag_v2 passthrough) that can be a ~64 KiB ASCII tag (269/270/271/272) or the XMP packet (tag 700, type BYTE — arbitrary bytes), and the number of tags the upload carries sets the IFD size, i.e. the overrun length (12 bytes per entry; 2384 bytes with a 331-entry IFD observed). After convert()/resize()/thumbnail()/copy() the tag_v2 entries are gone, but (still selecting libtiff) and survive and the ICC profile is written as tag 34675 (), an equally attacker-controlled out-of-line value: with the payload in the ICC profile produces the identical ASan report. The stale-byte half needs no attacker tag at all.
Proof (overflow). A 131 198-byte hand-built TIFF — mode L, 7×8191, Compression=32773 (PackBits), an ImageDescription of 65 535 characters — opened with Image.open() and saved with im.save(io.BytesIO(), "TIFF") aborts a stock build with glibc's malloc(): corrupted top size. The same call against an AddressSanitizer build of the C extensions reports
AddressSanitizer: heap-buffer-overflow ... WRITE of size 65536
#1 _tiffWriteProc src/libImaging/TiffDecode.c:115
#4 TIFFFlush
#5 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1056
0 bytes to the right of 131072-byte region, allocated by realloc in _tiffWriteProc src/libImaging/TiffDecode.c:103
Proof (uninitialised bytes). Saving one 300×300 RGB image as compression="tiff_lzw" with four odd-length ASCII tags to a BytesIO, once with MALLOC_PERTURB_=1 (glibc fills fresh allocations with 0xfe) and once with MALLOC_PERTURB_=170 (0x55), gives two 369 463-byte TIFFs that differ in exactly 4 bytes, 0xfe vs 0x55, at offsets 369177, 369417, 369423, 369451 — the pad byte between the odd-length last strip and the IFD, and the pad byte after each odd-length tag value except the last. The same save to a filename gives byte-identical files with 0x00 there. Without any allocator tweaks, after a few 64 KB–400 KB Python bytes objects filled with 0xa7 are created and freed, re-saving an uploaded LZW TIFF carrying ten odd-length ASCII tags yields 0xa7 at all nine pad offsets.
The sink's state is {data, size (allocated), loc (position), eof (high-water mark)}; the encoder path starts it as a zeroed 64 KiB block (TiffDecode.c:863, calloc(bufsize, 1) with bufsize = 64*1024, flrealloc = 1). Seeking is unconstrained:
/* src/libImaging/TiffDecode.c:124-141 _tiffSeekProc */
case 0: state->loc = off; break; /* may exceed eof and size */
case 1: state->loc += off; break;
case 2: state->loc = state->eof + off; break;
and writing assumes loc <= size:
/* src/libImaging/TiffDecode.c:90-117 _tiffWriteProc */
to_write = MIN(size, state->size - (tsize_t)state->loc); /* negative if loc > size */
if (state->flrealloc && size > to_write) {
tsize_t newsize = state->size;
while (newsize < (size + state->size)) { /* capacity target ignores loc */
…
newsize += 64 * 1024;
}
new_data = realloc(state->data, newsize); /* tail left uninitialised */
…
state->size = newsize;
to_write = size;
}
_TIFFmemcpy((UINT8 *)state->data + state->loc, buf, to_write); /* :115 */
state->loc += (toff_t)to_write;
state->eof = MAX(state->loc, state->eof);
A gdb trace of the sink calls during the overflowing save (stock build, breakpoints on the two procs) shows how libtiff drives it:
write size=63064 at loc=8
This page as .md · .json · in your agent: get_signal("CVE-2026-112393")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.
im.info["compression"]im.info["icc_profile"]:1822-1824Image.open(x).copy().save(BytesIO(), "TIFF")