fix(deps): update dependency pillow to v13 [security] #205
No reviewers
Labels
No labels
bug
cog
cog
Aurora
cog
Backup
cog
Bible
cog
EmojiInfo
cog
HotReload
cog
IssueCards
cog
Nerdify
cog
Pterodactyl
cog
RobloxVerify
cog
SeaUtils
cog
SentinelCore
cog
TickChanger
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
cswimr/SeaCogs!205
Loading…
Reference in a new issue
No description provided.
Delete branch "renovate/pypi-pillow-vulnerability"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This PR contains the following updates:
~=12.1→~=13.0~=12.1→~=13.0Pillow
BdfFontFile:Image.new()called without_decompression_bomb_check()— bomb protection bypass via font loadingBIT-pillow-2026-55379 / CVE-2026-55379 / GHSA-45hq-cxwh-f6vc / PYSEC-2026-2255
More information
Details
Summary
PIL/BdfFontFile.pybdf_char()(lines 84–88) reads theBBX width heightfield from a BDF font file and passes the dimensions directly toImage.new()without callingImage._decompression_bomb_check(). This completely bypasses Pillow's documented decompression bomb protection.Image.open()enforcesMAX_IMAGE_PIXELS = 89,478,485and raisesDecompressionBombErrorfor images exceeding2 × MAX = 178,956,970pixels. The BDF font loading path callsImage.new()directly, which only calls_check_size()(validates>= 0) — no pixel count limit.Vulnerable code (
PIL/BdfFontFile.pylines 84–88):Attack trigger: A BDF glyph with
BBX 20000 20000and an emptyBITMAPsection causesImage.frombytes()to raiseValueError, thenImage.new("1", (20000, 20000))allocates 50 MB of C-heap silently. Image.open() would raiseDecompressionBombErrorfor the same dimensions.Steps to reproduce
Minimal malicious BDF file (270 bytes):
Proof of Concept script:
Expected output:
Amplified attack (multiple glyphs):
A BDF file defining 256 glyphs each at
BBX 8000 8000causes256 × 7.6 MB = ~1.95 GBtotal C-heap allocation — all silently, bypassing documented bomb protection.Impact
ImageFont.load("user.bdf"),BdfFontFile(fp)) is affectedself.glyph[ch]for the lifetime of the font object — memory is NOT freed until the font is garbage collectedSeverity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path
BIT-pillow-2026-55798 / CVE-2026-55798 / GHSA-4x4j-2g7c-83w6 / PYSEC-2026-2257
More information
Details
1. Summary
WindowsViewer.get_command()constructs acmd.exeshell command by directly embedding afile path into an f-string without escaping. The result is passed to
subprocess.Popen(..., shell=True). Shell metacharacters in the file path — mostimportantly a double-quote (
") that breaks out of the wrapping, followed by&— allowinjection of arbitrary
cmd.execommands.The macOS equivalent (
MacViewer) correctly appliesshlex.quote()to the same parameter.The Linux equivalent (
UnixViewer) does likewise. Windows is the only platform missing thisprotection, despite
shlex.quotebeing already imported on line 21 ofImageShow.py.2. Vulnerable Code
File:
src/PIL/ImageShow.py, lines 133–150Contrast with macOS — SAFE (line 164–168):
Cross-platform summary:
shlex.quote()?shell=True?MacViewerUnixViewerWindowsViewershlex.quoteis imported on line 21. Its omission from the Windows path is a clearoversight, not a deliberate design choice.
3. Proof of Concept
A full working PoC is at
poc_pillow_injection.py. Key parts:Part A — Injection string construction (static, no execution):
Part B — Live execution via
os.system()(verified on Windows 11, Pillow 12.1.1):Severity
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow:
FontFile.compile():Image.new()called without_decompression_bomb_check()BIT-pillow-2026-54060 / CVE-2026-54060 / GHSA-5x94-69rx-g8h2 / PYSEC-2026-2254
More information
Details
Description
PIL/FontFile.pyFontFile.compile()assembles per-glyph images into a single combined bitmap usingImage.new("1", (xsize, ysize))without callingImage._decompression_bomb_check(). This is the base-class method shared by bothBdfFontFileandPcfFontFile, and it is triggered whenever a loaded font is converted to anImageFontor saved.Neither
BdfFontFile.BdfFontFile(fp)norPcfFontFile.PcfFontFile(fp)is registered withImage.register_open(), so Pillow's standard decompression bomb guard never fires for font objects. The compile step is the final opportunity to check the combined allocation — and it has no check.Vulnerable code (
PIL/FontFile.pylines ~64–92):"Slow accumulation" attack — per-glyph dimensions stay BELOW warning threshold:
With PCF-maximum glyph height (65,535):
Steps to reproduce
Proof of Concept script:
Expected output:
Verified live on Pillow 12.2.0 — compile() succeeds with no exception.
Real-world trigger using BDF font file:
Attack scenarios:
BdfFontFile(upload).to_imagefont())to_imagefont()Impact
compile()creates a combined bitmap whose pixel count scales asWIDTH × lines × max_glyph_heightwith no upper bound check. With max PCF glyph height (65,535) and 256 glyphs, the combined allocation is ~1.6 GB. With BDF (text-format, unbounded height), the allocation is limited only by system memory.Affected call paths:
BdfFontFile.BdfFontFile(fp).to_imagefont()→FontFile.compile()BdfFontFile.BdfFontFile(fp).save(filename)→FontFile.compile()PcfFontFile.PcfFontFile(fp).to_imagefont()→FontFile.compile()PcfFontFile.PcfFontFile(fp).save(filename)→FontFile.compile()Neither
BdfFontFilenorPcfFontFileis loaded viaImage.open(), so the standard decompression bomb guard is entirely absent from the font loading code path.compile()is the only point where the combined allocation size is known, and it has no check.Confirmed unpatched on
python-pillow/Pillowmainbranch as of 2026-06-08.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)
BIT-pillow-2026-54058 / CVE-2026-54058 / GHSA-62p4-gmf7-7g93 / PYSEC-2026-3493
More information
Details
Summary
When Pillow loads an uncompressed image whose tile uses the
rawcodec and a mode inImage._MAPMODES, and the image was opened from a filename, it memory-maps the file and builds the image's row pointers directly into the mapping viaPyImaging_MapBuffer(src/map.c). The per-row spacing (stride) is taken from the tile arguments.map.cvalidatesoffset + ysize*stride <= buffer_lenbut never checks thatstrideis at least the natural row widthxsize * pixelsize.The McIdas AREA plugin (
McIdasImagePlugin.py) derivesstride,offset,xsize, andysizedirectly from attacker-controlled 32-bit header words with no validation. By supplying astridefar smaller than the row width, an attacker makes each row pointer readxsize*pixelsizebytes that run past the mapped region. Accessing the pixels (e.g.Image.tobytes(),getpixel,convert,save) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service).Complete Code Trace
Step 1:
McIdasImageFile._open- turns attacker header words into image size, file offset, and row stride with no validation.Step 2:
ImageFile.load(mmap branch) - selects mmap and delegates tomap_buffer.Step 3:
PyImaging_MapBuffer- builds row pointers atstridespacing into the mmap; validates everything exceptstride >= row width.im->linesize(the number of bytes any consumer reads per row) isxsize * pixelsize = 200000, but the row pointers are onlystride = 1byte apart and the buffer is onlyoffset + ysize*stride = 2bytes "claimed". Nothing reconciles the two.Step 4: pixel access (
Image.tobytes()→ raw encodercopy1) - readslinesizebytes fromim->image[0], i.e.xsizebytes starting atview.buf + offset, running far past the mmap.Chain Summary
Proof of Concept
See attached poc.zip
Impact on a Parent Application
Any application that opens image files supplied by users from a path on disk (the common pattern: save upload to a temp file, then
Image.open(path)), has the default plugin set (McIdas is registered by default), and subsequently reads/returns/re-encodes the decoded pixels (thumbnailing, format conversion, serving a preview), is exposed:xsizereliably crashes the worker with SIGBUS.Suggested fix
Core fix in
src/map.c(PyImaging_MapBuffer): rejectoffset < 0andstride < im->linesize. Defense-in-depth inMcIdasImagePlugin._open: rejectoffset < 0orstride < xsize*pixelsize.Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/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 out-of-bounds write
Image.paste()/Image.crop()via signed coordinate overflowBIT-pillow-2026-59199 / CVE-2026-59199 / GHSA-6r8x-57c9-28j4 / PYSEC-2026-3451
More information
Details
Summary
Pillow's public image coordinate APIs can trigger a native heap out-of-bounds
write when given coordinates near the signed 32-bit integer limits. In 4-byte
pixel modes such as
RGBA, this becomes a controlled backward heap underwrite:for a source image of width
W, Pillow writes4 * Wattacker-controlled bytesstarting
4 * Wbytes before the destination row pointer. With successful largeimage allocation, the theoretical upper bound is ~2 GiB backwards from
the destination row.
Minimal public API trigger:
The same root cause is also reachable through
Image.crop()andImage.alpha_composite(). No private API, ctypes, custom Python object, ormalformed image file is needed.
This has been confirmed as an ASAN heap-buffer-overflow write. On normal
non-ASAN Pillow builds, the minimal trigger corrupts the heap and aborts with
double free or corruption (out)Details
src/PIL/Image.py:paste()accepts a 4-tuple box and passes it to the nativeImagingCore.paste()method:src/_imaging.c:_paste()parses the four Python coordinates into signedintvalues and calls
ImagingPaste():src/libImaging/Paste.c:ImagingPaste()computes and clips the region usingsigned
intarithmetic:With
dx0 = 2147483646anddx1 = -2147483648,dx1 - dx0wraps to2.That matches the 2-pixel source image, so the size check passes. The later
dx0 + xsizeclip check wraps around and does not reject the out-of-boundsdestination.
For 4-byte pixel modes such as
RGBA, the paste loop then multipliesdxbypixelsize:For the minimal PoC, this writes 8 attacker-controlled bytes 8 bytes before the
destination row allocation.
The primitive scales with the attacker-controlled source width:
Examples for
RGBA:Pillow's image creation guard currently limits
xsizeto roughlyINT_MAX / 4 - 1, so the theoretical upper bound for thisRGBAunderwrite is2,147,483,640bytes before the destination row pointer. In practice, theusable range depends on memory availability, allocator layout, and process heap
state.
Two other documented APIs reach the same sink:
Image.crop()keepsright - leftsmall, so the Python decompression-bombcheck allows it.
src/libImaging/Crop.cthen computes wrapped pastecoordinates and calls
ImagingPaste().PoC
The following standalone script exercises all three public API paths. Save it
as
b021_poc.pyand run it withpaste,crop, oralpha.Run against an ASAN build:
Observed ASAN signature for the direct
Image.paste()path:On non-ASAN Pillow
12.2.0and local12.3.0.dev0, the direct minimalImage.paste()trigger returns frompaste()and then the process abortsduring cleanup with:
Observed ASAN signature for the
Image.crop()andImage.alpha_composite()paths:
Suggested fix
Avoid signed overflow in paste/crop coordinate arithmetic. Use checked
arithmetic or a wider type before calculating widths and clipped endpoints.
For example, reject boxes whose endpoint subtraction cannot be represented
cleanly, and clip using non-overflowing comparisons:
ImagingCrop()should receive the same treatment forsx1 - sx0,dx0 = -sx0, anddx1 = imIn->xsize - sx0.Impact
This is a heap out-of-bounds write in Pillow's native C extension, reachable
through documented public image APIs.
Applications are impacted if an untrusted user can control image operation
coordinates passed to Pillow, for example crop boxes, paste boxes, or overlay
positions. The bytes written in the direct
Image.paste()variant are copiedfrom the source image, so attacker-controlled source pixels can influence the
out-of-bounds write. For
RGBA, the write is a backward heap underwrite whoseoffset and length are both
4 * source_width, bounded in practice by successfulimage allocation and heap layout.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow
PcfFontFile._load_bitmaps():Image.frombytes()called without_decompression_bomb_check()— bomb protection bypass via PCF font loadingBIT-pillow-2026-54059 / CVE-2026-54059 / GHSA-8v84-f9pq-wr9x / PYSEC-2026-2253
More information
Details
Description
PIL/PcfFontFile.py_load_bitmaps()(line 227) reads glyph dimensions from the PCFMETRICSsection and passes them directly toImage.frombytes()without callingImage._decompression_bomb_check(). Dimensions originate from unsigned 16-bit values:Maximum exploitable pixel count: 65,535 × 131,070 = 8,589,734,450 pixels — 48× the DecompressionBombError threshold.
Vulnerable code (
PIL/PcfFontFile.pyline 224–227):Image.frombytes()callsImage.new()first (allocating the full C-heap buffer), then attempts to fill it. This creates two distinct attack paths:frombytes()succeeds → image stored infont.glyph[ch]permanentlyImage.new()allocates the full buffer →ValueError→ buffer freed → but the spike occurs before Python can respondSteps to reproduce
Proof of Concept script:
Expected output:
Amplification table:
Impact
PcfFontFile(fp)) is affectedPcfFontFileis never loaded viaImage.open(), so the bomb check protection is completely absent from the entire PCF font loading pathpython-pillow/Pillowmainbranch as of 2026-06-07Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Controlled heap out-of-bounds write in Pillow
ImageCmsTransform.apply()via output mode mismatchBIT-pillow-2026-59205 / CVE-2026-59205 / GHSA-9hw9-ch79-4vh6 / PYSEC-2026-3453
More information
Details
Summary
Pillow's public
ImageCms.ImageCmsTransform.apply(im, imOut)API can triggercontrolled native heap corruption when the caller supplies an output image whose
mode does not match the transform's declared output mode.
For example, a transform built as
RGBA -> RGBAcan be applied to anLoutputimage. Pillow checks dimensions only, then calls LittleCMS with the output row
pointer. LittleCMS writes RGBA-sized rows into a 1-byte-per-pixel
Limage row.Details
src/PIL/ImageCms.py:ImageCmsTransform.apply()accepts an optional callersupplied
imOut:If
imOutis provided, Pillow does not check:The C wrapper in
src/_imagingcms.cunwraps both image cores and only checksthat the output dimensions are at least as large as the input dimensions:
findLCMStype()mapsRGB,RGBA, andRGBXtransform modes to LittleCMSTYPE_RGBA_8, which writes 4 bytes per pixel:So with a transform declared as
RGBA -> RGBA, LittleCMS writes4 * widthbytes to each output row. If the supplied output image is mode
L, Pillow onlyallocated
1 * widthbytes for that row.For width 4096:
The bug does not require a large image. Width 8 was enough to corrupt heap
metadata. At width 8,
apply()returned to Python and printedafter; glibcdetected the corrupted heap later during cleanup.
PoC
Tiny heap corruption trigger:
Observed locally on Pillow
12.3.0.dev0:Controlled overwrite evidence PoC:
Run under gdb:
Observed on Pillow
12.3.0.dev0:0x4443424144434241is the attacker-controlled source pixel patternb"ABCDABCD"interpreted as a little-endian pointer-sized value.Using source pixels
(1, 2, 3, 4)similarly produced a faulting pointer of0x403020104030201, matching the repeated pixel bytes.Impact
This is a heap out-of-bounds write in Pillow's native ImageCms extension,
reachable through public API.
Applications are impacted if untrusted users can control ImageCms transform
parameters and/or provide the output image object passed to
ImageCmsTransform.apply(). The source image pixels influence the bytes writtenout of bounds.
Suggested fix
Validate modes before calling into the native transform:
The C extension should also defensively reject mismatched image modes before
calling
cmsDoTransform().Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images
BIT-pillow-2026-59198 / CVE-2026-59198 / GHSA-fj7v-r99m-22gq / PYSEC-2026-3494
More information
Details
Summary
Pillow's TGA RLE encoder reads past its row buffer when saving a mode
"1"image. Adjacent process heap bytes can be copied into the generated TGA file.
The bug is reachable through the public save API:
Older affected Pillow versions use the equivalent public option
rle=True.For mode
"1", Pillow allocates a packed row buffer ofceil(width / 8)bytes, but
ImagingTgaRleEncode()treats the row as one full byte per pixel.The maximum valid TGA width is
65535. At that width:On non-ASAN Pillow
12.2.0, the public-only maximum-width PoC below serialized57297bytes from distinct out-of-bounds source offsets into one returned TGA,covering
99.92%of the maximum adjacent heap window. No heap grooming, ctypes,private API, or malformed input file was used. The disclosure is emitted across
many TGA packet payload copies of at most
128bytes each, not one largememcpy().Details
src/PIL/TgaImagePlugin.pyallows mode"1"TGA output and selects thetga_rleencoder when RLE compression is requested.src/encode.c:_setimage()allocates the row buffer using the packed-bitformula:
For mode
"1",state->bits == 1.src/libImaging/TgaRleEncode.cthen computes:This becomes
1, and the encoder uses pixel indexes as byte offsets:The packet payload
memcpy()later copies those out-of-bounds source bytes intothe output. Raw packets copy up to
128contiguous bytes, while RLE packets copyone representative byte:
A width-2 mode
"1"image allocates one row byte and already triggers an ASANheap-buffer-overflow read. Wider images increase the adjacent heap window and
the amount of heap data that can be serialized.
PoC
Minimal ASAN trigger
Observed on local Pillow
12.3.0.dev0ASAN target:Maximum-width heap disclosure
This PoC uses one maximum-width row. It parses the generated TGA packets and
extracts only payload bytes whose source offsets were outside the allocated
packed row. Rows are avoided because they mostly repeat the same adjacent heap window.
Run the following with a standard affected Pillow installation.
Observed on installed Pillow
12.2.0:Impact
This is a heap out-of-bounds read and potential information disclosure.
A maximum-width single-row image can cause nearly the full
57343-byte adjacent heap window to be incorporated into one output file.Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()
BIT-pillow-2026-59200 / CVE-2026-59200 / GHSA-jjj6-mw9f-p565 / PYSEC-2026-3495
More information
Details
Summary
PdfParser.PdfStream.decode()in Pillow'sPdfParser.pycallszlib.decompress()with thebufsizeparameter set to the value of the PDF stream'sLengthfield, without any upper bound on the actual decompressed output size. Python'szlib.decompress()bufsizeargument is an initial output buffer hint, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that usesPdfParserto read untrusted PDF files.Details
PdfStream.decode()inpdfminer/PdfParser.pyreads the stream's declaredLength(orDL) field from the PDF dictionary and passes it asbufsizetozlib.decompress():From the Python documentation: "The
bufsizeparameter is used as the initial size of the output buffer." It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while settingLengthto any value (including the actual compressed size) to avoid triggering format validation.PdfParseris instantiated with a filename or file object and callsread_pdf_info()on open, which parses the xref table and makes stream objects accessible.PdfStream.decode()is reachable whenever calling code accesses a compressed stream object from the parsed PDF.Confirmed reachable path:
PoC
Actual output (Pillow 12.1.1, Python 3.12):
Measured expansion:
Impact
This is a denial-of-service vulnerability. Any application that uses
PIL.PdfParser.PdfParserto read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required.Note: This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion
PIL/PdfImagePlugin.pydecompression issue. It exists specifically in Pillow's ownPdfParser.pymodule, which is distinct from pdfminer.six.Suggested fix:
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow EpsImagePlugin negative %%BeginBinary byte count causes infinite loop denial of service
BIT-pillow-2026-59203 / CVE-2026-59203 / GHSA-pg7v-jwj7-p798 / PYSEC-2026-3452
More information
Details
Summary
Pillow's EPS parser (PIL/EpsImagePlugin.py) accepts a negative byte count in the %%BeginBinary directive. A crafted EPS file can cause Image.open() to seek backwards to the same directive and parse it repeatedly, resulting in an infinite loop and CPU denial of service.
The issue is triggered during Image.open(), does not require Image.load(), and does not require Ghostscript execution.
Confirmed affected versions: Pillow 12.0.0 through 12.2.0.
Details
The issue is in the EPS parser in PIL/EpsImagePlugin.py. When parsing an EPS %%BeginBinary directive, Pillow reads the byte count from the file and passes it directly to a relative seek operation without validating that the value is non-negative.
Relevant code:
There is no validation that bytecount is non-negative.
If an attacker provides a negative value such as %%BeginBinary:-18, the parser moves the file pointer backwards from the end of the directive line to the same line region. The next parser iteration reads the same %%BeginBinary:-18 directive again, performs the same backward seek, and repeats indefinitely. This causes Image.open() to hang in an infinite loop and consume CPU.
In local testing, the issue is present in Pillow 12.0.0, 12.1.0, 12.1.1, and 12.2.0. Pillow 11.3.0 did not hang with the same PoC, so this appears to affect the 12.x EPS parsing path.
PoC
Save the following content as pillow_eps_beginbinary_dos.eps:
Then run:
Expected behavior: Pillow should reject the malformed EPS file with a parser exception.
Actual behavior: the process does not return. It hangs inside Image.open() and continuously consumes CPU.
The loop behavior can be observed by tracing the parser state. The file pointer repeatedly seeks from position 112 back to 94, causing the same %%BeginBinary:-18 line to be parsed again and again:
Impact
This is a denial-of-service vulnerability. An attacker who can provide an EPS file to an application using Pillow for image validation, metadata parsing, previews, uploads, or batch image processing can cause the image parsing process to hang during Image.open().
This can impact web services and backend workers that parse untrusted image files, especially if image parsing is performed in a main worker process without CPU limits, timeouts, or process isolation. The issue does not require Ghostscript execution and does not require calling Image.load(), so applications that only use Image.open() to validate or identify uploaded images may still be affected.
Suggested fix: validate the parsed %%BeginBinary byte count before seeking. If the byte count is negative, reject the file with a parsing exception instead of calling self.fp.seek(bytecount, os.SEEK_CUR).
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow
GdImageFile._open(): image dimensions accepted without_decompression_bomb_check()BIT-pillow-2026-55380 / CVE-2026-55380 / GHSA-phj9-mv4w-65pm / PYSEC-2026-2256
More information
Details
Description
PIL/GdImageFile.pyGdImageFile._open()reads image dimensions from the GD 2.x header and stores them inself._sizewithout callingImage._decompression_bomb_check(). BecauseGdImageFileis not registered withImage.register_open(), it never passes through the standardImage.open()code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point —PIL.GdImageFile.open(fp)— which directly instantiates the class, fully bypassing the documented protection.Vulnerable code (
PIL/GdImageFile.pylines 50–61):When
load()is subsequently called on the returned image object:Dimension arithmetic:
DecompressionBombErrorthresholdComparison with safe sibling plugin (
WalImageFile):WalImageFileis in the same category — not registered withImage.open(), loaded via its ownopen()helper. It was previously patched with the correct fix:GdImageFilewas never updated to match, leaving a gap in protection.Steps to reproduce
Proof of Concept script:
Expected output:
Verified live on Pillow 12.2.0.
Two attack paths:
load_prepare()attempts 4.3 GB C allocation →OSErrorafter spikeload()completes, 4.3 GB stays in memory for object lifetimeFor the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file.
Real-world scenario:
Impact
.gdfile causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down.Any service that calls
PIL.GdImageFile.open(user_file)followed by.load()(or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint.Confirmed unpatched on
python-pillow/Pillowmainbranch as of 2026-06-08.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service
BIT-pillow-2026-59204 / CVE-2026-59204 / GHSA-vjc4-5qp5-m44j / PYSEC-2026-3496
More information
Details
Summary
src/libImaging/Jpeg2KDecode.c:853accumulatestotal_component_widthacross every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in thetile_bytescalculation atsrc/libImaging/Jpeg2KDecode.c:868, which can make the decoder growstate->bufferviareallocatsrc/libImaging/Jpeg2KDecode.c:876up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.Details
src/libImaging/Jpeg2KDecode.c:853total_component_widthis initialized only once before the tile loop and keeps growing across tiles. It is then used to derivetile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles.tile_bytesis promoted intotile_info.data_size, thenstate->bufferis grown withreallocatsrc/libImaging/Jpeg2KDecode.c:876.Image.open(...).load()decoding.PoC
The attached helper script and testcase were used:
exercise_j2k_tile_realloc.zip
Generate the testcase:
Expected geometry from the helper:
3664 x 3664RGBA1832 x 1832(2x2tiles)image_bytes=53699584maxrss_kb=180264maxrss_kb=138404Load it with the current vulnerable build:
Load it again under a 160 MB address-space cap:
Impact
Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/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 out-of-bounds write in
ImageFilter.RankFiltervia integer overflow inImagingExpandBIT-pillow-2026-59197 / CVE-2026-59197 / GHSA-xj96-63gp-2gmr / PYSEC-2026-3454
More information
Details
Summary
Pillow's public rank-filter API can trigger a native heap out-of-bounds write
when given a very large odd filter size.
Minimal public API trigger:
ImageFilter.RankFilter.filter()callsimage.expand(size // 2, size // 2)before rank-filter size validation. With
size = 4294967295, theexpansion margin is
2147483647(INT_MAX).ImagingExpand()then computesthe output dimensions with unchecked signed
intarithmetic. On tested builds,this wraps to a tiny output image and the border-expansion loop writes past the
allocation.
This is reachable through documented public classes (
RankFilter,MedianFilter,MinFilter, andMaxFilter). No private API, ctypes, or customPython object is needed.
Details
Current
src/PIL/ImageFilter.py:The
expand()call is made beforeimage.rankfilter(...).Current
src/libImaging/Filter.c:ImagingExpand()does not check output-sizeoverflow:
For a
3x3image andxmargin = ymargin = INT_MAX, the computed output sizewraps to
1x1on tested builds. The following loop still uses the huge margin:src/libImaging/RankFilter.cdoes contain checks that would reject this size:But those checks are reached only after
RankFilter.filter()has alreadycalled
image.expand(...).Mode
"L"produces 1-byte OOB stores. Modes"I"and"F"produce 4-byte OOBstores. The repeated value written OOB is copied from the source image border
pixel, so attacker-supplied image bytes can influence it. This is a sequential
overwrite, not an arbitrary-address write.
PoC
Minimal ASAN crash PoC:
Observed on local Pillow
12.3.0.dev0ASAN target:4-byte write variant with source pixel loaded from normal image bytes:
Observed ASAN signature:
Version checks:
Impact
It is a heap out-of-bounds write in Pillow's native C extension, reachable
through public image-filter classes.
Applications are impacted if an untrusted user can control the rank-filter
size/configuration passed to Pillow. If the image is also attacker-supplied, the
source pixel value written out of bounds can be attacker-influenced, including
4-byte values for mode
"I"images.Possible fix
Validate the rank-filter size before calling
image.expand(...), and hardenImagingExpand()against invalid margins and overflow:Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
BIT-pillow-2026-54059 / CVE-2026-54059 / GHSA-8v84-f9pq-wr9x / PYSEC-2026-2253
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, PIL/PcfFontFile.py _load_bitmaps() read glyph dimensions from the PCF METRICS section and passed them directly to Image.frombytes() without calling Image._decompression_bomb_check(), allowing crafted PCF font data to cause excessive memory allocation. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-54060 / CVE-2026-54060 / GHSA-5x94-69rx-g8h2 / PYSEC-2026-2254
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, PIL/FontFile.py FontFile.compile() assembled per-glyph images into a combined bitmap with Image.new("1", (xsize, ysize)) without calling Image._decompression_bomb_check(), allowing a font to trigger excessive allocation during conversion or saving. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-55379 / CVE-2026-55379 / GHSA-45hq-cxwh-f6vc / PYSEC-2026-2255
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, PIL/BdfFontFile.py bdf_char() read the BBX width and height field from a BDF font file and passed attacker-controlled dimensions to Image.new() without calling Image._decompression_bomb_check(), bypassing Pillow's documented decompression bomb protection and allowing excessive memory allocation. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-55380 / CVE-2026-55380 / GHSA-phj9-mv4w-65pm / PYSEC-2026-2256
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, PIL/GdImageFile.py GdImageFile._open() read image dimensions from the GD 2.x header and stored them in self._size without calling Image._decompression_bomb_check(), allowing a crafted .gd file to trigger excessive C-heap allocation when loaded. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-55798 / CVE-2026-55798 / GHSA-4x4j-2g7c-83w6 / PYSEC-2026-2257
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, WindowsViewer.get_command() constructed a cmd.exe shell command by directly embedding a file path into an f-string without escaping and passed the result to subprocess.Popen(..., shell=True), allowing shell metacharacters in the file path to inject arbitrary cmd.exe commands. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:LReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-59199 / CVE-2026-59199 / GHSA-6r8x-57c9-28j4 / PYSEC-2026-3451
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, Pillow public image coordinate APIs can trigger a native heap out-of-bounds write when given coordinates near the signed 32-bit integer limits in Image.paste(), Image.crop(), or Image.alpha_composite(). This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-59203 / CVE-2026-59203 / GHSA-pg7v-jwj7-p798 / PYSEC-2026-3452
More information
Details
Pillow is a Python imaging library. From 12.0.0 through 12.2.0, Pillow's EPS parser in PIL/EpsImagePlugin.py accepts a negative byte count in the %%BeginBinary directive, allowing a crafted EPS file to cause Image.open() to seek backwards to the same directive and parse it repeatedly in an infinite loop. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-59205 / CVE-2026-59205 / GHSA-9hw9-ch79-4vh6 / PYSEC-2026-3453
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, Pillow's ImageCms.ImageCmsTransform.apply(im, imOut) API can trigger controlled native heap corruption when the caller supplies an output image whose mode does not match the transform's declared output mode. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-59197 / CVE-2026-59197 / GHSA-xj96-63gp-2gmr / PYSEC-2026-3454
More information
Details
Pillow is a Python imaging library. Prior to 12.3.0, Pillow's public rank-filter API can trigger a native heap out-of-bounds write when given a very large odd filter size because ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2) before rank-filter size validation and ImagingExpand() computes output dimensions with unchecked signed int arithmetic. This issue is fixed in version 12.3.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)
BIT-pillow-2026-54058 / CVE-2026-54058 / GHSA-62p4-gmf7-7g93 / PYSEC-2026-3493
More information
Details
Summary
When Pillow loads an uncompressed image whose tile uses the
rawcodec and a mode inImage._MAPMODES, and the image was opened from a filename, it memory-maps the file and builds the image's row pointers directly into the mapping viaPyImaging_MapBuffer(src/map.c). The per-row spacing (stride) is taken from the tile arguments.map.cvalidatesoffset + ysize*stride <= buffer_lenbut never checks thatstrideis at least the natural row widthxsize * pixelsize.The McIdas AREA plugin (
McIdasImagePlugin.py) derivesstride,offset,xsize, andysizedirectly from attacker-controlled 32-bit header words with no validation. By supplying astridefar smaller than the row width, an attacker makes each row pointer readxsize*pixelsizebytes that run past the mapped region. Accessing the pixels (e.g.Image.tobytes(),getpixel,convert,save) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service).Complete Code Trace
Step 1:
McIdasImageFile._open- turns attacker header words into image size, file offset, and row stride with no validation.Step 2:
ImageFile.load(mmap branch) - selects mmap and delegates tomap_buffer.Step 3:
PyImaging_MapBuffer- builds row pointers atstridespacing into the mmap; validates everything exceptstride >= row width.im->linesize(the number of bytes any consumer reads per row) isxsize * pixelsize = 200000, but the row pointers are onlystride = 1byte apart and the buffer is onlyoffset + ysize*stride = 2bytes "claimed". Nothing reconciles the two.Step 4: pixel access (
Image.tobytes()→ raw encodercopy1) - readslinesizebytes fromim->image[0], i.e.xsizebytes starting atview.buf + offset, running far past the mmap.Chain Summary
Proof of Concept
See attached poc.zip
Impact on a Parent Application
Any application that opens image files supplied by users from a path on disk (the common pattern: save upload to a temp file, then
Image.open(path)), has the default plugin set (McIdas is registered by default), and subsequently reads/returns/re-encodes the decoded pixels (thumbnailing, format conversion, serving a preview), is exposed:xsizereliably crashes the worker with SIGBUS.Suggested fix
Core fix in
src/map.c(PyImaging_MapBuffer): rejectoffset < 0andstride < im->linesize. Defense-in-depth inMcIdasImagePlugin._open: rejectoffset < 0orstride < xsize*pixelsize.Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images
BIT-pillow-2026-59198 / CVE-2026-59198 / GHSA-fj7v-r99m-22gq / PYSEC-2026-3494
More information
Details
Summary
Pillow's TGA RLE encoder reads past its row buffer when saving a mode
"1"image. Adjacent process heap bytes can be copied into the generated TGA file.
The bug is reachable through the public save API:
Older affected Pillow versions use the equivalent public option
rle=True.For mode
"1", Pillow allocates a packed row buffer ofceil(width / 8)bytes, but
ImagingTgaRleEncode()treats the row as one full byte per pixel.The maximum valid TGA width is
65535. At that width:On non-ASAN Pillow
12.2.0, the public-only maximum-width PoC below serialized57297bytes from distinct out-of-bounds source offsets into one returned TGA,covering
99.92%of the maximum adjacent heap window. No heap grooming, ctypes,private API, or malformed input file was used. The disclosure is emitted across
many TGA packet payload copies of at most
128bytes each, not one largememcpy().Details
src/PIL/TgaImagePlugin.pyallows mode"1"TGA output and selects thetga_rleencoder when RLE compression is requested.src/encode.c:_setimage()allocates the row buffer using the packed-bitformula:
For mode
"1",state->bits == 1.src/libImaging/TgaRleEncode.cthen computes:This becomes
1, and the encoder uses pixel indexes as byte offsets:The packet payload
memcpy()later copies those out-of-bounds source bytes intothe output. Raw packets copy up to
128contiguous bytes, while RLE packets copyone representative byte:
A width-2 mode
"1"image allocates one row byte and already triggers an ASANheap-buffer-overflow read. Wider images increase the adjacent heap window and
the amount of heap data that can be serialized.
PoC
Minimal ASAN trigger
Observed on local Pillow
12.3.0.dev0ASAN target:Maximum-width heap disclosure
This PoC uses one maximum-width row. It parses the generated TGA packets and
extracts only payload bytes whose source offsets were outside the allocated
packed row. Rows are avoided because they mostly repeat the same adjacent heap window.
Run the following with a standard affected Pillow installation.
Observed on installed Pillow
12.2.0:Impact
This is a heap out-of-bounds read and potential information disclosure.
A maximum-width single-row image can cause nearly the full
57343-byte adjacent heap window to be incorporated into one output file.Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:LReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()
BIT-pillow-2026-59200 / CVE-2026-59200 / GHSA-jjj6-mw9f-p565 / PYSEC-2026-3495
More information
Details
Summary
PdfParser.PdfStream.decode()in Pillow'sPdfParser.pycallszlib.decompress()with thebufsizeparameter set to the value of the PDF stream'sLengthfield, without any upper bound on the actual decompressed output size. Python'szlib.decompress()bufsizeargument is an initial output buffer hint, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that usesPdfParserto read untrusted PDF files.Details
PdfStream.decode()inpdfminer/PdfParser.pyreads the stream's declaredLength(orDL) field from the PDF dictionary and passes it asbufsizetozlib.decompress():From the Python documentation: "The
bufsizeparameter is used as the initial size of the output buffer." It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while settingLengthto any value (including the actual compressed size) to avoid triggering format validation.PdfParseris instantiated with a filename or file object and callsread_pdf_info()on open, which parses the xref table and makes stream objects accessible.PdfStream.decode()is reachable whenever calling code accesses a compressed stream object from the parsed PDF.Confirmed reachable path:
PoC
Actual output (Pillow 12.1.1, Python 3.12):
Measured expansion:
Impact
This is a denial-of-service vulnerability. Any application that uses
PIL.PdfParser.PdfParserto read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required.Note: This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion
PIL/PdfImagePlugin.pydecompression issue. It exists specifically in Pillow's ownPdfParser.pymodule, which is distinct from pdfminer.six.Suggested fix:
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service
BIT-pillow-2026-59204 / CVE-2026-59204 / GHSA-vjc4-5qp5-m44j / PYSEC-2026-3496
More information
Details
Summary
src/libImaging/Jpeg2KDecode.c:853accumulatestotal_component_widthacross every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in thetile_bytescalculation atsrc/libImaging/Jpeg2KDecode.c:868, which can make the decoder growstate->bufferviareallocatsrc/libImaging/Jpeg2KDecode.c:876up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.Details
src/libImaging/Jpeg2KDecode.c:853total_component_widthis initialized only once before the tile loop and keeps growing across tiles. It is then used to derivetile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles.tile_bytesis promoted intotile_info.data_size, thenstate->bufferis grown withreallocatsrc/libImaging/Jpeg2KDecode.c:876.Image.open(...).load()decoding.PoC
The attached helper script and testcase were used:
exercise_j2k_tile_realloc.zip
Generate the testcase:
Expected geometry from the helper:
3664 x 3664RGBA1832 x 1832(2x2tiles)image_bytes=53699584maxrss_kb=180264maxrss_kb=138404Load it with the current vulnerable build:
Load it again under a 160 MB address-space cap:
Impact
Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
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 https://github.com/advisories/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 on__libc_systemthe 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 theTiffDecode.cdecode-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
reprois 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 throughTIFFFdOpenand is correct, with the same save into anio.BytesIO, which goes through the_tiffWriteProcmemory sink.Part 1: heap overflow.
setup_env()buildsupload.tif(131 198 bytes) byte by byte: a mode L, 7×8191 image withCompression=32773(PackBits) and a 65 535-characterImageDescription. 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 runsImage.open("upload.tif").save(<target>, "TIFF")with no options, once with"out.tif"as the target and once withio.BytesIO(). The file save exits with 0. The BytesIO save is killed by SIGABRT, with glibc reportingmalloc(): 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 withcompression="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 uninitialisedrealloc()memory.Expected output on this tree:
The last line is
BUGif either part shows its defect, andOKotherwise. With the attached patch applied and rebuilt, the BytesIO save exits with 0, the two outputs are identical, and the script printsOK.Suggested fix
The attached patch makes
_tiffWriteProcinsrc/libImaging/TiffDecode.chonour sparse-file semantics: it sizes the buffer from the write position, and zeroes whatrealloc()adds (the initial block is alreadycalloc'd, so every byte libtiff skips then reads as 0, exactly as throughTIFFFdOpen). For the non-reallocating case (flrealloc == 0), whereloc > sizewould also maketo_writenegative, it rejects the write with aTIFFError, the same idiom_tiffReadProcuses:_tiffSeekProcis left unconstrained, since libtiff legitimately seeks past the end of data; with the write proc fixed,eofcan no longer exceedsize, so the read-out inImagingLibTiffEncode(:1067,:1073) stays within the block. The patch also addstest_save_bytesio_seek_past_endtoTests/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 aBytesIOand 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.cat the commit above.The reproducer
Reproducer —
repro, 5,434 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:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: TarIO negative size field causes infinite CPU-spin loop
BIT-pillow-2026-96642 / CVE-2026-96642 / GHSA-3j72-p6qg-6xp7 / PYSEC-2026-4341
More information
Details
Summary
TarIO.__init__does not check that a tar header's size field is positive. A crafted header with a negative size makes it seek backward onto itself. This causes an infinite loop that uses 100% CPU and never returns.Details
TarIO.__init__reads 512-byte tar headers one at a time, looking for a member with a given name. For each header it reads the size field and skips ahead to the next header:There is no check that size is zero or more. A tar header can set this field to a negative octal value. For size = -512, the seek offset comes out to exactly -512, which cancels the 512 bytes the loop had just read. The file position goes right back to where this loop step started.
The loop only stops on end of file or when a header name starts with a null byte. Neither happens, since the same header keeps showing up again. The loop never ends on its own.
Steps to reproduce
This uses Tests/images/hopper.tar, a normal file already in the Pillow test suite. It only changes the size field of the first header.
We confirmed this with ps. The process sits at 98 to 100% CPU and never exits on its own. It only stops if killed from outside.
A pytest version of this, using pytest-timeout so the test does not hang forever, is below. On the current code it fails with Failed: Timeout (>3.0s) from pytest-timeout.
Impact
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/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: P;2L and P;4L decoders may read 1 to 3 bytes past the passed buffer size
BIT-pillow-2026-112394 / CVE-2026-112394 / GHSA-4558-3h8r-v8xp / PYSEC-2026-4327
More information
Details
Summary
On
main, even with the guard in #9725, it's possible to have these decoders read 1 to 3 bytes past the end of the passed buffer with a suitable width/height.Details
ImagingRawDecodeand theP;2L/P;4Lunpackers disagree about how many bytes a line of data occupies.The raw decoder derives the line size from the bits per pixel
and uses it both for the bounds check and to advance to the next line. For
P;2LandP;4L,bitsis 2 and 4 respectively.But these two rawmodes are not packed pixels but 2 or 4 bit planes, each padded to a whole number of bytes.
unpackP2Lcomputess = (pixels + 7) / 8and reads plane k at offsetk * s, andunpackP4Ldoes the same across four planes. Their real footprint is thereforeplanes * ((xsize + 7) / 8).For some widths, padding each plane separately makes this footprint larger than
state->bytes. For example, at width 1, the decoder accounts for one byte, while the unpackers read two or four bytes. The decoder accepts data that is too short for the last line and on the other hand advances to subsequent lines by less than a line, so every line after the first is also read from the wrong offset.#9725's check is misshapen for this:
P;2Limage is 2 bytes but is not accepted.len(data), which is an element count rather than a byte count; e.g.memoryview(array("I", [0,0,0,0,0]))(20 bytes) is rejected whilemv.tobytes()is accepted.PoC (
ghsa_p2l_p4l.py)is the almost-simplest reproducer.
(
arrayis needed to get ASan to reliably scream, but the overread is observable with e.g. bytes literals. Python allocatesbyteses with an extra trailing null byte, from an arena by default to boot, so ASan doesn't see the overread.)On my Mac, I can get ASan to scream about this with
like so:
(To get it to scream differently about
unpackP4L, comment out the P;2L case.)The above output is with
unpackP2LandunpackP4Loff 5eee92031a1986dfdaac7c78cdf0b764c0697118 lazily instrumented to print out what it's reading:Impact
Only a very specific use case is affected:
Image.frombytes()orImage.frombuffer()calls of the shape above.The PCX decoder, which is the only Pillow-internal user of the P;2L and P;4L unpackers, already re-packs PCX rows into a format where overreads aren't an issue.
The impact is that up to 1 byte (
P;2L) or 3 bytes (P;4L) of heap memory following the caller's buffer are read and mixed into the pixel values of the last decoded row(s), one bit per affected plane per pixel. A crash is also possible if the read reaches unmapped memory.As far as I can tell, all published Pillow versions are affected.
Notes
These findings were momentarily public (though in no detail) in https://github.com/python-pillow/Pillow/pull/9723#issuecomment-5566006996; Andrew asked to retract the comment and post this instead.
Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/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 tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290)
BIT-pillow-2026-112395 / CVE-2026-112395 / GHSA-462x-pgg6-55fp
More information
Details
Summary
Pillow's libImaging
_decodeTiledecoder, used to decode tiled TIFF images, allocates a per-tile buffer usingrealloc(state->buffer, TIFFTileSize(tiff)). After eachTIFFReadTileit then iteratestile_yfrom 0 up tocurrent_tile_lengthand calls the configured row unpacker (herecopy4) with the source pointerstate->buffer + tile_y * row_byte_size. When libtiff returns atile_bytes_sizesmaller than the worst-caserow_byte_size * tile_length, the inner stride-and-copy loop walks past the realloc'd region andcopy4performs a 1024-bytememcpythat reads up to(tile_length-1) * row_byte_size + row_byte_sizebytes from a buffer sized only totile_bytes_size. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer.Details
Pillow's TIFF decoder, when libtiff reports the file is tiled, takes the
_decodeTilepath. The function:TIFFTileSize(tiff)(the bytes-per-tile) andTIFFTileRowSize(tiff)(the bytes per row as stored by libtiff).state->bufferto exactlytile_bytes_size.(x, y)and each plane, callsTIFFReadTileintostate->buffer, then walks the tile row by row, calling the configuredImagingShuffler(herecopy4) on each row's worth of source bytes.The validation done before the loop is:
This rejects oversized libtiff tiles, but it does not reject undersized ones — i.e. the case where libtiff returns a
tile_bytes_sizestrictly less thanrow_byte_size * tile_length. The loop body, however, indexes into the buffer assuming the buffer is at leastrow_byte_size * tile_lengthbytes long.Vulnerable Code
The PoC's tile metadata yields
tile_width = tile_length = 256,row_byte_size = 1024(4 bytes/pixel × 256 pixels), andtile_bytes_size = 196608(which is 256·256·3 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iteratestile_yfrom 0 to 255 and for the highest rows the source pointerstate->buffer + tile_y * 1024already lies at or past offset 196608 — the very last row'smemcpy(in=buffer+255·1024, 1024)reads 1024 bytes starting260096 - 196608 = 63488bytes past the allocation; ASAN flags the very first byte past the heap region (READ of size 1024 at … 0 bytes after 196608-byte region).The mismatch is real: libtiff's
TIFFTileSizereturns the packed per-tile storage (which depends on the file's photometric/separation layout — it can legitimately be smaller thanrow_byte_size × tile_length), while the inner loop strides as if the buffer always equalsrow_byte_size × tile_length.Attribution
This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Reachable undefined behavior in native integer operations, potentially leading to denial of service or logic integrity compromise
BIT-pillow-2026-96641 / CVE-2026-96641 / GHSA-4rm8-3hmm-39fg / PYSEC-2026-4340
More information
Details
Summary
Affected Object and Source
Package: Pillow 12.3.0
Source: PyPI sdist pillow-12.3.0.tar.gz
SHA-256: 3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce
Sink: src/_imaging.c:2809:15, xsize += dx
Details
The code in Pillow 12.3.0 is:
The PILfont glyph advance is parsed from a signed 16-bit field, so the valid range for
dxis[-32768, 32767]. The existing check only proves:dx > 0 => xsize <= INT_MAX - dxIt does not prove the other half of the contract:
dx < 0 => xsize >= INT_MIN - dxTaking
dx = -32768and repeating it 65,537 times, the mathematical sum is-2,147,516,416, which is less thanINT_MIN = -2,147,483,648. On the 65,537th addition, the operation-2147483648 + -32768is performed, resulting in C signed overflow undefined behavior.PoC
Observations from a regular 12.3.0 release build:
The adjacent control uses 65,536 repetitions, producing a mathematical result of exactly
INT_MIN, and the function subsequently returns 0 according to existing semantics. Adding just a single character flips the return value to2,147,450,880.Impact
The directly confirmed impact is width semantic integrity violation: getlength() and boundary calculations based on getsize() may return a huge positive value, while the true mathematical width is negative. The high-level getmask() typically passes through the decompression-bomb check, which mitigates the risk of direct massive allocation; however, internal font objects and applications that use the width for layout, quotas, serialization, or subsequent allocations may be affected by resource consumption or logic errors.
Therefore, the current recommended severity rating is "Reachable native integer undefined behavior, with potential DoS/logic integrity impact; specific security severity to be confirmed by the upstream threat model", rather than a memory corruption vulnerability.
Recommended Patch and Regression Tests
It is recommended to extend the overflow check to cover both directions:
At a minimum, four test cases should be added:
dx = 32767, repeat = 65538— upper adjacent control (should succeed);dx = 32767, repeat = 65539— should raiseOverflowError;dx = -32768, repeat = 65536— lower adjacent control (should succeed);dx = -32768, repeat = 65537— should raiseOverflowError.Additionally, it should be verified that exception propagation is consistent across
getlength(),getbbox(),getmask(), and the underlyingfont.getsize().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: Heap use-after-free when encoding TIFF to an in-memory buffer
BIT-pillow-2026-112396 / CVE-2026-112396 / GHSA-4rr5-89j8-mwwm / PYSEC-2026-4328
More information
Details
Summary
Saving an image as TIFF to an in-memory buffer can trigger a heap use-after-free if libtiff rejects a tag at encode time. When
TIFFWriteScanlinefails, the encoder frees its output buffer but leaves the libtiff handle open. Later, when the encoder object is torn down,TIFFCloseflushes buffered bytes through the write callback and writes into the freed buffer. An application that re-encodes attacker-influenced images to TIFF in memory can be crashed and is a candidate for remote code execution.Details
The failure path in
ImagingLibTiffEncodefreesclientstate->datafor the in-memory case but does not close the TIFF or nullclientstate->tiff(the file-descriptor branch just above it does settiff = NULL):src/libImaging/TiffDecode.c:1041-1046Teardown then calls
ImagingLibTiffEncodeCleanup, which runsTIFFClose(tiff)becausetiffis still set:src/encode.c:99-101src/libImaging/TiffDecode.c:955TIFFCloseflushes through_tiffWriteProc, which reallocs and memcpys into the freedstate->data:src/libImaging/TiffDecode.c:103-115The Predictor tag is a convenient trigger: the floating-point predictor (value 3) is invalid for integer sample data, so libtiff aborts the write.
PoC
Run it:
Observed on a source build of commit
3e2ab96with libtiff. The process aborts during cleanup after the encoder error. Depending on heap state the same run sometimes reportscorrupted double-linked listinstead of a segfault; both are the same write into freed memory.Under AddressSanitizer (build with
-fsanitize=address), relevant frames:The buffer allocated in
ImagingLibTiffEncodeInitis freed inImagingLibTiffEncode, then written by_tiffWriteProcduring theTIFFCloseflush that runs from the encoder's_dealloc.Impact
Use-after-free in a C extension. Affects any service that converts or thumbnails untrusted images to TIFF in memory (a
BytesIOor other non-seekable target). Result is a crash and possible memory corruption leading to code execution.Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Out-of-bounds access in _imagingmorph on a zero-width image
BIT-pillow-2026-112397 / CVE-2026-112397 / GHSA-58r4-6rv3-gfh8 / PYSEC-2026-4329
More information
Details
Summary
A PPM or PGM file can declare a width of 0, which Pillow accepts, producing an image of size
(0, N). Passing that image toImageMorph.MorphOp.apply()makes the native morphology code index a row buffer atoutrow[width - 1], which isoutrow[-1]when the width is 0. The process crashes.Details
The PPM parser reads width and height with no lower-bound check:
src/PIL/PpmImagePlugin.py:119The native
apply()writes the row edges with no guard that the width is positive:src/_imagingmorph.c:81setsoutrow[0] = outrow[width - 1] = 0;match()andget_on_pixels()use the same edge-indexing pattern.PoC
Run it:
Observed on a source build of commit
3e2ab96.Under AddressSanitizer:
With width 0 the
outrow[width - 1]write lands on an unmapped page, so it surfaces as a SEGV pinned to the edge-write line rather than a redzone overflow.Impact
Out-of-bounds write in a C extension. Affects any application that runs a morphology operation on an image opened from untrusted input. Result is a crash and possible memory corruption.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/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 FlashPix (FPX) JPEG decoding leads to code execution
BIT-pillow-2026-112398 / CVE-2026-112398 / GHSA-5x8w-8jj5-q52h / PYSEC-2026-4330
More information
Details
Credits
Researcher: Alex Bouffard (mixbo@pebwalker.com)
Summary
A heap buffer overflow in Pillow's FlashPix (FPX) JPEG-tile decoder lets an attacker achieve arbitrary code execution by getting an application to open a single crafted file with
Image.open(). The file is recognized by content (the OLE2 magic), not by extension, so any service that opens or thumbnails untrusted uploads with the optionalolefiledependency installed is reachable with no user interaction. In the PoC below, opening one crafted file executes an attacker-chosen command on stock Pillow 12.2.0 (x86_64 Ubuntu 24.04, glibc 2.39).Details
FpxImagePlugindecodes FlashPix (FPX) files, which are OLE2/CFBF compound documents.Image.open()auto-detects them from the OLE2 magicD0CF11E0...whenever the optionalolefilepackage is installed (Pillow's documented dependency for FPX/MIC support), regardless of the file's extension.For a JPEG-compressed subimage (
compression == 2), the plugin builds a"jpeg"decode tile and derives the(rawmode, jpegmode)pair from attacker-controlled FPX color IDs through theMODEStable (src/PIL/FpxImagePlugin.pyL25-L32):The Imaging output buffer for the tile is sized for the rawmode (here
"LA"= 2 bytes/pixel) (src/decode.cL223-L226):In
JpegDecode.cthe rawmode string is mapped to a libjpegout_color_space. Recognized modes (CMYK,YCbCr,YCbCrK, and so on) get a concrete colorspace, but"LA"and"YCC;P"are not recognized, so control reaches the fallback branch (src/libImaging/JpegDecode.cL219-L221):Under
JCS_UNKNOWN, libjpeg performs no color conversion and emitscinfo.num_componentsbytes per pixel, taken from the embedded JPEG's SOF marker, which the attacker sets to 4. Sojpeg_read_scanlineswrites4 * widthbytes per row into a buffer allocated for only2 * widthbytes (src/libImaging/JpegDecode.cL252):This is a heap out-of-bounds write with attacker-controlled length (the image width, up to tens of thousands of pixels, so about
2 * widthbytes of overflow per row) and attacker-controlled content (the decoded pixel data). The PoC weaponizes it: the overflow overwrites an adjacent Pythonbytesobject'sob_typewith a pointer to a fakePyTypeObject/PyBufferProcsvtable that the same overflow plants inside the decode buffer. When Pillow releases the input buffer,PyBuffer_Releasewalksob_type -> tp_as_buffer -> bf_releasebufferand executescall *rax; the PoC points that atsystem@plt(fixed, the interpreter binary is non-PIE) withrdiset to the command string, yielding command execution.PoC
To facilitate an exact reproduction on an x86_64 machine, this PoC uses the version-pinned, ASLR-disabled Docker image from Annex A.
The source code for the payload builder
make_payload.pyis in Annex B.vulnerable.pyis the program under test, a one-liner that opens the file it is given:The payload is a standalone
.fpxfile the attacker crafts on their own host; the target only ever opens the resulting file, so building it requires no access to or code execution on the target. We runmake_payload.pyinside the same container here purely for convenience, so its baked-in addresses (--offset,system@plt) match this pinned image. The payload redirects the hijacked indirect call tosystem@plt(0x4206c0, fixed because the interpreter binary is non-PIE) with a baked-in command:Then open it with
vulnerable.py:Image.open()of the crafted file runs the baked command viasystem("id"): command execution as the user running the image processing (root in the container).The trailing
corrupted size vs. prev_sizeis glibc aborting during interpreter teardown over the corrupted heap, afteridhasalready run.
The builder writes the command inline at the hijacked
rdi, so--cmdis limited to 7 bytes (for exampleid,id;w).--offsetandsystem@pltare constants of the pinned, ASLR-disabled image and hold across runs of it.Impact
This is a heap-based buffer overflow (CWE-122) / out-of-bounds write (CWE-787) that leads to code execution. Any application or service that opens attacker-influenced files with Pillow and has the optional
olefiledependency installed is affected: image upload and thumbnailing services, avatar and media processing, document and image conversion pipelines, and similar. Because detection is by OLE2 magic rather than file extension and the overflow is triggered during a normalImage.open()/load, a server that automatically processes an uploaded file is exploitable with no user interaction. The result is arbitrary command execution in the context of the process performing the image processing, leading to full compromise of that process and the data and privileges it holds.Annex A: Docker setup
The PoC runs in a pinned Docker image so the environment is deterministic; the image installs a version-pinned Python (3.12.3) and the Pillow 12.2.0 wheel, and the builder is pure Pillow. The image's entrypoint disables ASLR (
setarch -R) to reduce the system knowledge requirements. The single--security-opt seccomp=unconfinedflag ondocker runis required only because turning ASLR off uses thepersonality()syscall that Docker's default seccomp profile blocks. Run only on infrastructure you own.1. Build the image. Put
vulnerable.py(PoC) andmake_payload.py(Annex B) in a directory with this
Dockerfile:2. Start the (ASLR-disabled) container shell with the scripts mounted:
Annex B: payload builder source
make_payload.py:Severity
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: APNG background disposal allocates full canvas before decompression bomb check
BIT-pillow-2026-112399 / CVE-2026-112399 / GHSA-5xm2-6676-qcp9
More information
Details
A 126-byte animated PNG file makes Pillow's
Image.open()allocate and fill memory sized by the image dimensions the file claims, before Pillow's built-in check for oversized images runs. A file claiming 22000×22000 pixels costs about 1.9 GB. Anyone who can get an application to open an image they supply, with default settings and no further calls such asload(), can cause a multi-gigabyte memory spike per file. If the claimed size exceeds the machine's free memory, the operating system kills the process outright.The bug is in
PngImageFile._seek()insrc/PIL/PngImagePlugin.py(around line 1000). For any APNG,_open()callsself._seek(0). When the firstfcTLchunk requestsOP_BACKGROUNDdisposal,_seek()callsImage.core.fill(self.mode, self.size)with the full IHDR size, which allocates and zero-fills width×height×4 bytes.OP_PREVIOUStakes the same path, because frame 0 rewrites it toOP_BACKGROUND. This all happens inside the plugin factory, soImage.open()'s_decompression_bomb_check(im.size)only runs after the memory has been touched. A 1×1 frame rectangle passes the only checks on the way.Below available memory, the allocation is freed when
DecompressionBombErrorpropagates, so sustained pressure needs repeated or concurrent uploads. A 45000×45000 header gets the process killed by the out-of-memory killer on an 8 GB host. The attached reproducer opens two such files that differ in one byte (dispose_op0 vs 1). Both end inDecompressionBombError, but peak resident memory is 17 MB for the first and 1864 MB for the second. The eager fill was introduced in 5e4e0fa6ee67 ("Use disposal settings from previous frame", December 2020) and first shipped in 8.1.0. Two candidate patches with a regression test are attached.Details
For an APNG whose first
fcTLchunk requestsOP_BACKGROUNDdisposal,PngImageFile._open()creates and zero-fills a core image of the full IHDR-declared size. This happens inside the plugin factory, i.e. beforeImage.open()gets to_decompression_bomb_check(im.size), so the check that is meant to reject oversized headers without allocating anything runs only after the allocation has been made and touched.The following 126-byte file (all chunk lengths and CRCs valid) makes
Image.open(io.BytesIO(data)), with defaultMAX_IMAGE_PIXELSand noload(), reach 1.86 GB resident (ru_maxrss) and then raiseDecompressionBombError. The byte-identical file withdispose_op=0raises the same error at 17 MB.With
width=height=45000(8.1 GB canvas) the same call attempts the full allocation: under a 4 GiBRLIMIT_ASit fails withMemoryErrorfrom the allocator instead ofDecompressionBombError; without a cap, on an 8 GB host with no swap, the process is OOM-killed.Code path
Image.open()runs the bomb check only after the plugin's constructor (and therefore_open()) has returned:For a plain PNG that ordering is fine:
PngImageFile._open()only parses chunks up to the firstIDATand recordsself._sizefromIHDR; nothing is allocated from it. For an APNG (acTLseen, soim_n_framesis set)_open()additionally positions on frame 0:_seek(0)copiesdisposalandbboxfrom thefcTLchunk intoself.dispose_op/self.dispose_extentand then prepares the disposal image for the next frame eagerly, using the full image size:Image.core.fillis_fill()insrc/_imaging.c:711, which doesImagingNewDirty(mode, xsize, ysize)followed byImagingFill();ImagingFill(src/libImaging/Fill.c:23) writes every row, so the wholewidth*height*4bytes become resident rather than merely reserved.self.sizehere is the IHDR size (22000×22000), not the 1×1 frame rectangle. The_cropon the next line does call_decompression_bomb_check, but on thefcTLrectangle (1×1) and only after the fill has completed. Lines 991-992 rewriteOP_PREVIOUStoOP_BACKGROUNDon frame 0, sodispose_op=2takes the same path (verified: identical 1.86 GB peak). When the firstfcTLrectangle is large, the_cropcopy is a second extent-sized buffer on top of the full-canvas fill, so the transient peak can reach about twice the canvas: a 13000×13000 full-framefcTL(below the error threshold, soImage.open()succeeds with only aDecompressionBombWarning) peaks at 1.31 GB for a 0.66 GB canvas.The only validation
chunk_fcTLapplies (src/PIL/PngImagePlugin.py:740-745) is that the frame rectangle fits inside the IHDR size, which a 1×1 frame always does; IHDR width/height themselves are unconstrained 32-bit values at this point. Control then returns to_open_core,_decompression_bomb_check((22000, 22000))raises, and the 1.9 GB core image is released when the half-constructedPngImageFileis discarded.The eager full-size fill in
_seek()was introduced in5e4e0fa6ee67("Use disposal settings from previous frame", 2020-12-24) and first shipped in 8.1.0; before it, theOP_BACKGROUNDfill lived inload_end()and could not be reached fromImage.open().Impact
Any application that calls
Image.open()on untrusted bytes with default settings — the configuration in which the decompression-bomb check is supposed to make that call cheap and safe — can be made to allocate and write an attacker-chosen amount of memory from a ~126-byte PNG upload, before any error is reported and withoutload(), frame iteration, orLOAD_TRUNCATED_IMAGES. The amount is set directly by the IHDR width/height (4 bytes per pixel for colour type 6): values below the host's free memory cause a transient multi-GB spike per request (≈15,000,000× amplification at 22000×22000) that is freed when the exception propagates, so concurrent or repeated uploads are needed to sustain pressure; values above it get the worker process OOM-killed outright. PNG is accepted by essentially every upload pipeline, and the file passes_acceptand chunk CRC checks as a well-formed APNG.This is the same class as the recent bomb-check additions for WMF (load at a different DPI), FITS, GIF and FontFile — an allocation sized from file-declared numbers on a path
_decompression_bomb_checkdoes not cover — but here the uncovered path isImage.open()itself rather than a laterload()/seek().Reproducing
reprois a single Python script (no build step, no companion files). It builds two PNGs in memory, opens each one with plainImage.open(io.BytesIO(data))in a fresh child interpreter using default settings and noload(), and has each child report the exception it got and its own peak RSS (ru_maxrss). It needs about 2 GB of free RAM and finishes in about a second.The two files are 126 bytes each and differ in exactly one byte, the
dispose_opof the firstfcTLchunk:0(OP_NONE) for the control and1(OP_BACKGROUND) for the attack. Both declare IHDR 22000×22000 RGBA. That is 484 Mpixel, above the 2×MAX_IMAGE_PIXELSerror threshold, soImage.open()has to reject both withDecompressionBombError. Each chunk has a job:acTLchunk (2 frames) makes_open()call_seek(0).fcTLrectangle passeschunk_fcTL's bounds check and_crop's bomb check.IDATends header parsing.Expected output on this tree:
Both files are rejected, but the control is rejected at interpreter size. The attack file only reaches the bomb check after
Image.core.fill("RGBA", (22000, 22000))has allocated and zero-filled about 1.9 GB. The script printsBUGif the attack child's peak RSS is at least 1000 MB and at least 20× the control's, or if the attack child is killed bySIGKILL(for example by the OOM killer on a small host). Otherwise it printsOK; any other non-zero child exit is treated as a harness failure, printed with the child's output, and also ends inOK. With either attached patch applied, both children report about 17–22 MB and the script printsOK.The allocation grows with the header, so it can be scaled by changing
SIZEin the script. The 45000×45000 case (an 8.1 GB canvas) is covered in Details and is not part of the script.Suggested fix
Do not build the
OP_BACKGROUNDdisposal image from the declared size during_open(). Two alternative patches are attached; both change onlysrc/PIL/PngImagePlugin.py, and both add the same regression test toTests/test_file_apng.py(a 2³¹−1 × 1 APNG built in memory with a 1×1OP_BACKGROUNDfirst frame must makeImage.open()raiseDecompressionBombError; on the current tree the fill fails first withMemoryError). Attached patch 1 is the preferred fix.Attached patch 1 moves the fill to its only point of use, the
frame > 0branch of_seek()right afterself.load()— which can only run onceImage.open()has returned (bomb check passed) and the canvas itself has been allocated — and, asGifImagePluginalready does, fills only the previous frame's dispose extent instead of a full canvas that is then cropped:At that point
self.dispose_opandself.dispose_extentstill hold the previous frame's values (they are reassigned further down in_seek), so disposal semantics are unchanged; theOP_PREVIOUScopy stays eager becauseload_end()pastes into_prev_imin place (src/PIL/PngImagePlugin.py:1110). With this patchImage.open()allocates nothing from the declared size, including in theDecompressionBombWarningrange.Attached patch 2 is a smaller diff that keeps the eager disposal setup in
_seek()and mirrorsGifImagePlugin._seek()'sOP_BACKGROUNDhandling exactly: it computes the size of thefcTLrectangle (already validated bychunk_fcTLto lie within the image), runsImage._decompression_bomb_check()on it, and fills an image of only that size instead of fillingself.sizeand cropping. It also narrows thedispose_extentannotation totuple[int, int, int, int]so the bomb-check call type-checks. The trade-off is that a full-canvas first frame still allocates a bomb-checked image insideImage.open()— up to about 2×MAX_IMAGE_PIXELS×4 bytes (660 MB for a 13000×13000 full-framefcTL, withDecompressionBombWarning) — and a firstfcTLcovering a canvas above the error threshold now raisesDecompressionBombErrorfrom_seek(0)rather than fromImage.open()'s own check.With either patch applied to a clean checkout, the reproducer prints
OKand all 54 files inTests/images/apng/decode byte-identically frame by frame, including after a rewind.The patch (1 of 2)
Create the APNG OP_BACKGROUND disposal image when the next frame is sought, sized to the frame extent, not in _open() from the IHDR size
Applies to
src/PIL/PngImagePlugin.pyat the commit above.The patch (2 of 2)
Bomb-check and fill only the fcTL extent for APNG OP_BACKGROUND disposal instead of a full IHDR-sized canvas in PngImageFile._seek()
Applies to
src/PIL/PngImagePlugin.pyat the commit above.The reproducer
Reproducer —
repro, 3,564 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:N/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: Arrow export leaks stale heap memory when PILLOW_ALIGNMENT > 1
BIT-pillow-2026-114501 / CVE-2026-114501 / GHSA-654x-cwwv-5j9c / PYSEC-2026-4336
More information
Details
Summary
When
PILLOW_ALIGNMENTis set above 1, exporting an image to Arrow returns uninitialized heap memory mixed in with the pixel data. That memory can include pixels from images the process freed earlier.Details
With an alignment above 1, the default allocator pads each row up to
aligned_linesizeand starts the first row at an aligned offset into the block. It then exportsim->blocks[0].ptrasxsize * ysizetightly packed pixels, so exported data includes the padding bytes, and can include a few bytes from before the first row.Tested vulnerable on 11.2.1, 12.3.0, 4c1ed27a5.
PoC
Whether things leak may depend on allocator configuration. This does show a leak on my Mac.
Impact
Information disclosure for applications that set
PILLOW_ALIGNMENTand export images to Arrow. Likely any image whose row length isn't a multiple of the alignment will do. In a service handling several users' images, one user's exported data can contain pixels from another user's image, or other stale heap contents.Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: GIF seek keeps rejected size after DecompressionBombError, allowing DoS
BIT-pillow-2026-112400 / CVE-2026-112400 / GHSA-65qm-jvmh-6pqg
More information
Details
A GIF of about 60 bytes can make Pillow allocate gigabytes of memory. This affects applications that catch the "image too large" error raised while stepping to a later animation frame and then keep using the same image object. Pillow correctly refuses the oversized frame, but it stores the refused dimensions on the image object anyway, and later operations size their memory from them. An attacker who can upload a GIF to such a service can exhaust its memory: the process either fails with an out-of-memory error after committing gigabytes, or is killed by the operating system. There is no memory corruption.
The bug is in
GifImageFile._seek()(src/PIL/GifImagePlugin.py:292-294). When a frame's extent enlarges the image, the code assigns the new size toself._sizeand only then callsImage._decompression_bomb_check(). Nothing restores the old size whenDecompressionBombErroris raised, becauseseek()only rolls back onEOFError. After the caught error,im.sizereports the rejected size while the pixel bufferim.imis still the 1×1 first frame. Because the extent is a 16-bit offset plus a 16-bit size, that rejected size can be as large as 131070×131070.In the attached reproducer, the rejected size is 40000×40000. A retried
seek(1)followed byload()then reachesload_prepare(), which callsImage.core.fill()with that size to decode a single pixel. Peak memory rises from 17 MiB to 1700 MiB before a 3 GiB address-space cap stops the next allocation.Image.new("RGB", im.size, ...)androtate(..., expand=True)also allocate from the stale size.The default paths are unaffected: plain
Image.open()plusload(),ImageSequence.Iterator, andn_frames/is_animated. The ordering was introduced in c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30, first released in 9.2.0), the check added for CVE-2022-45198. The two dispose-extent checks later in the same function already validate before using the size, and the attached patch makes this check follow the same order.Details
GifImageFile._seek()lets a later frame enlarge the image, and performs thedecompression-bomb check for the new size only after storing it on the object:
_decompression_bomb_check()(src/PIL/Image.py:3586) raisesDecompressionBombErrorfor anything above2 * MAX_IMAGE_PIXELS(178 956 970 px by default), but by then
self._sizealready holds the rejectedvalue and nothing restores it:
seek()(GifImagePlugin.py:166-179) only rollsback on
EOFError, and_seek(0)only ever grows_size. The object that theapplication still holds therefore reports
im.size == (40000, 40000)whileim.imis the 1×1 buffer of frame 0. Compare the two dispose-extent checks afew lines further down in the same function (
GifImagePlugin.py:383and:407),which call
_decompression_bomb_check(dispose_size)before the correspondingImage.core.fill(), andTiffImagePlugin.load_prepare()(
src/PIL/TiffImagePlugin.py:1319-1321), which checks_tile_sizebeforeallocating from it.
With this 60-byte GIF (hex)
Image.open()succeeds (im.size == (1, 1)),im.seek(1)raisesDecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwardsim.size == (40000, 40000),im.im.size == (1, 1),im.tell() == 0.What happens next depends on what the application does with the object, but
several ordinary operations take their dimensions from
im.sizerather thanfrom the C buffer:
im.seek(1). The failed_seekleftself.__offset == 0and thefile pointer just past the 9-byte descriptor, so the retry parses whatever
follows — in the file above, a valid 1×1 frame — and succeeds.
im.load()then runs
GifImageFile.load_prepare(): which allocates andmemsets a 40000×40000 "P" image (1.6 GB) to decode a1×1 tile into, and
load_end()(GifImagePlugin.py:485-491) fills a secondone and converts it to RGB (another 1.6 GB + 6.4 GB).
im.rotate(angle, expand=True)for a non-multiple-of-90 angle, or thecommon flatten-onto-background idiom
Image.new("RGB", im.size, bg), allocatefrom
im.sizeas well (measured on an object poisoned to 20000×20000:rotate(45, expand=True)completes and touches 776 MiB;Image.new("RGB", im.size, "white")touches 1.5 GiB). Operations that passim.sizeas a box against the real buffer (thumbnail,resize,save)fail with
ValueError/SystemErrorinstead.The extent fields are 16-bit offset plus 16-bit size, so the stored size can be
pushed to 131070×131070 (17.2 Gpx; 17 GB for mode "P", 69 GB for "RGB"/"RGBA"
storage), 96× the error threshold.
n_frames/is_animatedare not affected: they call_seek(frame, update_image=False), which skips the_sizeassignment (the fileabove reports
n_frames == 2,is_animated == Trueand leavesim.sizeat1×1).
Impact
A remote user who can upload a GIF to a service that (a) seeks animation frames
and (b) catches the exception from
seek()— a broadexcept Exceptionarounda
while True: im.seek(im.tell()+1)frame-collection loop, or an explicitexcept DecompressionBombError: log and continue— and then keeps using thesame
Imageobject, ends up driving allocations of 1.6 GB to tens of GB from a60-byte file:
MemoryErrorafter the process has committed gigabytes, or anOOM kill of the worker when no address-space limit is set. The
MAX_IMAGE_PIXELSguard fires, but does not actually keep the rejecteddimensions out of the object it is guarding.
The precondition is real application behaviour but not the default path:
Image.open(); im.load()is unaffected, andImageSequence.Iteratorpropagatesthe error out of the
forstatement (atryinside the loop body does notswallow it). Hence low severity. This is adjacent to the GIF extent bomb check
added for CVE-2022-45198: the ordering was introduced with that check in
c9f1b35e9810("Added GIF decompression bomb check", 2022-06-30), firstreleased in Pillow 9.2.0. Before it, the same size growth happened with no
check at all; the check introduced there is the one that runs too late.
Reproducing
reprois a single Python script run against the installed Pillow. It needsabout 1.7 GB of free RAM.
setup_env()caps the process address space at 3 GiB withRLIMIT_AS, so the oversized allocations end inMemoryErrorinstead ofthe OOM killer. It then opens the 60-byte GIF shown above, built in memory
by
bomb_gif(). The GIF contains a 1×1 screen and a 1×1 frame 0, soImage.open()passes its own bomb check. Next comes a bare frame-1 imagedescriptor that declares 40000×40000 (1.6 Gpx, about 9× the error
threshold). A real 1×1 frame follows it.
poc()callsim.seek(1)and catchesDecompressionBombError, the wayan application that skips bad frames would.
im.sizenextto the real pixel buffer
im.im.size. If the rejected frame had left theobject unchanged, both would be
(1, 1). On the affected codeim.sizeis(40000, 40000).im.seek(1), which nowparses the trailing 1×1 frame, and calls
im.load(). It prints theprocess's peak RSS before and after.
load_prepare()sizes itsImage.core.fill()fromim.size, so loading a single pixel fills a1.6 GB buffer before the 3 GiB cap stops the next allocation.
Expected output on the affected tree:
The script prints
BUGonly if both checks hold: the over-limit size stayedon the object after the exception, and peak RSS grew by more than 1000 MiB
while loading a 1×1 frame. Otherwise it prints
OK. With the attached patchapplied, it prints
im.size=(1, 1), peak RSS stays at about 23 MiB, and thelast line is
OK.Suggested fix
The attached patch validates before mutating in
GifImageFile._seek()(
src/PIL/GifImagePlugin.py:292-294): the enlarged extent is computed into alocal, passed to
_decompression_bomb_check(), and only then assigned toself._size, matching the order of the twodispose_sizechecks further downthe same function. A rejected frame therefore leaves the object in its pre-seek
state; valid files behave as before, and the exception is still raised at the
same point:
n_frames/is_animatedgo throughupdate_image=Falseand are unaffected.The patch also extends
test_exception_gif_extentsinTests/test_decompression_bomb.pyto assert thatim.sizeis unchanged afterthe
DecompressionBombError. It does not changeseek()itself; optionally,seek()could also treatDecompressionBombErrorlikeEOFErrorand re-seekto
last_frame, so that a retriedseek()does not resume parsing from themiddle of the rejected frame.
The patch
GIF: run the decompression bomb check on an enlarging frame's extent before storing it in self._size
Applies to
src/PIL/GifImagePlugin.pyat the commit above.The reproducer
Reproducer —
repro, 2,474 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:N/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: Quadratic GIF comment concatenation allows DoS in Image.open()
BIT-pillow-2026-112401 / CVE-2026-112401 / GHSA-74r2-7ff9-mjj2
More information
Details
Opening a crafted GIF file with
Image.open()takes time that grows with the square of the file's size, so anyone who can submit an image to a service that uses Pillow can keep one worker busy at full processor load for a long time with a small file. A 2 MB file takes about 5 seconds to open, a 4 MB file about 35 seconds, and an 8 MB file about 3 minutes. An ordinary GIF of the same size opens in well under a second. The cost is paid insideImage.open()itself, with default settings, before the application has seen the image's size or format.The bug is in
GifImageFile._seek()insrc/PIL/GifImagePlugin.py, which buildsinfo["comment"]by appending to an immutablebytesobject with no size limit. It appends once per 255-byte sub-block (comment += block, line 261) and once per comment extension (info["comment"] += b"\n" + comment, line 266). Each append copies everything collected so far. A file made of many tiny comment extensions forces a full re-copy for each one. Because_open()ends withself._seek(0), any code that only opens an uploaded file to identify it, read its dimensions, or make a thumbnail pays the full cost. The decompression-bomb check does not help, since the image is 1×1. Memory use stays linear, so the effect is processor exhaustion and latency, bounded by whatever upload size the service allows (about 1 second at 1 MB). This is the same kind of issue as the EPS open-phase slowdown fixed as CVE-2021-28677, here in the GIF comment handling. The quadratic pattern was introduced in commit b2b737b68b06 ("Allow arbitrary number of comment extension subblocks", November 2018) and has shipped since Pillow 5.4.0. The attached script builds 2 MB and 4 MB test files and compares each against a byte-identical control whose extensions are labelled as plain text instead of comment. The 4 MB comment file took about 34 seconds to open, against 0.26 seconds for its control. A patch that collects the pieces in a list and joins them once is also attached.Details
GifImageFile._seek()collects GIF comment extensions intoinfo["comment"]usingbytesconcatenation, once per 255-byte sub-block and once per comment extension, with no limit on the total.bytesis immutable, so every+=allocates a new object and copies everything accumulated so far; the work done byImage.open()is therefore quadratic in the amount of comment data in frame 0.A 1×1 GIF89a whose body is nothing but the 5-byte comment extension
21 FE 01 41 00repeated makes this directly observable.Image.open(BytesIO(data))on such a file, stock build at this commit, 2-CPU machine:21 FE …)21 01 …)The control column is the identical byte stream with the extension label changed from
0xFEto0x01; it goes through the same loop and the sameself.data()reads but is discarded instead of concatenated, and scales linearly. Noload()is needed and no warning or exception is raised;im.info["comment"]simply ends up holding the multi-megabyte blob.The code,
src/PIL/GifImagePlugin.py:229-281(trimmed):self.data()(:97-102) reads one length byte and then that many bytes, so each call is cheap and bounded; the cost is entirely in the two+=lines. For a single comment extension of N bytes delivered as N/255 sub-blocks, line 261 copies about N²/510 bytes in total (4 MB → 2.3 s, 8 MB → 9.5 s, 16 MB → 38 s measured). Splitting the same data into many small comment extensions is worse, because line 266 re-copies the whole ofinfo["comment"]once per extension: with 1-byte extensions a file of F bytes causes roughly F²/25 bytes of copying, which is the table above (growth ×4–7 per doubling once the buffer outgrows the CPU caches).This runs during
Image.open():_open()ends withself._seek(0)(:135), so any application that merely opens an uploaded file to identify it, read its size, or thumbnail it pays the full cost before it has made any decision about the image.Image._decompression_bomb_checkis not involved (the image is 1×1), and unlike PNG text chunks (PngImagePlugin.MAX_TEXT_CHUNK/MAX_TEXT_MEMORY) there is no cap on GIF comment size. Later frames repeat the same loop for their own comments viaseek()/n_frames.With both
+=sites replaced by list-append and a singleb"".join()(as in the attached patch), the same 16 MB single-extension file opens in 0.03 s, confirming the concatenation is the whole cost.Impact
Anyone who can get a GIF to a service that calls
PIL.Image.open()on it — avatar/media uploads, thumbnailers, format sniffing, anything usingImageOps/thumbnail()on user files — can pin one worker process at 100 % CPU for about 5 s with a 2 MB file, 35 s with a 4 MB file and 3 minutes with an 8 MB file, under default settings, with the time growing quadratically in the permitted upload size. A benign GIF of the same size opens in well under a second, so request-cost assumptions based on file size do not hold. Memory use is only linear (the comment is retained inim.info["comment"]), so the effect is CPU/latency denial of service of the worker pool rather than memory exhaustion; a handful of concurrent uploads is enough to saturate a typical pool. Upload size limits bound it (≈1 s at 1 MB).This is the same shape as CVE-2021-28677 ("EPS DOS on _open",
docs/releasenotes/8.2.0.rst), where a quadratic method of accumulating data in the open phase, before the image was accepted, was treated as a denial-of-service vulnerability; here the format is GIF and the accumulator is the comment extension.The quadratic accumulation was introduced in
b2b737b68b06("Allow arbitrary number of comment extension subblocks", 2018-11-27), first released in Pillow 5.4.0.Reproducing
reprois a single Python script that uses the installed Pillow. Run it with no arguments. It needs no files, no network and no rebuild.It builds two GIFs of the same length, at 2 MB and at 4 MB. Each is the 13-byte GIF89a header for a 1×1 screen, followed by
size/5copies of the 5-byte extension21 <label> 01 41 00, then a minimal 1×1 image and the trailer:0xFE. Each extension is a 1-byte comment, so_seek()appends each one toinfo["comment"]with+=(GifImagePlugin.py:266).0x01(plain-text extension). The bytes are the same apart from the label._seek()reads them through the same loop and the sameself.data()calls, then throws them away.The script times
Image.open(io.BytesIO(data))on each file. It does not callload(), because_open()already parses frame 0. It checks that the image came out as 1×1. Typical output on a 2-CPU machine:For a linear parser, doubling the file would roughly double the time, and the two files would take about the same time. The script prints
BUGwhen all three of these hold:Otherwise it prints
OK. The run is deterministic, CPU-bound on one core, and takes about 40 s.Suggested fix
The attached patch changes
GifImageFile._seek()insrc/PIL/GifImagePlugin.pyto accumulate comment data in a local list instead of withbytesconcatenation, and to join it once when the frame's info is committed:This removes the quadratic copying at both sites (
:261per sub-block and:266per extension); the 4 MB reproducer file opens in about 0.3 s, the same as its control, and the reproducer no longer printsBUGon a patched build. The resultinginfo["comment"]is unchanged for empty, multi-sub-block, multi-extension and later-frame comments — includingTests/images/multiple_comments.gif→b"Test comment 1\nTest comment 2"— andTests/test_file_gif.pypasses unchanged. The GIF writer (_write_local_header/_save) is not affected.The patch deliberately adds no size cap. Unlike PNG
zTXt, whichPngImagePlugin.MAX_TEXT_CHUNKguards against decompression amplification, a GIF comment can be no larger than the file that carries it, so once the copy is linear there is nothing left to bound, and a hard cap would reject valid files with large comments. If a limit is wanted anyway, aMAX_COMMENT_SIZEcheck inside the sub-block loop is a small policy addition on top of this patch.The patch
GIF: collect comment extension sub-blocks in a list and join once per frame instead of repeated bytes += in GifImageFile._seek()
Applies to
src/PIL/GifImagePlugin.pyat the commit above.The reproducer
Reproducer —
repro, 2,263 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:N/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: Bundled libopenjp2 2.5.4 in all wheels since 11.1.0 carries OSV-2025-219 heap-buffer-overflow write reachable from Image.open().load()
GHSA-78g4-xqwv-q3g8
More information
Details
Summary
Every Pillow wheel from 11.1.0 onward bundles a libopenjp2 containing a heap-buffer-overflow write that is fixed upstream on master but has never appeared in an OpenJPEG release. See https://osv.dev/vulnerability/OSV-2025-219
Because the wheel bundles its own copy, a patched host OS does not help. It is reachable from
Image.open(fp); im.load()with default settings.I am reporting it here rather than only upstream because Pillow is where the vulnerable binary actually reaches users.
Note the inversion that makes this easy to miss: OpenJPEG 2.5.2 and earlier are safe. The bug was introduced in
954c6e3c(2024-06-25), so keeping the dependency current is what creates the exposure.Details
Affected wheels
From
.github/dependencies.json(openjpeg) at each tag:Confirmed on an installed wheel:
PIL.features.version_codec('jpg_2000')→2.5.4, bundled filepillow.libs/libopenjp2-*.so.2.5.4.mainstill pins2.5.4as of 2026-10-06.The defect
src/lib/openjp2/j2k.c, inopj_j2k_read_sod:tp_indexis neither null-checked nor bounds-checked, andl_current_tile_partcomes directly from the one-byteTPsotfield of the SOT marker.The correct guard already exists a few lines later in
opj_j2k_add_tlmarker(j2k.c:8459), writing the same array at the same index behindif (tp_index && l_current_tile_part < nb_tps). One writer checked, its sibling not.Reached by giving the codestream a valid TLM marker — so
tp_indexis allocated once byopj_j2k_build_tp_index_from_tlm, sized to the TLM entry count, andopj_j2k_read_sotthen skips all resizing — while settingTNsot = 0in every SOT, which keeps the TLM "valid" and letsTPsotwalk past the allocation.Patch status
Fixed upstream in
91d08b11("Fix heap buffer overlfow in opj_j2k_read_sod", 2026-02-10).The newest OpenJPEG tag is v2.5.4 (2025-09-20), and
compare/v2.5.4...91d08b11reports ahead 6, behind 0 — the fix is in no release, 8 months on.Suggested remediation
91d08b11as a patch in the wheel build until an OpenJPEG release includes it — three lines, identical to a guard already present in the same file.Image.open()ignores filename/Content-Type and will reach any compiled-in codec unlessformats=is passed would help downstream apps a lot.Not a Pillow bug, but relevant: the extension allowlist most apps rely on does not apply
Image.open()sniffs magic bytes, so a JPEG 2000 codestream in a file namedavatar.pngdeclaredContent-Type: image/pngreaches the JP2 decoder anyway.I verified this against a production service documenting PNG/JPEG-only uploads: it accepted and fully decoded a JP2 through exactly that path, with 16 formats reachable from one upload endpoint against 43 registered openers. That service has since fixed it by passing
formats=and validating on the detected format rather than the client'sContent-Type.That is the reason a decoder-level issue like this one has much wider reach than downstream maintainers tend to assume.
Proof of concept
ASan build of OpenJPEG 2.5.4 interposed under a stock Pillow 12.3.0, running
Image.open(p); im.load():Frames 3–5 are Pillow's own decoder, not a synthetic harness. No non-default decoder options are involved; strict mode is on, which is Pillow's behaviour since Pillow never calls
opj_decoder_set_strict_mode. A benign twin image runs the same path cleanly and returns a valid image, so this is the payload and not a parse failure.I have deliberately not attached the PoC file to this initial report. I'm happy to attach the PoC, the builder script and the RIP-control harness here on request, or hold everything until a fix is ready.
No disclosure deadline from me; I will not publish anything while this is being worked on.
Impact
Any application that calls
Image.open()/im.load()on an image it did not produce — the overwhelmingly common case for upload handlers, thumbnailers and preview pipelines — can be made to perform an out-of-bounds heap write by supplying one crafted file.Floor, already certain. On a stock non-sanitised wheel the overflow performs 245 writes walking to +5856 bytes past a 24-byte allocation, and glibc's own heap-metadata checker aborts the process (
corrupted size vs. prev_size,corrupted double-linked list, SIGABRT). One file reliably kills a long-lived worker.Ceiling, demonstrated as a primitive but not as a finished exploit. With same-size-class objects alive on both sides — an ordinary busy heap — the walk crosses
opj_event_mgr_t, which holds threeopj_msg_callbackfunction pointers, andopj_event_msgthen calls one (event.c:136):Shifting the codestream with a COM marker moves
ripwith it (0x16ce→0x4000), so the instruction pointer is attacker-chosen rather than incidentally invalid. A benign control image leaves the same groomed heap untouched.To be explicit about what I did not do: I have not built a working exploit. That additionally needs remote heap grooming and an ASLR defeat, and I attempted neither. I am reporting a controlled control-flow primitive, not a finished chain. The CVSS AC:H reflects that.
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Double free in ImagingScaleAffine on calloc failure via Image.transform
BIT-pillow-2026-112402 / CVE-2026-112402 / GHSA-7mwm-ghpf-9vrm / PYSEC-2026-4331
More information
Details
There is a double free in Pillow's
Image.transform()that crashes the process when an oversized transform runs out of memory. Instead of only raisingMemoryError, Pillow frees the output image's memory once while handling the error and then again when the Python object is cleaned up. Anyone who can choose the output size ofImage.transform()can trigger it, but only on a worker with a memory limit (a common hardening for image workers) or a 32-bit process. The result is a crash of the worker, plus corruption of its heap that has not been shown to be controllable.The bug is in
ImagingScaleAffine()insrc/libImaging/Geometry.c(line 866). When itscalloc()for the per-column lookup table of4 × widthbytes fails, it callsImagingDelete(imOut). When the call comes fromImage.transform(),imOutis theImagingstruct owned by the live PythonImagingCoreoutput object._dealloc()insrc/_imaging.clater runsImagingDelete()on the freed struct again, writing its mutex and refcount in freed memory and freeing the pixel blocks a second time. The path is reached withTransform.EXTENT, orTransform.AFFINEwith a pure scale/translate matrix, under the default NEAREST resampling. A very wide, 1-row output size makes the lookup table the allocation that fails while the image itself still fits. On 64-bit Linux with default overcommit, that requires an address-space limit such asulimit -v,uwsgi --limit-asorsystemd LimitAS=.resize(),thumbnail()androtate()are not affected. The attached reproducer runs on a stock build with a 1 GB limit:transform((300000000, 1), EXTENT)raises and catchesMemoryError, then the process dies with SIGSEGV infree()called from_dealloc(). A memory-checking build reports the same path as a heap use-after-free. The faulty line was inherited from PIL 1.1.7 in the original fork commit, so every Pillow release since 1.0 is affected. A patch is attached.Details
ImagingScaleAffine()frees an image it does not own. On the one error path where itscalloc()for the per-column lookup table fails, it callsImagingDelete(imOut). When reached throughImage.transform(),imOutis theImagingstruct held by a live PythonImagingCoreobject; that object's_dealloc()later callsImagingDelete()on the already-freed struct and frees its pixel blocks a second time.Shortest demonstration on the stock build (the address-space limit stands in for
ulimit -v/uwsgi --limit-as/systemd LimitAS=on a worker):The 300 MB output image allocates;
calloc(300000000, 4)(1.2 GB) does not.MemoryErroris raised as intended, but the interpreter then dies with SIGSEGV, gdb showingfree()←memory_return_block/ImagingDestroyArray(src/libImaging/Storage.c:396,408) ←ImagingDelete(Storage.c:271) ←_dealloc(src/_imaging.c:188). An AddressSanitizer build of the same sources (withmax_allocation_size_mbused to make thecallocfail instead of an rlimit) reportsheap-use-after-freeinImagingDelete src/libImaging/Storage.c:257called from_dealloc src/_imaging.c:188, on a 128-byte region "freed by"ImagingDelete←ImagingScaleAffine src/libImaging/Geometry.c:866←ImagingTransformAffine Geometry.c:1043←ImagingTransform Geometry.c:1135←_transform src/_imaging.c:2153, "previously allocated by"ImagingNewPrologueSubtype Storage.c:53← … ←_new src/_imaging.c:756.Code path
Image.transform()creates the output image as an ordinary Python-owned image and then transforms into it:__transformer()rewritesTransform.EXTENTinto an axis-aligned affine matrix (src/PIL/Image.py:3040-3046:data = (xs, 0, x0, 0, ys, y0),method = Transform.AFFINE) and callsself.im.transform(box, image.im, method, data, resample, fill)(Image.py:3117).resampledefaults toResampling.NEAREST(Image.py:2936).In C,
_transform()passes its own image as the destination:ImagingTransformAffine()takes the scaling fast path because the filter is NEAREST and the matrix has no shear:and
ImagingScaleAffine()allocates a table ofimOut->xsizeints:ImagingDelete()(src/libImaging/Storage.c:250-279) drops the refcount to 0, returns the pixel blocks to the arena /free()s them viaim->destroy, andfree(im).self->imagein theImagingCoreobject still points at the freed struct._transform()returnsNULL,Image.transform()propagatesMemoryError, the half-built outputImageloses its last reference, and_dealloc()(src/_imaging.c:188) callsImagingDelete(imagep->image)again: it locksim->mutexand decrementsim->refcountinside freed memory, then walksim->blocks(already freed, typically reused by then) and frees every entry a second time. In the observed stock-build runs the freedblocksarray had been overwritten, sofree()receives a mangled pointer and the process segfaults; with other heap states it is a plain double free of a 300 MB block.Every other error return in
Geometry.c(ImagingError_ModeError,ImagingError_Mismatch,"bad filter number","bad transform method") leavesimOutalone; line 866 is the only one that deletes it. The delete is only correct for the other caller,_resize()(src/_imaging.c:2026-2030), whereimOutis a freshImagingNewDirty()temporary that nothing else references. The delete predates Pillow's git history: it came in with the PIL 1.1.7 import,9a640e315715("Forking PIL", 2010-07-30), so every release since Pillow 1.0 is affected.The output width is what decides whether the path is hit: the output image costs
width × height × pixelsizebytes, the table an additional4 × width. A very wide, 1-row output (300 000 000 × 1 "L": 300 MB image, 1.2 GB table) makes the table the allocation that fails.ImagingNewPrologueSubtype()capsxsizeatINT_MAX/4 - 1, so the table is at most ~2 GB.Impact
Applications that let a user choose the output size of
Image.transform()—Transform.EXTENT(crop-and-scale),Transform.AFFINEwith a pure scale/translate matrix, or theImageTransform.ExtentTransform/AffineTransformhelpers, all with the default NEAREST resampling — can have an allocation failure turned into a use-after-free and double free inside the worker process instead of theMemoryErrorthe API is meant to raise.resize(),thumbnail()androtate()do not reach this path;Image.resize(..., NEAREST, box=...)goes throughImagingScaleAffine()but owns its output, and raises a cleanMemoryError. The second precondition is that the output image itself allocates but the extra4 × widthbytes do not; on 64-bit Linux with default overcommit this requires an address-space limit on the process (ulimit -v,uwsgi --limit-as,systemd LimitAS=, an in-appsetrlimit), which is a common hardening for image workers; 32-bit processes can reach it through ordinary address-space exhaustion. The demonstrated consequence is a crash of the process handling the request afterMemoryErrorhas already been raised and possibly handled. The secondImagingDelete()also writes into the freed 128-byte struct (mutex, refcount) and double-frees an attacker-sized block, which is heap corruption rather than a clean abort, but no control over the written data is shown.Reproducing
Run
repro(a self-contained Python script) from any directory. It uses the installed stock build, so nothing is rebuilt, and has no companion files. It runs the demonstration above in a child interpreter (python3 -c, takingWIDTHas an argument): the child disables core files, setsRLIMIT_ASto 1 GB (the kind of limit image workers are commonly given), creates an 8×8 "L" image, callssrc.transform((WIDTH, 1), Image.Transform.EXTENT, (0, 0, 8, 8))with the default NEAREST resampling, printsMemoryError caughtif that raises, then runsgc.collect()and printsalive. EXTENT with NEAREST is a pure scale, so the call goes throughImagingScaleAffine(), which callocs a4 × WIDTH-byte table in addition to theWIDTH-byte output image.The script runs the child twice:
calloc(300000000, 4)(1.2 GB) fails. Pillow correctly raisesMemoryError, and the child catches it. ButImagingScaleAffine()has already calledImagingDelete()on the output image it does not own. When the output image is released, it is freed again. The child printsMemoryError caughtand then dies with SIGSEGV instead of printingalive.If the attack child was killed by a signal and
gdbis installed, the script runs the attack again undergdband prints the backtrace. The crash is infree()←memory_return_block/ImagingDestroyArray(Storage.c:396,408) ←ImagingDelete(Storage.c:271) ←_dealloc(_imaging.c:188). In other words, the Python object's destructor is deleting the already-freed struct a second time. The verdict does not depend on this step.The last line is
BUGwhen the control exits 0 and the attack printsMemoryError caughtand then dies from a signal without printingalive. Otherwise it isOK. With the attached patch applied, the attack printsMemoryError caught, thenalive, exits 0, and the script printsOK.Suggested fix
ImagingScaleAffine()never ownsimOut. The attached patch removes theImagingDelete()atsrc/libImaging/Geometry.c:866and makes the one caller that relied on it,_resize()insrc/_imaging.c, clean up its own temporary, the same idiom_gaussian_blur()/_unsharp_mask()already use. This also fixes_resize()passing a possibleNULLfromImagingNewDirty()intoImagingTransform(), and leaking the temporary on the otherImagingTransform()error returns:Every
ImagingTransform*()path returns the sameimOuton success, so wrapping the original pointer inPyImagingNew()in_resize()is unchanged behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer printsOKandImage.resize(..., NEAREST, box=...)under the same limit raises a cleanMemoryError.The patch
Stop ImagingScaleAffine() deleting the caller-owned output image on calloc failure; make _resize() free its own temporary instead
Applies to
src/libImaging/Geometry.cat the commit above.The reproducer
Reproducer —
repro, 2,064 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:N/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 ImagingJpegDecode
BIT-pillow-2026-112403 / CVE-2026-112403 / GHSA-8g5g-8vmr-9782 / PYSEC-2026-4332
More information
Details
Summary
JpegImageFile.draft()— whichImage.thumbnail()calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinksself._sizeand the tile extents to match, so_setimage()allocates a single-row decode buffer ofceil(W/s)samples.ImagingJpegDecode()setscinfo.scale_num/scale_denom, callsjpeg_start_decompress(), and then reads scanlines into that buffer without ever comparingcinfo.output_width,cinfo.output_heightorcinfo.out_color_componentsagainststate->xsize,state->ysizeandstate->bits.libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested
scale_denomis silently discarded. It therefore returns full-widthW-sample rows into a buffer sized forceil(W/s). Eachjpeg_read_scanlines()call writes(W - ceil(W/s)) x componentsbytes past the end of the heap allocation, and the overflowing bytes are the decoded pixel values, which are byte-exact for an 8-bit grayscale lossless image.The input is a standards-compliant JPEG, so
Image.open(..., formats=["JPEG"])does not prevent it. Pillow's Python SOF parser already accepts marker0xFFC3("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as aJPEG/ modeLimage at its full declared size.Affected code
At
e41083f383c9cd3db95de52564cc0b6452313d4a(main, 2026-08-29):src/libImaging/JpegDecode.c:224-262—ImagingJpegDecode();jpeg_start_decompress()at :240,jpeg_read_scanlines()at :252, with no geometry check between them.src/decode.c:222-231—_setimage();state->buffer = calloc(1, state->bytes)withstate->bytes = (state->bits * state->xsize + 7) / 8.src/PIL/JpegImagePlugin.py:428-464—JpegImageFile.draft(); computessand shrinks_size, the tile extents anddecoderconfigwith no notion of lossless coding.src/PIL/JpegImagePlugin.py:271—0xFFC3: ("SOF3", "Spatial lossless", SOF).Because
state->ysize(ceil(H/s)) can never equal libjpeg'soutput_heightwhens >= 2, the decode always ends in an error after the rows have already been written: the caller sees an exception and gets no pixels back. TheOSErroris a consequence of the overflow, not the issue being reported.Primitive
Per scanline: a contiguous write of
(W - ceil(W/s)) x cbytes immediately past acalloc(ceil(W/s) x c)block, wherec = 1for modeLand 4 for RGB (RGBX) and CMYK; repeated forceil(H/s)rows over the same region. The buffer size class is chosen by the attacker throughW, and for grayscale the overflowing content is an arbitrary attacker-chosen byte string.Measured in the reproducer below: 1200x1100 mode
L,s=2— a 600-byte allocation, 600 bytes written past it, ASan reporting the whole 1200-byte row write as onememcpy.Threshold:
draft()must selects >= 2, i.e.W // requested_w >= 2andH // requested_h >= 2. Forthumbnail((256,256))with the defaultreducing_gap=2.0that meansW, H >= 1024.Reproduction
Self-contained Docker reproducer (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 and Pillow both built from source with
-fsanitize=address; Pillow checked out at${TARGET_COMMIT}, defaulte41083f383c9cd3db95de52564cc0b6452313d4a). Save the three files below asDockerfile,make_sof3.pyandtrigger.pyin an empty directory, then:make_sof3.pyis a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3P=8, Y=1100, X=1200, Nf=1/ SOSSs=1predictor 1,Se=0,Ah=Al=0/ entropy-coded row differences / EOI), whose rows carry a repeating marker pattern.trigger.pyopens it and callsthumbnail(), whose implicitdraft()pickss=2and allocates a 600-byte row buffer.Dockerfilemake_sof3.pytrigger.pyValidated output:
Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as
draft()computed it.Scope and caveats
mainate41083f383c9cd3db95de52564cc0b6452313d4a, which is currentorigin/mainas of 2026-08-30. Also reported against 12.3.0.Image.thumbnail()callsdraft()by default, and the triggering bytes are entirely file data.draft()before loading are affected.open().load()andresize()decode at scale 1 and are unaffected. Callingimg.load()beforethumbnail(), orthumbnail(..., reducing_gap=None), avoids the path in an application today.JERR_ARITH_NOTIMPLbefore any row; 12- and 16-bit lossless are rejected by Pillow'sSOF()because it requiresbits == 8; SOF5-7/13-15 fail withJERR_SOF_UNSUPPORTED.Credit and disclosure
Found by Anthropic using Claude to study the security of open-source software, validated manually by Ada Logics.
This report follows Anthropic's coordinated vulnerability disclosure policy
(https://www.anthropic.com/coordinated-vulnerability-disclosure), with a 90-day
disclosure window from the date of this report.
Credit goes to Claude (Anthropic) and Ada Logics.
Severity
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/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 over-read in WMF/EMF rendering via 16-bit dimension truncation (Windows builds)
BIT-pillow-2026-96644 / CVE-2026-96644 / GHSA-9v9v-9qvf-h2hj / PYSEC-2026-4343
More information
Details
Summary
On Windows builds, Pillow stores EMF and WMF image dimensions without a range check and passes them to the native
drawwmfhandler. That code sizes its allocation from dimensions truncated into 16-bit header fields but copies the result using the full 32-bit dimensions. A crafted metafile that is wide and short passes the decompression-bomb pixel check yet causes a heap over-read.Details
The EMF branch computes the size straight from signed 32-bit header fields and stores it with no positivity or range check:
src/PIL/WmfImagePlugin.py:121-129reads the bounding box;:148stores it; the size reaches the native handler throughsrc/PIL/WmfImagePlugin.py:56.In the native handler the DIB header fields
bcWidthandbcHeightare 16-bit and truncate the allocation, while the copy length uses the untruncated ints:src/display.c:793-794assign the 16-bit fieldssrc/display.c:800sizes the allocation from themsrc/display.c:827copiesheight * ((width * 3 + 3) & -4)bytes using the full valuesThe whole handler is compiled only on Windows (
#ifdef _WIN32,src/display.c:33-841).PoC
On Linux the first part is observable: the 88-byte file opens,
sizeis(70000, 1), and the bomb check passes. The native over-read only exists on Windows becauseImage.core.drawwmfis compiled out elsewhere (on Linuxim.load()raisesOSErrorinstead).Impact
CWE-125, out-of-bounds read in a C extension on Windows builds. A crafted EMF or WMF that passes the pixel check can read past a heap buffer, leaking memory or crashing the process.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/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: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels
BIT-pillow-2026-112404 / CVE-2026-112404 / GHSA-c6hw-5grf-ff96
More information
Details
Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.
The bug is in
_decodeTile()and_decodeAsRGBA()insrc/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), orimg.width * 4). They then unpack each row with the unpacker thatTiffImagePlugin._setup()chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced._decodeStrip()has exactly this check, added for OSS-Fuzzcrash-0da0in 8.2.0, but the tile and RGBA paths never got it.The two parsers can be made to disagree with a duplicated
PhotometricInterpretationorBitsPerSampletag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).
The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in
copy4←_decodeTile(TiffDecode.c:483) in most runs, depending on what is mapped after the buffer.Details
_decodeTile()and_decodeAsRGBA()insrc/libImaging/TiffDecode.csizestate->bufferfrom what libtiff says it will produce (TIFFTileSize(), resp.img.width * 4), but unpack each row with the shuffler thatTiffImagePlugin._setup()selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width * state->bits / 8bytes) fits inside the row libtiff wrote._decodeStrip()has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout — a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicatedPhotometricInterpretation/BitsPerSample/SamplesPerPixelentry (libtiff keeps the first occurrence,ImageFileDirectory_v2.load()the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (RGBX,RGB;16N,RGBA;16N,I;32,F;32F, …);RGBA;16N(64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.The ~400-byte TIFF
tile_count2.tifgenerated by the reproducer — one 16x16 tile,BitsPerSample 8,SamplesPerPixel 1,Compression 8(Adobe Deflate), andPhotometricInterpretationwritten as a SHORT with count 2 (6, 6) — is opened byImage.open()as modeRGBwith libtiff rawmodeRGBX, andload()succeeds on the stock build. Rows 13–15 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice underMALLOC_PERTURB_=85and=170yields identical rows 0–12 and different bytes in rows 13–15 (aavs55, i.e. glibc's fill pattern). Under an ASan build of the extensions the sameload()reportsheap-buffer-overflow READ of size 64incopy4(src/libImaging/Unpack.c:1290) called fromsrc/libImaging/TiffDecode.c:483, against the 256-byte region allocated atTiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV incopy4 ← _decodeTilereading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).Why the two parsers disagree
libtiff 4.5 rejects a
PhotometricInterpretationentry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this fileTIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, …)returns 0 and libtiff treats the data as one 8-bit sample per pixel:TIFFTileSize() = 256,TIFFTileRowSize() = 16.Pillow keeps the first value and carries on:
so
_setup()seesphoto == 6.OPEN_INFO[(II, 6, (1,), 1, (8,), ())]is("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:RGBXis a 32-bit rawmode, soget_unpacker()(src/decode.c:301-309) setsstate->bits = 32andstate->shuffle = copy4. The intent of thephoto == 6override is that libtiff will deliver RGBA throughTIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":The missing check
_decodeTile()only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactlyTIFFTileSize()bytes and feedsrow_byte_size-strided rows to the shuffler:Row
tile_yis read from[16·tile_y, 16·tile_y + 64); fortile_y= 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to(state->bits / libtiff_bits_per_pixel − 1) × row_byte_sizebytes, androw_byte_sizescales with the attacker-chosenTileWidth.Note also that the existing one-sided check at line 434 multiplies the file-controlled
tile_width/tile_length(UINT32, accepted up toINT_MAXat line 428) bystate->bitsin 32-bit unsigned arithmetic, sotile_width * state->bitswraps fortile_width ≥ 2^32 / bits(2^26 for the 64-bit rawmodes, 2^27 forRGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image withTILEWIDTH = 2^26+16,TILELENGTH = 1, 1-bit data for libtiff andRGBA;16Nfor Pillow makes libtiff allocate an ~8 MB tile from whichunpackRGBA16Lreads 16 MB, and the stock build SIGSEGVs inunpackRGBA16L ← _decodeTile (TiffDecode.c:483); a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.Compare the strip path, which refuses this situation (added for OSS-Fuzz
crash-0da0in 8.2.0):The identical file written with one strip instead of one tile fails
load()withOSError: decoder error -2because of this check.Same gap in
_decodeAsRGBA()_decodeAsRGBA()always producesimg.width * 4bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks withstate->shuffle(line 373) without looking atstate->bits. It is reached when libtiff sees YCbCr while Pillow does not forceRGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow'sImageFileDirectory_v2.load()overwrites (TiffImagePlugin.py:945, last wins). The reproducer'sycbcr_dup.tifis a 16x16 Deflate strip withPhotometricInterpretationpresent twice (6, then2) andBitsPerSampletwice (8,8,8, then16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selectsRGB;16N(state->bits = 48), andunpackRGB16Lreads 96 bytes per 64-byte row — ASan reportsheap-buffer-overflowinunpackRGB16L(Unpack.c:609) fromTiffDecode.c:373, past the 1024-byte region fromTiffDecode.c:339.The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample
photo == 6file only gets rawmodeRGBXsince the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with twoPhotometricInterpretationentries (1or2, then6) produces the samecopy4←_decodeTileover-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps6and unpacks 4 (the reproducer'stile_dup.tif).ycbcr_dup.tifabove reaches_decodeAsRGBA()the same way.Impact
Any application that calls
Image.open()+load()(orthumbnail(),convert(),save(), …) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff andTiffDecode.cwithoutREAD_LIBTIFFbeing set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included — up to three libtiff tile rows past the buffer for theRGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker.state->bufferis allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about(unpacker_bpp / libtiff_bpp − 1)tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sizedImagingrows, so this is disclosure / crash, not memory corruption.This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in
TiffDecode.c); that fix and the latercrash-0da0fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a ("Check for dimensions and sizes to fit into int", 2021-01-25), which switched_decodeTile()from a buffer sized by Pillow's unpacker toTIFFTileSize()/TIFFTileRowSize(); it is first in Pillow 8.2.0. The_decodeAsRGBA()half dates from eb8c1206d6b1 ("Fix CVE-2020-35654 - OOB Write in TiffDecode.c"), first in 8.1.0.Reproducing
Run
repro(a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and
TiffDecode.c. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:tile_count2.tif: a 16x16, 1-sample, 8-bit tile withPhotometricInterpretationwritten as SHORT count 2,(6, 6). Pillow keeps6and unpacks withcopy4(RGBX, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).tile_dup.tif: a 16x16, 3-sample, 8-bit tile withPhotometricInterpretationpresent twice,2then6. libtiff keeps2(RGB, 3 bytes per pixel) and Pillow keeps6(RGBX, 4 bytes per pixel). This variant also applies to released versions.ycbcr_dup.tif: the 16x16 strip withBitsPerSample8,8,8then16,16,16andPhotometricInterpretation6then2. libtiff decodes 8-bit YCbCr through_decodeAsRGBA()(4 bytes per pixel), and Pillow unpacksRGB;16NwithunpackRGB16L(6 bytes per pixel).tile_big.tif: thetile_count2.tiflayout with a single 65536x16 tile.For each of the first three files, the script runs
Image.open()+load()+tobytes()twice, once withMALLOC_PERTURB_=85and once with=170. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: theaa/55fill bytes show up, along with heap pointer bytes. The script then decodestile_big.tifand checks for a SIGSEGV (exit status -11). Ifgdbis available, it prints the top frames:copy4(Unpack.c:1290) ←_decodeTile(TiffDecode.c:483) ←ImagingLibTiffDecode. This crash depends on ASLR, so an occasional run may survive.Each step prints which
_imagingbuild it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line isBUGif any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it isOK.Suggested fix
The attached patch restores the
_decodeStrip()invariant (the unpacker's input row must fit inside libtiff's row) in the two sibling decoders insrc/libImaging/TiffDecode.c, at the point where each one sizes its buffer:_decodeTile()computesunpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8(thetsize_tcast keeps the product out of 32-bit arithmetic, so theTILEWIDTH = 2^26+16case above cannot wrap past the check on 64-bit builds). It reuses that value for the existingTIFFTileSize()sanity check, as_decodeStrip()does for strips. After that check and before therealloc, it fails withIMAGING_CODEC_BROKENwhenunpacker_row_byte_size > row_byte_size(TIFFTileRowSize())._decodeAsRGBA()fails withIMAGING_CODEC_BROKENwhenstate->bits > 32, becauseTIFFRGBAImagerows are always 32 bits per pixel. This check runs beforeTIFFRGBAImageBegin()and any allocation.With the patch, the tile and RGBA files fail
load()withOSError: decoder error -2, the same result the equivalent strip file already gets. The patch also adds a regression test toTests/test_file_libtiff.py(test_unpacker_row_wider_than_libtiff_row). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file's existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, andTests/test_file_tiff.py,Tests/test_file_libtiff.pyandTests/test_tiff_crashes.pypass.These checks close the over-read however the two parsers are made to disagree. Separately,
_setup()could stop deriving the libtiff rawmode from tags that libtiff will read differently — e.g. treat aPhotometricInterpretation/BitsPerSample/Compressionthat is not a single well-typed value as "unknown pixel mode", the wayImageWidth/ImageLengthare already required to beintatTiffImagePlugin.py:1473, or haveImageFileDirectory_v2.load()keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) — but that only narrows the ways to reach the C-side gap rather than removing it:TiffImagePlugincannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read.The patch
Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does
Applies to
src/libImaging/TiffDecode.cat the commit above.The reproducer
Reproducer —
repro, 4,178 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:N/PR:N/UI:N/VC:L/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: NULL pointer dereference in Image.merge via an unvalidated band 0
BIT-pillow-2026-112405 / CVE-2026-112405 / GHSA-cp9v-662q-vvx9 / PYSEC-2026-4333
More information
Details
Summary
Image.merge()checks the mode of every input band except band 0. If band 0 is a single-bandIorFimage, the native merge routine reads a pixel pointer that is NULL for those modes and the process crashes. The IPTC plugin reaches this from a plainImage.open()followed by a load, and IPTC has no magic-byte prefilter, so a crafted file with no recognizable header can drive it.Details
The validation loop skips band 0:
src/PIL/Image.py:3795iteratesfor band in bands[1:]and checks mode and size only for those.For an
IorFimage the 8-bit planeimage8is NULL (onlyimage32is set).ImagingMergedereferencesbands[0]->image8[y]for the multi-band output modes:src/libImaging/Bands.c:302,:311,:321The IPTC loader builds the band list from an attacker-typed sub-image and passes it to merge with the sub-image at index 0:
src/PIL/IptcImagePlugin.py:156-160src/PIL/IptcImagePlugin.py:169registers the opener with no accept function.PoC
Run it:
Observed on a source build of commit
3e2ab96.Under AddressSanitizer:
The null address confirms the dereference of the missing
image8plane for band 0.Impact
NULL pointer dereference in a C extension. A crafted file opened and loaded through the IPTC path crashes the worker process. This is a hard crash, not a Python exception an application can catch.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/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: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle
BIT-pillow-2026-112406 / CVE-2026-112406 / GHSA-f2g6-542v-9j58
More information
Details
A PDF file of a few hundred bytes makes Pillow hang forever, holding one processor core at 100%, when an application tries to append an image to it. Anyone who can supply the existing PDF to an application that calls
im.save(fp, "PDF", append=True)(orsave_all=True, append=True) can tie up that worker indefinitely. Memory use stays flat, so only an external timeout stops it.The bug is in
PdfParser.rewrite_pages()insrc/PIL/PdfParser.py(thewhile pages_tree_node_ref:loop at line 505). For each original page, it walks the/Parentchain upward and stops only when it reaches a node with no/Parent. It keeps no record of nodes it has already visited, so a loop in that chain never ends. The simplest case is a/Pagesroot that lists itself as its own/Parent; a longer cycle among page-tree nodes behaves the same way. The path isPdfImagePlugin._save()→write_catalog()→rewrite_pages(), and it is reached whether the save target is a file path or aBytesIO. PlainImage.open()pipelines are not affected, because Pillow only parses PDFs on the append path.This is a sibling of CVE-2026-42310 (the cyclic trailer
/Prevloop in the same parser). Theprocessed_offsetsguard added toread_prev_trailer()for that issue does not cover this walk. The loop was introduced in 24ecfe315ab5 (2018-03-03) and has been present in every release since 5.1.0.The attached reproducer builds two in-memory PDFs and appends a 4×4 image to each. The normal file finishes in about 45 ms. The 333-byte self-parented file is still spinning inside
rewrite_pages()when a 5-second watchdog interrupts it. Two alternative fixes are attached: one silently tolerates the cycle, and one rejects the file with an error.Details
PdfParser.rewrite_pages()walks the/Parentchain of every pre-existing page upward and stops only when a node has no/Parent. It keeps no record of visited nodes, so a page-tree node whose/Parentpoints at itself, or at any node below it, makes the walk spin forever.The following 333-byte file is a valid one-page PDF except that object 2, the page-tree root, declares
/Parent 2 0 R:Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)on these bytes never returns and holds one core at 100 % (the same happens when the target is a file path openedr+b). Interrupting it withSIGALRMafter 5 s gives this traceback every time:The same file without
/Parent 2 0 Ron object 2 is appended to in ~30 ms.The loop:
How the input gets there: with
append=True,PdfImagePlugin._save()opens the existing bytes withPdfParser.PdfParser(f=fp, …, mode="r+b")(src/PIL/PdfImagePlugin.py:199), whose constructor callsread_pdf_info(). That reads the catalog, reads the page-tree root throughread_indirect()— which stores every object it reads inself.cached_objects(src/PIL/PdfParser.py:1077) — and flattens the tree withlinearize_page_tree(), whichread_indirect()s every/Kidsentry. So after parsing,orig_pages == [3 0 R]and objects 2 and 3 are both incached_objects._save()then callsexisting_pdf.write_catalog()→rewrite_pages().For the file above, the first loop puts page 3's
/Parent,2 0 R, intopages_tree_nodes_to_delete. The innerwhilelooks object 2 up incached_objects, removes it from the xref table on the first pass, and setspages_tree_node_refto object 2's/Parent— which is2 0 Ragain. Nothing in the loop body changes on subsequent iterations, so it never exits:cached_objects[2 0 R]is never evicted, and deleting an already-deleted id fromXrefTableis silent —__delitem__only records the id indeleted_entries, while__contains__keeps answering fromexisting_entries. The loop performs no I/O and no allocation, so neitherMemoryErrornor the recursion limit intervenes. A longer cycle behaves identically as long as every node on it was reached throughlinearize_page_tree()and is therefore cached (e.g. root 2 →/Kids [4 0 R], node 4 →/Kids [3 0 R] /Parent 2 0 R, and root 2 declaring/Parent 4 0 R); a/Parentpointing at an object that was never read raisesKeyErrorinstead, which is harmless.The parser already defends against exactly this shape of problem one level up:
read_prev_trailer()(src/PIL/PdfParser.py:699-722) carries aprocessed_offsetslist and raisesPdfFormatError("trailer loop found")when a/Prevoffset repeats — the fix for CVE-2026-42310. The/Parentwalk inrewrite_pages()has no equivalent guard.The unguarded walk was introduced in
24ecfe315ab5("issue #2959: fix wrong Parent of pre-existing Page objects when appending", 2018-03-03) and has been in every release since 5.1.0.Impact
Any application that appends pages to a PDF it did not produce —
im.save(fp, "PDF", append=True)orsave_all=True, append=Trueon a user-supplied document — hangs indefinitely with one CPU core pinned, on an input of a few hundred bytes. The loop is pure Python doing two dict lookups per iteration, so memory stays flat and memory limits do not end it; only an external watchdog does. In a web worker or batch converter that is a denial of service of that worker per request. The attacker needs to control only the bytes of the existing PDF; no non-default Pillow setting is involved beyond the application choosing the append path. Pillow does not open PDFs for reading, so plainImage.open()pipelines are not affected.This is a sibling of CVE-2026-42310 (infinite loop on a cyclic trailer
/Prevchain in the same parser, same entry point): same class, different linked structure, and the guard added for that CVE does not cover it.Reproducing
reprois a single Python script that runs against the installed Pillow (/src/src/PIL). It needs no build step and no companion files, and takes about 5 seconds.The script builds two one-page PDFs in memory with
make_pdf(). Each has a Catalog (object 1), a/Pagesroot (object 2) and a/Page(object 3), and each has a correct xref table. The only difference between them is in object 2:controlhas<</Type /Pages /Kids [3 0 R] /Count 1>>. This is a normal file.cyclichas<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>, so the page-tree root names itself as its own/Parent. This is the 333-byte file shown above.For each PDF the script prints the bytes and then calls
Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)under a 5-secondSIGALRMwatchdog. If the watchdog fires, the script prints the Pillow frames the append was running in. The control runs first and has to finish. If it doesn't, the environment is broken, so the script printsOKwithout giving a verdict.Expected output on this commit (PDF dumps omitted):
The innermost one or two frames change from run to run, because the alarm can land anywhere in the loop body at lines 505–509. The
rewrite_pages()frame is always there. The control's timing shows that the same file without the self-reference is appended to in milliseconds. A fixed parser either finishes the cyclic append just as fast or rejects the file with an exception. In both cases the script printsOK. With attached patch 1 applied it prints[cyclic] append finished in 0.001sandOK. With attached patch 2 it prints[cyclic] raised PdfFormatError: page tree loop foundandOK. The last line isBUGonly when the cyclic append is still running when the watchdog fires.Suggested fix
Both attached patches fix the root cause, which is that the
/Parentwalk inrewrite_pages()keeps no record of the nodes it has visited. Either one can be merged; they differ only in what happens to a malformed file.Attached patch 1 (tolerant; our preference) adds one
processed_pages_tree_nodesset that is shared across all pages in the delete loop, and breaks out of thewhileas soon as it reaches a node it has already seen:Breaking loses nothing, because a visited node has already been removed from the xref table. The output is the same as today for every file that terminates. The file above is appended to normally, and object 2 ends up deleted. As a side effect, the ancestor chain that every page shares is walked once in total rather than once per page.
Attached patch 2 (strict) follows the
read_prev_trailer()guard from the CVE-2026-42310 fix. It keeps aprocessed_pages_tree_nodeslist for each page and callscheck_format_condition(pages_tree_node_ref not in processed_pages_tree_nodes, "page tree loop found"), so a cyclic file is rejected withPdfFormatError. The list is per page because ancestors are legitimately shared between the pages of a well-formed document. Note that the error is raised fromwrite_catalog(). By then the new page objects have already been written to ther+bfile, but the new xref/trailer has not. Any other error raised on the append path today leaves the file in the same partially written state.Each patch also adds a
test_page_tree_parent_loopregression test toTests/test_pdfparser.py. The test builds the self-parented PDF above in memory, so no binary test asset is needed. No other/Parentwalk exists in the parser, andlinearize_page_tree()does not follow/Parent.Note that this walk follows
/Parentlinks, which nothing else inPdfParserreads:linearize_page_tree()only follows/Kidsdownward and never looks at/Parent, so a visited-set added to the/Kidsrecursion (see Related findings) does not reach this loop. The file above has a perfectly tree-shaped/Kidsstructure and still hangs; both walks need their own guard, and they are natural to land together as one "validate page tree while appending" change.Related findings
PdfParser.linearize_page_tree()(repeated/Kidsreferences) hangs PDF append — the downward counterpart of this bug in the same parser and on the sameImage.save(..., "PDF", append=True)entry point:linearize_page_tree()(src/PIL/PdfParser.py:1080-1094) recurses into every/Kidsentry and re-parses it viaread_indirect()with no visited set, so a/Pageschain whose nodes list the same child N times over D levels costs ND object parses (a 1.9 KB, 10-wide × 12-level file never returns; a 5.8 KB, 300-wide × 3-level file runs for 13 minutes; a direct/Kidscycle ends inRecursionError). It differs from this report in the link followed (/Kidsdown vs/Parentup), the function and phase (linearize_page_tree()duringread_pdf_info()in thePdfParserconstructor vsrewrite_pages()duringwrite_catalog()), the introducing commit (6207b44ab1ffvs24ecfe315ab5), and the symptom (exponential but finite CPU vs a true infinite loop). It needs a separate hunk: the patches attached here only guard the/Parentwalk and leave the/Kidsreproducer hanging inlinearize_page_tree(), and the/Kidsguard conversely leaves this reproducer spinning inrewrite_pages()— verified by cross-applying each patch. Applied together (the/Kidsguard plus attached patch 2), both reproducers are rejected withPdfFormatError: page tree loop foundandTests/test_pdfparser.py+Tests/test_file_pdf.pypass (30 tests).The patch (1 of 2)
Track visited page-tree nodes in PdfParser.rewrite_pages() so a cyclic /Parent chain cannot loop forever when appending to a PDF
Applies to
src/PIL/PdfParser.pyat the commit above.The patch (2 of 2)
Raise PdfFormatError("page tree loop found") in PdfParser.rewrite_pages() when a page's /Parent chain loops back on itself
Applies to
src/PIL/PdfParser.pyat the commit above.The reproducer
Reproducer —
repro, 3,152 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:N/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: Quadratic GIF comment copying during save
BIT-pillow-2026-112891 / CVE-2026-112891 / GHSA-fc9g-5qg7-mf45
More information
Details
On Pillow 12.3.0 and upstream
main(ad277e2761b489681e71b51e91367d46b0ceabb9), a GIF with a long comment makes_get_global_header()copy portions of the comment repeatedly when saving, including comments carried over from untrusted images. I suggest provisional Moderate severity for crafted-image CPU denial of service, with CVSS-B 6.9 assuming a network service automatically opens unauthenticated uploads and they are resaved as GIFs; impact depends on input-size, concurrency, and worker CPU limits.Severity
CVSS:4.0/AV:N/AC:L/AT:N/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: PNG eXIf chunk makes EXIF parsing run in quadratic time
BIT-pillow-2026-96643 / CVE-2026-96643 / GHSA-g4qv-2jxx-769h / PYSEC-2026-4342
More information
Details
Summary
A PNG file with a large eXIf chunk full of repeated 6-byte markers makes Pillow's EXIF parser take seconds of CPU instead of milliseconds. The cost grows with the square of the chunk size, so a normal small-photo-sized file is enough to cause real delay.
Details
Exif.load()insrc/PIL/Image.pystrips a marker from the start of the EXIF data like this:Each pass makes a new copy of the remaining data. A real camera writes this marker once, so this is not normally a problem.
chunk_eXIf()insrc/PIL/PngImagePlugin.pyputs the raw PNG eXIf chunk straight intoinfo["exif"]with no size limit and no check of its content. PNG text chunks (tEXt, zTXt, iTXt) are capped withMAX_TEXT_CHUNKandMAX_TEXT_MEMORY. The eXIf chunk has no such cap.If the eXIf chunk is filled with repeated
Exif\x00\x00markers instead of real EXIF data, the strip loop runs once per marker, copying a large buffer each time. This is a classic quadratic time bug.Steps to reproduce
This builds a small PNG in memory and times
Image.open().getexif()on it. No external file needed.Expected output on a fixed version: well under 0.1 seconds, the same as a normal file this size.
Actual output on the current code: around 3.5 to 3.7 seconds, for a single 1.8MB file. We checked with a prohis time is spent inside the strip loop itself, not somewhere else. We also checked the growth rate across a
few sizes (200KB, 600KB, 1.2MB, 1.8MB) and it matches quadratic growth closely, not linear.
A pytest version of this test, using
pytest-timeoutso a test run does not hang if the bug is present, is it fails withFailed: Timeout (>2.0s) from pytest-timeout.Impact
Image.getexif(),Image._getexif(), andImageOps.exif_transpose().exif_transpose()is the function Pillow's own docs recommend for auto-rotating user photos, so this can hit normal upload handling code, not just an unusual call.Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/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 writing JPEG comment after ICC profile fills buffer
BIT-pillow-2026-96778 / CVE-2026-96778 / GHSA-g7cr-3p24-7wf7
More information
Details
A heap buffer overflow in Pillow's JPEG encoder lets anyone who can supply an image write up to 64 KB of bytes they choose, followed by the rest of the compressed image, past the end of a 64 KB heap buffer. The target is any application that re-saves the image as JPEG while keeping its colour profile. The reliable result is that the Python process is killed outright rather than raising an exception. Because the overwritten memory sits directly in front of function pointers that the encoder calls next, this could allow code execution, though the attached reproducer demonstrates only the crash.
The bug is in
ImagingJpegEncode()insrc/libImaging/JpegEncode.c. State 4 (line 330) callsjpeg_write_marker()for the COM comment marker without checking that the output buffer has room. Meanwhile,JpegImagePlugin._save()sizes that buffer from the EXIF and ICC/XMP data only and reserves nothing for the comment. When a 65498-byte ICC profile fills the 65536-byte buffer exactly, libjpeg'semit_byte()stores one byte past the end and then decrementsfree_in_bufferfrom 0. Thesize_tcounter wraps, so the marker, the comment and the entire compressed image are written out of bounds.Triggering it takes only a valid JPEG (or PNG) carrying that profile and any comment, passed through
Image.open()and thenim.save(..., "JPEG", icc_profile=im.info.get("icc_profile")). That is the documented way to preserve colour. The comment is forwarded fromim.infoautomatically, and no non-default setting is involved.The attached reproducer re-saves such a file on the stock build, and the process dies with SIGSEGV. An identical file without the comment saves normally. An ASan build of Pillow will not flag this, because the stray writes happen inside the system libjpeg-turbo. Valgrind does report it, as an invalid write immediately after the 65569-byte allocation.
The unguarded call was introduced in e50ae85ea406 ("Use jpeg_write_marker to write comment", 2022-12-05) and first shipped in 9.4.0.
Details
ImagingJpegEncode()(src/libImaging/JpegEncode.c) is a resumable state machine that fills a caller-supplied output buffer ofbytesbytes and returns when it is full. State 4 writes the JPEG COM marker withjpeg_write_marker()without first checking that the buffer has any room left. When the preceding state 3 — the verbatim copy of the "extra" APP segments (ICC profile / XMP) — ends exactly at the last byte of the buffer,jpeg_write_marker()is entered withfree_in_buffer == 0. libjpeg'semit_byte()stores the byte first and tests--free_in_buffer == 0afterwards, so thesize_tcounter wraps to 2^64-1 instead of triggering the "buffer full" path, and the 4-byte marker header, the whole comment, and then (state 5) the DQT/SOF/DHT/SOS headers and the entire entropy-coded image are written linearly past the end of the 65536-byte heap buffer allocated insrc/encode.c.A JPEG laid out as
opened with
Image.open()and re-saved with the usual colour-profile-preserving callkills the Python process on the stock build at this commit: glibc aborts with
realloc(): invalid next size/corrupted size vs. prev_size, or the process takes SIGSEGV insidejpeg_write_marker/jpeg_write_scanlines. Stopping atJpegEncode.c:330in gdb showsnext_output_byte - buf == 65536,free_in_buffer == 0,comment_size == 65533before the call, andnext_output_byte - buf == 131073,free_in_buffer == 18446744073709486079after it — 65537 bytes already written beyond a 65536-byte buffer before a single scanline has been encoded. An ASan build of Pillow does not flag this, because the out-of-bounds stores happen inside the (uninstrumented) system libjpeg-turbo; valgrind does, reportingInvalid write ... 0 bytes after a block of size 65,569fromjpeg_write_markercalled atJpegEncode.c:330before aborting on the corrupted heap metadata.Code path
The buffer.
JpegImagePlugin._save()sizes the encoder buffer from the metadata it is about to emit, allowing one spare byte after the extra block and nothing for the comment:ImageFile._save()then takesmax(MAXBLOCK, bufsize, width*4)(src/PIL/ImageFile.py:662,MAXBLOCK = 65536), and_encode()allocates exactly that:buf = PyBytes_FromStringAndSize(NULL, bufsize)(src/encode.c:134; the real-file path_encode_to_file()doesmalloc(bufsize)atsrc/encode.c:190). For the input abovelen(extra) == 65516, so the buffer is 65536 bytes. Theoptimize/progressivebranch just above (JpegImagePlugin.py:846-858) is not immune: itsbufsizeaddsw*h(or2*w*h), but the image dimensions and the ICC length both come from the file, so an exact fit is still constructible there (e.g. with a 19×1 image).The "+1 spare byte" does not account for what libjpeg itself has already put in the buffer: in state 1
jpeg_start_compress(&cinfo, TRUE)(JpegEncode.c:279) emits SOI + JFIF APP0, 20 bytes for an RGB source. State 3 then copies the extra block, clamped to the space remaining:Every other emitting state is guarded — state 2 requires
rawExifLen + 5 <= free_in_buffer(:287), state 5 requires 1024 free bytes (:340), state 6 requires 100 (:368). State 4 has no guard at all. The unguardedjpeg_write_marker()call was introduced ine50ae85ea406("Use jpeg_write_marker to write comment", 2022-12-05) and first shipped in 9.4.0; at 9.3.0 the same input re-saves correctly.With 20 header bytes plus 65516 extra bytes the copy consumes the buffer to the byte (
free_in_buffer == 0) and, because the extra block is complete, control falls straight into state 4. One re-wrapped ICC APP2 segment costs 18 bytes of overhead (JpegImagePlugin.py:788-806), hence the 65498-byte profile payload. Pillow's destination manager cannot suspend (empty_output_buffer()returns FALSE,JpegEncode.c:45-48), which is why a nearly full buffer is harmless: with 1..N free bytes the comment write decrementsfree_in_bufferto 0 part-way, libjpeg raisesJERR_CANT_SUSPEND, and the save fails withOSError: broken data stream. Only the exact-fit case skips that check, because libjpeg-turbo's marker writer isi.e. it writes
buf[65536]first and then wraps the counter. From there nothing limits the stream: the comment (up to 65533 attacker-chosen bytes) is written verbatim, state 5's 1024-byte check passes against the wrapped counter, andjpeg_write_scanlines()appends the full compressed image (≈100 KB for a 256×256 noisy source; proportional to the picture for larger ones). If the process survives that,ImagingJpegEncode()returnsnext_output_byte - buf(≈167000) as the byte count and_encode()calls_PyBytes_Resize(&buf, 167199)on the 65536-byte object, which is where glibc usually notices the smashed neighbouring chunk.All three ingredients come from the file.
JpegImagePluginreassembles APP2ICC_PROFILEchunks intoim.info["icc_profile"](:238-241) and stores the COM payload inim.info["comment"](:194);_save()forwards the comment on its own —comment = info.get("comment", im.info.get("comment"))(:808) — so the application only has to pass the ICC profile through, which is the standard way to keep colours correct when re-encoding (docs/handbook/image-file-formats.rst:608). Unlikeexifandxmp, there is no length check on the comment in_save(). A PNG source works the same way: itsiCCPchunk becomesim.info["icc_profile"]and atEXtchunk keyedcommentbecomesim.info["comment"](and, being zlib-compressed, the file is only ~60 KB); that variant was also confirmed to crash.thumbnail()andconvert()carryim.infoalong, so the usualopen → thumbnail → convert("RGB") → save("JPEG", icc_profile=…)handler reaches the same state.xmp=andextra=feed the sameextrablock (33 bytes of overhead for the XMP APP1) and reach the same state. If the application also forwards EXIF, the exact-fit condition becomeslen(extra) + len(exif) + 4 == 65516, both lengths again taken from the file. The comment length is irrelevant to triggering: a 17-byte"Created with GIMP"comment crashes the stock build just as reliably (SIGSEGV injpeg_write_scanlines), it only changes how many of the out-of-bounds bytes are chosen verbatim by the attacker.What lies behind the buffer
The buffer is
PyBytes_FromStringAndSize(NULL, 65536)=malloc(65569), a 0x10030-byte glibc chunk, sobuf[65536]is the first byte of the next chunk's header. In the sameencode()call, right after that allocation,jpeg_start_compress()mallocs libjpeg's PERMANENT pool (chunk 0xa40) and IMAGE pool (chunk 0x3f00); on a groomed or fresh heap these sit directly behind the buffer. The next thingImagingJpegEncode()does after the COM write (JpegEncode.c:352; the state-5 room check passes becausefree_in_bufferhas wrapped) is calljpeg_write_scanlines(), which dispatches through the method tables in that IMAGE pool before any allocator call touches the heap. In the SIGSEGV layout observed on the stock build the fault is atjpeg_write_scanlines+133: call *0x8(%rax)with%raxpointing just past the end of the buffer at memory now holding comment bytes — an indirect call through a function pointer (cinfo->main->process_data) read from bytes the file supplied.Impact
Any service that accepts JPEG uploads and re-encodes them as JPEG while preserving the embedded ICC profile (
icc_profile=im.info.get("icc_profile")— thumbnailers, CMS and e-commerce image pipelines, Django/Flask upload handlers) can be made to perform a linear heap overwrite starting immediately after a 65536-byte buffer: 4 marker bytes, then up to 65533 bytes taken verbatim from the file's COM segment, then tens to hundreds of kilobytes of compressed image data. No non-default Pillow setting is involved and the file is an otherwise valid 140–210 KB JPEG that decodes normally. The dependable outcome is termination of the worker process by SIGSEGV or a glibc heap-consistency abort (not a Python exception), i.e. denial of service of everything that process serves.It does not stop at a crash. Both the length and the content of the first 64 KB written out of bounds are attacker-chosen, the victim buffer is an ordinary
mallocchunk, and whatjpeg_start_compress()allocates directly behind it — libjpeg's PERMANENT and IMAGE pools, including the method pointersjpeg_write_scanlines()calls through immediately afterwards — is predictable. Against a long-lived stdlib-http.serverworker running exactly the handler above (stock/srcbuild, CPython 3.12 / glibc 2.36 / libjpeg62-turbo 2.1.5, ASLR/PIE/NX on, default Pillow settings, worker as root, attacker as uid 65534 given only the service address), this overflow was driven to shell command execution by the worker in two independent ways once library base addresses were known: (a) a JPEG with ~2400 APPn spray segments (heap grooming), the 65498-byte ICC profile and a 3020-byte COM whose bytes re-image the two libjpeg pools so that the realgrayscale_convert()copies a chosen 384-byte scanline over_PyRuntime, settingallocators.obj_arena.free = systemwithctx= the command — the request returns 200, the worker keeps serving, and the next pymalloc arena release runs the command (14/14 and 15/15 runs on fresh workers); (b) a 28-byte COM that rewrites thesize/nextof a groomed free tcache chunk sitting right behind the buffer so that Pillow's ownmalloc(comment_size)in the next JPEG save (src/encode.c:1250) returns_imaging.so's writable.got.plt, the comment replacesfreewithsystem, and the encoder's cleanupfree(context->comment)atJpegEncode.c:376runs the command (14/14, deterministic once groomed). The address knowledge came from separate memory-disclosure bugs in Pillow's TIFF paths that return library pointers in output pixels/tags; those are independent defects with their own fixes, but any such disclosure — present or future — is enough, and this overflow is the only memory write either chain needs. Fixing it reduces those leaks to disclosures rather than footholds.Reproducing
reprois a single Python script. It uses the Pillow already built in/srcand needs no rebuild. Run it as./repro. The last line of output isBUGorOK.The script builds a valid 256×256 baseline JPEG from deterministic noise, so the compressed image data comes to about 100 KB. Right after SOI it adds an APP2
ICC_PROFILEsegment (sequence 1 of 1) with a payload of65536 - 20 - 18 = 65498bytes. When Pillow re-encodes the image, libjpeg first writes 20 bytes of SOI and JFIF APP0, and then Pillow copies the re-wrapped ICC segment: 18 bytes of header plus the payload. Together those fill the 65536-byte encoder buffer to the last byte. The constants at the top of the script spell out this arithmetic.Each file goes through the same application code in a child
python3 -X faulthandlerprocess:Cbytes.JpegImagePlugin._save()forwardsim.info["comment"]automatically. Case 4 ofImagingJpegEncode()writes the COM marker into the full buffer, thefree_in_buffercounter wraps, and the comment and the entire compressed image are written past the end of the heap buffer.Expected output: the control prints
save returned normallyand-> exit status 0. For the PoC, faulthandler printsFatal Python error: Segmentation fault(sometimes a glibc message such asrealloc(): invalid next sizefollowed byAborted). The Python stack ends inImageFile._encode_tilecalled fromim.save, then-> exit status -11(or-6), and finallyBUG. The verdict isBUGonly if the PoC child was killed by a signal. A normal return or a Python exception such asOSErrorgivesOK.Suggested fix
The attached patch guards state 4 the way state 2 guards the EXIF marker: before calling
jpeg_write_marker()it requires room for the 4-byte marker header plus the comment plus libjpeg's one-byte slack (emit_bytemust never bringfree_in_bufferto 0 with a non-suspending destination), and otherwise returns to the caller for a fresh buffer:The encoder then returns with the buffer full,
_encode_tile()calls it again with an empty buffer, and the comment is emitted there. The C hunk is not sufficient on its own: state 4, like state 2, relies on the Python side guaranteeing that an empty buffer is always large enough, and a 65533-byte comment needs 65538 bytes against a 65536-byteMAXBLOCKbuffer — with only the C guard the encoder returns 0 bytes and no error forever and_encode_tile()spins. The patch therefore also changesJpegImagePlugin._save()to reservelen(comment) + 5in both sizing branches (added tobufsizein theoptimize/progressivebranch, and as a third term of themax()in the plain branch), and normalises the comment tobytesfirst (stris UTF-8-encoded,Nonebecomesb"") so the length is exact for non-ASCIIstrcomments;encode.calready UTF-8-encodesstrviaz#, so the bytes written are unchanged. A side effect visible to users: comments of up to 65533 bytes now save successfully in cases that previously raisedOSError: broken data streambecause the default buffer was too small. The patch addstest_icc_big_commenttoTests/test_file_jpeg.py, which round-trips a 65533-byte comment with ICC profiles sized around the exact-fit boundary; it crashes (or raisesOSError) on the unpatched tree and passes with the patch. With the patch applied to a clean tree and rebuilt, the reproducer no longer triggers, andTests/test_file_jpeg.pyandTests/test_file_mpo.pypass.The C guard still depends on the Python sizing to avoid a 0-byte/no-error busy loop, exactly as state 2 already does. A more defensive variant would fail with
IMAGING_CODEC_MEMORYinstead ofbreakwhen the marker does not fit and nothing has been written to the current buffer yet (next_output_byte == buf), so that a sizing mistake in any caller can never turn into a busy loop. It would also be consistent withexif/xmphandling for_save()to reject comments longer thanMAX_BYTES_IN_MARKER(65533) withValueError("Comment is too long")rather than leaving it to libjpeg'sJERR_BAD_LENGTH→OSError.Independently, the "+ 1 spare byte" / "+ 5" reasoning in
_save()under-counts by the 18–20 bytesjpeg_start_compress()writes before the EXIF/extra blocks; that is what lets attacker-sized metadata land the buffer on an exact boundary in the first place. The attached patch does not change that accounting.The patch
Check free output-buffer space before writing the JPEG COM marker in ImagingJpegEncode, and size the encoder buffer for the comment in JpegImagePlugin._save
Applies to
src/libImaging/JpegEncode.cat the commit above.The reproducer
Reproducer —
repro, 3,343 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:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Decompression bomb check bypass in SpiderImagePlugin.seek() leads to unbounded allocation DoS
BIT-pillow-2026-96637 / CVE-2026-96637 / GHSA-g842-9rc4-69pw / PYSEC-2026-4338
More information
Details
Summary
SpiderImagePlugin's seek() bypasses the decompression bomb check, allowing a tiny (<1KB) crafted SPIDER stack file to trigger a multi-gigabyte allocation on im.load().
Affected versions
All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84)
Root cause
Image.open() runs Image._decompression_bomb_check(im.size) exactly once, in _open_core (src/PIL/Image.py:3668), right after the initial _open().
SPIDER stack files are multi-frame: SpiderImageFile.seek(frame) (SpiderImagePlugin.py:179-190) computes an attacker-influenced stkoffset and calls self._open() AGAIN directly (line 190) — NOT through Image.open(). _open() re-parses a fresh 27-float header at that offset and reassigns:
straight from unvalidated header floats (isSpiderHeader only checks internal labrec*lenbyt==labbyt consistency, never bounds nsam/nrow). No bomb check runs on this new size.
On the subsequent load(), ImageFile.load_prepare() (ImageFile.py:436-437) executes
self.im = Image.core.new(self.mode, self.size)with the unchecked, attacker-chosen size. Mode is always "F" (4 bytes/pixel), so e.g. 40000x40000 = 6.4 GB.Why this is a real bug
Sibling multi-frame plugins DO re-check on their alternate paths:
SpiderImagePlugin has NO bomb check anywhere. This is the same class of bug just fixed for GdImageFile (GHSA-phj9-mv4w-65pm) and the FontFile classes (GHSA-5x94/8v84/45hq): an allocation reached on a non-Image.open() path sized by an attacker header field, independent of the one-time pixel bomb check.
Reproduction
Python 3.13, Pillow HEAD e7a7ea84
Attached: pillow_poc_spider_bomb.py
Sets Image.MAX_IMAGE_PIXELS=5000, then:
Real-world impact: a large frame-1 nsam/nrow (e.g. 40000x40000) causes im.load() to allocate ~6.4 GB (mode F) with the bomb check fully bypassed, from a file under 1 KB.
Suggested fix
Call Image._decompression_bomb_check(self.size) at the end of SpiderImageFile._open() (or in seek() after _open()), mirroring GifImagePlugin / TiffImagePlugin. Optionally validate nsam/nrow are positive and sane in _open().
Credit: Yazan Balawneh - CyStack Security Team
pillow_poc_spider_bomb.py
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/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: MSP RLE decoder expands a tiny file into unbounded memory
BIT-pillow-2026-112892 / CVE-2026-112892 / GHSA-gg2r-wvfm-q9rx
More information
Details
Summary
The MSP RLE decoder expands run-length data into an in-memory buffer with no cap tied to the declared image size. A small file that passes the pixel-count check can force hundreds of megabytes or more of allocation when the application loads pixels.
Details
The MSP decoder writes each run into an unbounded
io.BytesIOand can expand roughly 85x per run:src/PIL/MspImagePlugin.py:144-146Impact
Memory-exhaustion denial of service from a tiny crafted image in any application that loads pixels. The expansion is decoupled from the declared pixel count, so
MAX_IMAGE_PIXELSdoes not bound it.Severity
CVSS:4.0/AV:N/AC:L/AT:N/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: Invalid free in FreeType glyph rendering when a stroker is active
BIT-pillow-2026-112893 / CVE-2026-112893 / GHSA-j4m9-6w36-pw47
More information
Details
Summary
When text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If
FT_Load_Glyphfails for a glyph, the cleanup path callsFT_Done_Glyphon theglyphvariable. That variable is not initialized before the first measurement loop, and in the render loop it is not reset to NULL after each glyph is freed. A malformed font loaded from untrusted bytes can force the load error and reach this free. This is a latent memory-safety defect; whether it crashes depends on the value in the uninitialized or stale pointer.Details
glyphis declared without a NULL initializer:src/_imagingft.c:848The first measurement loop can jump to
glyph_errorbeforeglyphis ever assigned:src/_imagingft.c:1010-1013In the render loop the glyph is freed at the end of each iteration and not set back to NULL:
src/_imagingft.c:1242The cleanup frees it whenever a stroker exists:
src/_imagingft.c:1253-1256ImageFont.truetypeaccepts a file object or bytes, so the font is attacker-suppliable.PoC
This needs
fontToolsand any base TTF (the example uses one bundled with Pillow's tests). It rewrites glyph "A" to claim 5000 contours in a glyph body far too short for them, soFT_Load_Glyphreturns an error while the face still opens.Result on the pinned build:
FT_Load_Glyphreturns an error and execution reaches theglyph_errorcleanup that callsFT_Done_Glyph(glyph)(the plugin surfacesOSError: invalid outline).Impact
Applications that draw text with
stroke_width != 0using fonts loaded from untrusted bytes can hit an invalid or double free. The correct fix is small: initializeglyph = NULLand set it back to NULL after eachFT_Done_Glyph.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: Quadratic-time DoS in PDF save_all (PdfParser.next_object_id)
BIT-pillow-2026-112894 / CVE-2026-112894 / GHSA-jg86-v3jv-hrpm
More information
Details
Pillow's PDF writer takes time proportional to the square of the number of frames when it saves a multi-frame image as a multi-page PDF. Anyone who can get an application to convert their uploaded image this way can tie up a worker at full CPU for minutes with a small file. A 240 KB animated image takes about 70 seconds, a 720 KB one an estimated 10 minutes, and a 125-byte animated PNG that merely declares a large frame count triggers the same slowdown with no upper bound short of 2³¹ frames. Memory stays under 100 MB and the decompression-bomb limit never applies.
The cause is
PdfParser.next_object_id()insrc/PIL/PdfParser.py, which finds the next free id by computingmax(self.xref_table.keys()).XrefTable.keys()rebuilds three sets over every id reserved so far on each call.PdfImagePlugin._save()calls it three times (four for palette images with transparency) per frame, forn_framesframes, before any page is written. That makes the id-reservation loop alone O(N²). Becausen_framesis taken from the file before any frame is seeked, APNG'sacTLcount drives the loop even when the frames don't exist. In that case the save raisesOSErroronly after the full delay.The precondition is that the application calls
im.save(..., "PDF", save_all=True)on the attacker's file;Image.open()andload()alone do not reach this code. The attached reproducer shows 4× the frames costing about 11× the CPU for a valid GIF and about 16× for a fixed 125-byte APNG. The behaviour was introduced in6207b44ab1ff(2018-01-18) and first shipped in Pillow 5.1.0. A small patch that tracks the highest id inXrefTablemakes the save linear.Details
Saving a multi-frame image as a multi-page PDF (
im.save(fp, "PDF", save_all=True)) costs CPU time proportional to the square of the frame count, independent of pixel content, because every object id the PDF writer reserves is found by rebuilding and scanning the set of all ids reserved so far.A 1×1-pixel animated GIF made of N identical 15-byte frames (built by the reproducer) gives, on a 2-CPU container with the stock build at this commit (absolute timings vary by machine; the growth rate does not):
im.save(BytesIO(), "PDF", save_all=True)Each doubling of the input roughly quadruples the time.
cProfileof the 8 000-frame save attributes 13.5 s of 17.6 s to 24 003 calls ofPdfParser.next_object_id(8.9 s insideXrefTable.keys, 3.9 s insidemax); encoding and writing the 8 000 pages is the remaining ~4 s and scales linearly.The per-page id reservation loop runs before any page is written:
n_framescomes straight from the file (for GIF it is the number of image blocks; the GIF plugin counts them by walking block headers, which is cheap). The frames do not even have to exist: the loop runs onn_framesbefore any frame is seeked, and for APNGn_framesis thenum_framesvalue declared in theacTLchunk, rejected only if it is 0 or above0x80000000(src/PIL/PngImagePlugin.py:716-720). A 125-byte APNG declaring 8 000 frames therefore costs the same ~10–13 s as the 120 KB GIF, and more without bound as the declared count grows. Each of the 3 (or 4, forPimages with transparency) calls per frame lands here:_savepassesoffset=0, so every reserved id is inserted intoxref_table.new_entries. The k-th call therefore materialises three sets covering all k−1 previous ids, computes a difference and a union, and thenmax()scans the result — O(k) work to produce the integer k. Summed over 3N calls this is O(N²): for 48 000 frames, 144 002 calls over sets growing to 144 002 elements, on the order of 10¹⁰ set-element operations, all spent before_write_imageis reached for the first frame. Nothing on this path consultsMAX_IMAGE_PIXELS(the frames are 1×1) and nothing raises; for the GIF the save eventually completes and produces a valid PDF, and for the declared-only APNG it raisesOSErroronly once the id loop has finished and the first missing frame is seeked.This reservation scheme was introduced in
6207b44ab1ff("issue #2959: support appending to existing PDFs", 2018-01-18) and first shipped in Pillow 5.1.0; before it, the writer numbered objects arithmetically.For contrast,
write_obj(ref, …)with an already-reservedref(src/PIL/PdfParser.py:548-555) only does a dict store, which is why the page-writing phase that follows is linear; the super-linear cost is entirely in how the next free id is computed.Impact
The precondition is that the application saves the attacker's image as PDF with
save_all=True;Image.open()andload()alone do not reach this code. An application that converts uploaded images to PDF this way — the documented way to get one page per frame from animated GIF/APNG/WebP or multi-page TIFF input — can have a worker held at 100 % CPU for about 10 minutes by a 720 KB GIF, about 4½ minutes by a 480 KB one, with the cost continuing to grow quadratically with upload size. With APNG the file size does not matter at all: a 125-byte file declaring N frames inacTLdrives the same quadratic loop, up to the 2³¹ cap. The attacker controls only the file bytes; no unusual dimensions, modes or options are needed, memory use stays under 100 MB so memory limits do not intervene, and the decompression-bomb guard is not applicable. The consequence is denial of service of the process performing the conversion. Single-image PDF saves, andsave_allsaves of images with tens or hundreds of frames, are unaffected in practice.This is the same family as earlier accidentally-quadratic parsing issues fixed in Pillow (e.g. CVE-2021-28677, EPS
readline), but on the PDF writer side, reached only through a save rather than on open, and driven by frame count rather than by line structure.Reproducing
reprois a single Python script. Run it with the Python that has Pillow installed (./repro). It uses under 100 MB of RAM and writes no files.Input. Two kinds of file are built in memory, each with 2 000 and 8 000 frames:
gif(n): a valid animated GIF — aGIF89aheader with a 1×1 logical screen and a 2-colour global palette, thenncopies of one 15-byte frame (a 1×1 image descriptor plus the LZW data for one pixel), then the trailer (30 and 120 KB). The only unusual thing about it is how many frames it has.apng(n): a 125-byte APNG — a 1×1 greyscaleIHDR, anacTLchunk declaringnframes, onefcTL, oneIDATandIEND. Only the first frame exists; the othern − 1are merely declared.Action. Each file goes through what an upload-to-PDF converter would do:
Image.open(BytesIO(data)), thenim.save(BytesIO(), "PDF", save_all=True), timed withtime.process_time(). For the APNG the save raisesOSErroronce the writer tries to seek to the second frame; that happens after the id-reservation loop has run for allndeclared frames, so the exception is caught and the CPU time up to it is recorded.Expected output (stock build at this commit):
A linear save would take about 4× as long for 4× the frames; here the GIF save grows ~11× and the APNG save ~16×, the latter with the file size fixed at 125 bytes. The script prints
BUGwhen the save CPU for either format grows at least 8× for 4× the frames (twice the linear expectation), otherwiseOK. Extending the same curve gives the figures in the table above: about 70 s at 16 000 frames (240 KB GIF), and about 10 minutes for a 720 KB file.With the attached patch applied, the GIF save takes 0.32 s / 1.20 s (3.7×) and the APNG save 0.01 s / 0.03 s, and the script prints
OK.Suggested fix
Make
next_object_id()O(1) by tracking the highest object id ever stored inXrefTableinstead of rebuilding the key set on every call. The attached patch does this insrc/PIL/PdfParser.py:existing_entriesis only ever populated through__setitem__(fromread_xref_table/read_prev_trailer), so the counter also covers ids read from an existing file inappend=Truemode. The one behavioural difference: when the highest-numbered existing object has just been deleted (del_root()/rewrite_pages()in append mode), the current code hands that id out again with generation 0, whereas the patch moves on to the next unused id — the more conservative choice for an xref table, and the one the existingTODO: support reuse of deleted objectscomment points at. With the patch applied and Pillow rebuilt, the 16 000-frame save drops from ~70 s to ~2.5 s, fresh PDF output is unchanged,Tests/test_file_pdf.pyandTests/test_pdfparser.pypass, and the reproducer printsOK.An alternative limited to the writer would be to reserve the
3 × number_of_pagesids inPdfImagePlugin._save()arithmetically from a singlenext_object_id()call, but fixing the allocator also coverswrite_obj(None, …)callers.The patch does not address a separate, linear issue:
PdfImagePlugin._save()still trustsn_framesbefore any frame is decoded, so a tiny file whose format reports a declared-only frame count (APNGacTLup to 2³¹) still makes it loop3 × n_framestimes before the first failing seek. Reserving ids per successfully seeked frame, or validatingn_framesin such plugins, would close that.The patch
Track the highest object ID in XrefTable so PdfParser.next_object_id() is O(1) instead of rebuilding and scanning the key set
Applies to
src/PIL/PdfParser.pyat the commit above.The reproducer
Reproducer —
repro, 1,791 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:N/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 PDF append accepts malformed shared page-tree DAG causing resource exhaustion
BIT-pillow-2026-96640 / CVE-2026-96640 / GHSA-mrfp-p49p-3hf2
More information
Details
Summary
Pillow's documented PDF append path can spend disproportionate CPU time on a compact malformed PDF page tree.
An attacker who can supply an existing PDF to an application that appends an image with
Image.save(..., format="PDF", append=True)can provide a malformed/Pagestree where each level repeats the same child/Pagesreference in/Kids. Pillow treats the graph as a tree and expands each repeated reference inPdfParser.linearize_page_tree()without tracking visited or duplicate indirect references.In the bounded proof below, a
1410-byte PDF expands to8192page references from one distinct page object and takes about9.6seconds through the append path. A comparable linear control with the same depth stays at one page reference and completes in about0.02seconds.This is in scope for Pillow's security policy because it is reproducible in
main, affects the latest public release tested (12.3.0), and is not just an unexpected Python exception. The vulnerable operation accepts attacker-controlled PDF bytes through Pillow's PDF parser and causes deterministic resource consumption before normal completion.Details
Affected versions
Pillowpip/ PyPIsrc/PIL/PdfParser.pypage-tree traversal reached fromsrc/PIL/PdfImagePlugin.pyPDF append handling12.3.013.0.0.dev0b39b9649312fbc381060e8c7c7f9f0de4cc63daasha256(git archive --format=tar HEAD) = 2ccefca23d432456765be8d3c34564c65ba016b0c9da1e44ca389762f7eda05412.3.0commit:bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d12.3.0source artifact hash:sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c815.1.0,12.2.0,12.3.0, andHEAD>= 5.1.0Evidence fact strings:
5.0.0containssrc/PIL/PdfImagePlugin.pybut does not containsrc/PIL/PdfParser.py.PdfParser.pywas introduced by6207b44ab1ff4a91d8ddc7579619876d0bb191a4(2018-01-18 issue #​2959: support appending to existing PDFs), which shipped in5.1.0.Technical details
The reachable path is:
A service or application receives an attacker-influenced existing PDF and appends an image using Pillow's public PDF save path:
src/PIL/PdfImagePlugin.pyroutesappend=TruetoPdfParser:src/PIL/PdfParser.pyparses the existing PDF and linearizes the page tree:linearize_page_tree()recursively follows every/Kidsentry and extends the page list. It does not track page-tree node identity, duplicate references, or active traversal references:During append,
rewrite_pages()then iterates the expandedorig_pageslist and scans/replaces entries inself.pages, adding more cost to the already expanded duplicate-reference list.The missing invariant is:
The proof intentionally uses a malformed PDF page-tree DAG: each
/Pagesnode repeats the same next/Pageschild reference twice. Pillow accepts this graph and expands it as if the repeated reference represented distinct subtrees.PoC
Environment used for validation:
3.13.1113.0.0.dev0, commitb39b9649312fbc381060e8c7c7f9f0de4cc63daa12.3.0Save this as
poc_pillow_pdf_page_tree_dag.py:Run:
Expected result:
Observed on current
main/13.0.0.dev0:Observed on Pillow
12.3.0:For comparison, the linear control in the same harness uses a comparable-depth page tree with
fanout=1; it produces one page reference and completed append in about0.016to0.028seconds in my validation runs.Impact
This is an availability issue in Pillow's PDF append handling. Applications are impacted if untrusted users can supply an existing PDF that the application later appends to, watermarks, or exports using Pillow's
Image.save(..., format="PDF", append=True)route.The demonstrated primitive is deterministic CPU/resource amplification from compact attacker-controlled PDF bytes. The proof does not demonstrate confidentiality impact, integrity impact, memory corruption, filesystem access, network access, or impact through ordinary
Image.open().Requires attacker-influenced existing PDF processed by Pillow's documented PDF append path; not ordinary Image.open().
This should not be treated as just an unexpected Python exception. In the tested affected builds, the malformed PDF is accepted and processed to normal completion after the expensive expansion.
Remediation
Reject duplicate and cyclic page-tree references before constructing
self.pagesandself.orig_pages.One possible fix is to pass traversal state through
linearize_page_tree():/Pagesreferences to reject cycles.PdfFormatErrorwhen the page tree is not an acyclic tree of unique indirect references.Suggested regression tests:
/Kidsarray repeats the same child/Pagesreference should raisePdfFormatErrorquickly when used throughImage.save(..., format="PDF", append=True).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: Unbounded recursion parsing a PDF page tree or trailer chain (incomplete fix for CVE-2026-42310)
BIT-pillow-2026-112895 / CVE-2026-112895 / GHSA-p4wg-p73j-c528
More information
Details
Summary
When an application appends to an existing PDF with
Image.save(..., format="PDF", append=True), Pillow parses that file withPdfParser. Two recursive parsers have no effective bound.linearize_page_treefollows/Kidswith no visited set or depth limit, so a cyclic or duplicated page tree recurses without end.read_prev_trailerrejects only an exactly repeated/Prevoffset, so a long chain of unique offsets still recurses. The trailer-loop guard added for CVE-2026-42310 does not cover either case.Details
linearize_page_treerecurses on/Kidswith no cycle or depth check:src/PIL/PdfParser.py:1078-1092read_prev_trailercatches only a repeated offset:src/PIL/PdfParser.py:697-720(theprocessed_offsetscheck at:714-718is the CVE-2026-42310 fix; a unique-offset chain slips past it)PoC
Run it:
The 315-byte file triggers the recursion on parse. A duplicated (non-cyclic)
/Kidstree instead produces super-linear CPU and memory work, and a long unique-offset/Prevchain triggers the trailer-side recursion.Impact
A service that appends to user-supplied PDFs, for example a stamping or watermarking pipeline, can be driven into uncaught recursion or heavy CPU and memory use before any image is written. Follow-up to CVE-2026-42310. Since Pillow's policy discourages filing plain Python exceptions as denial of service, lead with the cyclic-tree case, which burns CPU and memory rather than only raising
RecursionError. The fix is a visited set plus a depth limit in both parsers.Severity
CVSS:4.0/AV:N/AC:L/AT:N/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: Decompression-bomb protection bypass via multi-frame seek() in MPO and DCX (MpoImageFile.seek / DcxImageFile.seek)
BIT-pillow-2026-96636 / CVE-2026-96636 / GHSA-p8hx-v2p6-rp84 / PYSEC-2026-4337
More information
Details
Summary
Pillow's decompression-bomb protection (
Image._decompression_bomb_check, gated byImage.MAX_IMAGE_PIXELS) is enforced only on the first frame, insideImage.open(). For multi-frame formats,seek()re-parses the target frame's header and resetsself._size.GifImagePluginandTiffImagePluginre-run the bomb check onseek(), butMpoImageFile.seek()andDcxImageFile.seek()do not. A crafted MPO or DCX whose first frame is tiny (passing the check) but whose later frame declares enormous dimensions therefore bypasses the protection: loading that frame decodes an over-limit image and commits large amounts of memory — a denial of service (memory exhaustion / OOM).Same class as the recently assigned CVE-2026-55379 (BdfFontFile), CVE-2026-55380 (GdImageFile) and CVE-2026-40192 (FITS GZIP).
Details
Image.open()→_open_core()runs_decompression_bomb_check(im.size)once, on the frame present at open time. Both vulnerableseek()methods re-parse a new frame and set a newself._sizewithout re-checking:PIL/MpoImagePlugin.py,MpoImageFile.seek(): callsJpegImagePlugin.JpegImageFile._open(self)(which setsself._sizefrom the frame's JPEG SOF) and rebuildsself.tile, with noImage._decompression_bomb_check(self.size).PIL/DcxImagePlugin.py,DcxImageFile.seek(): callsPcxImageFile._open(self)(which setsself._sizefrom the page's PCX header), with no check.Confirmed present in 12.3.0 (latest release) and on
main(13.0.0.dev0). CompareGifImagePlugin._seekandTiffImagePlugin, which call_decompression_bomb_checkcorrectly on seek.PoC
Self-contained; the victim uses default
MAX_IMAGE_PIXELS:Measured on Pillow 12.3.0 (default limit 89,478,485 px; error threshold 178,956,970 px):
DecompressionBombError(correctly blocked)The DCX vector (
0x3ADE68B1magic + offset directory + two PCX pages) behaves identically viaDcxImageFile.seek(). Scaling frame 1 toward JPEG's ~65500×65500 maximum drives multi-GB commitments from a small file, reliably OOM-killing the process.Impact
Denial of service (memory exhaustion / OOM) — CWE-400 / CWE-1284, a bypass of Pillow's documented decompression-bomb mitigation.
seek()+load()is the normal way to iterate frames (e.g.PIL.ImageSequence.Iterator, thumbnailers, converters, 3D-photo handlers). Because MPO is auto-detected from a JPEG carrying MPF metadata, a file served with a.jpgextension can be opened as MPO, so any application that iterates frames of untrusted images is exposed.Suggested fix
Re-run the check after
seek()re-parses a frame, mirroring GIF/TIFF: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: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read
BIT-pillow-2026-112896 / CVE-2026-112896 / GHSA-pchr-8j38-3x4p / PYSEC-2026-4334
More information
Details
Summary
A small attacker-supplied TIFF can be opened and loaded normally, then reach an out-of-bounds read when an application saves it back to TIFF with retained metadata. The fixed reproducer is a 1×1, 16-bit grayscale, PackBits-compressed TIFF containing tag 320 (ColorMap) with 768 SHORT values: three zero-filled planes of 256 entries each. No Python object is forged and no metadata is injected after loading.
Pillow's TIFF encoder allocates 768 × 2 = 1536 bytes, then passes three plane pointers separated by 256 SHORT entries to libtiff. With
BitsPerSample = 16, libtiff instead expects 65536 SHORT values per plane and attempts a 131072-byte read from a plane pointer. Existing ASAN observations identify a heap-buffer-overflow READ crossing the 1536-byte allocation made inPyImaging_LibTiffEncoderNew.The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required.
Impact and prerequisites
An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, not demonstrated by extracting arbitrary heap contents in this report. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed.
The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output.
Tested environment and limits of the evidence
The existing observations were recorded on August 30, 2026, with CPython 3.11.8 on macOS ARM64, using the unmodified official Pillow 12.3.0 wheel and its bundled libtiff 4.7.1:
pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl4785266bytes37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6bb1d8e8ab8d29048624d96e3ee53cecf7c13d13de8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1Apple's ASAN runtime was injected at process startup. This is not an ASAN-rebuilt Pillow wheel; allocation/memcpy interception detects the reported boundary violation, but the wheel's native instructions are not comprehensively instrumented.
Only this concrete full-package environment is established here. No affected-version range, current development-branch result, or patched release is asserted. Severity is Unknown; no CVSS score is proposed.
Separate earlier evidence reached the original Pillow 8.0.1 encoder constructor compiled as a component against a modern Python/libtiff environment. That was not a complete Pillow 8.0.1 installation and does not establish the historical package's full runtime behavior. It is not part of this reproducer or an affected-version range.
Observed evidence and controls
The earlier integration harness performed the natural sequence
Image.open(BytesIO(input)),image.load(), thenimage.save(BytesIO(), format="TIFF"), without replacing the image's metadata. Each case was recorded three times:colormap16I;16; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3).colormap8colormap_absentAddress-free summary of the recorded native diagnostic:
Input SHA-256 values (also enforced inside the script):
colormap16:5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4dcolormap8:fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405ccolormap_absent:bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556Root cause and source references
src/encode.c, lines 870–899, pinned revision: the ColorMap tuple must contain exactly 768 entries; a 1536-byte allocation is passed with a fixed stride of 256. The check does not reconcile that extent withBitsPerSample.src/PIL/TiffImagePlugin.py, lines 1944–1991, pinned revision: save carries supplied tags into the libtiff encoder and sorts the tags, so sample depth is established before ColorMap.tif_dir.c, lines 441–448, pinned revision:TIFFTAG_COLORMAPcomputes the per-plane count as1L << td->td_bitspersampleand copies that many SHORTs from each pointer.The reported boundary is Pillow's construction of a fixed-sized array for a native API whose required size depends on metadata; this does not establish a standalone libtiff defect for a correctly sized caller buffer.
Prior art and disclosure scope
Pillow PR #5148, merged December 28, 2020, is related TIFF metadata prior art, but its archived diff replaces a short-lived stack field-name pointer with the persistent literal
"CustomField". That is a different field-name-lifetime fix, not this plane-size mismatch. The reviewed material does not establish an exact prior public report or fix for this root, and novelty is not asserted. This specific report remains NOT_SENT; association with other observations is not a prior disclosure of it.Reproduction instructions
Use an already reviewed, existing installation of the exact wheel above, an existing compatible macOS ARM64 CPython 3.11 interpreter, and an existing compatible Apple ASAN runtime.
--package-pathis the absolute directory containingPIL, not thePILdirectory itself. The script does not install, download, build, patch, or submit anything. It checks selected key package files against the original installation's hashes and checks the loaded package/version; these checks are not a full installation attestation.Save the complete Python block below as
reproduce.pyand run:Both arguments are required. The runtime must be loaded at child startup, not by late
dlopen. Each child resolves__asan_init,__asan_address_is_poisoned, and__asan_region_is_poisonedin its actual process symbol table before importing Pillow or opening a fixture. Merely settingDYLD_INSERT_LIBRARIESis not accepted as proof of ASAN availability. A missing symbol or package mismatch prevents the operation.Each of the three cases runs once in a separate child with a 30-second wall limit, 10-second hard CPU limit, core dumps disabled, and 1 MiB regular-file-size limit. The parent supplies a clean allowlisted environment, isolated Python startup (
-I -S -B), an empty temporary working directory, no stdin, and a 128 KiB combined pipe-output cap. Timeout/output-cap handling kills the child's process group. These limits are containment measures, not a security sandbox. No TIFF is written to disk. Temporary directories are removed; raw diagnostics are never forwarded or persisted.The compressed/base64 constants below are the unchanged original benign TIFF fixtures, with decompressed size and SHA-256 checks. There are no external fixture dependencies. The standalone script supplied with this description is byte-for-byte the same as this block.
Separate August 31, 2026 inline validation: the exact embedded standalone script passed all three fixed cases: one ASan READ of 131,072 bytes crossing a 1,536-byte allocation and two passing controls. Child guards and limits passed, with no timeout. This is additional bounded evidence, separate from earlier runs; no new build, install, full historical chain or older-version integration replay was performed.
Expected results from the standalone script
Cases run in order:
colormap8,colormap_absent,colormap16. Every printed line should haveguard_passed: true,within_limits: true, andexpected_observation: true. The first two require successful saves and the expected retained sample-depth/ColorMap fields. The last requires ASAN exit 86 and the specific READ/extent/stack signatures, not merely any crash. Only the two controls inspect encoded metadata; the unsafe case never exports an encoded TIFF.The parent exits 0 only if all three expectations match; 1 means a case did not match (including guard failure or a changed ASAN diagnostic), and 2 means parent setup failed. A failed guard, timeout, unrelated crash, or missing diagnostic is inconclusive, not evidence that the defect is absent. Do not bypass the ASAN guard to obtain an output TIFF.
Complete standalone reproducer
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/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: TiffDecode returns uninitialized memory as pixels for short JPEG strips
BIT-pillow-2026-112897 / CVE-2026-112897 / GHSA-q45j-58j2-45rg
More information
Details
Pillow hands back leftover process memory as image pixels when it opens a small crafted TIFF file. Anyone who can get an application to open their image, and can see something produced from its pixels (a thumbnail, a preview, a converted copy), can read heap memory that the process had previously used and freed. This works in the default configuration: opening and loading the 478-byte file succeeds with no error or warning, and every row below the first eight holds stale memory instead of picture content.
The bug is in
_decodeStrip()insrc/libImaging/TiffDecode.c, and_decodeTile()has the same shape. The functionrealloc()sstate->buffertoTIFFStripSize()bytes without initialising it and callsTIFFReadEncodedStrip(). Whenever that call does not return -1, it copies allRowsPerStriprows of the buffer into the image. libtiff's JPEG codec only warns when the JPEG embedded in a strip has fewer rows than the strip, and Pillow silences libtiff warnings. The codec decodes the rows that exist and still returns the full strip size, so the untouched remainder is copied out as pixels. libtiff's Group 4 fax codec behaves the same way for a stream that ends early.The attached reproducer builds a 256×256 TIFF whose single strip or tile is a 256×8 JPEG. Before loading it, the script frees buffers filled with
b"LEAK". It then finds 15,872 copies ofLEAKin rows 8–255 of the decoded image (the same freed chunk was reused in 500 of 500 runs). Under glibc'sMALLOC_PERTURB_, those rows are entirely the allocator's fill byte. A well-formed control file decodes correctly in both cases.This is a read of uninitialised heap memory, not memory corruption. Each strip can disclose up to width ×
RowsPerStripbytes, and what comes back depends on the allocator's state at the time. The strip path was introduced in e91b851fdc1c ("Add reading old-JPEG compressed TIFFs", first released in Pillow 6.0.0). The tile path has had the same pattern since f0436a4ddc95 (Pillow 5.3.0).Details
src/libImaging/TiffDecode.cdecodes compressed TIFFs through libtiff into a strip(or tile) buffer that it
realloc()s toTIFFStripSize()bytes and never initialises, andthen copies all
RowsPerStriprows of that buffer into the image wheneverTIFFReadEncodedStrip()does not return -1. libtiff's JPEG codec does not fail when theJPEG stream inside a strip has fewer rows than the strip: it decodes the rows that exist
and returns the full strip size. The rows it did not write are whatever the heap held
before, and Pillow returns them as pixels.
A 478-byte file shows it: a classic little-endian TIFF, 256×256,
BitsPerSample=8,Compression=7,PhotometricInterpretation=1,RowsPerStrip=256, one strip whose datais a baseline JPEG of 256×8 pixels (as written by
Image.new("L",(256,8),200).save(…,"JPEG")).Image.open(BytesIO(data)).load()succeeds with default settings (compressed TIFFs alwaystake the libtiff path). Rows 0–7 of the result are the JPEG content (value 200); the
remaining 63488 bytes of
im.tobytes()are heap garbage: run under glibc'sMALLOC_PERTURB_=1they are all0xFE, underMALLOC_PERTURB_=170all0x55(thefresh-
mallocfill byte), and in a process that has just freed buffers containingb"LEAK"they contain 15872 copies ofLEAK.Feeding the same file to libtiff 4.5.0 directly confirms the library side:
TIFFReadEncodedStrip(t, 0, buf, 65536)printsJPEGPreDecode: Warning, Improper JPEG strip/tile size, expected 256x256, got 256x8.,returns 65536, and leaves 63488 of the 65536 buffer bytes untouched. Pillow installs
TIFFSetWarningHandler(NULL)(TiffDecode.c:682), so the warning is not visible either.The strip path:
state->bufferarrives here as the one-rowcallocmade bydecode.c:230(
(bits*xsize+7)/8= 256 bytes for the example). Thereallocto 65536 bytes hands backa different, uninitialised chunk; nothing zeroes it.
TIFFReadEncodedStrip()returns thenominal strip size on success, not the number of rows produced, and the only check is
== -1, so the row loop unconditionally copies 256 rows × 256 bytes intoim->image(which itself is zeroed by
core.new, so the garbage provably comes fromstate->buffer)._decodeTile()has the same shape (TiffDecode.c:448realloctoTIFFTileSize(), thentile_lengthrows copied after eachTIFFReadTile()at 460–488).For a well-formed file the JPEG inside the strip is 256×256, libtiff fills all 65536 bytes
and the copy is correct. The malicious file differs only in the SOF height of the embedded
JPEG; libtiff treats "JPEG smaller than strip" as a warning, clamps the row count to the
JPEG height, and reports success.
JPEG is not the only libtiff codec that succeeds on a short strip.
Fax4Decode(
Compression=4) returns success for a T.6 stream that ends early: a 133-byte 512×2561-bit TIFF whose strip is the Group 4 stream of a 512×16 image loads without error,
libtiff writes 17 rows (1088 bytes) of the 16384-byte buffer
(
Fax4Decode: Warning, Premature EOL at line 16, return value 16384) and the other15296 bytes are again heap contents. LZW, Deflate, PackBits, Group 3 and CCITT RLE were
checked and do return -1 on short data, so Pillow raises
OSErrorfor those.The strip path was introduced in
e91b851fdc1c("Add reading old-JPEG compressed TIFFs",2018-12-08), first released in Pillow 6.0.0, which replaced row-by-row
TIFFReadScanline()(where libtiff errors on the first missing row) with the whole-strip
realloc()and copy.The tile path has had the same shape since it was added in
f0436a4ddc95(2018-07-02,Pillow 5.3.0).
Impact
Any application that opens attacker-supplied images with Pillow in the default
configuration and returns something derived from the pixels (thumbnail, preview, format
conversion, OCR text) discloses freed heap memory of the worker process to the uploader:
everything below the rows the embedded JPEG/G4 stream actually encodes, up to
ImageWidth × RowsPerStrip × SamplesPerPixelbytes per strip and tens of MB per imagewithin the default
MAX_IMAGE_PIXELS.load()raises nothing, so the application has nosignal that the image is malformed. What comes back depends on allocator state; in a
long-running web worker it is typically recently freed request bodies, other users'
decoded image buffers and Python object contents. There is no memory corruption; this is
a read of uninitialised (previously used) heap memory, analogous in effect to an
out-of-bounds read that lands in pixels.
Reproducing
reprois a short Python script that runs against the installed Pillow build withdefault settings: no rebuild, no sanitizer, no files written. It runs twice, once normally
and once re-executing itself under glibc's
MALLOC_PERTURB_=170, and the last line itprints is
BUGorOK.Input.
tiff(jpeg_rows, tiled)builds a little-endian TIFF in memory. The headerdeclares a 256×256 8-bit greyscale image with
Compression=7(JPEG), either as one strip(
RowsPerStrip=256) or as one 256×256 tile (TileWidth/TileLength). The strip or tileis a baseline JPEG of 256×
jpeg_rowspixels, all value 200, written by Pillow's own JPEGencoder. For each layout the script builds two files:
jpeg_rows=256. This is a well-formed file, and libtiff fills the whole64 KiB strip/tile.
jpeg_rows=8, 478 bytes (strip) or 490 bytes (tile). The only differenceis the embedded JPEG's height. libtiff decodes 8 rows (2048 bytes), warns
Improper JPEG strip/tile size(Pillow silences the warning) and returns the fullstrip/tile size as success.
Steps. Each file is opened (the script asserts it takes the
libtiffdecoder). Justbefore
im.load()the script allocates and frees six 64 KiBbytearrays filled withb"LEAK", which leaves freed heap chunks about the size of the buffer that_decodeStrip()/_decodeTile()is about torealloc(). For rows 8–255 it then countscopies of
LEAKand bytes equal to0x55(glibc's fresh-mallocfill underMALLOC_PERTURB_=170). The files contain noLEAKbytes (the script asserts this), soany copy in the image came from freed heap memory. It reports
BUGif a crafted fileshows any
LEAKin the normal pass, or rows 8–255 entirely0x55in the perturbed pass.Expected output on this tree:
im.load()succeeds for every file. For the crafted files rows 8–255 are the uninitialisedstrip/tile buffer, copied as-is into the image. With the attached patch applied, rows 8–255
of the crafted files come back as
\x00bytes, with noLEAKand no0x55, and thescript ends with
OK.The
LEAKcount depends on where glibc places the buffer (the same freed chunk was reusedin 500 of 500 runs here); the
MALLOC_PERTURB_pass does not. The script covers the JPEGcodec only. The Group 4 variant in the Details section (
Compression=4, a T.6 stream thatends early) goes through the same
_decodeStrip()copy, and the same fix covers it.Suggested fix
The attached patch zeroes
state->bufferbefore each libtiff read in both loops ofsrc/libImaging/TiffDecode.c— beforeTIFFReadTile()in_decodeTile()and beforeTIFFReadEncodedStrip()in_decodeStrip()— so rows a codec does not produce aredeterministic zeros rather than stale heap:
A
memsetper strip/tile (rather than onecallocafter the size is known) is neededbecause the same buffer is reused for every strip, and a short last strip would otherwise
expose the previous strip's rows — harmless for disclosure, but the per-read
memsetkeeps the behaviour uniform at the cost of one extra pass over the buffer. Promoting
libtiff warnings to errors via a warning handler is not a good alternative: both codecs
only warn (
Improper JPEG strip/tile size,Premature EOL), but so do many legitimatefiles (unknown tags, bad tag counts), and the return value of
TIFFReadEncodedStrip()carries no row count to check against. Initialising the buffer covers every codec.
_decodeAsRGBA()needs no change:TIFFRGBAImageGet()fills its whole raster, and JPEGfiles do not reach it (
JPEGCOLORMODE_RGBsends them down the strip path).Valid files decode exactly as before; rows a codec does not produce are now black instead
of garbage, and nothing that previously loaded now fails. The patch also adds
test_jpeg_short_striptoTests/test_file_libtiff.py, parametrised over strip and tile,which builds the 256×8-JPEG-in-256×256 file and asserts rows 8–255 are zero. It is a
regression guard for the fixed code rather than a reliable detector of the bug: in a fresh
pytest process the unfixed strip variant can pass because the new heap chunk happens to be
zero (the tiled variant fails before the fix). With the patch,
Tests/test_file_libtiff.pyand
Tests/test_file_tiff.pypass and the reproducer printsOK.The patch
Zero the libtiff strip/tile buffer before each TIFFReadEncodedStrip/TIFFReadTile in TiffDecode.c so short strips do not expose uninitialised heap
Applies to
src/libImaging/TiffDecode.cat the commit above.The reproducer
Reproducer —
repro, 2,688 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:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Image.quantize() with empty palette leaks uninitialized stack memory
BIT-pillow-2026-112898 / CVE-2026-112898 / GHSA-rfgq-q3w2-pmv2
More information
Details
When an application lets a user supply the palette image for Pillow's
Image.quantize(palette=...), the user can get back 512 bytes of the server process's stack memory in the pixels of the returned image. An 80-byte PNG with an empty palette is enough, and the leaked bytes survive saving to PNG or GIF. In the runs examined, those bytes included memory addresses inside the stack, heap, libc, libpython and Pillow's own C module, along with the stack-protector canary.The bug is in
ImagingPaletteCacheUpdate()insrc/libImaging/Palette.c. It fills a localUINT8 c[512]only inside loops bounded bypalette->size. When the palette has zero entries, the array is never written, but it is still copied into the colour cache.topalette()insrc/libImaging/Convert.cthen emits the cached values as output pixel indices.topalette()rejects aNULLpalette but not one withsize == 0. Three inputs produce such a zero-entry"P"image: a PNG with a zero-lengthPLTEchunk opened withImage.open(), a colour-mapped TGA with colour-map length 0, or a bareImage.new("P", ...). The attacker must control the palette image; the source image can be anything, andRGBsources are affected with either dither mode. There is no out-of-bounds access and no memory corruption. The consequence is a bounded stack disclosure that can reveal address layout and the canary of a long-lived worker, with no crash and no log entry. The attached reproducer quantizes a 512-colour strip against the empty-palette PNG and finds 8 of the 64 output words pointing into the process's own[stack]mapping, while a one-entry palette control yields all zeros. The behaviour was introduced in commit54cb09d8b48c(first released in 9.1.0). TheImage.new("P", ...)route exists since 10.1.0.Details
ImagingPaletteCacheUpdate()(src/libImaging/Palette.c:154) computes, for one 8×8×8 box of the colour cache, the nearest palette index for each of the 512 slots into a localUINT8 c[512]and then copiesc[]into the cache.c[]is only ever written insidefor (i = 0; i < palette->size; i++). When the palette has zero entries nothing writes it, the untouched stack bytes are copied into the cache, andtopalette()(src/libImaging/Convert.c:1145) emits them as the output pixels ofImage.quantize(palette=...). The root cause is thattopalette()only rejects aNULLpalette, never one withpalette->size == 0, whileImagingPaletteCacheUpdate()silently assumessize >= 1. A zero-entry"P"image is whatImage.open()produces for a PNG whosePLTEchunk has length 0 (or a colour-mapped TGA with colour-map length 0);Image.new("P", size)without a subsequentputpalette()yields one as well. Both dither modes (Dither.NONEand the default Floyd-Steinberg) are affected forRGBsources (anLsource takes the grayscale copy path and never consults the cache).Showing it with the installed build, no special flags. The 80-byte PNG below is 4×4, bit depth 8, colour type 3, with a zero-length
PLTEand an all-zeroIDAT:The 512 source colours are chosen so that each pixel lands in a distinct slot of the cache box for
r,g,b ∈ [0,32), in slot order, soout.tobytes()is exactlyc[0..511]. Read as 64 little-endian 8-byte words and resolved against the same process's/proc/self/maps, a typical run gives 43 words that are addresses inside the process's own mappings — 8 in[stack], several in[heap],libc.so.6,libpython3.12.soand_imaging.cpython-312-x86_64-linux-gnu.so— and 3 words equal to the thread's stack-protector canary (read fromfs:0x28for comparison). The same PNG with a 3-bytePLTE(one entry) gives 512 zero bytes in every run.out.save(buf, "PNG")succeeds, so the bytes leave the process in an ordinary image. Under valgrind (run against astrip --strip-debugcopy of_imaging.soor a gcc build, since valgrind 3.19 cannot read clang's DWARF5) the report carries the originUninitialised value was created by a stack allocation at ImagingPaletteCacheUpdate. WithDither.NONEtopaletteonly copies the bytes, so the firstuse of uninitialised valueis flagged where Python first branches on the pixel data; with the default Floyd-Steinberg dither it is flagged insidetopalette(inlined intoImagingConvert), where the cached index is used to addresspalette->palette.Code path
File → empty C palette.
PngImagePlugin.chunk_PLTE(src/PIL/PngImagePlugin.py:499-505) stores the chunk payload whatever its length,_openwraps it asImagePalette.raw("RGB", b"")(:836-838), andImage.load()realises it withself.im.putpalette("RGB", "RGB", b"")(src/PIL/Image.py:989-990)._putpalette(src/_imaging.c:1850-1868) rejects only more than 256 entries and setspalette->size = palettesize * 8 / bits, i.e. 0.TgaImagePlugin._open(src/PIL/TgaImagePlugin.py:121-135) builds the same empty palette from a colour-mapped TGA whose colour-map-length field is 0.API → sink.
Image.quantize(palette=pal)(src/PIL/Image.py:1329-1338) checks onlypalette.mode == "P"and callsself.im.convert("P", dither, palette.im)→ImagingConvert→topalette(..., inpalette=<that palette>, dither).topalettetests the palette forNULL(Convert.c:1162,:1178) but not forsize == 0, allocates the cache (every slot0x100= "not computed"), and for each source pixel:Inside
ImagingPaletteCacheUpdatethe only store toc[]is in step 2, and both step 1 and step 2 iterate over palette entries:For any non-empty palette every slot is written: the entry that produced
dmaxpassesdmin[i] <= dmax, and becaused[j]starts at~0the first visiting entry always stores intoc[j]. Withsize == 0the loop is skipped entirely and step 3 publishes the raw stack array:cache[0]is then ≤ 255 andpalette->paletteis a fixed 1024-byte array, so the subsequentpalette->palette[cache[0] * 4]reads in the dither path stay in bounds; the defect is purely that stack contents become pixel values. Because no deeper call happens between successiveImagingPaletteCacheUpdateinvocations in the pixel loop, every box yields the same 512 bytes — whatever earlier, deeper call chains (the PNG decode, the interpreter) left at that stack depth.The behaviour was introduced in
54cb09d8b48c("When converting to P, restrict colors to palette size", 2022-02-16), first released in 9.1.0; before it, the cache update iterated over all 256 palette slots. TheImage.new("P", ...)route additionally depends onfd310512b95a("Set C palette to be empty by default", first in 10.1.0); before that a fresh"P"image carried a 256-entry grey ramp.Impact
An application that lets a user supply the reference palette image for
Image.quantize(palette=...)— palette-swap / "apply this palette" features, sprite and GIF tooling, pixel-art converters — and returns the quantised result hands that user 512 bytes of the worker's C stack, encoded as pixel indices (directly readable from the saved PNG/GIF, since index values survive losslessly). In the build examined those bytes contain return addresses and frame pointers into the stack, heap, libc, libpython and_imaging.so, plus the stack-protector canary: an ASLR and canary disclosure from a long-lived server process, obtained with an 80-byte upload, no crash and no log entry. The precondition is that the attacker controls the palette image (the source image can be anything with at least one colour per cache box of interest); when the palette image is application-owned the path is not reachable. There is no out-of-bounds access and no memory corruption.Reproducing
reprois a short, single-file Python script that runs against the installed Pillow. It doesn't rebuild anything or start any other process, and it finishes in about 0.1 s.png_with_plte()builds a 4×4 palette-mode PNG (bit depth 8, colour type 3) around a givenPLTEpayload. With an empty payload it produces exactly the 80-byte file shown above.quantize_against()does what an "apply this palette" feature does: it opens the PNG withImage.open()and callsquantize(palette=pal, dither=Image.Dither.NONE)on a 512×1 RGB strip. The strip holds the colours(r*4, g*4, b*4)forr, g, bin0..7, one for each slot of the colour-cache boxr, g, b ∈ [0,32)and in slot order. That makes output pixeljexactlyc[j]ofImagingPaletteCacheUpdate().PLTE. The only valid index is 0, and every output pixel is 0.PLTE.quantize()returns an image without complaint, and its pixels contain about 80 distinct values.[stack]range in the script's own/proc/self/maps. It prints each word that points into the stack. A typical run finds 8, e.g.pixels 0..7 = 0x00007ffd959c3fa0 <- pointer into [stack]. Pixel data can only hold stack addresses if it was copied from the stack.The script prints
BUGif the control is all zeros and at least two output words are stack addresses. It printsOKifquantize()rejects the empty palette withValueError, which is what the attached patch does, or if the output has no stack pointers. The script was also checked against an out-of-tree rebuild, which is not part of the script. With the attached patch it printsattack (empty PLTE): rejected: empty palette/OK. With only thePalette.chalf of the patch, the output is 512 zero bytes and it printsOK.Suggested fix
The attached patch rejects an empty palette in
topalette()before any work is done, withValueError("empty palette")in the same idiom as the neighbouring "no palette" / "conversion not supported" checks, soImage.quantize()raises a normalValueError. It also zero-fillsc[]alongsided[]inImagingPaletteCacheUpdate()so no present or future caller can copy stack contents into the cache, and adds a regression testtest_empty_palettetoTests/test_image_quantize.py.quantize(palette=)is the only path that hands a caller-supplied palette totopalette(); theNULL-palette branches always build 256- or 226-entry palettes and are unaffected. The one behaviour change is that quantizing against an empty palette (including a bareImage.new("P", ...)) now raises instead of returning garbage indices. The C changes:Applied to a clean copy of the tree and rebuilt, the reproducer's attack step fails with
ValueError: empty paletteand the script printsOK; the one-entry control is unchanged. Apart from the regression test, the patch does not touch the Python code insrc/PIL. OptionallyPngImagePlugin.chunk_PLTEcould additionally raiseSyntaxErrorfor aPLTEwhose length is 0 or not a multiple of 3, as the PNG specification requires 1–256 entries, andImage.quantize()could raise a clearer Python-level error whenpalette.paletteis empty.The patch
Raise ValueError in topalette() for a palette with no entries and zero-initialise the cache slot array in ImagingPaletteCacheUpdate()
Applies to
src/libImaging/Convert.cat the commit above.The reproducer
Reproducer —
repro, 2,975 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:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow multi-frame loaders can reuse stale native image storage after mode transitions
BIT-pillow-2026-96639 / CVE-2026-96639 / GHSA-v9pv-5gc8-w2m6 / PYSEC-2026-4339
More information
Details
Summary
Pillow multi-frame image loaders can retain native
ImagingCorestorage allocated for one frame while changing the Python-side imagemodeand decoder tiles for a later frame.If frame 0 allocates storage for a narrow mode such as
L, and a later frame of the same dimensions switches to a wider mode such asRGBorCMYK, Pillow may decode wider pixels into the stale narrow native buffer. This causes nativeout-of-bounds writes and process crashes. In the MPO/JPEG path, I also demonstrated command execution in matching Python/Pillow runtime profiles through Pillow's documented
ImageSequence.Iterator+frame.load()path.Details
The root cause appears to be that
ImageFile.load_prepare()only reallocates native storage when_im is None. It does not verify that the retained nativeImagingCorestorage still matchesself.modeandself.size.Several multi-frame
seek()/_open()paths can updateself._modeand decoder tiles for a later frame without invalidating_im. The decoder then writes or reads according to the new frame mode while the native storage still belongs to the old frame mode.Verified affected surfaces:
PSD
PsdImageFile.seek()can switch from grayscale/nativeLstorage to RGB layer decoding while retaining_im. This produces attacker-controlled native out-of-bounds writes and reliable crashes.MPO/JPEG
A crafted two-frame MPO can make frame 0 allocate
Lstorage, then make frame 1 decodeCMYK/RGBdata into the retainedLstorage. The documentedImageSequence.Iteratorpath reaches this withframe.load().Verified native memory corruption, crash, over-read behavior, and scoped harmless-marker command execution on Pillow 12.3.0.
DCX
DcxImageFile.seek()callsPcxImageFile._open(), which changes mode/tile state without a native-storage mode guard. A crafted DCX withLthenRGBPCX frames produces retained-storage mismatch and crash.MIC
MIC can call
TiffImageFile._open()directly and bypass TIFF's normal guardedseek()behavior. With aNextIFD-based reachable frame transition, this produces retained-storage mismatch and crash.TIFF appears to be a useful positive control because its
seek()path checksboth size and mode before reusing storage.
Impact
Stronger proven impact:
Server-side deployment impact:
_im=Nonecontrol was clean 10/10 times.Reproduction Evidence
Tested version:
MPO terminal proof environments:
Server-side web-upload proof:
The terminal proof uses a fixed harmless marker command only. It does not use a reverse shell, persistence, data exfiltration, or arbitrary attacker-selected commands.
I can provide the following private artifacts to maintainers:
Minimal Reproduction Shape
For the MPO path, the vulnerable consumer shape is:
The crafted image shape is:
Frame 0 allocates native
Lstorage. Frame 1 changes the logical mode and decoder state but can reuse the old native storage. The frame 1 decoder writes using the wider mode into the narrow storage.Suggested Fix Direction
Before reusing native
ImagingCorestorage, Pillow should verify that the retained storage is compatible with the current logical image mode and size.A central guard in
ImageFile.load_prepare()could compare native storage mode/size againstself.mode/self.sizeand allocate fresh storage when they differ. Alternatively, every mode-changingseek()/_open()path should explicitly invalidate_imbefore decoding.Caution: TIFF has specialized tile-size behavior and already has a mode/size guard, so a central fix should avoid regressing TIFF tile-buffer handling.
Credit
Methodology Note
AI-assisted analysis was used for discovery and verification support. Claims in this report are limited to the local artifacts, controls, and command outputs described above.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: EPS load(scale) lets Ghostscript render past the decompression bomb limit
CVE-2026-112899 / GHSA-vx6w-vv9f-6gvg
More information
Details
When an application opens an untrusted Encapsulated PostScript file and asks Pillow to render it at a larger scale, Pillow has Ghostscript produce an image several times bigger than Pillow's configured size limit allows. The size check runs only after Ghostscript has finished rendering and written the full raw image to a temporary file. An attacker who can upload a file of about 100 bytes makes each such request spend scale² times the Ghostscript rendering work and temporary disk space that the limit is meant to permit. For example, at scale 2 that is a 972 MB temporary file.
The cause is in
Ghostscript()insrc/PIL/EpsImagePlugin.py. It multiplies the file-declared size by the caller'sscale(lines 93-94) and passes the product togsas the-goutput geometry. It never callsImage._decompression_bomb_check()on the scaled size. The only check is the oneImage.open()ran on the unscaled size, so a file declaring 9000×9000 passes, andload(scale=2)then asks Ghostscript for 18000×18000 (3.6×MAX_IMAGE_PIXELS). A file declaring 18000×18000 directly would be refused before Ghostscript ever ran.DecompressionBombErrorfires only when Pillow reopens the output file, so Pillow's own in-process allocation stays bounded and there is no memory corruption.The effect is a constant-factor overshoot rather than an unbounded one, for three reasons:
Image.open()already lets files up to 2× the limit reach Ghostscript atscale=1.gsruns with no timeout.This is the EPS counterpart of the WMF
load(dpi)gap closed in af70b075a. It has been present since the decompression-bomb limit was introduced in ce2955ec7 (2.5.0). The attached reproducer stubs out Ghostscript and shows a 76-byte file passingImage.open()and reachinggswith-g18000x18000. A one-line patch mirroring the WMF check is included.Details
EpsImageFile.load(scale=N)(the documented way to rasterise an EPS at higher resolution) renderswidth*N × height*Npixels through Ghostscript, but the only decompression-bomb check on the way is the oneImage.open()ran on the unscaled, file-declaredwidth × height. The scaled product is never passed toImage._decompression_bomb_check()beforegsis started.This 111-byte EPS declares 9000×9000 = 81,000,000 pixels, below the default
MAX_IMAGE_PIXELS(89,478,485), soImage.open()accepts it without even a warning:im.load(scale=2)on it then executesi.e. asks Ghostscript for an 18000×18000 = 324,000,000-pixel raster (3.6×
MAX_IMAGE_PIXELS), whosepnmrawoutput is a 972,000,019-byte file in the temp directory. An EPS that declares 18000×18000 directly is rejected byImage.open()withDecompressionBombErrorbefore Ghostscript is ever run; viascale=2the same raster is rendered first and theDecompressionBombErroronly appears afterwards, when Pillow opens the file Ghostscript produced. (Between 1× and 2×MAX_IMAGE_PIXELS,Image.open()only emitsDecompressionBombWarning, so with the default warning filter an EPS declaring e.g. 13377×13377 is already rendered atscale=1— a 537 MB PNM. Against that baseline,scale=2is 4× an already-permitted render.)The arithmetic is in
Ghostscript():and its caller does nothing between taking
scalefrom the application and invoking it:sizehere isself._size, computed in_open()from the file's%%BoundingBox(or%ImageData) and checked once byImage.open()atsrc/PIL/Image.py:3700. For the benign path (scale=1) that check covers exactly what Ghostscript is asked to render. Forscale=Nthe rendered pixel count is N² times what was checked, so a file can sit just under the limit at open time and have Ghostscript render 4× (scale 2), 9× (scale 3), 16× (scale 4) the configured budget. The first point at which Pillow looks at the real size again isImage.open(outfile)at line 166 — aftersubprocess.check_callhas returned, i.e. after Ghostscript has spent the CPU and writtenwidth*height*3bytes totempfile.mkstemp()'s directory.WmfStubImageFile.load(dpi)has the same shape (file-derived extent × caller-supplied factor → new raster) and, since af70b075a, callsImage._decompression_bomb_check(self.size)on the recomputed size before rendering (src/PIL/WmfImagePlugin.py:127-131). The EPS path lacks the equivalent line. The gap dates from ce2955ec7128 ("Throw an exception when an opened image is larger than an arbitrary limit", 2014-05-14, first released in 2.5.0), which added the check toImage.open()without covering thescalepath.Impact
Affected are applications that open untrusted EPS/PS with Ghostscript installed and call
load(scale=N)with a fixed N > 1, as the handbook suggests for non-blurry output. Handing untrusted EPS to Ghostscript at all already lets the same attacker tie up a worker with an equally tiny file (e.g. a PostScript{} loop; Pillow runsgswith no timeout), so this bug adds a constant factor rather than a new denial-of-service capability. Within that setting, a ~110-byte upload makes each request run Ghostscript over N² ×MAX_IMAGE_PIXELSpixels (N² × 2·MAX_IMAGE_PIXELSif the application does not turnDecompressionBombWarninginto an error) and write aW·H·3-byte PNM into the temp directory — 972 MB for the example above at scale 2, 2.2 GB at scale 3, 8.6 GB for a 178-Mpixel declaration at scale 4 — before any limit is applied. The operator'sMAX_IMAGE_PIXELSsetting is exceeded by a factor of N²; N is an integer chosen by the application, not by the file, so the work stays bounded at N² × 2·MAX_IMAGE_PIXELS. The consequence is N² more Ghostscript CPU and temp-disk use per request than the configured limit intends; Pillow's own in-process pixel allocation remains bounded becauseImage.open(outfile)re-checks the PNM's size, and there is no memory corruption.This is a sibling of the WMF
load(dpi)issue fixed in af70b075a ("Add decompression bomb check when loading at a different DPI"); the EPSscalepath was not covered by that change. Unlike the WMF case, where the file-controlled_inchmakes the ratio unbounded, the EPS multiplier is fixed by the application.Reproducing
Run
reproagainst the installed Pillow. Ghostscript does not need to be installed: the script setsEpsImagePlugin.gs_binary = "gs"so thathas_ghostscript()skips itsgs --versionprobe, and replacessubprocess.check_callwith a function that records the command line Pillow builds and then raisesOSError, so nothing is actually rendered.It writes a 76-byte EPS declaring
%%BoundingBox: 0 0 9000 9000(the example above without the drawing line; 81 Mpixel, below the defaultMAX_IMAGE_PIXELSof 89,478,485) and turnsDecompressionBombWarninginto an error, so any bomb check — warning or hard error — counts. It then callsImage.open()andim.load(scale=2).Image.open()accepts the file andload(scale=2)reaches the intercepted Ghostscript call with-g18000x18000(324,000,000 pixels, 3.6×MAX_IMAGE_PIXELS, a 972 MBpnmrawoutput with real Ghostscript). The script prints the geometry it captured.It prints
BUGwhen Ghostscript was invoked with a-ggeometry above2*MAX_IMAGE_PIXELS(178,956,970) and no decompression-bomb check fired before that call; otherwise it printsOK. With the attached patch applied,load(scale=2)raisesDecompressionBombErrorbefore Ghostscript is called and the script printsOK.Suggested fix
The attached patch checks the scaled size in
Ghostscript()right after the scaledwidth/heightare computed (src/PIL/EpsImagePlugin.py:93-94) and before thegscommand is built, mirroringWmfStubImageFile.load:load(scale=N)now raisesDecompressionBombError/ emitsDecompressionBombWarningunder the sameMAX_IMAGE_PIXELSthresholdsImage.open()applies, before Ghostscript runs instead of after. Placing the check inGhostscript()rather than inEpsImageFile.load()is the single point where the rendered extent is decided, so it also covers direct callers of the module-level function. With the patch applied to a clean checkout the reproducer printsOKandTests/test_file_eps.pypasses. One cosmetic side effect: atscale=1the check re-emits theDecompressionBombWarningthatImage.open()already issued for sizes between 1× and 2× the limit; guarding the call withif scale > 1:would avoid the duplicate.The patch also adds
test_load_scale_decompression_bombtoTests/test_file_eps.py, in the style of the WMF test: it setsMAX_IMAGE_PIXELStoim.width * im.heightand expectsim.load(scale=2)to raiseDecompressionBombError. Like the rest of that file it is skipped without Ghostscript, becausehas_ghostscript()is consulted at the top ofGhostscript(); moving the check above that call would let the test run without it.The patch
Add decompression bomb check on the scaled size in EpsImagePlugin.Ghostscript() before Ghostscript is invoked
Applies to
src/PIL/EpsImagePlugin.pyat the commit above.The reproducer
Reproducer —
repro, 1,845 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:N/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: Argument Injection in Ghostscript call, potentially leading into RCE
BIT-pillow-2026-112900 / CVE-2026-112900 / GHSA-w889-685w-c8fh
More information
Details
Summary
PIL.EpsImagePlugin.Ghostscript()builds the Ghostscript command line using the input file path (fp.name) without sanitization, placing it at-f <infile>. A filename beginning with-is interpreted by Ghostscript as an option, giving command-line argument injection; chainable to RCE (see Impact). Reproduces with Pillow's default invocation (with-dSAFERenabled).Details
Give all details on the vulnerability. Pointing to the incriminated source code is very helpful for the maintainer.
Type
CWE-88 (Argument Injection) (→ CWE-78 (OS Command Injection) optionally, with attacker controlled text file)
Vulnerable Codes
Root cause.
When rendering an EPS image, PIL.EpsImagePlugin.Ghostscript() constructs the Ghostscript command line using the input file path (fp.name) and places it directly at the -f position of the argument list. This path is user-controllable, yet Pillow performs no validation or neutralization of tokens that Ghostscript treats specially during command-line argument parsing — in particular a leading - or -- (which Ghostscript interprets as an option rather than a filename) and a leading @ (Ghostscript's argument-file syntax, which causes it to read additional command-line arguments from the named file). Because the filename is neither normalized (e.g., forced to an absolute path or prefixed with ./) nor checked against these prefixes before being handed to the subprocess, an attacker who controls the filename passed to Image.open() can inject arbitrary Ghostscript command-line arguments. This is a classic argument-injection flaw (CWE-88), and it is not mitigated by Pillow's default -dSAFER setting.
Affected
src/PIL/EpsImagePlugin.py,Ghostscript()—infile = fp.name, and the command list entry"-f", infile.Suggested fix
Normalize the filename to an absolute path before it is passed to Ghostscript, so it can never be parsed as an option (
-/--) or as an argument-file token (@):os.path.abspath()guarantees the path starts with a path separator (or a drive letter on Windows), so a leading-or@can no longer reach Ghostscript's argument parser. Theelsebranch already builds a safetempfile.mkstemp()path and needs no change.PoC
dockerfile, Default run shows the argument-injection demo (poc.py); for the RCE PoC run python3 poc-rce.py.
poc.py
poc-rce.py
arg
Impact
Confirmed — Ghostscript argument injection. Because the filename is not validated, a leading
-/--is parsed by Ghostscript as an option rather than a file. An attacker who controls the filename passed toImage.open()can therefore inject a Ghostscript command-line argument. Single-argument injection always succeeds.Potential RCE (verified). The filename is also not validated against Ghostscript's
@-file syntax: a filename of the form@<path>causes Ghostscript to read additional command-line arguments from<path>, allowing multiple arguments to be injected at once. If the attacker can additionally control the referenced file, this can be escalated to arbitrary command execution — an escalation we verified in our PoC (execution ofid).Applies under Pillow's default configuration (with
-dSAFERenabled), on Pillow 12.3.0 with Ghostscript 10.02.1. With same GS arguments, I also confirmed RCE with latest GS version (10.07.1)Why this is a Pillow bug (not the documented "block untrusted EPS" risk)
This is not the malicious-EPS-content risk described in Pillow's security policy: the flaw is triggered by the filename, not by anything in the PostScript document. A completely benign EPS file still results in Ghostscript argument injection whenever the filename passed to Image.open() is attacker-influenced, so applications that legitimately process EPS remain affected — the guidance to block untrusted EPS input does not cover this.
Because Pillow is the component that constructs the Ghostscript command line, ensuring a filename cannot be reinterpreted as an option (-/--) or an argument-file token (@) is Pillow's responsibility, not that of every downstream caller. The precondition — attacker influence over a filename that reaches Image.open() — is realistic (e.g., file-upload endpoints that preserve or derive the stored name from user input), and chaining it with this flaw yields RCE in practice, not just in theory. A one-line normalization (os.path.abspath(fp.name)) closes it without any change to how applications use the API.
Author
Chanu Kang (from CysecLab at KAIST) @HLe4s
Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes
BIT-pillow-2026-112901 / CVE-2026-112901 / GHSA-xgh8-jp6q-p5x8 / PYSEC-2026-4335
More information
Details
A crafted 512-byte file makes Pillow's
Image.open()spend about six minutes of processor time on one core before it gives up withUnidentifiedImageError. Anyone who can hand bytes to an application that opens images with Pillow's default settings can trigger this, for example through an upload form, a thumbnailer, or a service that checks whether a blob is an image. A few such files sent at once can tie up a typical pool of worker processes.The cause is the
xbm_headregular expression insrc/PIL/XbmImagePlugin.py:32-40, whichXbmImageFile._open()(line 58) runs over the first 512 bytes of any stream whose prefix starts with#define. Several adjacent parts of the pattern can match the same characters.[ \t]+overlaps with.*in the width and height lines and with[^_]*in the optional hotspot lines, and[\r\n]+closing the hotspot group overlaps with the following[\000-\377]*. The header sets a run of k spaces after each#defineand a run of k line terminators after_y_hot, then omits the[]after_bits. That forces Python's backtracking matcher to try every way of splitting those runs and rescan the remaining bytes each time. The cost grows roughly as k to the fifth power: k = 60 measured 375 s, while a well-formed XBM of the same size opens in about 40 ms. Because this happens before any format is identified, none of Pillow's size, pixel-count or decompression-bomb limits apply, and upload size limits do not help because the file is tiny.ImageFile.Parser.feed()repeats the cost on every chunk until the stream is identified. The only existing mitigation is passing aformats=allowlist that excludes"XBM".The attached reproducer opens the same header with and without the trailing
[]. The valid version opens as XBM in 52 ms. The broken version takes 0.1 s, 0.75 s, 3.0 s and 8.6 s at k = 10, 15, 20 and 25, and the slow k = 60 case is not run. This is the same class of bug as CVE-2021-25292 inPdfParser, but in a different plugin and reachable from plainImage.open().Details
XbmImageFile._open()identifies an XBM file by running the module-level regexxbm_headover the first 512 bytes of the stream. The pattern has several adjacent quantifier pairs that can consume the same characters, followed by a[\000-\377]*_bits\[]tail that rescans the rest of the buffer for every way of splitting them. A 512-byte input built to exercise all of them and then fail the final literal keepsImage.open()inxbm_head.match()for minutes before it raisesUnidentifiedImageError.This 512-byte input (k = 60) takes 375 s of CPU in
Image.open(io.BytesIO(data))with default settings, measured on the stock build at this commit:Calling
XbmImagePlugin.xbm_head.match(data)directly gives the same picture: k = 10 → 0.11 s, k = 15 → 0.76 s, k = 20 → 3.0 s, k = 25 → 8.5 s, k = 30 → 19.9 s (roughly ×27 per doubling of k, i.e. about k^4.7). A well-formed 512-byte XBM matches in 10 µs and opens and loads throughImage.open()in ~40 ms including plugin import; the same k = 60 header with_bits[]present matches in 5 µs.The regex,
src/PIL/XbmImagePlugin.py:32:and the only call site,
src/PIL/XbmImagePlugin.py:58:For a well-formed header (
#define im_width 8\n…static char im_bits[] = {) each[ \t]+eats the single space,.*/[^_]*eat the identifier prefix, and[\000-\377]*_bits\[]finds the literal on its first backward scan, so the match is linear. The crafted header diverges at every one of those points:[ \t]+.*_widthand[ \t]+.*_height: the run of k spaces after#definecan be split between[ \t]+and.*in k ways, all leading to the same continuation. Factor k each. (The choice of\rterminators is incidental: the same header with\nterminators is equally slow.)[ \t]+[^_]*_x_hotand[ \t]+[^_]*_y_hotinside the optionalhotspotgroup:[^_]*also matches spaces, so the same k-way split exists twice more. Factor k each. These two lines are essential: without them the same input is rejected in about 10 ms.[\r\n]+closing thehotspotgroup, followed by)?and[\000-\377]*: the run of k\rbytes after_y_hot 1can be divided between[\r\n]+and[\000-\377]*in k ways. Factor k.[\000-\377]*_bits\[]: for every combination above, the engine swallows the remaining ~150 bytes of padding and walks back looking for_bits[]. The input contains_bitsbut never[], so this always fails and every combination is visited.CPython's
reis a backtracking engine without memoisation, so the total work is on the order of k^5 × (512 − header length) steps — about 10^11 for k = 60, consistent with the measured 375 s. The 512-byte read caps k (5k + ~60 ≤ 512), so the cost peropen()is bounded, but the bound is minutes of CPU.The path is the default one.
Image.open()(src/PIL/Image.py:3719) reads a 16-byte prefix and offers it to every registered plugin's accept hook;XbmImagePlugin._accept()(src/PIL/XbmImagePlugin.py:43) returns true for anything whose left-stripped prefix starts with#define, and_open()then runs the regex. No size, pixel-count or decompression-bomb check can apply before this point because nothing has been parsed yet, and since the match ultimately fails the file is never identified, so the only outcome for the caller isUnidentifiedImageErrorafter the delay. File name and extension are irrelevant because dispatch is by content. Afaulthandlerstack dump taken 20 s into the call shows the interpreter atXbmImagePlugin.py:58 in _open←ImageFile.py:154 in __init__←Image.py:3699 in _open_core←Image.py:3719 in open.ImageFile.Parser.feed()(src/PIL/ImageFile.py:584) amplifies this further: until the stream is identified it callsImage.open()on all accumulated data at everyfeed(), so an application feeding the stream in chunks pays the regex cost once per chunk (measured: k = 15 header fed in 8 chunks → 4.6 s versus 0.76 s for a singleopen()).Impact
Anyone who can get 512 bytes in front of
Image.open()— an upload endpoint, thumbnailer, or any service that uses Pillow to sniff whether a blob is an image — pins one CPU core of the handling process for roughly six minutes per request with the default plugin set and default limits. Nothing large is allocated and noDecompressionBombWarning/Errorfires; application-side upload size limits do not help because the file is tiny, andImage.MAX_IMAGE_PIXELSis never consulted because no image is ever identified. A handful of concurrent requests is enough to exhaust a typical worker pool. The per-call cost is bounded by the 512-byte header read, so this is a CPU-exhaustion denial of service of the handling process rather than an infinite loop. Applications that pass an explicitformats=allowlist toImage.open()that excludes"XBM"are not affected.This is the same bug class as CVE-2021-25292 (catastrophic backtracking in the
PdfParserregexes, fixed in 8.1.1) and CVE-2021-23437 (ImageColor.getrgbReDoS, fixed in 8.3.2), in a different plugin; unlike the PDF case it is reached from plainImage.open()on any byte stream.Reproducing
reprois a single Python script. It uses the installed Pillow through the publicImage.open()API and needs no rebuild.Each input is the 512-byte header from Details: four
#definelines with k spaces after#define, lines ending in\r, k\rbytes after_y_hot 1, padding withAup to the 512 bytes that_open()reads. The only thing that changes between inputs is the tail:_bits[](valid) or_bits(the final literal can never match)._bits[]. The regex matches on its first try and Pillow reportsXBM (1, 1)in about 50 ms. This shows that the same bytes are only slow when the match has to fail._bitsat k = 10, 15, 20 and 25. Each one raisesUnidentifiedImageError, after about 0.1 s, 0.75 s, 3.0 s and 8.6 s. The time grows polynomially, not linearly. The k = 60 input from Details, which takes about 6 minutes of CPU, is not run.Expected output on this commit:
The script prints
BUGonly when all of these hold:XBMin under 1 s;Otherwise it prints
OK. With the attached patch applied, every attack input is rejected at once, so the script printsOK.Suggested fix
The attached patch removes the overlap between
[ \t]+and the quantifier that matches the identifier prefix inxbm_head(src/PIL/XbmImagePlugin.py:32-40), so the whitespace after#definecan be consumed in exactly one way and the identifier cannot run across line terminators. It matches the identifier with[^ \t\r\n]*(and[^ \t\r\n_]*in the hotspot group, which keeps the existing "no underscore before_x_hot/_y_hot" behaviour); the remaining quantifier pairs are then either over disjoint character classes or reduce to a single linear rescan of at most 512 bytes:This mirrors the
PdfParserfix for CVE-2021-25292. The patch also adds atest_redoscase toTests/test_file_xbm.py, in the style of the one inTests/test_file_pdf.py: it builds the k = 60 header from Details and, under@timeout_unless_slower_valgrind(1), expectsXbmImageFileto raiseSyntaxError. With the patch applied to a clean checkout and rebuilt, the reproducer printsOKand the existingTests/test_file_xbm.py(includinghopper.xbm,hopper_underscore.xbmand the hotspot round-trip) still passes. The change does reject headers whose identifier contains whitespace (#define foo bar_width 16), which is not valid C and is not produced by Pillow's own XBM writer.The patch
Use more specific regex chars in the XBM header regex to prevent ReDoS in Image.open() on a crafted 512-byte header
Applies to
src/PIL/XbmImagePlugin.pyat the commit above.The reproducer
Reproducer —
repro, 1,138 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:N/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: Memory disclosure and segfault in libtiff encoder via crafted TIFF tags
CVE-2026-112902 / GHSA-xvh5-g6gv-559v
More information
Details
A crafted TIFF file of a few hundred bytes makes Pillow copy an attacker-chosen amount of its own process memory into the TIFF it writes back out, or crash the process outright. The trigger is an ordinary
Image.open()followed by a bareim.save(fp, "TIFF"). The only precondition is that the application re-saves images as TIFF. When the uploaded file is compressed, Pillow routes that save through libtiff automatically, with no options from the application. The attached reproducer does this with a 356-byte LZW TIFF: one crafted tag comes back holding 65536 bytes taken from_imaging.so's read-only data, and a second crafted tag makes the worker die with SIGSEGV on every run.The bug is in
PyImaging_LibTiffEncoderNew(src/encode.c). It decides what arguments to pass to the variadicTIFFSetField()from the shape of the Python value (scalar, tuple or bytes). It assumes itsImagingLibTiffMergeFieldInfo()call defined how libtiff will read them. ButTIFFMergeFieldInfo()never overrides libtiff's built-in field table, so libtiff reads the arguments according to its own definition of the tag:Where that unwritten slot points depends on the compiler. In the tested clang build it points into
_imaging.so, and a larger count returned thousands of pointers into libpython, libc and libtiff, which defeats address-space randomisation for anyone chaining another bug. When the application preserves metadata withtiffinfo=im.tag_v2, a 64-bit entry turns ActiveArea into a 16-byte read from an address the attacker chooses. All observed memory errors are reads; no out-of-bounds write was found.Other branches of the same loop share the defect and are reachable from crafted TIFFs or from EXIF in JPEG/PNG/WebP re-saved as compressed TIFF. These are the
bytes, RATIONAL-tuple and typed-tuple branches, and a BYTE-typed ColorMap. The file-reachable forwarding was introduced ina3d45e9cefd0("Added custom int and float TIFF tags") and first released in 5.4.0.Details
PyImaging_LibTiffEncoderNew(src/encode.c:777-1060) chooses the argumentlist it hands to the variadic
TIFFSetField()from the Python value of eachtag — scalar vs. tuple vs.
bytes, and a type that is either autodetected orcopied from the source file's IFD — and assumes that its own
ImagingLibTiffMergeFieldInfo()registration defines how libtiff will read theva_list. It does not: libtiff keeps its built-in definition for every tag italready knows, and core tags are never registered at all. libtiff therefore
consumes the varargs as
(count, pointer),(pointer)or three pointersaccording to its field table, whatever Pillow actually pushed. The encoder
never consults that table (
TIFFFindField/TIFFFieldPassCount/TIFFFieldWriteCount/TIFFFieldDataType) before the call.The most severe instance, and the one the attached reproducer exercises: when a
TIFF is re-saved through the libtiff writer, every tag of the source IFD that
Pillow does not recognise is forwarded to libtiff with
TIFFSetField(). Acount-1 integer entry is passed as a single scalar vararg. If libtiff's own
field table defines that tag number as an array field, libtiff consumes the
varargs as
(count, pointer)or(pointer)instead, so the attacker's integerbecomes either the number of bytes to copy from a never-written vararg slot, or
the source address itself. The same mismatch is reachable through the typed
tuple branches (tuple length consumed as a pointer for fixed-count built-in
tags such as
CFARepeatPatternDim, even when the source file is well-formed),the
bytes,TIFF_RATIONAL-tuple and core-tag branches of the same loop;those are listed
under Other branches with the same defect below and need to be covered by the
same fix.
A 181-byte TIFF (16×16 RGB, one LZW strip, plus one extra IFD entry
50740 / LONG / count 1 / value 4096— DNGPrivateData) opened withImage.open()and saved with a bareim.save(buf, "TIFF")produces a 4296-byteoutput whose tag 50740 holds 4096 bytes that occur nowhere in the input; scanning
/proc/self/memfinds them in the read-only data segment of_imaging.so.Changing the extra entry to
50829 / LONG / 1 / 64(ActiveArea) makes the sametwo calls segfault in
memcpy(dst, src=0x40, n=16)underImagingLibTiffSetField←PyImaging_LibTiffEncoderNew. An ASan build of theextensions reports
READ of size 4096inmemcpycalled fromsrc/encode.c:1014for the first input.Code path
_savecopies all tags of the opened image into the libtiff attribute list.For a tag that is neither in
LIBTIFF_COREnor inTiffTags.TAGS_V2, no typeis recorded, so the value goes through untyped:
compressiondefaults to the source image'sim.info["compression"](
TiffImagePlugin.py:1731-1746), so a compressed source selects the libtiffwriter without the application asking for it.
In the encoder the untyped
intis autodetected asTIFF_LONG(
src/encode.c:805-815), the tag is "registered" as a scalar custom field, andthen set with one 32-bit vararg:
The registration does not do what the caller assumes.
TIFFMergeFieldInfo()only adds definitions for tags libtiff does not already know and returns 0
(success) either way, so for tags in libtiff's built-in table the built-in
definition stays in force. Queried against the linked libtiff 4.5.0 after
exactly Pillow's merge call:
libtiff's setter for
FIELD_CUSTOMtags then interprets theva_listaccording to its definition: for
passcount=1it readsva_arg(ap, int)asthe element count — the attacker's 4096 — allocates
count * sizebytes andmemcpys that many bytes fromva_arg(ap, void *), a slotPyImaging_LibTiffEncoderNewnever wrote. On x86-64 that slot is registerrcx; in this build it holds a pointer into_imaging.so's.rodata, so thecopy succeeds deterministically (a value of 2 000 000 also succeeds and yields a
2 000 200-byte output).
TIFFWriteDirectorywrites the copied bytes into theoutput file as the tag's value. For fixed-count array fields (
passcount=0,writecount=4for ActiveArea) there is no count argument, so the attacker'sscalar is consumed as the
void *source and 16 bytes are read from address64 → SIGSEGV.
Tags in libtiff 4.5's built-in table that Pillow forwards untyped and that take
the
(count, pointer)form include DNGPrivateData 50740, CFAPattern 33422,CFAPlaneColor 50710, LinearizationTable 50712, BlackLevel 50714, WhiteLevel
50717, ColorMatrix1/2 50721/50722, AsShotNeutral 50728, OriginalRawFileName/Data
50827/50828, AsShotICCProfile 50831, CurrentICCProfile 50833. Fixed-count ones
(pointer only → crash) include ActiveArea 50829, DNGVersion/DNGBackwardVersion
50706/50707, DefaultScale/DefaultCropOrigin/DefaultCropSize 50718–50720,
AsShotWhiteXY 50729, CFARepeatPatternDim 33421, BlackLevelRepeatDim 50713,
ImageLayer 34732, RawDataUniqueID 50781.
When the application saves with
tiffinfo=im.tag_v2(the documented way topreserve metadata), the file-declared type is honoured, and a
LONG8entryreaches the
(uint64_t)PyLong_AsLongLong(value)branch atsrc/encode.c:1049-1052. For ActiveArea that is a 16-byte read from a full64-bit attacker-chosen address into the output: a source entry
50829 / LONG8 / 1 / <address of a Python bytes object>produced an outputActiveArea of
(b'SECR', b'ET-T', b'OKEN', b'-012')in testing.The comment above
blocklist +=atTiffImagePlugin.py:1938-1944already notesthat array tags with the wrong length "will result in segfaults"; the blocklist
covers only a handful of tags Pillow itself knows, not the ones libtiff knows and
Pillow does not. Forwarding of these tags was introduced in
a3d45e9cefd0("Added custom int and float TIFF tags", 2018-10-25), first released in 5.4.0;
releases 2.0.0–3.0.0 forwarded them as scalars too, until
9fa6634f4("Partial fix for #1597") limited
_savetoLIBTIFF_CORE.Other branches with the same defect
The scalar-integer branch above is one of several places in the same loop
where the call shape is derived from the Python value rather than from
libtiff's definition. All of the following were confirmed on this commit (stock
build crashes and/or ASan reports, stack always
memcpy ← libtiff ← ImagingLibTiffSetField src/libImaging/TiffDecode.c:934 ← PyImaging_LibTiffEncoderNew), and all are reachable from a sub-1 KB craftedfile followed by an ordinary
save():(len, av)for a fixed-count built-in field(
src/encode.c:904-994, the SHORT/LONG/SBYTE/SSHORT/SLONG/FLOAT/DOUBLEtuple branches all call
ImagingLibTiffSetField(..., len, av)). For libtiffbuilt-in fields with
passcount=0and a fixedwritecount—CFARepeatPatternDim33421 (SHORT×2),BlackLevelRepeatDim50713(SHORT×2),
ImageLayer34732 (LONG×2),ActiveArea50829 (LONG×4),MatrixWorldToScreen/Camera33305/33306 (FLOAT×16) — libtiff takes thefirst vararg as the source pointer, so the tuple length becomes the
memcpysource address. A perfectly well-formed 206-byte 8×8 PackBits TIFFcarrying
33421 / SHORT / 2 / (2, 2)re-saved with a bareim.save(out, "TIFF")dies with SIGSEGV inmemcpy(gdb:rsi = 0x2, thetuple length) on every run; so does
Image.new("L", (8, 8)).save(buf, "TIFF", compression="packbits", tiffinfo={33421: (2, 2)}), and a JPEG whose EXIF carries the tag saved withexif=im.getexif()into a compressed TIFF.bytesvalues for built-infixed-count BYTE tags (e.g.
DNGVersion50706, BYTE×4) crash the same waythrough the
(PyBytes_Size, PyBytes_AsString)call atsrc/encode.c:860-865.The mirror image for
passcount=1fields:Photoshop34377 declaredSHORT, count 1, value 60000 takes the scalar branch at
:1009-1012, libtiffreads 60000 as the count and an unpassed vararg as the pointer, and the
re-save succeeds with a 60162-byte output whose tag 34377 holds 60000 bytes
of process memory (here C prototype strings from
_imaging.so's.rodata).bytesvalue → singlechar *(src/encode.c:805-815autodetectsbytesasTIFF_ASCII;:1033-1042passesPyBytes_AsString(value)alone).ImageFileDirectory_v2._setitemstores a BYTE-typed entry as barebytesand an ASCII-typed one as
str(encoded tobytesby_save), so thefile's declared type selects this branch.
ColorMap(320) declared BYTE with 4 bytes of data in a non-palettePackBits TIFF: 320 is in
core_tags[], no type is consulted, the valueskips the 768-tuple
TIFFTAG_COLORMAPbranch (:871-895) and libtiff readsthree
uint16_t *and copies1 << BitsPerSampleshorts from each — a512-byte over-read of the 5-byte Python bytes buffer, then dereference of
two stale argument registers → SIGSEGV on every run of
Image.open(f); im.save(out, "TIFF").COLORMAPis not in_save'sblocklist.Photoshop(34377) declared ASCII:_savecopies value and type(
TiffImagePlugin.py:1802-1818; onlyIPTC_NAA_CHUNKhas a BYTE/UNDEFINEDtype guard there, from commit 2245df0a —
PHOTOSHOP_CHUNKandXMPdonot). libtiff's definition is BYTE,
passcount=1, so the low 32 bits ofthe
char *become the byte count for amemcpyfrom a stale register(ASan:
READ of size 3518384; stock: SIGSEGV in about half of runs,otherwise
RuntimeError).ICCProfile(34675) declared ASCII behaves thesame way via
im.info["icc_profile"]→ifd[ICCPROFILE](
TiffImagePlugin.py:1822-1824).TIFF_RATIONALtuple → array with no count (src/encode.c:995-1006;compare the
TIFF_FLOATbranch at:969-981, which passeslen, av).passcount=1(TiffDecode.c:904-908), so libtiff takes the low 32 bits ofavas the element count and the next stale vararg as the source:heap-buffer-overflow READ of size 119872 … right of 8-byte regionunderASan (
119872 == 4 * low32(av)), SIGSEGV in ~4 of 5 stock runs. Reached bya JPEG whose EXIF carries one private RATIONAL×2 tag saved with
im.save(out, "TIFF", compression="tiff_lzw", exif=im.info["exif"]), or aTIFF re-saved with
tiffinfo=im.tag_v2(_setitemauto-types a tuple ofIFDRationalas RATIONAL,TiffImagePlugin.py:689-701;types[tag] = ifd.tagtype[tag]at:1968).WhitePoint(318, 2),PrimaryChromaticities(319, 6) andYCbCrCoefficients(529, 3), which_saveforwards from any source TIFF and types RATIONAL viaTiffTags.lookup, the pointer-only convention happens to match, butlibtiff copies its fixed count from an array that is only as long as the
file declared: a PackBits TIFF with
PrimaryChromaticitiescount 1 and abare
im.save(out, "TIFF")givesREAD of size 24 … right of 4-byte region, and the over-read floats are written to tag 319 of the output.REFERENCEBLACKWHITE/TRANSFERFUNCTIONare blocklisted atTiffImagePlugin.py:1941-1952for exactly this reason; 318/319/529 are not.re-save idiom
im.save(out, "TIFF", tiffinfo=im.tag_v2),_savecopiesifd.tagtype[key] = info.tagtype[key]for every key(
TiffImagePlugin.py:1768-1776) and later assigns its own values to some ofthe same tags without resetting the type (
ifd[COLORMAP] = colormapat:1870-1877; likewise BitsPerSample, SamplesPerPixel, ExtraSamples,ICCProfile, …). For a 418-byte PackBits palette TIFF whose ColorMap entry
is declared BYTE,
_setitem(:751-770) truncates_save's 768-entrypalette to a single
int(with only a "too many entries" warning), theencoder receives
(320, 0), autodetectsTIFF_LONG, andsrc/encode.c:1013-1016callsTIFFSetField(TIFFTAG_COLORMAP, (UINT32)0)—libtiff reads three
uint16_t *and faults at an address ≤ 65280 on everyrun. The stale-tagtype copy is a Python-side hygiene bug in its own right,
but its memory-safety consequence exists only because the encoder lets a
non-tuple value reach
TIFFSetField(COLORMAP).Impact
Any service that accepts a TIFF and writes it back out as TIFF through libtiff —
which happens with a plain
im.save(fp, "TIFF")whenever the uploaded file iscompressed, or whenever a
compression=other than raw is requested — returnsto the uploader, inside one metadata tag of the produced file, an
attacker-chosen number of bytes read from a stale pointer in the worker process,
deterministically and without retries. Which memory that is depends on the
compiler's register allocation for
PyImaging_LibTiffEncoderNew: in this clangbuild the pointer is
_imaging.so + 0x1588, so a request for 262 144 bytes(which succeeded every time in testing, as did 2 000 000) covers the
extension's
.rodata,.data.rel.ro/GOT and.dataand returned 2 800+resolved pointers into libpython, libc, libtiff, libjpeg and others — a complete
ASLR bypass for chaining with any other memory bug; a gcc build of the same
source left a different, unrelated address in that slot. The fixed-count
variant kills the worker process with SIGSEGV from a 181-byte upload on every
request. With
tiffinfo=im.tag_v2the sourceaddress is fully attacker-chosen (16-byte arbitrary read into the output for
ActiveArea, or a crash). No non-default Pillow setting is involved; the only
precondition is TIFF-to-TIFF re-encoding.
The other branches add further deterministic process-kill inputs (BYTE-typed
ColorMap in any RGB or palette TIFF;
thumbnail()keepstag_v2, soopen→thumbnail→save-as-TIFF is affected too) and extend the reach to
JPEG/PNG/WebP uploads whose EXIF is preserved with
exif=when converting tolibtiff-compressed TIFF. All the memory errors are reads; libtiff sizes its
destination buffers with the same count it copies, so no out-of-bounds write
was found.
Reproducing
reprois one Python script, run against the stock build with the installedPIL. It needs no compiler and no extra files.upload(tag, value)has Pillow write a 16×16 red RGB image as an LZW-compressed TIFF, then appends a new IFD holding the same entries plus one extra<tag> / LONG / count 1 / value(kept in tag order) and points the header at it — a 356-byte input on this tree. The source is LZW-compressed on purpose. A bareim.save(out, "TIFF")keeps the source's compression, so the save goes through the libtiff writer even though the application passes no arguments.resave()is the whole "application":Image.open(BytesIO(src)); im.save(out, "TIFF").The script runs three cases:
4096. This is what should happen to every tag.bytesof length 65536 that do not occur in the input. The script prints the first 32 of them and the file offset at which they occur in_imaging.soon disk. On this build they always do (offset0x57588): the stale pointer aims into the extension's read-only data.repro child, core dumps disabled) to do this re-save, and the child exits with-11, meaning SIGSEGV.Expected output on the affected tree:
The script prints
BUGif case 2 leaks or case 3 crashes. Both are deterministic on this build. A fixed Pillow printsOK: the tag does not come back as 65536 bytes of foreign data (it reads back asNone, orsave()raisesValueError/RuntimeError, which the script catches), and the child exits with something other than SIGSEGV (0, or 1 ifsave()raised).The other branches can be checked by hand on the stock build in the same way
(each as
Image.open(f); im.save(BytesIO(), "TIFF")in a child process):a PackBits RGB TIFF with an extra IFD entry
320 / BYTE / 4 / "ABCD"→SIGSEGV every run; a PackBits palette TIFF whose ColorMap is
320 / BYTE / 2saved with
tiffinfo=im.tag_v2→ SIGSEGV every run; a Pillow-written JPEGwhose EXIF holds tag 50000 = RATIONAL×2 saved with
compression="tiff_lzw", exif=im.info["exif"]→ SIGSEGV in most runs,RuntimeError("Failed to allocate memory for custom tag binary object(-N elements of 4 bytes each)") in the rest.
Suggested fix
The encoder must not guess the calling convention; it has to ask libtiff what
the tag's effective definition is after
ImagingLibTiffMergeFieldInfoandmarshal accordingly, or refuse. The attached patch does this for non-core tags:
ImagingLibTiffFindField()insrc/libImaging/TiffDecode.c(declaredin
TiffDecode.h, next to the other libtiff wrappers) returns libtiff'sTIFFFindFieldtype,passcountandwritecountfor a tag;PyImaging_LibTiffEncoderNew(src/encode.c), for a non-core tag thatlibtiff already knows, Pillow adopts libtiff's type when it has none of its
own, and after
ImagingLibTiffMergeFieldInfoa newtiff_field_matches()checks the value against libtiff's definition: a scalar only for a
single-value field whose C argument type (int /
uint64_t/ double /char *) matches, an array only with libtiff's element size and, forfixed-count fields, exactly
writecountelements. Anything else raisesValueError("Tag value does not match libtiff field definition")instead ofreaching
TIFFSetField— this covers the reproducer's DNGPrivateData andActiveArea inputs, ASCII-typed Photoshop, short PrimaryChromaticities and the
LONG8tiffinfo=variant;bytesbranch now goes throughset_tiff_array_field(), whichpasses
lenonly when the field haspasscountset; this also fixes theTIFF_RATIONALbranch, which never passed a count (and extends it toTIFF_SRATIONAL);TIFF_UNDEFINEDfields are registered as variable-length, matching how theirbytesvalues are passed;Tests/test_file_libtiff.pygains a regression test (scalar DNGPrivateData,scalar and 2-tuple ActiveArea rejected; well-formed values round-trip).
Core tags and tags libtiff does not know keep their current behaviour. With the
patch applied to a clean checkout and rebuilt, the reproducer no longer
triggers and the test suite passes. It is not a complete fix, however: the
whole check sits inside
if (!is_core_tag), so the BYTE-typed ColorMap variantstays open. The PackBits RGB TIFF carrying
320 / BYTE / 4, re-saved with abare
save(), no longer segfaults at this point but writes 1536 bytes —over-read from the 5-byte
bytesobject and two stale varargs, including 22live heap pointers — into the output ColorMap, which is still a disclosure.
Before merging, it needs a guard that accepts
TIFFTAG_COLORMAPonly throughthe existing 768-tuple branch, never through the scalar or
bytesbranches,and ideally the same shape check for the other core tags libtiff special-cases
in
_TIFFVSetField(YCbCrSubsampling 530, TransferFunction 301,ReferenceBlackWhite 532).
Narrower Python-side mitigations in
_saveclose individual entry points butnot the class, and are worth adding alongside the C-side check: drop tags
with
TiffTags.lookup(tag).type is Nonefromsupplied_tags(closes theImage.open()→save()path shown here but nottiffinfo=callers); removeCOLORMAPfromsupplied_tagsnext toEXIFIFD/SAMPLEFORMATand extend theIPTC_NAA_CHUNKBYTE/UNDEFINED typeguard to
PHOTOSHOP_CHUNKandXMP; add 318/319/529 to the blocklist next toREFERENCEBLACKWHITE; and have_saveifd.tagtype.pop(tag, None)beforeassigning its own ColorMap/BitsPerSample/ICCProfile/… values so a
tiffinfo=-imported type cannot reshape them. The C-side check is the fix torely on.
The patch
Pass TIFF tag values to libtiff according to its own field definition, and raise ValueError when they do not fit
Applies to
src/encode.cat 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,545 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:N/PR:N/UI:N/VC:H/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).
BIT-pillow-2026-112392 / CVE-2026-112392 / GHSA-27h8-gf7j-v5hc / PYSEC-2026-4326
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow's rank-based filters can dereference invalid image storage for a zero-size image. ImagingExpand() allocates a non-empty output and then faults while replicating edges by reading the first pixel from empty input storage; ImagingRankFilter is never reached. An attacker can supply or cause creation of a zero-size image to an application that applies a rank, median, minimum, or maximum filter. This causes a native process crash. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112394 / CVE-2026-112394 / GHSA-4558-3h8r-v8xp / PYSEC-2026-4327
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow's P;2L and P;4L raw decoders can read past the end of an input buffer. ImagingRawDecode calculates a packed-pixel row size, while the unpackers read separately padded bit planes. A caller can supply a buffer and image dimensions whose accepted size is smaller than the unpacker's actual footprint. This can disclose up to three adjacent heap bytes in output pixels or crash the process. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112396 / CVE-2026-112396 / GHSA-4rr5-89j8-mwwm / PYSEC-2026-4328
More information
Details
Pillow is a Python imaging library. From 11.3.0 until 13.0.0, Pillow has a heap use-after-free when libtiff rejects a tag while encoding TIFF data to an in-memory buffer. When an application saves attacker-influenced TIFF tags to BytesIO, libtiff can reject a tag during encoding in ImagingLibTiffEncode; the error path then frees the output buffer without closing or clearing the TIFF handle, and later cleanup flushes through the write callback into freed memory, which can crash the process and may enable code execution. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112397 / CVE-2026-112397 / GHSA-58r4-6rv3-gfh8 / PYSEC-2026-4329
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow's ImageMorph.MorphOp.apply() can access memory before a row buffer for a zero-width image. PPM and PGM input can create an image with width zero, and the native morphology routine indexes outrow[width - 1] without rejecting that geometry. An attacker can supply such an image to an application that applies a morphology operation. This can crash the process. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112398 / CVE-2026-112398 / GHSA-5x8w-8jj5-q52h / PYSEC-2026-4330
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow's FlashPix JPEG-tile decoder can write decoded scanlines beyond the allocated tile buffer. FlashPix (FPX) JPEG decoding selects raw mode LA with two bytes per pixel, while an unrecognized JPEG mode can be decoded as JCS_UNKNOWN with four components, causing ImagingJpegDecode to write four-component scanlines into the two-byte tile buffer. An attacker can supply a crafted OLE2 FlashPix file to an application with the optional olefile dependency installed that opens or thumbnails untrusted input. This can corrupt heap memory and may allow arbitrary code execution. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112402 / CVE-2026-112402 / GHSA-7mwm-ghpf-9vrm / PYSEC-2026-4331
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow can double free image memory when ImagingScaleAffine encounters a selective allocation failure. The affected fast path handles Transform.EXTENT or pure scale-and-translate Transform.AFFINE operations with the default NEAREST resampling. The output image allocation must succeed while the additional per-column lookup allocation fails, after which the error path deletes image storage that later cleanup releases again. An attacker who can drive such a large Image.transform() operation under memory pressure may trigger the failure. This can crash the process and may corrupt memory. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112403 / CVE-2026-112403 / GHSA-8g5g-8vmr-9782 / PYSEC-2026-4332
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow's JPEG decoder can write a full-width scanline into a buffer sized for a requested draft scale. ImagingJpegDecode does not verify libjpeg's actual output width and component count after scaling is requested, and an 8-bit lossless SOF3 JPEG decoded by a build linked to libjpeg-turbo 3.x can ignore the requested downscaling geometry. An attacker can supply such a JPEG to an application that uses draft() or thumbnail() to request downscaling. This can corrupt heap memory, crash the process, or potentially allow code execution. Older libjpeg-turbo 2.x and IJG builds reject the SOF3 input. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112405 / CVE-2026-112405 / GHSA-cp9v-662q-vvx9 / PYSEC-2026-4333
More information
Details
Pillow is a Python imaging library. From 12.0.0 until 13.0.0, Pillow's Image.merge() can dereference a null image8 plane. Validation skips the first input band, allowing a 32-bit integer or floating-point image backed by image32 storage to be merged as a narrower multiband image. A crafted IPTC file can reach this path when an application opens and loads it, or a caller can invoke Image.merge() directly with such a first band. This causes a native process crash. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112896 / CVE-2026-112896 / GHSA-pchr-8j38-3x4p / PYSEC-2026-4334
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow's TIFF encoder can read beyond a ColorMap buffer when retained TIFF metadata has a plane size inconsistent with BitsPerSample. A small TIFF can be opened normally and then retain a crafted ColorMap that is reused when the image is saved. An application that re-encodes the attacker's TIFF with libtiff can trigger the out-of-bounds read. This can disclose heap memory in the output or crash the process. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-112901 / CVE-2026-112901 / GHSA-xgh8-jp6q-p5x8 / PYSEC-2026-4335
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, Pillow's XBM header parser is vulnerable to regular-expression denial of service. In XbmImageFile._open, when an application calls Image.open() on a small crafted XBM file, XbmImagePlugin applies a backtracking expression to the attacker-controlled header before image dimensions are accepted, stalling a worker for minutes and denying service. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-114501 / CVE-2026-114501 / GHSA-654x-cwwv-5j9c / PYSEC-2026-4336
More information
Details
Pillow is a Python imaging library. From 11.2.1 until 13.0.0, Arrow export assumes that an image's rows are tightly contiguous and exports from im->blocks[0].ptr even when PILLOW_ALIGNMENT makes aligned_linesize include row padding or an aligned offset before the first row. The resulting Arrow buffer can include uninitialized padding, bytes preceding the first row, or stale heap contents from previously freed images. Applications that configure alignment and export images to Arrow can therefore disclose one user's pixels or other process memory to another user. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-96636 / CVE-2026-96636 / GHSA-p8hx-v2p6-rp84 / PYSEC-2026-4337
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, MpoImageFile.seek() and DcxImageFile.seek() reparse later MPO or DCX frames and replace self._size without rerunning Image._decompression_bomb_check(). A crafted multi-frame image can use a tiny first frame to pass Image.open() and then declare an over-limit later frame. Normal frame iteration through seek(), load(), or ImageSequence.Iterator can decode that frame and commit hundreds of megabytes or more, causing memory-exhaustion denial of service. This issue is fixed in version 13.0.0.
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 PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-96637 / CVE-2026-96637 / GHSA-g842-9rc4-69pw / PYSEC-2026-4338
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, SpiderImageFile.seek() in src/PIL/SpiderImagePlugin.py reparses a later SPIDER stack frame and replaces self._size without rerunning Image._decompression_bomb_check(). A tiny file can use a safe first frame to pass Image.open() and then declare very large dimensions in a later mode F frame. Loading that frame allocates approximately four bytes per attacker-declared pixel and can consume multiple gigabytes of memory. This issue is fixed in version 13.0.0.
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 PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-96639 / CVE-2026-96639 / GHSA-v9pv-5gc8-w2m6 / PYSEC-2026-4339
More information
Details
Pillow is a Python imaging library. From 11.0.0 until 13.0.0, multi-frame loaders can retain an ImagingCore allocation created for a narrow frame mode while a later frame of the same dimensions changes the logical mode and decoder tiles to wider RGB or CMYK data. ImageFile.load_prepare() reuses the stale native storage because _im is non-null without verifying that its mode and size still match. Crafted PSD, MPO, DCX, or MIC frame transitions can then write wider pixels into the narrow allocation, causing native out-of-bounds writes and crashes. The MPO path can also cause a native over-read and, in demonstrated runtime-specific profiles, command execution. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-96641 / CVE-2026-96641 / GHSA-4rm8-3hmm-39fg / PYSEC-2026-4340
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, PILfont text-width accumulation in src/_imaging.c checks positive glyph advances against INT_MAX but does not check negative advances against INT_MIN. Repeating a signed 16-bit advance of -32768 can therefore execute a signed C integer overflow in xsize += dx, producing a large positive width from a negative mathematical result. Applications that use getlength(), getsize(), or derived layout, quota, serialization, or allocation calculations can experience logic-integrity errors or resource consumption, while the high-level mask path commonly retains a decompression-bomb check. This issue is fixed in version 13.0.0.
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 PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-96642 / CVE-2026-96642 / GHSA-3j72-p6qg-6xp7 / PYSEC-2026-4341
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0, the TarIO initializer in src/PIL/TarIO.py accepts a negative octal member size and computes a backward seek that returns to the same 512-byte header. When the requested member name does not match, the parser repeatedly reads that header, consumes a full CPU core, and never returns. TarIO is reached only when an application directly uses PIL.TarIO.TarIO rather than the default Image.open() path. This issue is fixed in version 13.0.0.
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 PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-96643 / CVE-2026-96643 / GHSA-g4qv-2jxx-769h / PYSEC-2026-4342
More information
Details
Pillow is a Python imaging library. From 11.0.0 until 13.0.0, Exif.load() in src/PIL/Image.py repeatedly slices the remaining EXIF data while it begins with the six-byte Exif header. src/PIL/PngImagePlugin.py accepts an uncapped PNG eXIf chunk, so a file filled with repeated headers causes a new shrinking copy on every iteration and quadratic CPU work. The condition is reachable through Image.getexif(), Image._getexif(), and ImageOps.exif_transpose(), including common photo-upload rotation workflows. This issue is fixed in version 13.0.0.
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 PyPI Advisory Database (CC-BY 4.0).
BIT-pillow-2026-96644 / CVE-2026-96644 / GHSA-9v9v-9qvf-h2hj / PYSEC-2026-4343
More information
Details
Pillow is a Python imaging library. Prior to 13.0.0 on Windows, src/PIL/WmfImagePlugin.py passes unchecked 32-bit WMF or EMF dimensions to the native drawwmf handler in src/display.c. The handler sizes a DIB allocation from 16-bit-truncated bcWidth and bcHeight fields but copies pixel data using the original full dimensions. A crafted wide, short metafile can pass the pixel-count decompression-bomb check and make the Windows-only renderer read beyond the heap buffer, potentially disclosing memory or crashing the process. This issue is fixed in version 13.0.0.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
Release Notes
python-pillow/Pillow (pillow)
v13.0.0Compare Source
https://pillow.readthedocs.io/en/stable/releasenotes/13.0.0.html
Removals
GifImagePlugin._save_netpbm#9901 [@akx]Deprecations
Documentation
Dependencies
Testing
pathlib.Pathto find test images and fonts #10079 [@akx]ubuntu-slimfor lightweight jobs #9846 [@hugovk]pytest.approx,pytest.raisesandpytest.skip#9844 [@hugovk]Type hints
TYPE_CHECKINGblock #9827 [@hugovk]Other changes
Image.getbbox(); fix it for I;16 modes #9768 [@akx]getpixel()andget_flattened_data()#9881 [@akx]PIL._binary: Useint.to_bytes()for faster conversion #9981 [@akx]MIN/MAXmacro arguments #9928 [@akx]Image.filter()forI;16*#9760 [@akx]Image.offset#9753 [@akx]getModeData/getRawModeData#9932 [@akx]mixed-line-endingto prek and convert to LF #9886 [@hugovk]fmt: skipinstead offmt: off/fmt: on#9828 [@akx]zip_safeflag #9758 [@hugovk]Configuration
📅 Schedule: (UTC)
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about these updates again.
This PR has been generated by Mend Renovate CLI.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.