---
id: "CVE-2026-112899"
kind: "vulnerability"
aliases: ["CVE-2026-112899", "GHSA-vx6w-vv9f-6gvg"]
title: "Pillow: EPS load(scale) lets Ghostscript render past the decompression bomb limit"
severity: "medium"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 2.3.0, <= 12.3.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-770", "image-upload", "package", "pillow", "python", "self-hosted"]
not_affected_if: ["fixed-version-13.0.0-or-later", "no-eps-input", "no-ghostscript-installed", "scale-1-only"]
published_at: "2026-10-20T20:53:21+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T20:13:20+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-vx6w-vv9f-6gvg", "first_seen_at": "2026-10-23T19:11:32+00:00"}]
---

# Pillow: EPS load(scale) lets Ghostscript render past the decompression bomb limit

Pillow's EPS load(scale=N) passes an N-times-scaled size to Ghostscript without running the decompression-bomb check on it, so a tiny EPS can make Ghostscript render and write far more pixels than the configured limit. An attacker who can upload a crafted EPS file gets roughly N-squared times the intended Ghostscript CPU and temporary disk use per request.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:Pillow affected >= 2.3.0, <= 12.3.0 fixed 13.0.0

## How to check

pip show pillow to read the installed version; affected if it is >= 2.3.0 and <= 12.3.0.
Check whether code calls Image.open() on untrusted .eps/.ps files and then load(scale=N) with N > 1.

## Fix

Upgrade Pillow to 13.0.0 or later.
Until then, do not call load(scale=N) with N > 1 on untrusted EPS input, or do not accept EPS uploads.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-vx6w-vv9f-6gvg

## From the sources

```
============================================================================
EPS load(scale) lets Ghostscript render past the decompression bomb limit
Location: src/PIL/EpsImagePlugin.py:92  in Ghostscript()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

When an application opens an untrusted Encapsulated PostScript file and asks Pillow to render it at a larger scale, Pillow has Ghostscript produce an image several times bigger than Pillow's configured size limit allows. The size check runs only after Ghostscript has finished rendering and written the full raw image to a temporary file. An attacker who can upload a file of about 100 bytes makes each such request spend scale² times the Ghostscript rendering work and temporary disk space that the limit is meant to permit. For example, at scale 2 that is a 972 MB temporary file.

The cause is in `Ghostscript()` in `src/PIL/EpsImagePlugin.py`. It multiplies the file-declared size by the caller's `scale` (lines 93-94) and passes the product to `gs` as the `-g` output geometry. It never calls `Image._decompression_bomb_check()` on the scaled size. The only check is the one `Image.open()` ran on the unscaled size, so a file declaring 9000×9000 passes, and `load(scale=2)` then asks Ghostscript for 18000×18000 (3.6× `MAX_IMAGE_PIXELS`). A file declaring 18000×18000 directly would be refused before Ghostscript ever ran. `DecompressionBombError` fires only when Pillow reopens the output file, so Pillow's own in-process allocation stays bounded and there is no memory corruption.

The effect is a constant-factor overshoot rather than an unbounded one, for three reasons:

* The multiplier is an integer chosen by the application, not by the file.
* With the default warning filter, `Image.open()` already lets files up to 2× the limit reach Ghostscript at `scale=1`.
* Any application feeding untrusted files to Ghostscript is already exposed to a tiny file that keeps Ghostscript busy indefinitely, since `gs` runs with no timeout.

This is the EPS counterpart of the WMF `load(dpi)` gap closed in af70b075a. It has been present since the decompression-bomb limit was introduced in ce2955ec7 (2.5.0). The attached reproducer stubs out Ghostscript and shows a 76-byte file passing `Image.open()` and reaching `gs` with `-g18000x18000`. A one-line patch mirroring the WMF check is included.

## Details

`EpsImageFile.load(scale=N)` (the documented way to rasterise an EPS at higher resolution) renders `width*N × height*N` pixels through Ghostscript, but the only decompression-bomb check on the way is the one `Image.open()` ran on the *unscaled*, file-declared `width × height`. The scaled product is never passed to `Image._decompression_bomb_check()` before `gs` is started.

This 111-byte EPS declares 9000×9000 = 81,000,000 pixels, below the default `MAX_IMAGE_PIXELS` (89,478,485), so `Image.open()` accepts it without even a warning:

```
%!PS-Adobe-3.0 EPSF-3.0
%%BoundingBox: 0 0 9000 9000
%%EndComments
0 0 moveto 9000 9000 lineto stroke
showpage
```

`im.load(scale=2)` on it then executes

```
gs -q -g18000x18000 -r144.000000x144.000000 -dBATCH -dNOPAUSE -dSAFER -sDEVICE=pnmraw -sOutputFile=/tmp/tmpXXXX -c 0 0 translate -f attack.eps -c showpage
```

i.e. asks Ghostscript for an 18000×18000 = 324,000,000-pixel raster (3.6× `MAX_IMAGE_PIXELS`), whose `pnmraw` output is a 972,000,019-byte file in the temp directory. An EPS that declares 18000×18000 directly is rejected by `Image.open()` with `DecompressionBombError` before Ghostscript is ever run; via `scale=2` the same raster is rendered first and the `DecompressionBombError` only appears afterwards, when Pillow opens the file Ghostscript produced. (Between 1× and 2× `MAX_IMAGE_PIXELS`, `Image.open()` only emits `DecompressionBombWarning`, so with the default warning filter an EPS declaring e.g. 13377×13377 is already rendered at `scale=1` — a 537 MB PNM. Against that baseline, `scale=2` is 4× an already-permitted render.)

The arithmetic is in `Ghostscript()`:

```python
# src/PIL/EpsImagePlugin.py:91-97
    # Hack to support hi-res rendering
    scale = int(scale) or 1
    width = size[0] * scale
    height = size[1] * scale
    # resolution is dependent on bbox and size
    res_x = 72.0 * width / (bbox[2] - bbox[0])
    …
# src/PIL/EpsImagePlugin.py:139-142
    command = [
        gs_binary,
        "-q",  # quiet mode
        f"-g{width:d}x{height:d}",  # set output geometry (pixels)
    …
# src/PIL/EpsImagePlugin.py:165-168
        subprocess.check_call(command, startupinfo=startupinfo)
        with Image.open(outfile) as out_im:
            out_im.load()
            return out_im.im.copy()
```

and its caller does nothing between taking `scale` from the application and invoking it:

```python
# src/PIL/EpsImagePlugin.py:411-417
    def load(self, scale: int = 1, transparency: bool = False) -> …:
        # Load EPS via Ghostscript
        if self.tile:
            assert self.fp is not None
            self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
```

`size` here is `self._size`, computed in `_open()` from the file's `%%BoundingBox` (or `%ImageData`) and checked once by `Image.open()` at `src/PIL/Image.py:3700`. For the benign path (`scale=1`) that check covers exactly what Ghostscript is asked to render. For `scale=N` the rendered pixel count is N² times what was checked, so a file can sit just under the limit at open time and have Ghostscript render 4× (scale 2), 9× (scale 3), 16× (scale 4) the configured budget. The first point at which Pillow looks at the real size again is `Image.open(outfile)` at line 166 — after `subprocess.check_call` has returned, i.e. after Ghostscript has spent the CPU and written `width*height*3` bytes to `tempfile.mkstemp()`'s directory.

`WmfStubImageFile.load(dpi)` has the same shape (file-derived extent × caller-supplied factor → new raster) and, since af70b075a, calls `Image._decompression_bomb_check(self.size)` on the recomputed size before rendering (`src/PIL/WmfImagePlugin.py:127-131`). The EPS path lacks the equivalent line. The gap dates from ce2955ec7128 ("Throw an exception when an opened image is larger than an arbitrary limit", 2014-05-14, first released in 2.5.0), which added the check to `Image.open()` without covering the `scale` path.

## Impact

Affected are applications that open untrusted EPS/PS with Ghostscript installed and call `load(scale=N)` with a fixed N > 1, as the handbook suggests for non-blurry output. Handing untrusted EPS to Ghostscript at all already lets the same attacker tie up a worker with an equally tiny file (e.g. a PostScript `{} loop`; Pillow runs `gs` with no timeout), so this bug adds a constant factor rather than a new denial-of-service capability. Within that setting, a ~110-byte upload makes each request run Ghostscript over N² × `MAX_IMAGE_PIXELS` pixels (N² × 2·`MAX_IMAGE_PIXELS` if the application does not turn `DecompressionBombWarning` into an error) and write a `W·H·3`-byte PNM into the temp directory — 972 MB for the example above at scale 2, 2.2 GB at scale 3, 8.6 GB for a 178-Mpixel declaration at scale 4 — before any limit is applied. The operator's `MAX_IMAGE_PIXELS` setting is exceeded by a factor of N²; N is an integer chosen by the application, not by the file, so the work stays bounded at N² × 2·`MAX_IMAGE_PIXELS`. The consequence is N² more Ghostscript CPU and temp-disk use per request than the configured limit intends; Pillow's own in-process pixel allocation remains bounded because `Image.open(outfile)` re-checks the PNM's size, and there is no memory corruption.

This is a sibling of the WMF `load(dpi)` issue fixed in af70b075a ("Add decompression bomb check when loading at a different DPI"); the EPS `scale` path was not covered by that change. Unlike the WMF case, where the file-controlled `_inch` makes the ratio unbounded, the EPS multiplier is fixed by the ap

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
