CVE-2026-54058: Pillow Out-of-bounds Read Vulnerability

HIGH
Advisory Identifier: GHSA-62p4-gmf7-7g93
CWE-125
Published
Advisory Summary

CVE-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

CVE Identifier CVE-2026-54058
GHSA Identifier GHSA-62p4-gmf7-7g93
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-125: Out-of-bounds Read
Affected Product Pillow
Package Ecosystem PyPI
Published Date
Last Updated

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.

pillow fixed: 12.3.0
pip install pillow>=12.3.0
Verify Direct and Transitive Dependencies

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

## Summary When 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`**. The **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()`, `getpixel`, `convert`, `save`) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service). ## Complete Code Trace **Step 1: `McIdasImageFile._open`** - turns attacker header words into image size, file offset, and row stride with no validation. ```python # src/PIL/McIdasImagePlugin.py:41-70 s = self.fp.read(256) if not _accept(s) or len(s) != 256: # _accept: prefix == b"\x00\x00\x00\x00\x00\x00\x00\x04" raise SyntaxError(...) self.area_descriptor = w = [0, *struct.unpack("!64i", s)] # w[1..64] = signed BE int32, ALL attacker-controlled if w[11] == 1: mode = rawmode = "L" # pixelsize 1, in _MAPMODES elif w[11] == 2: mode = rawmode = "I;16B" # pixelsize 2, in _MAPMODES ... self._mode = mode self._size = w[10], w[9] # (xsize, ysize) <-- attacker offset = w[34] + w[15] # <-- attacker stride = w[15] + w[10] * w[11] * w[14] # <-- attacker (set w[14]=0, w[15]=1 => stride=1) self.tile = [ ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) ] ``` **Step 2: `ImageFile.load` (mmap branch)** - selects mmap and delegates to `map_buffer`. ```python # src/PIL/ImageFile.py:322-348 if use_mmap: # use_mmap = self.filename and len(self.tile) == 1 decoder_name, extents, offset, args = self.tile[0] if (decoder_name == "raw" and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES): if offset < 0: # only lower-bound guard on offset raise ValueError("Tile offset cannot be negative") with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) if offset + self.size[1] * args[1] > self.map.size(): # == offset + ysize*stride; NO stride>=linesize check raise OSError("buffer is not large enough") self.im = Image.core.map_buffer( self.map, self.size, decoder_name, offset, args # args = ("L", stride, 1) ) ``` **Step 3: `PyImaging_MapBuffer`** - builds row pointers at `stride` spacing into the mmap; validates everything except `stride >= row width`. ```c /* src/map.c:65-140 */ if (!PyArg_ParseTuple(args, "O(ii)sn(sii)", &target, &xsize, &ysize, &codec, &offset, &mode_name, &stride, &ystep)) return NULL; ... const ModeID mode = findModeID(mode_name); /* "L" */ if (stride <= 0) { /* attacker sets stride=1 (>0) -> NOT recomputed */ if (mode == IMAGING_MODE_L || mode == IMAGING_MODE_P) stride = xsize; else if (isModeI16(mode)) stride = xsize * 2; else stride = xsize * 4; } if (stride > 0 && ysize > PY_SSIZE_T_MAX / stride) {/* overflow guard only */ PyErr_SetString(PyExc_MemoryError, "Integer overflow in ysize"); return NULL; } size = (Py_ssize_t)ysize * stride; /* = 1*1 = 1 */ if (offset > PY_SSIZE_T_MAX - size) { ... } ... if (offset + size > view.len) { /* 1 + 1 = 2 <= 256 -> PASSES */ PyErr_SetString(PyExc_ValueError, "buffer is not large enough"); PyBuffer_Release(&view); return NULL; } im = ImagingNewPrologueSubtype(mode, xsize, ysize, sizeof(ImagingBufferInstance)); /* im->linesize = xsize * pixelsize = 200000 (the REAL per-row read width) */ /* setup file pointers -- NO check that stride >= im->linesize */ if (ystep > 0) { for (y = 0; y < ysize; y++) { im->image[y] = (char *)view.buf + offset + y * stride; /* row points into mmap, spacing=1 */ } } else { ... } ``` `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. **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. ```c /* the raw "L" packer copies linesize (=xsize) bytes per row from im->image[y]; for row 0 that is view.buf+1 .. view.buf+1+200000, vs a 256-byte file. */ ``` ## Chain Summary ``` SOURCE: McIdas AREA header words w[9],w[10],w[11],w[14],w[15],w[34] (Image.open on a path) ↓ McIdasImagePlugin._open: stride = w[15]+w[10]*w[11]*w[14] -> attacker sets stride=1 [McIdasImagePlugin.py:66] ↓ tile = ("raw", (0,0,xsize,1), offset, ("L", 1, 1)) [McIdasImagePlugin.py:68] GADGET: ImageFile.load mmap branch -- only checks offset+ysize*stride<=len <- BUG: no stride>=linesize check [ImageFile.py:343] ↓ core.map_buffer(map, (xsize,1), "raw", offset, ("L",1,1)) [ImageFile.py:346] SINK: PyImaging_MapBuffer: im->image[0] = view.buf + offset + 0*stride; linesize=xsize [map.c:134] ↓ Image.tobytes() raw "L" encoder reads linesize (=xsize) bytes from im->image[0] IMPACT: reads xsize bytes from a tiny mmap -> OOB read of adjacent process memory (leak) or SIGBUS (DoS) ``` ## Proof of Concept See attached [poc.zip](https://github.com/user-attachments/files/28460498/poc.zip) ## Impact on a Parent Application Any 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: - **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. - **Denial of service (High):** a larger `xsize` reliably crashes the worker with SIGBUS. ## Suggested fix Core 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` .

CVE-2026-54058 Affected Pillow Versions

1 component(s)
Fixed Versions:
12.3.0
Product / Package Ecosystem Affected Versions Fixed Version
pillow
PyPI
< 12.3.0
12.3.0

References

Frequently Asked Questions

What is CVE-2026-54058?
CVE-2026-54058 is a security vulnerability in Pillow involving Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files) under CWE-125: Out-of-bounds Read.
Which Pillow versions are affected?
Affected versions include releases prior to 12.3.0 across affected packages: pillow.
What version fixes CVE-2026-54058?
Upgrading to 12.3.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is pillow affected?
Yes, pillow is affected in releases prior to 12.3.0. Upgrade your dependency to 12.3.0 or later.
What CWE is associated with CVE-2026-54058?
CVE-2026-54058 is classified under CWE-125: Out-of-bounds Read (The product reads data past the end, or before the beginning, of the intended buffer.).
How can I check PyPI dependencies for CVE-2026-54058?
Run the dependency tree command: 'pip show pillow'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "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"
  }
}