Vulnerability GHSA-wqgw-9xfh-qf2g
Summary
Pillow: Memory DoS in TIFF and EXIF IFD parsing via overlapping tag data
Details
============================================================================
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 ImageFileDirectory_v2.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 ImageFileDirectory_v2.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._safe_read(fp, count * unit_size) and keeps it in _tagdata. _safe_read() 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_read_dpi_from_exif(). - A 96 KB PNG
eXIfchunk costs 754 MiB ongetexif()orImageOps.exif_transpose(). - 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.ImageFileDirectory_v2.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.exif_transpose() 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 MAX_IMAGE_PIXELS is irrelevant) after 1.5 s, with the process RSS grown from 15 MiB to 1,671 MiB and im.tag_v2 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.
# src/PIL/TiffImagePlugin.py:902-946 (ImageFileDirectory_v2.load)
for i in range(tag_count):
tag, typ, count, data = ( … self._unpack("HHL4s", self._ensure_read(fp, 12)))
…
size = count * unit_size # 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._safe_read(fp, size) # 927: new bytes object of `size`
fp.seek(here)
…
self._tagdata[tag] = data # 945: kept
self.tagtype[tag] = typ
ImageFile._safe_read() 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 _load_dispatch 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 (tag_count is a Q) has no 16-bit entry limit at all.
Reachability:
- TIFF:
TiffImageFile._open()(TiffImagePlugin.py:1187) →self._seek(0)(:1210) →self.tag_v2.load(self.fp)(:1258, and again at:1271). This runs insideImage.open(), beforeload()and before any size check (_decompression_bomb_checkonly looks atwidth × heightand is satisfied by the 1×1 image), and the resulting directory stays referenced asim.tag_v2._seek()parses the IFD twice, butreset()drops the first copy, so the peak does not double; it is paid again for every frame onseek()/n_frames. - EXIF in other formats:
Image.Exif.load()/load_from_fp()/_get_ifd_dict()(Image.py:4146-4196,:4119-4133) wrap the sameImageFileDirectory_v2.load()around the EXIF blob.PngImagePlugin.chunk_eXIf(PngImagePlugin.py:697) stores the whole chunk asinfo["exif"],JpegImagePluginconcatenates APP1Exifsegments intoinfo["exif"](JpegImagePlugin.py:114-117), and WebP exposes its EXIF chunk the same way;im.getexif(),ImageOps.exif_transpose()and_getexif()then parse it and keep the values inim._exif. For JPEG no explicit call is needed:JpegImageFile._open()ends withself._read_dpi_from_exif()(JpegImagePlugin.py:413), which callsgetexif()whenever no JFIF DPI is present, so the APP1 IFD is parsed duringImage.open(). Note that an EXIF blob is parsed into several IFDs (main, EXIF, GPS, Interop via_get_ifd_dict), each through its ownload()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 ImageFileDirectory_v2, so every release since 1.0 is affected. The per-tag _safe_read 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.exif_transpose() 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, MAX_IMAGE_PIXELS 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. aliased_ifd() 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 ImageFileDirectory_v2.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, MAX_IMAGE_PIXELS 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.exif_transpose() 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.tag_v2 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 ImageFileDirectory_v2.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.SEEK_END), then back to self._offset; load() already requires a seekable fp, since both the TIFF plugin and Exif do), keeps a running total_size of out-of-line values, and, before the fp.seek(offset) / _safe_read() 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 test_tag_data_larger_than_file to Tests/test_file_tiff_metadata.py, next to test_tag_offset. 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.MAX_TEXT_MEMORY (e.g. a 64 MiB TiffImagePlugin.MAX_IFD_DATA) 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 ImageFileDirectory_v1 (from_v2/to_v2) 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 ImageFileDirectory_v2.load() would exceed the size of the stream
Applies to src/PIL/TiffImagePlugin.py at the commit above.
diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py
index 7b35a0196..ccf923128 100644
--- a/Tests/test_file_tiff_metadata.py
+++ b/Tests/test_file_tiff_metadata.py
@@ -500,6 +500,21 @@ def test_tag_offset() -> None:
ifd.load(f)
+def test_tag_data_larger_than_file() -> None:
+ ifd = TiffImagePlugin.ImageFileDirectory_v2()
+
+ # Two BYTE tags that both claim the whole 30 byte IFD as their value
+ tag_count = struct.pack("<H", 2)
+ tags = struct.pack("<HHLL", 0, 1, 30, 0) + struct.pack("<HHLL", 1, 1, 30, 0)
+ next_offset = struct.pack("<L", 0)
+
+ f = io.BytesIO(tag_count + tags + next_offset)
+ with pytest.warns(UserWarning, match="Tag data larger than file"):
+ ifd.load(f)
+ assert len(ifd[0]) == 30
+ assert 1 not in ifd
+
+
def test_tag_group_data() -> None:
base_ifd = TiffImagePlugin.ImageFileDirectory_v2()
interop_ifd = TiffImagePlugin.ImageFileDirectory_v2(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 ImageFileDirectory_v2(_IFDv2Base):
def load(self, fp: IO[bytes]) -> None:
self.reset()
self._offset = fp.tell()
+ fp.seek(0, io.SEEK_END)
+ file_size = fp.tell()
+ fp.seek(self._offset)
try:
tag_count = (
@@ -899,6 +902,7 @@ class ImageFileDirectory_v2(_IFDv2Base):
if self._bigtiff
else self._unpack("H", self._ensure_read(fp, 2))
)[0]
+ total_size = 0
for i in range(tag_count):
tag, typ, count, data = (
self._unpack("HHQ8s", self._ensure_read(fp, 20))
@@ -923,6 +927,16 @@ class ImageFileDirectory_v2(_IFDv2Base):
if offset >= 2**63:
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.
+ total_size += size
+ if total_size > file_size:
+ warnings.warn(
+ f"Tag data larger than file. Skipping tag {tag}"
+ )
+ continue
fp.seek(offset)
data = ImageFile._safe_read(fp, size)
fp.seek(here)
The reproducer
Reproducer —repro, 2,254 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
# ImageFileDirectory_v2.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.RLIMIT_AS, (4 << 30, 4 << 30)) # MemoryError, not OOM-killer
def aliased_ifd(n_alias, real=()):
"""Classic LE TIFF: header + one IFD. n_alias BYTE tags each cover the whole file."""
n = len(real) + n_alias
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 + n_alias):
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 = aliased_ifd(6000, real)
r0 = rss(); im = Image.open(io.BytesIO(tif)); r1 = rss()
held_t = sum(len(v) for v in im.tag_v2._tagdata.values())
print(f"TIFF {len(tif)} B, {im.size} {im.mode}: Image.open() keeps {held_t >> 20} MiB of tag data "
f"({held_t // len(tif)}x file); RSS {r0 >> 20} -> {r1 >> 20} MiB")
# 2) Same IFD as EXIF in a 1x1 PNG -> im.getexif() (what ImageOps.exif_transpose calls)
exif = aliased_ifd(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()
held_e = 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 {held_e >> 20} MiB ({held_e // 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 held_t > 2000 * len(tif) and held_e > 2000 * len(png) else "OK")
Related Vulnerabilities
Other vulnerabilities affecting the same packages