Repository navigation
chore(deps): update dependency pillow to v13 [security] - #71
Open
renovate[bot] wants to merge 1 commit into
Open
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
renovate
Bot
force-pushed
the
renovate/pypi-pillow-vulnerability
branch
3 times, most recently
from
October 26, 2026 15:25
27d479c to
7e32c1d
Compare
renovate
Bot
force-pushed
the
renovate/pypi-pillow-vulnerability
branch
from
October 28, 2026 19:00
7e32c1d to
5598869
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
==12.*→==13.*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
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
olefilepackage. Any application that opens an untrusted image and then readsim.n_framescan have one worker tied up indefinitely. That includes the common "is this image animated?" check and re-saving withsave_all=Trueto 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.
MicImageFilesubclassesTiffImageFileand overridesseek()andtell()so that a "frame" means one stream in the container. It still inheritsTiffImageFile.n_frames(src/PIL/TiffImagePlugin.py:1212), which walks the embedded TIFF's directory chain withself._seek(self.tell() + 1)until_n_framesis set. Two things stop that walk from making progress:MicImageFile.tell()(src/PIL/MicImagePlugin.py:86) returns the constant stream index.MicImageFile.seek()callsTiffImageFile._open(), which resets_n_framestoNone.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_animatedandImageSequenceiteration do not hang on this file. The regression was introduced in 0b33a87b0d15 (April 2020, first released in 7.2.0), which removedMicImageFile's ownn_framesproperty. 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 forImage.openwheneverolefileis installed) subclassesTiffImageFileand redefines a "frame" as one OLE*.ACI/Imagestream, overridingseek()andtell()accordingly. It does not override then_framesproperty, so the inherited TIFF implementation runs, and that implementation relies ontell()advancing as IFDs are loaded.MicImageFile.tell()returns the OLE stream index, which never changes insiden_frames, so the loop re-parses the same IFD forever.A 5632-byte OLE2 compound file whose
img0.ACI/Imagestream is a 351-byte TIFF with three chained 1×1 IFDs is enough.Image.open()on it succeeds (formatMIC, detected by the OLE magic, no extension needed); evaluatingim.n_framesthen never returns and keeps one core at 100 %. A Python stack taken 27 s in isand instrumenting
_seekshows it being called withframe=1,tell()==0,_frame_pos==[8, 122],_n_frames is Noneon every iteration — identical state, so the loop is non-terminating, not merely slow. The same 351 TIFF bytes opened directly given_frames == 3in milliseconds, and the same container around a 1-IFD TIFF givesn_frames == 1.The loop,
src/PIL/TiffImagePlugin.py:1212:and the overrides that break its progress assumption,
src/PIL/MicImagePlugin.py:76:For a plain TIFF,
_seek(k)(TiffImagePlugin.py:1236) loads IFDs up tok, appending each offset to_frame_pos, setting_n_frameswhen an IFD's next-pointer is 0 (or already seen), and setting the private__frame = k;TiffImageFile.tell()returns__frame, soself.tell() + 1walks the chain to its end.For a MIC file,
Image.open→MicImageFile._opensetsself._n_frames = len(self.images)at line 69, but then callsself.seek(0)→TiffImageFile._open(line 82), which unconditionally setsself._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_framesstaysNoneafter open.n_framesthen runs:current = self.tell()is0(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_framesis stillNone. Thewhilebody computesself.tell() + 1=0 + 1and calls_seek(1)again;len(_frame_pos)is already 2, so nothing new is loaded — IFD 1 is re-read viatag_v2.loadand_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 = 2on the first_seek(1)); three or more are.Tests/test_file_mic.py::test_n_framespasses only becausehopper.mic's stream has a single IFD, so_seek(0)sets_n_frames = 1and the loop is never entered; thelen(self.images)assignment at line 69 is only consumed by the initial_seek_check(0)and is overwritten before any caller can readn_frames.A side effect of the same mismatch: with a 2-IFD stream
n_framesreturns 2 (TIFF IFDs) whileseek(1)indexesself.images[1](OLE streams) and raisesIndexError— visible withsave_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 whathasattr(im, "n_frames")/getattr(im, "n_frames", 1)in Pillow's ownTiffImagePlugin._save_all(:2333),PdfImagePlugin._save(:250) andWebPImagePlugin._save_all(:171) evaluate when the app re-saves withsave_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;FpxImagePluginrejects it on CLSID andMicImagePluginaccepts it), default limits do not intervene, memory does not grow, and no exception is raised, so only an external watchdog ends it. Preconditions:olefileinstalled (it is an optional dependency; without it the MIC plugin is not registered) and the application touchingn_framesor asave_allpath.Image.open()itself,load(),thumbnail(),convert(),is_animatedandImageSequenceiteration 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 onolefile; it differs in not being reached by the default open/load path. The regression was introduced in0b33a87b0d15("Replaced property methods for n_frames and is_animated with normal properties", 2020-04-13), first released in 7.2.0, which replacedMicImageFile's ownn_framesproperty with the_n_framesattribute thatTiffImageFile._openoverwrites.Reproducing
reprois a single Python script that runs against the installed Pillow (witholefile, which is what registers the MIC plugin). It builds every input in code, writes each one to a temporary file namedxwith no extension (so Pillow identifies the format from its magic bytes), and opens it in a child Python process that runsImage.open(path)and printsim.formatandim.n_frames.It builds two things in code:
tiff(n): ann-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 namedimg0.ACIthat contains a stream namedImage, which is the layoutMicImageFile._openlooks 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:
TIFF n_frames = 3.MIC n_frames = 1.The timeout is enforced by killing the child rather than by an in-process alarm, because a
TimeoutErrorraised whileImageFileDirectory_v2.load()is parsing would be caught by itsexcept OSError(TiffImagePlugin.py:958) and leave the IFD half-parsed instead of stopping the loop.The script prints
BUGwhen both controls return and the attack child is still running after 20 seconds; otherwise it printsOK. With the attached patch applied, the attack case printsMIC n_frames = 1and the script printsOK.Suggested fix
The attached patch restores the OLE-level frame count after the embedded TIFF stream has been opened, so the inherited
n_framesnever enters its IFD-walking loop andn_frames/seek()agree on what a frame is. InMicImageFile.seek,src/PIL/MicImagePlugin.py:82, right afterTiffImagePlugin.TiffImageFile._open(self):Doing this in
seek()restores the invariant on every seek, not only the first. The existing assignments at lines 69–70 of_openare kept: the initialseek(0)→_seek_check(0)readsn_framesbeforeTiffImageFile._openhas run, so they are still needed. Plain read-onlyn_frames/is_animatedproperties, as before 0b33a87b0, are no longer a drop-in option becauseTiffImageFile._seekassignsself.is_animated. With the patch applied to a clean tree and rebuilt, the attack file returnsn_frames == 1immediately, a 2-IFD stream raisesEOFErroronseek(1)instead ofIndexError,save_all=Trueto TIFF and PDF completes, andTests/test_file_mic.pyandTests/test_file_tiff.pypass. No regression test is included, since one would need a new binary MIC fixture inTests/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
_imreset thatTiffImageFile.seekperforms after_seek(TiffImagePlugin.py:1229), which matters when switching between OLE streams of different size or mode; andTiffImageFile.n_framesstill advances via the overridabletell()rather than its private__frame, so making it iterate on__framewould protect any other subclass that redefinestell().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.pyat 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.The reproducer
Reproducer —
repro, 2,330 bytes (click to open)Its last line is
BUGwhen the issue triggers andOKwhen it does not. Re-run on a clean build of that commit it printedBUG.Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:NReferences
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
datawith dest_length = xsize*ysize, loopingwhile len(data) < dest_length. The loop condition is only checked at the TOP; individual branches append attacker-controlled amounts UNBOUNDED by dest_length: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
databytearray (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:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:HReferences
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'sget_value()can recurse without any limit and crash with an uncaughtRecursionError. It is reachable throughImage.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()insrc/PIL/PdfParser.pyThis is a classmethod that takes
max_nesting: int = -1and has a guard: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/Pagestree, sinceread_pdf_info()parses the/Rootobject'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-1default never reaches the== 0check). 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.
Expected: a clean
PdfFormatError.Actual: raises a raw
RecursionError: maximum recursion depth exceeded.Impact
Image.save(..., format="PDF", append=True)) can crash that request with a RecursionError, using a file of a few hundred bytes.Image.open().PdfParseris 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:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:NReferences
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
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()insrc/libImaging/Filter.c. This is the edge-padding step thatRankFilter.filter()always runs through the coreexpand()method, soMedianFilter,MinFilterandMaxFiltergo through it too. It fills the margin by copying the input's edge pixels, readingimIn->image[y][0]and rows0/ysize-1, and never checks that the input has any pixels. For an empty image,ImagingAllocateArrayleaves 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()andImage.new();ImageLength = 0, on whichseek(1)andload()succeed.Every other
ImageFilterreturns 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 themargin == 0fix 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-dimensionImagingOffsetcrash 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
ImagingExpandfor empty input is included.Details
ImagingExpand()(src/libImaging/Filter.c), the edge-padding helper thatImageFilter.RankFilterruns 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, soImage.filter()withMedianFilter,MinFilter,MaxFilterorRankFilterkills the interpreter with SIGSEGV instead of returning or raising:Under gdb on the stock build the fault is at
ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105(theEXPAND(UINT8, image8)expansion), called from_expand_imageatsrc/_imaging.c:1151, withimIn->xsize = 0andimIn->image[0] = 0x0. The same happens for every mode the rank filters accept (L,RGB,RGBA,I,F), and the coreexpand(m)method withm >= 1crashes the same way in any mode, includingP. The same input throughBoxBlur,GaussianBlur,Kernel,ModeFilterandUnsharpMaskreturns a(0, 10)image; a(1, 10)crop throughMedianFilteralso 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 sincefd907fbdc("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 themargin == 0divide-by-zero fix in #9755 — sameexpand()→ImagingExpand()path, but the unhandled degenerate value here is the image size rather than the kernel size.The path:
expand(m)withm > 0isImagingExpand(self->image, m)(src/_imaging.c:1142-1151). It validates onlymargin, 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:Why the line pointers are NULL:
ImagingNewPrologueSubtypecallocs theim->imagepointer array (src/libImaging/Storage.c:216, at least one entry), andImagingAllocateArrayreturns before assigning any of them for an empty image:So with
xsize == 0everyimIn->image[y]is NULL andimIn->image[yin][0]faults; withysize == 0the top-margin pass readsimIn->image[0](the single calloc'd NULL slot) and faults the same way, and the bottom-margin pass would indeximIn->image[-1].ImagingRankFilteris 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 == rightorupper == loweryields a 0×N / N×0 image.ImageOps.crop(im, border)withborder == width // 2, andImage.new(mode, (0, n)), do the same.ImageFile.__init__refuses a zero first-frame size (src/PIL/ImageFile.py:167-173), butTiffImagePlugin._setup()takesImageWidth/ImageLengthof every later IFD as-is onseek()(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) andImage.core.map_bufferbuilds an8×0core image without complaint (src/map.c:127-141), soim.seek(1); im.load()succeeds and a followingim.filter(ImageFilter.MedianFilter(3))segfaults inImagingExpandexactly 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 == x1ory0 == y1passescrop()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 withImageLength = 0crashes 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 inImagingOffsetin 39d14a0db (GHSA-f87v-9578-p47w).Reproducing
reprois 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 buildsImage.new("L", (10, 10)).crop(box), prints its size, callsim.filter(...)and then prints the size of the result.(1, 10)crop ((3, 0, 4, 10)) throughMedianFilter(3). The rank filter accepts a thin image and must return normally.(0, 10)crop (left == right,(3, 0, 3, 10)) throughMedianFilter(3)andMaxFilter(5).(10, 0)crop (upper == lower,(0, 3, 10, 3)) throughMinFilter(3).(0, 0)crop ((3, 3, 3, 3)) throughRankFilter(3, 4).Expected output on the stock build:
The size printed before
->shows that the empty image was created without error. The crash happens insidefilter(), inImagingExpand. The last line isBUGif the control returned normally and at least one rank-filter child was killed by SIGSEGV. Otherwise it isOK. 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 hasImageLength = 0). With the attached patch, every PoC line prints the unchanged size (for example(0, 10) -> (0, 10)) and the script printsOK.Suggested fix
The attached patch handles the empty input in
ImagingExpand(src/libImaging/Filter.c), right after themarginchecks and before theImagingNewDirtyallocation and the replication loops. Withxsize == 0orysize == 0there are no edge pixels to replicate, so it returns a zeroed (ImagingNew, notImagingNewDirty) image of the expanded size, with the input's palette copied. This keepsImagingExpand's size contract, soImagingRankFilterthen produces an output ofxsize - 2*margin×ysize - 2*margin, i.e. the original empty size, matching every other filter. It mirrors the existing zero-dimension early returns insrc/libImaging/BoxBlur.c(127004368) andsrc/libImaging/Offset.c(39d14a0db):The patch also adds
test_rankfilter_zero_dimensionnext totest_rankfilter_errorinTests/test_image_filter.py, which filtersImage.new("L", size)forsizein((0, 6), (6, 0), (0, 0))withMinFilter,MedianFilter,MaxFilterandRankFilter(3, 2)and asserts the size is preserved. Applied to a clean checkout and rebuilt, the reproducer printsOKandTests/test_image_filter.pypasses. A Python-level guard inRankFilter.filter(if 0 in image.size: return image.copy()) would also stop the crash, but the C-level check protects any other caller ofexpand().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.cat the commit above.The reproducer
Reproducer —
repro, 1,333 bytes (click to open)Its last line is
BUGwhen the issue triggers andOKwhen it does not. Re-run on a clean build of that commit it printedBUG.Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:NReferences
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
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.BytesIOrather 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
_tiffWriteProcinsrc/libImaging/TiffDecode.c. This is the write callback for the in-memory sink that Pillow gives libtiff whenever the save target has nofileno(). While writing the directory, libtiff seeks past the current end of data and writes tag values there._tiffWriteProcsizes itsrealloc()fromstate->size + sizeinstead of from the write positionstate->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()inim.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 theBytesIOsave aborts with glibc'smalloc(): corrupted top size. A second part of the script shows uninitialised heap bytes at the padding offsets of aBytesIOsave 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 usablefileno()(io.BytesIO, an HTTP response object, a storage backend stream), libtiff is opened withTIFFClientOpen()on Pillow's own in-memory sink,_tiffWriteProc/_tiffSeekProcinsrc/libImaging/TiffDecode.c. libtiff treats that sink like a file: when it writes the directory it seeks beyond the current end of data (toeof + 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:_tiffWriteProcsizes itsrealloc()fromstate->size + sizeinstead ofstate->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:compressiondefaults to the source file's (src/PIL/TiffImagePlugin.py:1734), any compression other than raw selects the libtiff writer (:1746), the source'stag_v2entries are passed through to libtiff (:1959), and a target withoutfileno()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 openedTiffImageFilesaved directly (tag_v2passthrough) 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). Afterconvert()/resize()/thumbnail()/copy()thetag_v2entries are gone, butim.info["compression"](still selecting libtiff) andim.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), anImageDescriptionof 65 535 characters — opened withImage.open()and saved withim.save(io.BytesIO(), "TIFF")aborts a stock build with glibc'smalloc(): corrupted top size. The same call against an AddressSanitizer build of the C extensions reportsProof (uninitialised bytes). Saving one 300×300 RGB image as
compression="tiff_lzw"with four odd-length ASCII tags to aBytesIO, once withMALLOC_PERTURB_=1(glibc fills fresh allocations with 0xfe) and once withMALLOC_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 Pythonbytesobjects 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)withbufsize = 64*1024,flrealloc = 1). Seeking is unconstrained:and writing assumes
loc <= size:A gdb trace of the sink calls during the overflowing save (stock build, breakpoints on the two procs) shows how libtiff drives it:
For that last call
to_write = 65536 − 65662 = −126, the loop growsnewsizeuntil it is ≥65536 + 65536, i.e. to 131072, and thememcpywrites[65662, 131198)into a 131072-byte block: 126 bytes past the end, containing the last 126 bytes of the description string.loc/eofbecome 131198 >size;ImagingLibTiffEncodelater setssize = 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…)BitsPerSampleis stored inline and the first out-of-line value in tag order isDocumentName(269) orImageDescription(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-byteBitsPerSamplearray 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 therealloc()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 iscalloc'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()inim.infotogether 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-classreallocblock, 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.serverworker doing nothing butImage.open(BytesIO(body)).save(BytesIO(), "TIFF")per upload executed an attacker-chosen shell command (id>/tmp/r.XXXXXX), answered200and 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. BecauseTiffImagePluginturns every IFD entry into onebytesobject 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, andgetexif()fromload_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 KiBreallocof the sink lands directly below a live 0x540 chunk followed by libtiff's ownTIFFhandle (malloc(1313)fromTIFFClientOpen). The 2384 overflowing bytes rewrite the start of theTIFFstruct with the command string, a zeroedtif_dir,tif_diroff = 65536, andtif_cleanup = systemat +0x3f8; libtiff resumesTIFFWriteDirectorySecon the modified handle and calls(*tif->tif_cleanup)(tif). With gdb o