GitHub Advisoryadvisory · PyPI
GHSA-62p4-gmf7-7g93 Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)
HIGH
Official page ↗
- Aliases
- CVE-2026-54058
- Ecosystem
- PyPI
- Packages
- pillow
- Severity
- High
- CVSS vector
- 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
- Weaknesses
- CWE-125
- Reviewed
- 2026-07-20
Affected packages
• PyPI pillow: < 12.3.0 → fixed in 12.3.0
Description
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 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 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) -> 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 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 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=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
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 linesize`. Defense-in-depth in `McIdasImagePlugin._open`: reject `offset < 0` or `stride < xsize*pixelsize` .
Related
Alias · CVE (1)
- CVE-2026-54058 Pillow is a Python imaging library. vulnerability
Weakness · CWE (1)
- CWE-125 Out-of-bounds Read weakness
References
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-62p4-gmf7-7g93 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-54058 advisory
- https://github.com/python-pillow/Pillow/pull/9719 web
- https://github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d web
- https://github.com/python-pillow/Pillow package
- https://github.com/python-pillow/Pillow/releases/tag/12.3.0 web