GHSA-462X-PGG6-55FP
Vulnerability from github – Published: 2026-10-20 20:49 – Updated: 2026-10-20 20:49Summary
Pillow's libImaging _decodeTile decoder, used to decode tiled TIFF images, allocates a per-tile buffer using realloc(state->buffer, TIFFTileSize(tiff)). After each TIFFReadTile it then iterates tile_y from 0 up to current_tile_length and calls the configured row unpacker (here copy4) with the source pointer state->buffer + tile_y * row_byte_size. When libtiff returns a tile_bytes_size smaller than the worst-case row_byte_size * tile_length, the inner stride-and-copy loop walks past the realloc'd region and copy4 performs a 1024-byte memcpy that reads up to (tile_length-1) * row_byte_size + row_byte_size bytes from a buffer sized only to tile_bytes_size. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer.
Details
Pillow's TIFF decoder, when libtiff reports the file is tiled, takes the _decodeTile path. The function:
- Asks libtiff for
TIFFTileSize(tiff)(the bytes-per-tile) andTIFFTileRowSize(tiff)(the bytes per row as stored by libtiff). - Reallocates
state->bufferto exactlytile_bytes_size. - For each tile origin
(x, y)and each plane, callsTIFFReadTileintostate->buffer, then walks the tile row by row, calling the configuredImagingShuffler(herecopy4) on each row's worth of source bytes.
The validation done before the loop is:
if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
state->errcode = IMAGING_CODEC_BROKEN;
return -1;
}
This rejects oversized libtiff tiles, but it does not reject undersized ones — i.e. the case where libtiff returns a tile_bytes_size strictly less than row_byte_size * tile_length. The loop body, however, indexes into the buffer assuming the buffer is at least row_byte_size * tile_length bytes long.
Vulnerable Code
// src/libImaging/TiffDecode.c:446-490 — _decodeTile
new_data = realloc(state->buffer, state->bytes); // bytes = tile_bytes_size
if (!new_data) { ... }
state->buffer = new_data;
for (y = state->yoff; y < state->ysize; y += tile_length) {
for (plane = 0; plane < planes; plane++) {
ImagingShuffler shuffler = unpackers[plane];
for (x = state->xoff; x < state->xsize; x += tile_width) {
if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { ... }
current_tile_width = min((INT32)tile_width, state->xsize - x);
current_tile_length = min((INT32)tile_length, state->ysize - y);
for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
shuffler(
(UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
state->buffer + tile_y * row_byte_size, // <-- OOB stride
current_tile_width
);
}
}
}
}
// src/libImaging/Unpack.c:1288-1291 — copy4 (the crash site)
static void
copy4(UINT8 *out, const UINT8 *in, int pixels) {
/* RGBA, CMYK quadruples */
memcpy(out, in, 4 * pixels); // <-- ASAN-detected READ here
}
The PoC's tile metadata yields tile_width = tile_length = 256, row_byte_size = 1024 (4 bytes/pixel × 256 pixels), and tile_bytes_size = 196608 (which is 256·256·3 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iterates tile_y from 0 to 255 and for the highest rows the source pointer state->buffer + tile_y * 1024 already lies at or past offset 196608 — the very last row's memcpy(in=buffer+255·1024, 1024) reads 1024 bytes starting 260096 - 196608 = 63488 bytes past the allocation; ASAN flags the very first byte past the heap region (READ of size 1024 at … 0 bytes after 196608-byte region).
The mismatch is real: libtiff's TIFFTileSize returns the packed per-tile storage (which depends on the file's photometric/separation layout — it can legitimately be smaller than row_byte_size × tile_length), while the inner loop strides as if the buffer always equals row_byte_size × tile_length.
Attribution
This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "pillow"
},
"ranges": [
{
"events": [
{
"introduced": "8.2.0"
},
{
"fixed": "13.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-112395"
],
"database_specific": {
"cwe_ids": [
"CWE-122"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-20T20:49:26Z",
"nvd_published_at": "2026-10-19T21:17:07Z",
"severity": "MODERATE"
},
"details": "### Summary\n\nPillow\u0027s libImaging `_decodeTile` decoder, used to decode tiled TIFF images, allocates a per-tile buffer using `realloc(state-\u003ebuffer, TIFFTileSize(tiff))`. After each `TIFFReadTile` it then iterates `tile_y` from 0 up to `current_tile_length` and calls the configured row unpacker (here `copy4`) with the source pointer `state-\u003ebuffer + tile_y * row_byte_size`. When libtiff returns a `tile_bytes_size` smaller than the worst-case `row_byte_size * tile_length`, the inner stride-and-copy loop walks past the realloc\u0027d region and `copy4` performs a 1024-byte `memcpy` that reads up to `(tile_length-1) * row_byte_size + row_byte_size` bytes from a buffer sized only to `tile_bytes_size`. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer.\n\n### Details\n\nPillow\u0027s TIFF decoder, when libtiff reports the file is tiled, takes the `_decodeTile` path. The function:\n\n1. Asks libtiff for `TIFFTileSize(tiff)` (the bytes-per-tile) and `TIFFTileRowSize(tiff)` (the bytes per row *as stored by libtiff*).\n2. Reallocates `state-\u003ebuffer` to exactly `tile_bytes_size`.\n3. For each tile origin `(x, y)` and each plane, calls `TIFFReadTile` into `state-\u003ebuffer`, then walks the tile row by row, calling the configured `ImagingShuffler` (here `copy4`) on each row\u0027s worth of source bytes.\n\nThe validation done before the loop is:\n\n```c\nif (tile_bytes_size \u003e ((tile_length * state-\u003ebits / planes + 7) / 8) * tile_width) {\n state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n return -1;\n}\n```\n\nThis rejects oversized libtiff tiles, but it does **not** reject *undersized* ones \u2014 i.e. the case where libtiff returns a `tile_bytes_size` strictly **less** than `row_byte_size * tile_length`. The loop body, however, indexes into the buffer assuming the buffer is at least `row_byte_size * tile_length` bytes long.\n\n### Vulnerable Code\n\n```c\n// src/libImaging/TiffDecode.c:446-490 \u2014 _decodeTile\nnew_data = realloc(state-\u003ebuffer, state-\u003ebytes); // bytes = tile_bytes_size\nif (!new_data) { ... }\nstate-\u003ebuffer = new_data;\n\nfor (y = state-\u003eyoff; y \u003c state-\u003eysize; y += tile_length) {\n for (plane = 0; plane \u003c planes; plane++) {\n ImagingShuffler shuffler = unpackers[plane];\n for (x = state-\u003exoff; x \u003c state-\u003exsize; x += tile_width) {\n if (TIFFReadTile(tiff, (tdata_t)state-\u003ebuffer, x, y, 0, plane) == -1) { ... }\n\n current_tile_width = min((INT32)tile_width, state-\u003exsize - x);\n current_tile_length = min((INT32)tile_length, state-\u003eysize - y);\n for (tile_y = 0; tile_y \u003c current_tile_length; tile_y++) {\n shuffler(\n (UINT8 *)im-\u003eimage[tile_y + y] + x * im-\u003epixelsize,\n state-\u003ebuffer + tile_y * row_byte_size, // \u003c-- OOB stride\n current_tile_width\n );\n }\n }\n }\n}\n```\n\n```c\n// src/libImaging/Unpack.c:1288-1291 \u2014 copy4 (the crash site)\nstatic void\ncopy4(UINT8 *out, const UINT8 *in, int pixels) {\n /* RGBA, CMYK quadruples */\n memcpy(out, in, 4 * pixels); // \u003c-- ASAN-detected READ here\n}\n```\n\nThe PoC\u0027s tile metadata yields `tile_width = tile_length = 256`, `row_byte_size = 1024` (4 bytes/pixel \u00d7 256 pixels), and `tile_bytes_size = 196608` (which is 256\u00b7256\u00b73 = three-channel-byte storage). The realloc\u0027d buffer is 196608 bytes. The unpacker iterates `tile_y` from 0 to 255 and for the highest rows the source pointer `state-\u003ebuffer + tile_y * 1024` already lies at or past offset 196608 \u2014 the very last row\u0027s `memcpy(in=buffer+255\u00b71024, 1024)` reads 1024 bytes starting `260096 - 196608 = 63488` bytes past the allocation; ASAN flags the very first byte past the heap region (`READ of size 1024 at \u2026 0 bytes after 196608-byte region`).\n\nThe mismatch is real: libtiff\u0027s `TIFFTileSize` returns the *packed* per-tile storage (which depends on the file\u0027s photometric/separation layout \u2014 it can legitimately be smaller than `row_byte_size \u00d7 tile_length`), while the inner loop strides as if the buffer always equals `row_byte_size \u00d7 tile_length`.\n\n\n## Attribution\n\nThis issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually.",
"id": "GHSA-462x-pgg6-55fp",
"modified": "2026-10-20T20:49:27Z",
"published": "2026-10-20T20:49:26Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-462x-pgg6-55fp"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112395"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/commit/76869efdbbf2ad7dccd7536520fb61b7a00ab61e"
},
{
"type": "PACKAGE",
"url": "https://github.com/python-pillow/Pillow"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "Pillow tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290)"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.