============================================================================
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._decompression_bomb_check(). 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 load_prepare(), 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 n_frames/is_animated. 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:
# 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 update_image:
self._size = max(x1, self.size[0]), max(y1, self.size[1]) # state mutated
Image._decompression_bomb_check(self._size) # ...then validated
_decompression_bomb_check() (src/PIL/Image.py:3586) raises
DecompressionBombError for anything above 2 * MAX_IMAGE_PIXELS
(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 _decompression_bomb_check(dispose_size) before the corresponding
Image.core.fill(), and TiffImagePlugin.load_prepare()
(src/PIL/TiffImagePlugin.py:1319-1321), which checks _tile_size 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.load_prepare():
# src/PIL/GifImagePlugin.py:452-456
elif self.mode in ("RGB", "RGBA"):
self._prev_im = self.im
if self._frame_palette:
self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
which allocates and memsets a 40000×40000 "P" image (1.6 GB) to decode a
1×1 tile into, and load_end() (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.
n_frames / is_animated are not affected: they call
_seek(frame, update_image=False), which skips the _size assignment (the file
above reports n_frames == 2, is_animated == 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
MAX_IMAGE_PIXELS 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.
setup_env() caps the process address space at 3 GiB with
RLIMIT_AS, 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 bomb_gif(). 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.
poc() calls im.seek(1) and catches DecompressionBombError, the way
an application that skips bad frames would.
- 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).
- 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. load_prepare() 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 _decompression_bomb_check(), and only then assigned to
self._size, matching the order of the two dispose_size 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:
--- 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 update_image:
- self._size = max(x1, self.size[0]), max(y1, self.size[1])
- Image._decompression_bomb_check(self._size)
+ size = max(x1, self.size[0]), max(y1, self.size[1])
+ Image._decompression_bomb_check(size)
+ self._size = size
frame_dispose_extent = x0, y0, x1, y1
flags = s[8]
n_frames / is_animated go through update_image=False and are unaffected.
The patch also extends test_exception_gif_extents in
Tests/test_decompression_bomb.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 last_frame, 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 --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py
index 98d833736..83cc785fc 100644
--- a/Tests/test_decompression_bomb.py
+++ b/Tests/test_decompression_bomb.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 test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Set limit to trigger exception on the test file
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 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 update_image:
- self._size = max(x1, self.size[0]), max(y1, self.size[1])
- Image._decompression_bomb_check(self._size)
+ size = max(x1, self.size[0]), max(y1, self.size[1])
+ Image._decompression_bomb_check(size)
+ self._size = size
frame_dispose_extent = x0, y0, x1, y1
flags = s[8]
The reproducer
Reproducer — repro, 2,474 bytes (click to open)
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
# 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.MAX_IMAGE_PIXELS
def frame_1x1():
# 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 bomb_gif():
return (
b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" * 3 + b"\xff" * 3
+ frame_1x1() # frame 0: 1x1, passes open()
+ b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0) # frame 1 claims 40000x40000
+ frame_1x1() # a real 1x1 frame follows
+ b";"
)
def peak_rss_mib():
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024
def setup_env():
# Cap the address space so the oversized allocations end in MemoryError
# instead of the OOM killer.
resource.setrlimit(resource.RLIMIT_AS, (3 << 30, 3 << 30))
data = bomb_gif()
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 = setup_env()
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 = peak_rss_mib()
im.seek(1)
try:
im.load()
outcome = "completed"
except MemoryError:
outcome = "MemoryError at the 3 GiB cap"
after = peak_rss_mib()
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")
References
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 toself._sizeand only then callsImage._decompression_bomb_check(). Nothing restores the old size whenDecompressionBombErroris raised, becauseseek()only rolls back onEOFError. After the caught error,im.sizereports the rejected size while the pixel bufferim.imis 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 byload()then reachesload_prepare(), which callsImage.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, ...)androtate(..., expand=True)also allocate from the stale size.The default paths are unaffected: plain
Image.open()plusload(),ImageSequence.Iterator, andn_frames/is_animated. 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 thedecompression-bomb check for the new size only after storing it on the object:
_decompression_bomb_check()(src/PIL/Image.py:3586) raisesDecompressionBombErrorfor anything above2 * MAX_IMAGE_PIXELS(178 956 970 px by default), but by then
self._sizealready holds the rejectedvalue and nothing restores it:
seek()(GifImagePlugin.py:166-179) only rollsback on
EOFError, and_seek(0)only ever grows_size. The object that theapplication still holds therefore reports
im.size == (40000, 40000)whileim.imis the 1×1 buffer of frame 0. Compare the two dispose-extent checks afew lines further down in the same function (
GifImagePlugin.py:383and:407),which call
_decompression_bomb_check(dispose_size)before the correspondingImage.core.fill(), andTiffImagePlugin.load_prepare()(
src/PIL/TiffImagePlugin.py:1319-1321), which checks_tile_sizebeforeallocating from it.
With this 60-byte GIF (hex)
Image.open()succeeds (im.size == (1, 1)),im.seek(1)raisesDecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwardsim.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.sizerather thanfrom the C buffer:
im.seek(1). The failed_seekleftself.__offset == 0and thefile 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.load_prepare():memsets a 40000×40000 "P" image (1.6 GB) to decode a1×1 tile into, and
load_end()(GifImagePlugin.py:485-491) fills a secondone 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 thecommon flatten-onto-background idiom
Image.new("RGB", im.size, bg), allocatefrom
im.sizeas 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 passim.sizeas a box against the real buffer (thumbnail,resize,save)fail with
ValueError/SystemErrorinstead.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.
n_frames/is_animatedare not affected: they call_seek(frame, update_image=False), which skips the_sizeassignment (the fileabove reports
n_frames == 2,is_animated == Trueand leavesim.sizeat1×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 broadexcept Exceptionarounda
while True: im.seek(im.tell()+1)frame-collection loop, or an explicitexcept DecompressionBombError: log and continue— and then keeps using thesame
Imageobject, ends up driving allocations of 1.6 GB to tens of GB from a60-byte file:
MemoryErrorafter the process has committed gigabytes, or anOOM kill of the worker when no address-space limit is set. The
MAX_IMAGE_PIXELSguard fires, but does not actually keep the rejecteddimensions out of the object it is guarding.
The precondition is real application behaviour but not the default path:
Image.open(); im.load()is unaffected, andImageSequence.Iteratorpropagatesthe error out of the
forstatement (atryinside the loop body does notswallow 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), firstreleased 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
reprois a single Python script run against the installed Pillow. It needsabout 1.7 GB of free RAM.
setup_env()caps the process address space at 3 GiB withRLIMIT_AS, so the oversized allocations end inMemoryErrorinstead ofthe OOM killer. It then opens the 60-byte GIF shown above, built in memory
by
bomb_gif(). The GIF contains a 1×1 screen and a 1×1 frame 0, soImage.open()passes its own bomb check. Next comes a bare frame-1 imagedescriptor that declares 40000×40000 (1.6 Gpx, about 9× the error
threshold). A real 1×1 frame follows it.
poc()callsim.seek(1)and catchesDecompressionBombError, the wayan application that skips bad frames would.
im.sizenextto the real pixel buffer
im.im.size. If the rejected frame had left theobject unchanged, both would be
(1, 1). On the affected codeim.sizeis(40000, 40000).im.seek(1), which nowparses the trailing 1×1 frame, and calls
im.load(). It prints theprocess's peak RSS before and after.
load_prepare()sizes itsImage.core.fill()fromim.size, so loading a single pixel fills a1.6 GB buffer before the 3 GiB cap stops the next allocation.
Expected output on the affected tree:
The script prints
BUGonly if both checks hold: the over-limit size stayedon 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 patchapplied, it prints
im.size=(1, 1), peak RSS stays at about 23 MiB, and thelast 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 alocal, passed to
_decompression_bomb_check(), and only then assigned toself._size, matching the order of the twodispose_sizechecks further downthe 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:
n_frames/is_animatedgo throughupdate_image=Falseand are unaffected.The patch also extends
test_exception_gif_extentsinTests/test_decompression_bomb.pyto assert thatim.sizeis unchanged afterthe
DecompressionBombError. It does not changeseek()itself; optionally,seek()could also treatDecompressionBombErrorlikeEOFErrorand re-seekto
last_frame, so that a retriedseek()does not resume parsing from themiddle 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.pyat the commit above.The reproducer
Reproducer —
repro, 2,474 bytes (click to open)Its last line is
BUGwhen the issue triggers andOKwhen it does not. Re-run on a clean build of that commit it printedBUG.References