CVE-2026-128066: Pillow: Memory DoS in TIFF and EXIF IFD parsing via overlapping tag data
============================================================================ ANT-2026-D7STJUWL [medium] CWE-770 dos Memory DoS in TIFF and EXIF IFD parsing via overlapping tag data Location: src/PIL/TiffImagePlugin.py:927 in ImageFileDirectoryv2.load() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================
A small, crafted image file can make Pillow use thousands of times its own size in memory while the file is only being opened, which lets anyone who can submit an image crash or stall the process handling it. A 144 KB TIFF makes Image.open() return normally while holding 1.6 GiB of memory. The largest such standard TIFF, about 786 KB, asks for roughly 48 GiB, which in practice gets the worker killed by the operating system. The file declares a 1×1 image, so the decompression-bomb check never triggers and no warning is raised. There is no memory corruption; the impact is denial of service.
The bug is in ImageFileDirectoryv2.load() in src/PIL/TiffImagePlugin.py (around line 927). For every directory entry whose value is stored elsewhere in the file, it reads that value into a new bytes object with ImageFile.saferead(fp, count unitsize) and keeps it in tagdata. saferead() only checks that the bytes exist. Nothing limits the total read across entries, and nothing stops every entry from pointing at the same bytes. An attacker can therefore give each of N distinct private tags an offset and count that covers the whole file, at a cost of 12 bytes per entry. Memory grows as N × file size, which is quadratic in the file size, and BigTIFF removes the 65,535-entry ceiling.
The same loop parses EXIF data, so the problem reaches beyond TIFF:
A 66 KB JPEG APP1 segment costs about 340 MiB inside Image.open() itself, via readdpifromexif(). A 96 KB PNG eXIf chunk costs 754 MiB on getexif() or ImageOps.exiftranspose(). WebP EXIF and MPO reach the same code.
The attached reproducer builds a 72 KB TIFF and a 72 KB PNG. Using only Image.open() and getexif(), each causes 412 MiB of tag data to be retained, about 6,000 times the file size. The loop dates to the original PIL 1.1.7 import, so every Pillow release since 1.0 is affected.
Details
TiffImagePlugin.ImageFileDirectoryv2.load() reads the out-of-line value of every IFD entry into its own bytes object and keeps it in self.tagdata. Nothing bounds the sum of those reads, and nothing prevents every entry from pointing at the same region of the file. An IFD with N entries whose (offset, count) each cover the whole file therefore costs N × filesize bytes of memory, while the IFD itself costs only 12 bytes per entry. This one loop is the IFD parser for TIFF frames and for Image.Exif alike, so it is reachable from Image.open() on a TIFF or JPEG and from getexif() / ImageOps.exiftranspose() on PNG, JPEG, WebP and MPO.
Observed on the stock build at this commit: a valid 144,110-byte 1×1 greyscale TIFF with 12,000 such entries makes Image.open() return normally (no exception, no warning, 1 declared pixel so MAXIMAGEPIXELS is irrelevant) after 1.5 s, with the process RSS grown from 15 MiB to 1,671 MiB and im.tagv2 holding 1,649 MiB of tag data for as long as the image object lives. The same IFD placed in the eXIf chunk of a 96 KB PNG makes im.getexif() retain 754 MiB. A 65,870-byte JPEG whose APP1 Exif segment is such an IFD (5,400 entries × 65,519 bytes) allocates ~340 MiB inside Image.open() itself, with no further call.
python src/PIL/TiffImagePlugin.py:902-946 (ImageFileDirectoryv2.load) for i in range(tagcount): tag, typ, count, data = ( … self.unpack("HHL4s", self.ensureread(fp, 12))) … size = count unitsize # 918: attacker-chosen if size > (8 if self.bigtiff else 4): here = fp.tell() (offset,) = self.unpack("Q" if self.bigtiff else "L", data) … fp.seek(offset) # 926: attacker-chosen data = ImageFile.saferead(fp, size) # 927: new bytes object of size fp.seek(here) … self.tagdata[tag] = data # 945: kept self.tagtype[tag] = typ
ImageFile.saferead() only guarantees that size bytes are actually present in the stream (it reads in SAFEBLOCK chunks and raises on truncation); it is a guard against allocating for data that does not exist, not against reading the same existing data many times. For a well-formed file each entry points at its own disjoint value, so the total read is at most the file size. For the crafted file every one of the N entries has a distinct tag number (so none overwrites another in tagdata), type BYTE, count = filesize - 1 and offset = 1; each iteration allocates and fills a fresh filesize - 1-byte object and stores it. load() does not care whether the tag number is known or whether the type makes sense for it; any 16-bit tag number and any type in loaddispatch is accepted. Memory is N × filesize, and since filesize ≈ 12 N, it is quadratic in the file size: 12,000 entries / 144 KB → 1.6 GiB (measured); the classic-TIFF maximum of 65,535 entries / 786 KB → ~48 GiB; BigTIFF (tagcount is a Q) has no 16-bit entry limit at all.
Reachability:
- TIFF: TiffImageFile.open() (TiffImagePlugin.py:1187) → self.seek(0) (:1210) → self.tagv2.load(self.fp) (:1258, and again at :1271). This runs inside Image.open(), before load() and before any size check (decompressionbombcheck only looks at width × height and is satisfied by the 1×1 image), and the resulting directory stays referenced as im.tagv2. seek() parses the IFD twice, but reset() drops the first copy, so the peak does not double; it is paid again for every frame on seek()/nframes. - EXIF in other formats: Image.Exif.load() / loadfromfp() / getifddict() (Image.py:4146-4196, :4119-4133) wrap the same ImageFileDirectoryv2.load() around the EXIF blob. PngImagePlugin.chunkeXIf (PngImagePlugin.py:697) stores the whole chunk as info["exif"], JpegImagePlugin concatenates APP1 Exif segments into info["exif"] (JpegImagePlugin.py:114-117), and WebP exposes its EXIF chunk the same way; im.getexif(), ImageOps.exiftranspose() and getexif() then parse it and keep the values in im.exif. For JPEG no explicit call is needed: JpegImageFile.open() ends with self.readdpifromexif() (JpegImagePlugin.py:413), which calls getexif() whenever no JFIF DPI is present, so the APP1 IFD is parsed during Image.open(). Note that an EXIF blob is parsed into several IFDs (main, EXIF, GPS, Interop via getifddict), each through its own load() call.
The only failure path in load() is except OSError → warning; running out of memory is not an OSError, so on Linux the realistic outcome for a larger N is the kernel OOM killer terminating the process rather than a Python exception.
The loop predates Pillow: it came in with the PIL 1.1.7 import in 9a640e315715 ("Forking PIL", 2010-07-30) and was carried over unchanged into ImageFileDirectoryv2, so every release since 1.0 is affected. The per-tag saferead bounding added in the CVE-2019-16865 era does not address the sum.
Impact
Any service that calls Image.open() on an uploaded TIFF or JPEG — or getexif() / ImageOps.exiftranspose() on an uploaded PNG, JPEG or WebP, which thumbnailers routinely do to honour orientation — can be made to allocate and retain memory roughly entries × filesize, i.e. thousands of times the upload size, from a file of a few hundred KB that declares a 1×1 image and passes the decompression-bomb check. A ~800 KB TIFF requests ~48 GiB; in practice the worker (or its container) is OOM-killed within seconds, taking down all requests it was serving, and repeated uploads keep it down. No pixel decoding, load(), or further API call is required for the TIFF and JPEG cases, MAXIMAGEPIXELS does not apply, and no DecompressionBomb warning gives the application a chance to refuse the file. There is no memory corruption; the effect is denial of service of the process handling the input.
This is the same class as CVE-2021-27921/27922/27923 (BLP/ICNS/ICO "arbitrarily large memory allocations" from unchecked reported sizes, fixed in 8.1.2) and CVE-2021-28675 (PSD layer count not checked against the data size, DoS on Image.open() prior to load()), here in the TIFF/EXIF directory parser shared by several formats.
Reproducing
repro is a single Python script that runs against the stock Pillow in /src; nothing is compiled. It needs about 1 GB of free RAM (it caps its own address space at 4 GiB so that a larger run ends in MemoryError rather than the OOM killer).
Input. aliasedifd() builds, in memory, a little-endian classic TIFF: an 8-byte header, one IFD, nothing else. Every crafted entry is a private tag (1000 upwards) of type BYTE with offset = 1 and count = file size - 1, i.e. each claims the whole file body as its value. Each costs 12 bytes in the file but a full file-sized read in ImageFileDirectoryv2.load().
TIFF path. The first file adds 8 ordinary tags (ImageWidth = ImageLength = 1, BitsPerSample = 8, no compression, BlackIsZero, one 1-byte strip pointing at a header byte) so that Pillow accepts it as a 1×1 L image, plus 6,000 aliased tags (72,110 bytes). Because the image is 1×1, MAXIMAGEPIXELS never comes into play. The script calls Image.open() on it and nothing else: no load(), no pixel access.
EXIF path. The same 6,000-entry IFD (without the image tags) is passed as exif= when saving a 1×1 PNG, so it lands in an eXIf chunk; the script opens that PNG and calls im.getexif(), which is what ImageOps.exiftranspose() calls.
Check. For each path the script reads VmRSS from /proc/self/status before and after, and adds up len() over the resulting directory's tagdata. In a well-formed IFD each entry points at its own bytes, so the tag data can never be larger than the file. The script prints BUG if both the TIFF's im.tagv2 and the PNG's EXIF directory hold more than 2000× the size of their file, otherwise OK. On this commit each holds 412 MiB (~6000× the file) and RSS grows by about as much for each.
The memory cost is entries × file size, and the file size itself grows at 12 bytes per entry, so the cost is quadratic: doubling to 12,000 entries (144 KB) gives the 1.6 GiB quoted above, and the classic-TIFF maximum of 65,535 entries (~786 KB file) comes to ~48 GiB. A JPEG APP1 segment or a WebP EXIF chunk carrying the same IFD behaves like the PNG case (for JPEG, already during Image.open()).
Suggested fix
The attached patch bounds the out-of-line tag data a single ImageFileDirectoryv2.load() call may pull in by the length of the stream it reads from. The values of a well-formed IFD do not overlap, so their total can never exceed the file; only aliased entries can push past it. load() now measures the stream once on entry (fp.seek(0, io.SEEKEND), then back to self.offset; load() already requires a seekable fp, since both the TIFF plugin and Exif do), keeps a running totalsize of out-of-line values, and, before the fp.seek(offset) / saferead() at TiffImagePlugin.py:926-927, warns "Tag data larger than file. Skipping tag …" and continues for any entry that would take the total past the file size, in the same warn-and-skip idiom as the existing "Tag offset too large" check. No fixed constant is introduced and no valid file changes behaviour, including TIFFs with very large Photoshop/ImageSourceData (tag 37724) payloads, which routinely exceed 64 MB. Because Image.Exif, the PNG/JPEG/WebP EXIF paths and MPO all go through this one method, they are covered too. The patch adds testtagdatalargerthanfile to Tests/testfiletiffmetadata.py, next to testtagoffset. With it applied, both paths in the reproducer drop to 1× the file size. One visible side effect: a single oversize entry now skips that tag with this warning instead of aborting the rest of the IFD via the OSError path.
A fixed module-level cap in the style of PngImagePlugin.MAXTEXTMEMORY (e.g. a 64 MiB TiffImagePlugin.MAXIFDDATA) would be the weaker alternative on its own: a 140 KB file could still retain ~63 MiB per IFD — and a JPEG's EXIF is parsed into several IFDs (main, EXIF, GPS, Interop), so a 66 KB APP1 segment could still request roughly 1000× its size — and legitimate TIFFs with more than 64 MiB of tag data would lose those tags with a warning. It could still be added on top of the stream-length check as a user-adjustable knob.
An alternative that removes the amplification entirely is to store (offset, size) in tagdata and read lazily in getitem, which is what the class docstring already describes ("we don't decode tags unless they're asked for"); that is a larger change because tagdata is shared with ImageFileDirectoryv1 (fromv2/tov2) and exposed as the legacy tagdata property, and lazy reads would need the file object to still be open when a tag is first accessed.
The patch
Skip IFD entries once the total out-of-line tag data read by ImageFileDirectoryv2.load() would exceed the size of the stream
Applies to src/PIL/TiffImagePlugin.py at the commit above.
diff diff --git a/Tests/testfiletiffmetadata.py b/Tests/testfiletiffmetadata.py index 7b35a0196..ccf923128 100644 --- a/Tests/testfiletiffmetadata.py +++ b/Tests/testfiletiffmetadata.py @@ -500,6 +500,21 @@ def testtagoffset() -> None: ifd.load(f) +def testtagdatalargerthanfile() -> None: + ifd = TiffImagePlugin.ImageFileDirectoryv2() + + # Two BYTE tags that both claim the whole 30 byte IFD as their value + tagcount = struct.pack("<H", 2) + tags = struct.pack("<HHLL", 0, 1, 30, 0) + struct.pack("<HHLL", 1, 1, 30, 0) + nextoffset = struct.pack("<L", 0) + + f = io.BytesIO(tagcount + tags + nextoffset) + with pytest.warns(UserWarning, match="Tag data larger than file"): + ifd.load(f) + assert len(ifd[0]) == 30 + assert 1 not in ifd + + def testtaggroupdata() -> None: baseifd = TiffImagePlugin.ImageFileDirectoryv2() interopifd = TiffImagePlugin.ImageFileDirectoryv2(group=40965) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 42c1cc96c..c9d506933 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -892,6 +892,9 @@ class ImageFileDirectoryv2(IFDv2Base): def load(self, fp: IO[bytes]) -> None: self.reset() self.offset = fp.tell() + fp.seek(0, io.SEEKEND) + filesize = fp.tell() + fp.seek(self.offset) try: tagcount = ( @@ -899,6 +902,7 @@ class ImageFileDirectoryv2(IFDv2Base): if self.bigtiff else self.unpack("H", self.ensureread(fp, 2)) )[0] + totalsize = 0 for i in range(tagcount): tag, typ, count, data = ( self.unpack("HHQ8s", self.ensureread(fp, 20)) @@ -923,6 +927,16 @@ class ImageFileDirectoryv2(IFDv2Base): if offset >= 263: warnings.warn("Tag offset too large") continue + # The values of a well-formed IFD do not overlap, so in + # total they cannot be larger than the file they are read + # from. Do not let entries that alias the same bytes load + # many copies of it. + totalsize += size + if totalsize > filesize: + warnings.warn( + f"Tag data larger than file. Skipping tag {tag}" + ) + continue fp.seek(offset) data = ImageFile.saferead(fp, size) fp.seek(here)
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 2,254 bytes (click to open)</summary>
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.
python #!/usr/bin/env python3 ImageFileDirectoryv2.load(): N IFD entries that all alias the file body make Image.open() (TIFF) and getexif() (PNG eXIf etc.) retain ~N copies of the file. Pure Python, stock Pillow in /src. Needs ~1 GB RAM, runs in ~1 s. import io, struct, resource from PIL import Image resource.setrlimit(resource.RLIMITAS, (4 << 30, 4 << 30)) # MemoryError, not OOM-killer
def aliasedifd(nalias, real=()): """Classic LE TIFF: header + one IFD. nalias BYTE tags each cover the whole file.""" n = len(real) + nalias size = 8 + 2 + 12 n + 4 ents = {t: struct.pack("<HHLL", t, typ, 1, v) for t, (typ, v) in dict(real).items()} for t in range(1000, 1000 + nalias): ents[t] = struct.pack("<HHLL", t, 1, size - 1, 1) # BYTE[size-1] @ offset 1 blob = b"II\0" + struct.pack("<LH", 8, n) + b"".join(ents[t] for t in sorted(ents)) + b"\0" 4 assert len(blob) == size return blob
def rss(): return next(int(l.split()[1]) << 10 for l in open("/proc/self/status") if l.startswith("VmRSS"))
1) TIFF: 1x1 L image (pixel = a header byte) + 6000 aliased private tags -> Image.open() only real = {256: (3, 1), 257: (3, 1), 258: (3, 8), 259: (3, 1), 262: (3, 1), 273: (4, 8), 278: (3, 1), 279: (4, 1)} tif = aliasedifd(6000, real) r0 = rss(); im = Image.open(io.BytesIO(tif)); r1 = rss() heldt = sum(len(v) for v in im.tagv2.tagdata.values()) print(f"TIFF {len(tif)} B, {im.size} {im.mode}: Image.open() keeps {heldt >> 20} MiB of tag data " f"({heldt // len(tif)}x file); RSS {r0 >> 20} -> {r1 >> 20} MiB")
2) Same IFD as EXIF in a 1x1 PNG -> im.getexif() (what ImageOps.exiftranspose calls) exif = aliasedifd(6000) buf = io.BytesIO(); Image.new("L", (1, 1)).save(buf, "PNG", exif=exif); png = buf.getvalue() r0 = rss(); im2 = Image.open(io.BytesIO(png)); e = im2.getexif(); r1 = rss() helde = sum(len(v) for v in e.info.tagdata.values()) if e.info is not None else 0 print(f"PNG {len(png)} B: getexif() keeps {helde >> 20} MiB ({helde // len(png)}x file); " f"RSS {r0 >> 20} -> {r1 >> 20} MiB")
A well-formed IFD's values cannot exceed the stream they come from (1x). print("BUG" if heldt > 2000 len(tif) and helde > 2000 len(png) else "OK") </details>
---
Other sources
Pillow is a Python imaging library. From 1.0 through 12.3.0, ImageFileDirectoryv2.load() in src/PIL/TiffImagePlugin.py retains a separate bytes object for each out-of-line IFD tag value without limiting the total data read or preventing distinct tags from referencing the same file region. A small crafted TIFF or EXIF-bearing JPEG, PNG, WebP, or MPO can therefore cause memory use quadratic in the input size. TIFF and some JPEG paths trigger parsing during Image.open(), while other formats are exposed when EXIF is requested, including through getexif() or ImageOps.exiftranspose(). The declared pixel dimensions do not bound this metadata allocation, so decompression-bomb checks do not prevent process memory exhaustion. This issue is fixed in version 13.0.0.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/Pillowto a version that resolves this vulnerability.Fixed in 13.0.0 - Upgrade
Upgrade
pip/pillowto a version that resolves this vulnerability.Fixed in 13.0.0 - Upgrade
Upgrade
Pillowto a version that resolves this vulnerability.Fixed in 13.0.0
Event History
Frequently Asked Questions
Who is exposed to this denial-of-service issue?
Any application that uses Pillow to open TIFF images from untrusted submitters is exposed. An attacker only needs to provide a crafted image file; opening it is sufficient to trigger excessive memory allocation.
Does Pillow's decompression-bomb protection prevent this attack?
No. The crafted file can declare a 1×1 image, so the decompression-bomb check does not trigger and no warning is raised.