CVE-2026-54058: Pillow Out-of-bounds Read Vulnerability
HIGHCVE-2026-54058 is a HIGH out-of-bounds read vulnerability (CWE-125: Out-of-bounds Read) affecting Pillow libraries in the PyPI ecosystem. The issue involves out-of-bounds read via attacker-controlled row stride on pillow's mmap path (mcidas area files). Remediation is available by upgrading affected packages to version 12.3.0.
CVE-2026-54058 at a Glance
How to Fix CVE-2026-54058
To remediate this vulnerability, upgrade all affected Pillow dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
pip install pillow>=12.3.0
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
pip show pillow
How to Check if You Are Affected
To determine whether your Python environment is affected by CVE-2026-54058, inspect your installed packages using pip:
pip show pillow
Analyze the terminal output to locate any version of Pillow in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 12.3.0), your application is affected.
Note: The pip show pillow command checks currently installed packages in your active Python environment.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-125: Out-of-bounds Read, where the product reads data past the end, or before the beginning, of the intended buffer. In Pillow, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-54058 Affected Pillow Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
pillow
|
PyPI |
< 12.3.0
|
12.3.0
|
References
Frequently Asked Questions
What is CVE-2026-54058?
Which Pillow versions are affected?
What version fixes CVE-2026-54058?
Is pillow affected?
What CWE is associated with CVE-2026-54058?
How can I check PyPI dependencies for CVE-2026-54058?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-62p4-gmf7-7g93",
"modified" : "2026-07-20T21:08:14Z",
"published" : "2026-07-20T21:08:13Z",
"aliases" : [ "CVE-2026-54058" ],
"summary" : "Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)",
"details" : "## Summary\n\nWhen Pillow loads an uncompressed image whose tile uses the `raw` codec and a mode in `Image._MAPMODES`, and the image was opened **from a filename**, it memory-maps the file and builds the image's row pointers directly into the mapping via `PyImaging_MapBuffer` (`src/map.c`). The per-row spacing (`stride`) is taken from the tile arguments. `map.c` validates `offset + ysize*stride <= buffer_len` but **never checks that `stride` is at least the natural row width `xsize * pixelsize`**.\n\nThe **McIdas** AREA plugin (`McIdasImagePlugin.py`) derives `stride`, `offset`, `xsize`, and `ysize` directly from attacker-controlled 32-bit header words with no validation. By supplying a `stride` far smaller than the row width, an attacker makes each row pointer read `xsize*pixelsize` bytes that run past the mapped region. Accessing the pixels (e.g. `Image.tobytes()`,\n`getpixel`, `convert`, `save`) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service).\n\n\n## Complete Code Trace\n\n**Step 1: `McIdasImageFile._open`** - turns attacker header words into image size, file offset, and row stride with no validation.\n\n```python\n# src/PIL/McIdasImagePlugin.py:41-70\ns = self.fp.read(256)\nif not _accept(s) or len(s) != 256: # _accept: prefix == b\"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x04\"\n raise SyntaxError(...)\nself.area_descriptor = w = [0, *struct.unpack(\"!64i\", s)] # w[1..64] = signed BE int32, ALL attacker-controlled\n\nif w[11] == 1:\n mode = rawmode = \"L\" # pixelsize 1, in _MAPMODES\nelif w[11] == 2:\n mode = rawmode = \"I;16B\" # pixelsize 2, in _MAPMODES\n...\nself._mode = mode\nself._size = w[10], w[9] # (xsize, ysize) <-- attacker\noffset = w[34] + w[15] # <-- attacker\nstride = w[15] + w[10] * w[11] * w[14] # <-- attacker (set w[14]=0, w[15]=1 => stride=1)\nself.tile = [\n ImageFile._Tile(\"raw\", (0, 0) + self.size, offset, (rawmode, stride, 1))\n]\n```\n\n**Step 2: `ImageFile.load` (mmap branch)** - selects mmap and delegates to `map_buffer`.\n\n```python\n# src/PIL/ImageFile.py:322-348\nif use_mmap: # use_mmap = self.filename and len(self.tile) == 1\n decoder_name, extents, offset, args = self.tile[0]\n if (decoder_name == \"raw\" and isinstance(args, tuple) and len(args) >= 3\n and args[0] == self.mode and args[0] in Image._MAPMODES):\n if offset < 0: # only lower-bound guard on offset\n raise ValueError(\"Tile offset cannot be negative\")\n with open(self.filename) as fp:\n self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ)\n if offset + self.size[1] * args[1] > self.map.size(): # == offset + ysize*stride; NO stride>=linesize check\n raise OSError(\"buffer is not large enough\")\n self.im = Image.core.map_buffer(\n self.map, self.size, decoder_name, offset, args # args = (\"L\", stride, 1)\n )\n```\n\n**Step 3: `PyImaging_MapBuffer`** - builds row pointers at `stride` spacing into the mmap; validates everything except `stride >= row width`.\n\n```c\n/* src/map.c:65-140 */\nif (!PyArg_ParseTuple(args, \"O(ii)sn(sii)\",\n &target, &xsize, &ysize, &codec, &offset, &mode_name, &stride, &ystep))\n return NULL;\n...\nconst ModeID mode = findModeID(mode_name); /* \"L\" */\n\nif (stride <= 0) { /* attacker sets stride=1 (>0) -> NOT recomputed */\n if (mode == IMAGING_MODE_L || mode == IMAGING_MODE_P) stride = xsize;\n else if (isModeI16(mode)) stride = xsize * 2;\n else stride = xsize * 4;\n}\n\nif (stride > 0 && ysize > PY_SSIZE_T_MAX / stride) {/* overflow guard only */\n PyErr_SetString(PyExc_MemoryError, \"Integer overflow in ysize\"); return NULL;\n}\nsize = (Py_ssize_t)ysize * stride; /* = 1*1 = 1 */\n\nif (offset > PY_SSIZE_T_MAX - size) { ... }\n...\nif (offset + size > view.len) { /* 1 + 1 = 2 <= 256 -> PASSES */\n PyErr_SetString(PyExc_ValueError, \"buffer is not large enough\");\n PyBuffer_Release(&view); return NULL;\n}\n\nim = ImagingNewPrologueSubtype(mode, xsize, ysize, sizeof(ImagingBufferInstance));\n/* im->linesize = xsize * pixelsize = 200000 (the REAL per-row read width) */\n\n/* setup file pointers -- NO check that stride >= im->linesize */\nif (ystep > 0) {\n for (y = 0; y < ysize; y++) {\n im->image[y] = (char *)view.buf + offset + y * stride; /* row points into mmap, spacing=1 */\n }\n} else { ... }\n```\n\n`im->linesize` (the number of bytes any consumer reads per row) is `xsize * pixelsize = 200000`, but the row pointers are only `stride = 1` byte apart and the buffer is only `offset + ysize*stride = 2` bytes \"claimed\". Nothing reconciles the two.\n\n**Step 4: pixel access (`Image.tobytes()` → raw encoder `copy1`)** - reads `linesize` bytes from `im->image[0]`, i.e. `xsize` bytes starting at `view.buf + offset`, running far past the mmap.\n\n```c\n/* the raw \"L\" packer copies linesize (=xsize) bytes per row from im->image[y];\n for row 0 that is view.buf+1 .. view.buf+1+200000, vs a 256-byte file. */\n```\n\n## Chain Summary\n\n```\nSOURCE: McIdas AREA header words w[9],w[10],w[11],w[14],w[15],w[34] (Image.open on a path)\n ↓ McIdasImagePlugin._open: stride = w[15]+w[10]*w[11]*w[14] -> attacker sets stride=1 [McIdasImagePlugin.py:66]\n ↓ tile = (\"raw\", (0,0,xsize,1), offset, (\"L\", 1, 1)) [McIdasImagePlugin.py:68]\nGADGET: ImageFile.load mmap branch -- only checks offset+ysize*stride<=len <- BUG: no stride>=linesize check [ImageFile.py:343]\n ↓ core.map_buffer(map, (xsize,1), \"raw\", offset, (\"L\",1,1)) [ImageFile.py:346]\nSINK: PyImaging_MapBuffer: im->image[0] = view.buf + offset + 0*stride; linesize=xsize [map.c:134]\n ↓ Image.tobytes() raw \"L\" encoder reads linesize (=xsize) bytes from im->image[0]\nIMPACT: reads xsize bytes from a tiny mmap -> OOB read of adjacent process memory (leak) or SIGBUS (DoS)\n```\n## Proof of Concept\n\nSee attached [poc.zip](https://github.com/user-attachments/files/28460498/poc.zip)\n\n\n## Impact on a Parent Application\n\nAny application that opens image files supplied by users **from a path on disk** (the common pattern: save upload to a temp file, then `Image.open(path)`), has the default plugin set (McIdas is registered by default), and subsequently reads/returns/re-encodes the decoded pixels (thumbnailing, format conversion, serving a preview), is exposed:\n\n- **Information disclosure (High):** the decoded \"image\" contains bytes of the worker process's adjacent heap/mapped memory, which the app then serves or stores - potentially leaking secrets, credentials, or other users' data.\n- **Denial of service (High):** a larger `xsize` reliably crashes the worker with SIGBUS.\n\n## Suggested fix\nCore fix in `src/map.c` (`PyImaging_MapBuffer`): reject `offset < 0` and `stride < im->linesize`. Defense-in-depth in `McIdasImagePlugin._open`: reject `offset < 0` or `stride < xsize*pixelsize` .",
"severity" : [ {
"type" : "CVSS_V4",
"score" : "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N"
} ],
"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-62p4-gmf7-7g93"
}, {
"type" : "ADVISORY",
"url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-54058"
}, {
"type" : "WEB",
"url" : "https://github.com/python-pillow/Pillow/pull/9719"
}, {
"type" : "WEB",
"url" : "https://github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d"
}, {
"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-125" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-20T21:08:13Z",
"nvd_published_at" : "2026-07-14T17:17:03Z"
}
}