Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)GHSA-62p4-gmf7-7g93
Fix: python-pillow/Pillow#9719GHSA-62p4-gmf7-7g93 is a Out-of-bounds Read vulnerability in pillow. A fix is available for pillow — see the affected versions and patch details below.
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for GHSA-62p4-gmf7-7g93.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
Real-World Exposure
pillowReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
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 <= 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.
# 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.
# 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.
/* 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.
/* 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
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
xsizereliably 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 .
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | pillow | all versions | 12.3.0pip install --upgrade 'pillow==12.3.0' |
Affected Products
pillowpythonDetection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for pillow, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update pillow to 12.3.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-62p4-gmf7-7g93 is resolved across your whole dependency graph.
Workarounds
Stop feeding it untrusted input: reject or quarantine files and payloads from unverified sources until you can upgrade, restrict accepted formats to the ones you actually need, and run the parsing or decoding step in a least-privileged sandbox or short-lived worker so a crash or corrupted read cannot reach the rest of the process.
Fixing This On Your OS
If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.
Pillow is vulnerable to an out-of-bounds read when processing a crafted uncompressed McIdas AREA image via the mmap raw codec. A remote attacker who can supply such an image to a service that loads it without user interaction may disclose adjacent process memory or trigger a denial of service. Fixed in Pillow 12.3.0.
| Product | Fixed in | Advisory |
|---|---|---|
| Red Hat Enterprise Linux 8 | python-pillow-0:5.1.1-23.el8_10 | RHSA-2026:48021 |
| Red Hat Enterprise Linux 8.4 Advanced Mission Critical Update Support | python-pillow-0:5.1.1-15.el8_4.2 | RHSA-2026:52551 |
| Red Hat Enterprise Linux 8.6 Advanced Mission Critical Update Support | python-pillow-0:5.1.1-20.el8_6.1 | RHSA-2026:54528 |
| Red Hat Enterprise Linux 8.8 Telecommunications Update Service | python-pillow-0:5.1.1-20.el8_8.1 | RHSA-2026:54417 |
| Red Hat AI Inference Server 3.3 | rhaiis/model-opt-cuda-rhel9:1787601159 | RHSA-2026:59518 |
| Red Hat AI Inference Server 3.4 | rhaii/vllm-spyre-rhel9:1789681201 | RHSA-2026:69464 |
| Red Hat AI Inference Server 3.4 | rhaii/vllm-cpu-rhel9:1789681128 | RHSA-2026:69466 |
| Red Hat AI Inference Server 3.4 | rhaii/vllm-cuda-rhel9:1789681126 | RHSA-2026:69467 |
Frequently Asked Questions
Is GHSA-62p4-gmf7-7g93 in your dependencies?
Find it across PyPI, including transitive dependencies.