Skip to content

chore(deps): update dependency pillow to v13 [security] - #71

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-pillow-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-pillow-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
Pillow (changelog) ==12.* → ==13.* age confidence

Pillow: Infinite loop in MIC n_frames when the embedded TIFF

BIT-pillow-2026-112390 / CVE-2026-112390 / GHSA-22hg-r758-rrw9

More information

Details

============================================================================
Infinite loop in MIC n_frames when the embedded TIFF has 3+ IFDs
Location: src/PIL/MicImagePlugin.py:86  in MicImageFile.tell()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A crafted 5.6 KB image file makes Pillow loop forever at 100% CPU when an application asks how many frames the image has. This only affects installations that have the optional olefile package. Any application that opens an untrusted image and then reads im.n_frames can have one worker tied up indefinitely. That includes the common "is this image animated?" check and re-saving with save_all=True to TIFF, PDF or WebP. The call never returns, raises no exception, and does not grow memory, so only an external watchdog stops it.

The file is a Microsoft Image Composer (MIC) container, which Pillow recognises by its magic bytes with no extension needed. Inside it is a TIFF stream with three chained image directories. MicImageFile subclasses TiffImageFile and overrides seek() and tell() so that a "frame" means one stream in the container. It still inherits TiffImageFile.n_frames (src/PIL/TiffImagePlugin.py:1212), which walks the embedded TIFF's directory chain with self._seek(self.tell() + 1) until _n_frames is set. Two things stop that walk from making progress:

  • MicImageFile.tell() (src/PIL/MicImagePlugin.py:86) returns the constant stream index.
  • MicImageFile.seek() calls TiffImageFile._open(), which resets _n_frames to None.

As a result, the loop re-parses the second directory forever whenever the embedded stream has three or more directories. Image.open(), load(), thumbnail(), convert(), is_animated and ImageSequence iteration do not hang on this file. The regression was introduced in 0b33a87b0d15 (April 2020, first released in 7.2.0), which removed MicImageFile's own n_frames property. The attached reproducer builds the file in code and checks two controls, which return 3 and 1 frames as expected; the malicious file is still running when it is killed after 20 seconds.

Details

MicImageFile (registered for Image.open whenever olefile is installed) subclasses TiffImageFile and redefines a "frame" as one OLE *.ACI/Image stream, overriding seek() and tell() accordingly. It does not override the n_frames property, so the inherited TIFF implementation runs, and that implementation relies on tell() advancing as IFDs are loaded. MicImageFile.tell() returns the OLE stream index, which never changes inside n_frames, so the loop re-parses the same IFD forever.

A 5632-byte OLE2 compound file whose img0.ACI/Image stream is a 351-byte TIFF with three chained 1×1 IFDs is enough. Image.open() on it succeeds (format MIC, detected by the OLE magic, no extension needed); evaluating im.n_frames then never returns and keeps one core at 100 %. A Python stack taken 27 s in is

  File "src/PIL/TiffImagePlugin.py", line 1622 in _setup
  File "src/PIL/TiffImagePlugin.py", line 1271 in _seek
  File "src/PIL/TiffImagePlugin.py", line 1219 in n_frames

and instrumenting _seek shows it being called with frame=1, tell()==0, _frame_pos==[8, 122], _n_frames is None on every iteration — identical state, so the loop is non-terminating, not merely slow. The same 351 TIFF bytes opened directly give n_frames == 3 in milliseconds, and the same container around a 1-IFD TIFF gives n_frames == 1.

The loop, src/PIL/TiffImagePlugin.py:1212:

    @property
    def n_frames(self) -> int:
        current_n_frames = self._n_frames
        if current_n_frames is None:
            current = self.tell()
            self._seek(len(self._frame_pos))
            while self._n_frames is None:
                self._seek(self.tell() + 1)      # 1219
            self.seek(current)

and the overrides that break its progress assumption, src/PIL/MicImagePlugin.py:76:

    def seek(self, frame: int) -> None:
        …
        self.fp = self.ole.openstream(filename)
        TiffImagePlugin.TiffImageFile._open(self)   # 82: resets _n_frames to None
        self.frame = frame

    def tell(self) -> int:
        return self.frame                            # 87: OLE stream index, not TIFF __frame

For a plain TIFF, _seek(k) (TiffImagePlugin.py:1236) loads IFDs up to k, appending each offset to _frame_pos, setting _n_frames when an IFD's next-pointer is 0 (or already seen), and setting the private __frame = k; TiffImageFile.tell() returns __frame, so self.tell() + 1 walks the chain to its end.

For a MIC file, Image.open → MicImageFile._open sets self._n_frames = len(self.images) at line 69, but then calls self.seek(0) → TiffImageFile._open (line 82), which unconditionally sets self._n_frames = None (TiffImagePlugin.py:1203) and _seek(0)s into the embedded stream. With the 3-IFD stream, IFD 0 has a non-zero next pointer, so _n_frames stays None after open. n_frames then runs: current = self.tell() is 0 (the OLE index); _seek(len(_frame_pos)) = _seek(1) loads IFD 1, whose next pointer is also non-zero and not yet in _frame_pos, so _n_frames is still None. The while body computes self.tell() + 1 = 0 + 1 and calls _seek(1) again; len(_frame_pos) is already 2, so nothing new is loaded — IFD 1 is re-read via tag_v2.load and _setup() and the state is unchanged. This repeats indefinitely; IFD 2 is never reached. Two chained IFDs are not enough (IFD 1's next pointer of 0 sets _n_frames = 2 on the first _seek(1)); three or more are.

Tests/test_file_mic.py::test_n_frames passes only because hopper.mic's stream has a single IFD, so _seek(0) sets _n_frames = 1 and the loop is never entered; the len(self.images) assignment at line 69 is only consumed by the initial _seek_check(0) and is overwritten before any caller can read n_frames.

A side effect of the same mismatch: with a 2-IFD stream n_frames returns 2 (TIFF IFDs) while seek(1) indexes self.images[1] (OLE streams) and raises IndexError — visible with save_all=True.

Impact

Any application that lets Pillow open an uploaded file and then reads im.n_frames — the usual way to decide whether an image is animated (getattr(im, "n_frames", 1) > 1), and also what hasattr(im, "n_frames")/getattr(im, "n_frames", 1) in Pillow's own TiffImagePlugin._save_all (:2333), PdfImagePlugin._save (:250) and WebPImagePlugin._save_all (:171) evaluate when the app re-saves with save_all=True — can be made to spin one worker at 100 % CPU indefinitely with a 5.6 KB upload. The file needs no particular extension (OLE magic; FpxImagePlugin rejects it on CLSID and MicImagePlugin accepts it), default limits do not intervene, memory does not grow, and no exception is raised, so only an external watchdog ends it. Preconditions: olefile installed (it is an optional dependency; without it the MIC plugin is not registered) and the application touching n_frames or a save_all path. Image.open() itself, load(), thumbnail(), convert(), is_animated and ImageSequence iteration do not hang on this file. This is the same class of tiny-file, pure-Python non-termination as CVE-2021-28676 (FLI) and CVE-2021-28677 (EPS), and like CVE-2019-19911 (FPX) it depends on olefile; it differs in not being reached by the default open/load path. The regression was introduced in 0b33a87b0d15 ("Replaced property methods for n_frames and is_animated with normal properties", 2020-04-13), first released in 7.2.0, which replaced MicImageFile's own n_frames property with the _n_frames attribute that TiffImageFile._open overwrites.

Reproducing

repro is a single Python script that runs against the installed Pillow (with olefile, which is what registers the MIC plugin). It builds every input in code, writes each one to a temporary file named x with no extension (so Pillow identifies the format from its magic bytes), and opens it in a child Python process that runs Image.open(path) and prints im.format and im.n_frames.

It builds two things in code:

  • tiff(n): an n-page TIFF of 4×4 greyscale frames written by Pillow itself (save_all=True), so the IFDs are chained exactly as Pillow's own writer chains them. The last next-IFD pointer is 0.
  • mic(stream): a minimal OLE2 compound file with a storage named img0.ACI that contains a stream named Image, which is the layout MicImageFile._open looks for. Sector 0 is the FAT and sector 1 is the directory. The stream starts at sector 2 and is padded to 4096 bytes, so the file doesn't need a mini-FAT. The result is 5632 bytes.

It runs three inputs, each in its own child process with a hard 20-second kill:

  1. control: the plain 3-page TIFF. Expected TIFF n_frames = 3.
  2. control: a MIC wrapping a 1-page TIFF. Expected MIC n_frames = 1.
  3. attack: the same MIC container wrapping the 3-page TIFF.

The timeout is enforced by killing the child rather than by an in-process alarm, because a TimeoutError raised while ImageFileDirectory_v2.load() is parsing would be caught by its except OSError (TiffImagePlugin.py:958) and leave the IFD half-parsed instead of stopping the loop.

The script prints BUG when both controls return and the attack child is still running after 20 seconds; otherwise it prints OK. With the attached patch applied, the attack case prints MIC n_frames = 1 and the script prints OK.

Suggested fix

The attached patch restores the OLE-level frame count after the embedded TIFF stream has been opened, so the inherited n_frames never enters its IFD-walking loop and n_frames/seek() agree on what a frame is. In MicImageFile.seek, src/PIL/MicImagePlugin.py:82, right after TiffImagePlugin.TiffImageFile._open(self):

         TiffImagePlugin.TiffImageFile._open(self)
 
+        # TiffImageFile._open() resets the frame count to that of the
+        # embedded TIFF stream, but the frames of an MIC file are its streams
+        self._n_frames = len(self.images)
+        self.is_animated = self._n_frames > 1
+
         self.frame = frame

Doing this in seek() restores the invariant on every seek, not only the first. The existing assignments at lines 69–70 of _open are kept: the initial seek(0) → _seek_check(0) reads n_frames before TiffImageFile._open has run, so they are still needed. Plain read-only n_frames/is_animated properties, as before 0b33a87b0, are no longer a drop-in option because TiffImageFile._seek assigns self.is_animated. With the patch applied to a clean tree and rebuilt, the attack file returns n_frames == 1 immediately, a 2-IFD stream raises EOFError on seek(1) instead of IndexError, save_all=True to TIFF and PDF completes, and Tests/test_file_mic.py and Tests/test_file_tiff.py pass. No regression test is included, since one would need a new binary MIC fixture in Tests/images.

The patch passed that check but has not been reviewed beyond it as the final form of the fix. Two things it leaves alone: it does not add the _im reset that TiffImageFile.seek performs after _seek (TiffImagePlugin.py:1229), which matters when switching between OLE streams of different size or mode; and TiffImageFile.n_frames still advances via the overridable tell() rather than its private __frame, so making it iterate on __frame would protect any other subclass that redefines tell().

The patch

Restore the OLE stream frame count in MicImageFile.seek() after TiffImageFile._open() resets it, so n_frames cannot loop forever

Applies to src/PIL/MicImagePlugin.py at the commit above. It passed the mechanical check — applied to a clean tree, rebuilt, the reproducer stopped firing — but a reviewer did not endorse it as the right fix.

diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py
index f8cf3a76f..2bb0109ce 100644
--- a/src/PIL/MicImagePlugin.py
+++ b/src/PIL/MicImagePlugin.py
@@ -81,6 +81,11 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
 
         TiffImagePlugin.TiffImageFile._open(self)
 
+        # TiffImageFile._open() resets the frame count to that of the
+        # embedded TIFF stream, but the frames of an MIC file are its streams
+        self._n_frames = len(self.images)
+        self.is_animated = self._n_frames > 1
+
         self.frame = frame
 
     def tell(self) -> int:
The reproducer
Reproducer — repro, 2,330 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
##### MicImageFile inherits TiffImageFile.n_frames, whose loop `self._seek(self.tell()+1)`

##### relies on tell() advancing; MicImageFile.tell() returns the OLE stream index (constant),
##### so a MIC file whose Image stream is a TIFF with >=3 chained IFDs makes im.n_frames spin forever.
import io, struct, subprocess, sys, tempfile, os
from PIL import Image

def tiff(n):  # n-page TIFF written by Pillow itself
    b = io.BytesIO(); fr = [Image.new("L", (4, 4), i) for i in range(n)]
    fr[0].save(b, "TIFF", save_all=True, append_images=fr[1:]); return b.getvalue()

def mic(stream):  # minimal OLE2 file: storage "img0.ACI" containing stream "Image"
    END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD
    data = stream.ljust(4096, b"\0"); n = len(data) // 512
    fat = [FATSECT, END] + [3 + k for k in range(n - 1)] + [END]; fat += [FREE] * (128 - len(fat))
    def entry(name, kind, child, start, size):
        raw = name.encode("utf-16-le") + b"\0\0"
        return raw.ljust(64, b"\0") + struct.pack("<HBBiii", len(raw), kind, 1, -1, -1, child) + b"\0"*36 + struct.pack("<III", start, size, 0)
    d = entry("Root Entry", 5, 1, END, 0) + entry("img0.ACI", 1, 2, 0, 0) + entry("Image", 2, -1, 2, len(data)) + b"\0"*128
    hdr = (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\0"*16 + struct.pack("<HHHHH6x", 0x3E, 3, 0xFFFE, 9, 6)
           + struct.pack("<9I", 0, 1, 1, 0, 4096, END, 0, END, 0) + struct.pack("<I", 0) + b"\xff"*432)
    return hdr + struct.pack("<128I", *fat) + d + data

CHILD = "import sys;from PIL import Image;im=Image.open(sys.argv[1]);print(im.format, 'n_frames =', im.n_frames)"
def run(label, data, limit=20):
    p = os.path.join(tempfile.mkdtemp(), "x"); open(p, "wb").write(data)
    try:
        r = subprocess.run([sys.executable, "-c", CHILD, p], capture_output=True, text=True, timeout=limit)
        out = (r.stdout.strip() or r.stderr.strip().splitlines()[-1]); hung = False
    except subprocess.TimeoutExpired:
        out, hung = f"STILL RUNNING after {limit}s (killed)", True
    print(f"{label:32s} {len(data):6d} bytes -> {out}"); return hung

c1 = run("control: 3-page TIFF, direct", tiff(3))
c2 = run("control: MIC around 1-page TIFF", mic(tiff(1)))
bug = run("attack:  MIC around 3-page TIFF", mic(tiff(3)))
print("BUG" if bug and not c1 and not c2 else "OK")

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: BMP RLE decoder memory-amplification DoS via unbounded delta escape / absolute mode

BIT-pillow-2026-96638 / CVE-2026-96638 / GHSA-24rc-8hj2-v98c

More information

Details

Summary

BmpRleDecoder can be forced to allocate gigabytes of memory from a kilobyte-sized crafted BMP file via unbounded growth in the delta escape and absolute mode branches.

Affected versions

All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84)

Root cause

BmpRleDecoder.decode() (BmpImagePlugin.py:327-395) builds a Python bytearray data with dest_length = xsize*ysize, looping while len(data) < dest_length. The loop condition is only checked at the TOP; individual branches append attacker-controlled amounts UNBOUNDED by dest_length:

  • delta escape (line 370): data += b"\x00" * (right + up * self.state.xsize) with right,up in 0..255 -> up to ~255*xsize bytes in ONE iteration.
  • absolute mode (line 385): data += bytes_read (up to 255).

Because a single delta can push len(data) to ~256 * (row size), a tiny RLE stream combined with a large-but-legal xsize (xsize*ysize just under MAX_IMAGE_PIXELS, ~89M) yields a multi-GB data bytearray (then copied again by bytes(data) and set_as_raw). No heap OOB (bytearray/set_as_raw are bounds-safe), but a severe memory-amplification DoS: the attacker file stays tiny.

Reproduction

Python 3.13, Pillow HEAD e7a7ea84

Attached: pillow_poc_bmp_rle_dos.py

A 1084-byte BMP (BI_RLE8, width=3,000,000, height=1 -> 3M px, passes bomb check) with one delta escape (00 02 00 150) produces ~429 MB data; measured peak RSS 911 MB. Scaling width toward the bomb limit and up=255 produces tens of GB from a ~1 KB file.

Suggested fix

Cap growth at dest_length in the delta and absolute branches, e.g. clamp the delta fill to (dest_length - len(data)) and stop appending once len(data) >= dest_length; reject deltas that would move the cursor beyond the image, matching the existing per-row clamp at line 341-343.

Credit: Yazan Balawneh - CyStack Security Team (GitHub: sonicnew)

pillow_poc_bmp_rle_dos.py

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: PdfParser can recurse without limit in get_value() and linearize_page_tree()

BIT-pillow-2026-112391 / CVE-2026-112391 / GHSA-254f-3936-288h

More information

Details

Summary

PdfParser's get_value() can recurse without any limit and crash with an uncaught RecursionError. It is reachable through Image.save(im, fp=<pdf>, format="PDF", append=True) when the existing PDF file is attacker-controlled. This is the same class of bug as GHSA-r73j-pqj5-w3x7 (the /Prev trailer chain loop, fixed earlier this year), in the same file, but in two different functions that fix did not touch.

Details

get_value() in src/PIL/PdfParser.py

This is a classmethod that takes max_nesting: int = -1 and has a guard:

if max_nesting == 0:
    return None, None

Every recursive call (nested arrays, dictionaries, indirect object definitions) passes max_nesting=max_nesting - 1. Since the default and every call site start at -1, this only ever counts down further negative: -1, -2, -3, and so on. It never equals exactly 0, so the guard never runs. A PDF object with a value nested a few thousand levels deep is enough to hit Python's recursion limit. It does not even need a valid /Pages tree, since read_pdf_info() parses the /Root object's own dictionary first, before checking anything else.

This advisory reports a single vulnerability: an ineffective recursion-depth guard in PdfParser.get_value() (the sentinel -1 default never reaches the == 0 check). The mention of GHSA-r73j-pqj5-w3x7 is comparative context for an already-fixed, already-CVE'd bug in a different function — it is not a second vulnerability being reported. The three trigger paths (nested arrays/dicts/indirect objects) are not independently fixable; they share one root cause and one fix location in get_value() .

Steps to reproduce

Both PDFs below are plain text, built directly in Python, no PDF library needed.

from PIL.PdfParser import PdfParser

def build_pdf(root):
    out = bytearray(b"%PDF-1.4\n")
    offset = len(out)
    out += f"1 0 obj\n".encode()
    out += root
    out += b"\nendobj\n"
    out += b"xref\n"
    out += f"0 2\n".encode()
    out += b"0000000000 65535 f \n"
    out += f"{offset:010d} 00000 ".encode() + b"n \n"
    xref_offset = len(out)
    out += b"xref\n"
    out += f"0 2\n".encode()
    out += b"0000000000 65535 f \n"
    out += f"{offset:010d} 00000 ".encode() + b"n \n"
    out += b"trailer\n"
    out += f"<< /Size 2 /Root 1 0 R >>\n".encode()
    out += b"startxref\n"
    out += f"{xref_offset}\n".encode()
    out += b"%%EOF"
    return bytes(out)

bomb = b"[" * 3000 + b"]" * 3000
root = b"<< /Type /Catalog /Pages 2 0 R /Bomb " + bomb + b" >>"
with open("nesting_bomb.pdf", "wb") as f:
    f.write(build_pdf(root))

PdfParser("nesting_bomb.pdf")     # raises RecursionError

Expected: a clean PdfFormatError.

Actual: raises a raw RecursionError: maximum recursion depth exceeded.

Impact
  • Availability: an attacker who controls the content of a PDF file that an application later appends an image page to (Image.save(..., format="PDF", append=True)) can crash that request with a RecursionError, using a file of a few hundred bytes.
  • Confidentiality: none.
  • Integrity: none.
  • Pillow cannot open PDFs for reading through Image.open(). PdfParser is only reached through the append-mode save path, so the app has to be doing that specific thing (for example, stamping or watermarking an existing PDF) for this to matter.

Severity

  • CVSS Score: 6.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: NULL dereference in ImagingExpand via rank filters on zero-size images

BIT-pillow-2026-112392 / CVE-2026-112392 / GHSA-27h8-gf7j-v5hc / PYSEC-2026-4326

More information

Details

============================================================================
NULL dereference in ImagingExpand via rank filters on zero-size images
Location: src/libImaging/Filter.c:79  in ImagingExpand()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Applying Pillow's median, minimum, maximum or rank filter to an image with zero width or zero height crashes the whole Python process instead of raising an error. Empty images like this are easy to produce with ordinary calls, for example a crop box whose left and right edges (or top and bottom edges) are equal, or the second page of a crafted multi-page image file. An attacker who can supply crop coordinates or an uploaded file to an application that runs one of these filters can kill its worker process. The effect is a crash only, with no memory disclosure or corruption.

The fault is in ImagingExpand() in src/libImaging/Filter.c. This is the edge-padding step that RankFilter.filter() always runs through the core expand() method, so MedianFilter, MinFilter and MaxFilter go through it too. It fills the margin by copying the input's edge pixels, reading imIn->image[y][0] and rows 0 / ysize-1, and never checks that the input has any pixels. For an empty image, ImagingAllocateArray leaves the line pointers NULL, so the first read dereferences NULL and the process dies with SIGSEGV, in every mode.

Zero-size images reach this point through public API alone:

  • Image.crop(), ImageOps.crop() and Image.new();
  • a multi-page TIFF opened by path whose second IFD declares ImageLength = 0, on which seek(1) and load() succeed.

Every other ImageFilter returns an empty image for the same inputs. The zero-height case has crashed since the original PIL import, and the zero-width case since Pillow 4.3.0. This is separate from the margin == 0 fix in #​9755: the path is the same, but here the degenerate value is the image size rather than the kernel size. It has the same shape as the zero-dimension ImagingOffset crash fixed under GHSA-f87v-9578-p47w.

The attached reproducer shows a one-pixel-wide control crop filtering normally, while four empty crops passed through the rank filters are each killed by SIGSEGV. A patch that returns early in ImagingExpand for empty input is included.

Details

ImagingExpand() (src/libImaging/Filter.c), the edge-padding helper that ImageFilter.RankFilter runs before the actual rank filter, reads the first pixel of each input line without checking that the input has any pixels. For an image with width 0 or height 0 the line pointers are NULL, so Image.filter() with MedianFilter, MinFilter, MaxFilter or RankFilter kills the interpreter with SIGSEGV instead of returning or raising:

from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop((3, 0, 3, 10))   # size (0, 10); crop() accepts left == right
im.filter(ImageFilter.MedianFilter(3))               # SIGSEGV

Under gdb on the stock build the fault is at ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105 (the EXPAND(UINT8, image8) expansion), called from _expand_image at src/_imaging.c:1151, with imIn->xsize = 0 and imIn->image[0] = 0x0. The same happens for every mode the rank filters accept (L, RGB, RGBA, I, F), and the core expand(m) method with m >= 1 crashes the same way in any mode, including P. The same input through BoxBlur, GaussianBlur, Kernel, ModeFilter and UnsharpMask returns a (0, 10) image; a (1, 10) crop through MedianFilter also returns normally.

The unchecked edge reads date from the PIL import (9a640e315, 2010, so every Pillow release): the zero-height case has crashed since then, and the zero-width case since fd907fbdc ("Fix 0-width and 0-height images other way", Pillow 4.3.0), which stopped backing zero-width images with a dummy block and left their row pointers NULL. It is adjacent to the margin == 0 divide-by-zero fix in #​9755 — same expand() → ImagingExpand() path, but the unhandled degenerate value here is the image size rather than the kernel size.

The path:

##### src/PIL/ImageFilter.py:111-116   RankFilter.filter  (MedianFilter/MinFilter/MaxFilter subclass it)
        image = image.expand(self.size // 2)
        return image.rankfilter(self.size, self.rank)

expand(m) with m > 0 is ImagingExpand(self->image, m) (src/_imaging.c:1142-1151). It validates only margin, allocates an output of (xsize+2*margin) × (ysize+2*margin) — which is non-empty, so the allocation succeeds — and then fills the margin by replicating edge pixels of the input:

// src/libImaging/Filter.c:70-101
    imOut =
        ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
    …

#define EXPAND_LINE(type, image, yin, yout)                       \
    {                                                             \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][x] = imIn->image[yin][0];          \   // line 79
        }                                                         \
        …                                                         \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][margin + imIn->xsize + x] =        \
                imIn->image[yin][imIn->xsize - 1];                \   // [-1] when xsize == 0
        }                                                         \
    }

#define EXPAND(type, image)                                                      \
    {                                                                            \
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, 0, y);                                      \   // line 0 of a 0-line image
        }                                                                        \
        …
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \   // line -1 when ysize == 0
        }                                                                        \
    }

Why the line pointers are NULL: ImagingNewPrologueSubtype callocs the im->image pointer array (src/libImaging/Storage.c:216, at least one entry), and ImagingAllocateArray returns before assigning any of them for an empty image:

// src/libImaging/Storage.c:423-426
    /* 0-width or 0-height image. No need to do anything */
    if (!im->linesize || !im->ysize) {
        return im;
    }

So with xsize == 0 every imIn->image[y] is NULL and imIn->image[yin][0] faults; with ysize == 0 the top-margin pass reads imIn->image[0] (the single calloc'd NULL slot) and faults the same way, and the bottom-margin pass would index imIn->image[-1]. ImagingRankFilter is never reached.

Zero-size images are ordinary objects in Pillow and arrive at filter() through public API only:

  • Image.crop(box) rejects only inverted boxes (src/PIL/Image.py:1385-1390); left == right or upper == lower yields a 0×N / N×0 image. ImageOps.crop(im, border) with border == width // 2, and Image.new(mode, (0, n)), do the same.
  • From a file: ImageFile.__init__ refuses a zero first-frame size (src/PIL/ImageFile.py:167-173), but TiffImagePlugin._setup() takes ImageWidth/ImageLength of every later IFD as-is on seek() (src/PIL/TiffImagePlugin.py:1468-1481). For an uncompressed page of an image opened by path, ImageFile.load() takes the mmap branch (src/PIL/ImageFile.py:307, 325-347) and Image.core.map_buffer builds an 8×0 core image without complaint (src/map.c:127-141), so im.seek(1); im.load() succeeds and a following im.filter(ImageFilter.MedianFilter(3)) segfaults in ImagingExpand exactly as above (verified under gdb: imIn->ysize = 0, imIn->image[0] = 0x0). A 384-byte two-page TIFF is enough.
Impact

Denial of service: the whole embedding process (web worker, pipeline process) dies with SIGSEGV rather than the request failing with a Python exception. It is reachable by a remote user in two realistic shapes: (a) an application that crops an upload to user-supplied coordinates and then applies a median/min/max filter (denoise, dilate/erode, thumbnail clean-up) — a box with x0 == x1 or y0 == y1 passes crop() and crashes the worker; (b) an application that opens an uploaded TIFF from disk by path, iterates its pages and applies a rank filter to each — a crafted second IFD with ImageLength = 0 crashes it. The faulting access is a read at address 0 (or a fixed small negative offset from the pointer array for the [-1] cases, which is not reached because the first read already faults), so there is no memory disclosure or corruption beyond the crash. The same shape, a zero-dimension image crashing C code reached through public API, was fixed in ImagingOffset in 39d14a0db (GHSA-f87v-9578-p47w).

Reproducing

repro is a single Python script that uses the installed Pillow as-is (no rebuild). It runs each case in a fresh child interpreter, with core dumps disabled. That way a segfault shows up as the child's exit status and the script itself keeps running. Each child builds Image.new("L", (10, 10)).crop(box), prints its size, calls im.filter(...) and then prints the size of the result.

  • Control: a (1, 10) crop ((3, 0, 4, 10)) through MedianFilter(3). The rank filter accepts a thin image and must return normally.
  • PoC: the rank filters on empty crops.
    • A (0, 10) crop (left == right, (3, 0, 3, 10)) through MedianFilter(3) and MaxFilter(5).
    • A (10, 0) crop (upper == lower, (0, 3, 10, 3)) through MinFilter(3).
    • A (0, 0) crop ((3, 3, 3, 3)) through RankFilter(3, 4).

Expected output on the stock build:

  crop(3, 0, 4, 10)    MedianFilter(3)    (1, 10) -> (1, 10)
  crop(3, 0, 3, 10)    MedianFilter(3)    (0, 10) -> killed by SIGSEGV
  crop(3, 0, 3, 10)    MaxFilter(5)       (0, 10) -> killed by SIGSEGV
  crop(0, 3, 10, 3)    MinFilter(3)       (10, 0) -> killed by SIGSEGV
  crop(3, 3, 3, 3)     RankFilter(3, 4)   (0, 0) -> killed by SIGSEGV
BUG

The size printed before -> shows that the empty image was created without error. The crash happens inside filter(), in ImagingExpand. The last line is BUG if the control returned normally and at least one rank-filter child was killed by SIGSEGV. Otherwise it is OK. The script covers the crop route only; the TIFF route described above fails the same way (im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3)) on a by-path two-page TIFF whose second IFD has ImageLength = 0). With the attached patch, every PoC line prints the unchanged size (for example (0, 10) -> (0, 10)) and the script prints OK.

Suggested fix

The attached patch handles the empty input in ImagingExpand (src/libImaging/Filter.c), right after the margin checks and before the ImagingNewDirty allocation and the replication loops. With xsize == 0 or ysize == 0 there are no edge pixels to replicate, so it returns a zeroed (ImagingNew, not ImagingNewDirty) image of the expanded size, with the input's palette copied. This keeps ImagingExpand's size contract, so ImagingRankFilter then produces an output of xsize - 2*margin × ysize - 2*margin, i.e. the original empty size, matching every other filter. It mirrors the existing zero-dimension early returns in src/libImaging/BoxBlur.c (127004368) and src/libImaging/Offset.c (39d14a0db):

--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {

The patch also adds test_rankfilter_zero_dimension next to test_rankfilter_error in Tests/test_image_filter.py, which filters Image.new("L", size) for size in ((0, 6), (6, 0), (0, 0)) with MinFilter, MedianFilter, MaxFilter and RankFilter(3, 2) and asserts the size is preserved. Applied to a clean checkout and rebuilt, the reproducer prints OK and Tests/test_image_filter.py passes. A Python-level guard in RankFilter.filter (if 0 in image.size: return image.copy()) would also stop the crash, but the C-level check protects any other caller of expand().

The patch

Return a zeroed image from ImagingExpand when the input has zero width or height instead of reading NULL line pointers

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

diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py
index 974d4b7a3..8dc8e1a1a 100644
--- a/Tests/test_image_filter.py
+++ b/Tests/test_image_filter.py
@@ -155,6 +155,23 @@ def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None:
         im.filter(filter).getpixel((1, 1))
 
 
+@pytest.mark.parametrize(
+    "filter",
+    (
+        ImageFilter.MinFilter,
+        ImageFilter.MedianFilter,
+        ImageFilter.MaxFilter,
+        ImageFilter.RankFilter(3, 2),
+    ),
+)
+@pytest.mark.parametrize("size", ((0, 6), (6, 0), (0, 0)))
+def test_rankfilter_zero_dimension(
+    filter: ImageFilter.RankFilter, size: tuple[int, int]
+) -> None:
+    im = Image.new("L", size)
+    assert im.filter(filter).size == size
+
+
 def test_rankfilter_properties() -> None:
     rankfilter = ImageFilter.RankFilter(3, 2)
 
diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c
index 3492a871f..d702db5f9 100644
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {
The reproducer
Reproducer — repro, 1,333 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: rank filters (Median/Min/Max/RankFilter) on a 0-wide or 0-high image

##### SIGSEGV in ImagingExpand (src/libImaging/Filter.c) - NULL line pointer read.
##### Each case runs in a child interpreter; BUG if the control returns and any

##### zero-size case dies of SIGSEGV.
import signal, subprocess, sys

CHILD = """
import resource; resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop(%s)      # public API; crop accepts x0==x1 / y0==y1
print(im.size, "->", end=" ", flush=True)
print(im.filter(ImageFilter.%s).size)
"""

def run(box, flt):
    p = subprocess.run([sys.executable, "-c", CHILD % (box, flt)],
                       capture_output=True, text=True)
    out = p.stdout.strip()
    if p.returncode < 0:
        out += " killed by " + signal.Signals(-p.returncode).name
    print(f"  crop{box!s:<16} {flt:<18} {out}")
    return p.returncode

ctrl = run((3, 0, 4, 10), "MedianFilter(3)")          # (1,10): must work
rcs = [run((3, 0, 3, 10), "MedianFilter(3)"),        # (0,10)
       run((3, 0, 3, 10), "MaxFilter(5)"),
       run((0, 3, 10, 3), "MinFilter(3)"),           # (10,0)
       run((3, 3, 3, 3), "RankFilter(3, 4)")]        # (0,0)
bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs)
print("BUG" if bug else "OK")

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO

BIT-pillow-2026-112393 / CVE-2026-112393 / GHSA-332p-7hrc-92wf

More information

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
 #&#8203;1 _tiffWriteProc src/libImaging/TiffDecode.c:115
 #&#8203;4 TIFFFlush
 #&#8203;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 o

[!IMPORTANT]
✂ PR body was truncated to here.

@renovate renovate Bot added the dependencies Pull requests that update a dependency file label Oct 21, 2026
@renovate renovate Bot changed the title chore(deps): update dependency pillow to v13 [security] chore(deps): update dependency pillow to v13 [security] - autoclosed Oct 24, 2026
@renovate renovate Bot closed this Oct 24, 2026
@renovate
renovate Bot deleted the renovate/pypi-pillow-vulnerability branch October 24, 2026 05:53
@renovate renovate Bot changed the title chore(deps): update dependency pillow to v13 [security] - autoclosed chore(deps): update dependency pillow to v13 [security] Oct 24, 2026
@renovate renovate Bot reopened this Oct 24, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-pillow-vulnerability branch 3 times, most recently from 27d479c to 7e32c1d Compare October 26, 2026 15:25
@renovate renovate Bot changed the title chore(deps): update dependency pillow to v13 [security] chore(deps): update dependency pillow to v13 [security] - autoclosed Oct 28, 2026
@renovate renovate Bot closed this Oct 28, 2026
@renovate renovate Bot changed the title chore(deps): update dependency pillow to v13 [security] - autoclosed chore(deps): update dependency pillow to v13 [security] Oct 28, 2026
@renovate renovate Bot reopened this Oct 28, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-pillow-vulnerability branch from 7e32c1d to 5598869 Compare October 28, 2026 19:00

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants