Files
vuln-osv/data/PyPI/GHSA-62p4-gmf7-7g93.json
T

1 line
9.3 KiB
JSON

{"schema_version":"1.9.0","id":"GHSA-62p4-gmf7-7g93","published":"2026-07-20T21:08:13Z","modified":"2026-09-10T03:51:10.632149522Z","aliases":["BIT-pillow-2026-54058","CVE-2026-54058","PYSEC-2026-3493"],"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` .","affected":[{"package":{"name":"pillow","ecosystem":"PyPI","purl":"pkg:pypi/pillow"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"12.3.0"}]}],"versions":["1.0","1.1","1.2","1.3","1.4","1.5","1.6","1.7.0","1.7.1","1.7.2","1.7.3","1.7.4","1.7.5","1.7.6","1.7.7","1.7.8","10.0.0","10.0.1","10.1.0","10.2.0","10.3.0","10.4.0","11.0.0","11.1.0","11.2.1","11.3.0","12.0.0","12.1.0","12.1.1","12.2.0","2.0.0","2.1.0","2.2.0","2.2.1","2.2.2","2.3.0","2.3.1","2.3.2","2.4.0","2.5.0","2.5.1","2.5.2","2.5.3","2.6.0","2.6.1","2.6.2","2.7.0","2.8.0","2.8.1","2.8.2","2.9.0","3.0.0","3.1.0","3.1.0.rc1","3.1.0rc1","3.1.1","3.1.2","3.2.0","3.3.0","3.3.1","3.3.2","3.3.3","3.4.0","3.4.1","3.4.2","4.0.0","4.1.0","4.1.1","4.2.0","4.2.1","4.3.0","5.0.0","5.1.0","5.2.0","5.3.0","5.4.0","5.4.0.dev0","5.4.1","6.0.0","6.1.0","6.2.0","6.2.1","6.2.2","7.0.0","7.1.0","7.1.1","7.1.2","7.2.0","8.0.0","8.0.1","8.1.0","8.1.1","8.1.2","8.2.0","8.3.0","8.3.1","8.3.2","8.4.0","9.0.0","9.0.1","9.1.0","9.1.1","9.2.0","9.3.0","9.4.0","9.5.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-62p4-gmf7-7g93/GHSA-62p4-gmf7-7g93.json"}}],"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"],"github_reviewed":true,"github_reviewed_at":"2026-07-20T21:08:13Z","nvd_published_at":"2026-07-14T17:17:03Z","severity":"HIGH"},"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"}]}