GHSA-vx6w-vv9f-6gvg CVE-2026-112899
Pillow: EPS load(scale) lets Ghostscript render past the decompression bomb limit
- Severity
- 0.0 / 10
- Medium · CVSS 4.0
- Exploit chance (EPSS)
- —
- No score published
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-770 Allocation of Resources Without Limits or Throttling
- Fix
13.0.0- Upgrade pillow to this version or later
Description
============================================================================
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 atscale=1. - Any application feeding untrusted files to Ghostscript is already exposed to a tiny file that keeps Ghostscript busy indefinitely, since
gsruns with no timeout.
This is the EPS counterpart of the WMF load(dpi) gap closed in af70b075a. It has been present since the decompression-bomb limit was introduced in ce2955ec7 (2.5.0). The attached reproducer stubs out Ghostscript and shows a 76-byte file 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():
# 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:
# 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 application.
Reproducing
Run repro against the installed Pillow. Ghostscript does not need to be installed: the script sets EpsImagePlugin.gs_binary = "gs" so that has_ghostscript() skips its gs --version probe, and replaces subprocess.check_call with a function that records the command line Pillow builds and then raises OSError, so nothing is actually rendered.
It writes a 76-byte EPS declaring %%BoundingBox: 0 0 9000 9000 (the example above without the drawing line; 81 Mpixel, below the default MAX_IMAGE_PIXELS of 89,478,485) and turns DecompressionBombWarning into an error, so any bomb check — warning or hard error — counts. It then calls Image.open() and im.load(scale=2). Image.open() accepts the file and load(scale=2) reaches the intercepted Ghostscript call with -g18000x18000 (324,000,000 pixels, 3.6× MAX_IMAGE_PIXELS, a 972 MB pnmraw output with real Ghostscript). The script prints the geometry it captured.
It prints BUG when Ghostscript was invoked with a -g geometry above 2*MAX_IMAGE_PIXELS (178,956,970) and no decompression-bomb check fired before that call; otherwise it prints OK. With the attached patch applied, load(scale=2) raises DecompressionBombError before Ghostscript is called and the script prints OK.
Suggested fix
The attached patch checks the scaled size in Ghostscript() right after the scaled width/height are computed (src/PIL/EpsImagePlugin.py:93-94) and before the gs command is built, mirroring WmfStubImageFile.load:
--- a/src/PIL/EpsImagePlugin.py
+++ b/src/PIL/EpsImagePlugin.py
@@ -92,6 +92,7 @@ def Ghostscript(
scale = int(scale) or 1
width = size[0] * scale
height = size[1] * scale
+ Image._decompression_bomb_check((width, height))
# resolution is dependent on bbox and size
res_x = 72.0 * width / (bbox[2] - bbox[0])
res_y = 72.0 * height / (bbox[3] - bbox[1])
load(scale=N) now raises DecompressionBombError / emits DecompressionBombWarning under the same MAX_IMAGE_PIXELS thresholds Image.open() applies, before Ghostscript runs instead of after. Placing the check in Ghostscript() rather than in EpsImageFile.load() is the single point where the rendered extent is decided, so it also covers direct callers of the module-level function. With the patch applied to a clean checkout the reproducer prints OK and Tests/test_file_eps.py passes. One cosmetic side effect: at scale=1 the check re-emits the DecompressionBombWarning that Image.open() already issued for sizes between 1× and 2× the limit; guarding the call with if scale > 1: would avoid the duplicate.
The patch also adds test_load_scale_decompression_bomb to Tests/test_file_eps.py, in the style of the WMF test: it sets MAX_IMAGE_PIXELS to im.width * im.height and expects im.load(scale=2) to raise DecompressionBombError. Like the rest of that file it is skipped without Ghostscript, because has_ghostscript() is consulted at the top of Ghostscript(); moving the check above that call would let the test run without it.
The patch
Add decompression bomb check on the scaled size in EpsImagePlugin.Ghostscript() before Ghostscript is invoked
Applies to src/PIL/EpsImagePlugin.py at the commit above.
diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py
index 70eced62a..de331d97f 100644
--- a/Tests/test_file_eps.py
+++ b/Tests/test_file_eps.py
@@ -98,6 +98,14 @@ def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None:
assert image.format == "EPS"
+@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
+def test_load_scale_decompression_bomb(monkeypatch: pytest.MonkeyPatch) -> None:
+ with Image.open(FILE1) as im:
+ monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", im.width * im.height)
+ with pytest.raises(Image.DecompressionBombError):
+ im.load(scale=2)
+
+
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
def test_load() -> None:
with Image.open(FILE1) as im:
diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py
index 5eaefe339..ccc6f1d85 100644
--- a/src/PIL/EpsImagePlugin.py
+++ b/src/PIL/EpsImagePlugin.py
@@ -92,6 +92,7 @@ def Ghostscript(
scale = int(scale) or 1
width = size[0] * scale
height = size[1] * scale
+ Image._decompression_bomb_check((width, height))
# resolution is dependent on bbox and size
res_x = 72.0 * width / (bbox[2] - bbox[0])
res_y = 72.0 * height / (bbox[3] - bbox[1])
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 1,845 bytes (click to open)</summary>
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
#!/usr/bin/env python3
# EpsImagePlugin.Ghostscript(): load(scale=N) hands gs a -g geometry of
# (W*N)x(H*N) with no _decompression_bomb_check on the scaled size; only the
# unscaled WxH was checked by Image.open(). gs is not installed here, so we
# set gs_binary and intercept subprocess.check_call to read the argv Pillow builds.
import os, subprocess, tempfile, warnings
from PIL import Image, EpsImagePlugin
EpsImagePlugin.gs_binary = "gs" # skip the `gs --version` probe
path = os.path.join(tempfile.mkdtemp(), "a.eps")
with open(path, "w") as f: # 9000x9000 = 81 Mpx < MAX_IMAGE_PIXELS (89.5 Mpx)
f.write("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 9000 9000\n%%EndComments\nshowpage\n")
calls = []
def fake_check_call(cmd, **kw):
calls.append(cmd)
raise OSError("gs intercepted") # stop here; we only need the argv
subprocess.check_call = fake_check_call
hard = 2 * Image.MAX_IMAGE_PIXELS
bomb_before_gs = False
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
try:
with Image.open(path) as im:
print("Image.open accepted", os.path.getsize(path), "byte EPS, size", im.size)
im.load(scale=2)
except (Image.DecompressionBombError, Image.DecompressionBombWarning) as e:
bomb_before_gs = not calls
print("bomb check fired", "before" if bomb_before_gs else "after", "gs:", e)
except OSError as e:
print("load() reached gs:", e)
over = False
for c in calls:
g = next(a for a in c if a.startswith("-g"))[2:]
w, h = map(int, g.split("x"))
over |= w * h > hard
print(f"gs argv geometry -g{g} = {w*h} px ({w*h/Image.MAX_IMAGE_PIXELS:.1f}x MAX_IMAGE_PIXELS, "
f"hard limit {hard}); pnmraw output would be {w*h*3} bytes")
print("BUG" if calls and over and not bomb_before_gs else "OK")
</details>
Affected versions
PyPI
| Package | Vulnerable | Fixed in |
|---|---|---|
| pillow | >= 2.3.0, < 13.0.0 |
13.0.0 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
pillow PyPI → 13.0.0
- pip
pip install --upgrade "pillow>=13.0.0"- poetry
poetry add "pillow>=13.0.0"- uv
uv add "pillow>=13.0.0"
Severity breakdown
CVSS 4.0 0.0
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
- Attack vector
- Network
- Attack complexity
- Low
- Attack requirements
- None
- Privileges required
- None
- User interaction
- None
- Confidentiality (vulnerable system)
- None
- Integrity (vulnerable system)
- None
- Availability (vulnerable system)
- Low
- Confidentiality (subsequent systems)
- None
- Integrity (subsequent systems)
- None
- Availability (subsequent systems)
- None
Weakness
CWE-770: Allocation of Resources Without Limits or Throttling
The product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated.
How to prevent it
- Requirements: Clearly specify the minimum and maximum expectations for capabilities, and dictate which behaviors are acceptable when resource allocation reaches limits.
- Architecture and Design: Limit the amount of resources that are accessible to unprivileged users. Set per-user limits for resources. Allow the system administrator to define these limits. Be careful to avoid CWE-410.
- Architecture and Design: Design throttling mechanisms into the system architecture. The best protection is to limit the amount of resources that an unauthorized user can cause to be expended. A strong authentication and access control model will help prevent such attacks from occurring in the first place, and it will help the administrator to identify who is committing the abuse. The login application should be protected against DoS attacks as much as possible. Limiting the database access, perhaps by caching result sets, can help minimize the resources expended. To further limit the potential for a DoS attack, consider tracking the rate of requests received from users and blocking requests that exceed a defined rate threshold.
- Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.
- Architecture and Design: For any security checks that are performed on the client side, ensure that these checks are duplicated on the server side, in order to avoid CWE-602. Attackers can bypass the client-side checks by modifying values after the checks have been performed, or by changing the client to remove the client-side checks entirely. Then, these modified values would be submitted to the server.
- Architecture and Design: Mitigation of resource exhaustion attacks requires that the target system either: recognizes the attack and denies that user further access for a given amount of time, typically by using increasing time delays uniformly throttles all requests in order to make it more difficult to consume resources more quickly than they can again be freed. The first of these solutions is an issue in itself though, since it may allow attackers to prevent the use of the system by a particular valid user. If the attacker impersonates the valid user, they may be able to prevent the user from accessing the server in question. The second solution can be difficult to effectively institute -- and even when properly done, it does not provide a full solution. It simply requires more resources on the part of the attacker.
- Architecture and Design: Ensure that protocols have specific limits of scale placed on them.
- Architecture and Design, Implementation: If the program must fail, ensure that it fails gracefully (fails closed). There may be a temptation to simply let the program fail poorly in cases such as low memory conditions, but an attacker may be able to assert control before the software has fully exited. Alternately, an uncontrolled failure could cause cascading problems with other downstream components; for example, the program could send a signal to a downstream process so the process immediately knows that a problem has occurred and has a better chance of recovery. Ensure that all failures in resource allocation place the system into a safe posture.
- Operation, Architecture and Design: Use quotas or other resource-limiting settings provided by the operating system or environment. For example, when managing system resources in POSIX, setrlimit() can be used to set limits for certain types of resources, and getrlimit() can determine how many resources are available. However, these functions are not available on all operating systems. When the current levels get close to the maximum that is defined for the application (see CWE-770), then limit the allocation of further resources to privileged users; alternately, begin releasing resources for less-privileged users. While this mitigation may protect the system from attack, it will not necessarily stop attackers from adversely impacting other users. Ensure that the application performs the appropriate error checks and error handling in case resources become unavailable (CWE-703).
References
Packages
Other links
Timeline
- Published
- Last updated by the source
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| PYSEC-2023-175 Pillow versions before v10.0.1 bundled libwebp binaries in wheels that are vulnerable to CVE-2023-5129 (previously CVE-2023-4863). | High Known exploited | Also affects pillow |
| GHSA-j7hp-h8jx-5ppr libwebp: OOB write in BuildHuffmanTable | High Known exploited | Also affects pillow |
| GHSA-78g4-xqwv-q3g8 Pillow: Bundled libopenjp2 2.5.4 in all wheels since 11.1.0 carries OSV-2025-219 heap-buffer-overflow write reachable from Image.open().load() | High | Also affects pillow |
| GHSA-654x-cwwv-5j9c Pillow: Arrow export leaks stale heap memory when PILLOW_ALIGNMENT > 1 | Medium | Also affects pillow |
| GHSA-xvh5-g6gv-559v Pillow: Memory disclosure and segfault in libtiff encoder via crafted TIFF tags | High | Also affects pillow |
| GHSA-g7cr-3p24-7wf7 Pillow: Heap buffer overflow writing JPEG comment after ICC profile fills buffer | High | Also affects pillow |
| GHSA-9v9v-9qvf-h2hj Pillow: Heap over-read in WMF/EMF rendering via 16-bit dimension truncation (Windows builds) | High | Also affects pillow |
| GHSA-g4qv-2jxx-769h Pillow: PNG eXIf chunk makes EXIF parsing run in quadratic time | Medium | Also affects pillow |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor