---
id: "GHSA-wqgw-9xfh-qf2g"
page: "https://infrastack.ai/live/GHSA-wqgw-9xfh-qf2g"
kind: "vulnerability"
aliases: ["GHSA-wqgw-9xfh-qf2g"]
title: "Memory DoS in TIFF and EXIF IFD parsing via overlapping tag data"
severity: "medium"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": "<= 12.3.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-770", "image-upload", "package", "pillow", "python", "thumbnailers"]
not_affected_if: ["no-untrusted-images", "pillow-fixed-13.0.0"]
published_at: "2026-11-02T01:28:05+00:00"
first_seen_at: "2026-11-09T22:53:21+00:00"
updated_at: "2026-11-09T22:58:49+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-wqgw-9xfh-qf2g", "first_seen_at": "2026-11-09T22:53:21+00:00"}]
exploit_sources: []
---

# Memory DoS in TIFF and EXIF IFD parsing via overlapping tag data

Link for people: https://infrastack.ai/live/GHSA-wqgw-9xfh-qf2g (this .md is for agents)

Pillow's TIFF and EXIF directory parser reads each out-of-line tag value into memory without a total cap, so overlapping entries let a small file allocate thousands of times its size. An attacker who can submit an image can crash or stall the process that opens it, a denial of service with no code execution.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:Pillow affected <= 12.3.0 fixed 13.0.0

## How to check

- Check the installed Pillow version with `python -c "import PIL; print(PIL.__version__)"`, affected up to and including `12.3.0`
- Search dependencies for pinned Pillow with `pip show pillow`
- Find services that call `Image.open()` on uploads or `getexif()` / `ImageOps.exif_transpose()` on uploaded PNG, JPEG or WebP

## Fix

- Pillow: move to `13.0.0`

## Timeline

- 2026-11-09T22:53:21+00:00 ghsa-repo https://github.com/python-pillow/Pillow/security/advisories/GHSA-wqgw-9xfh-qf2g

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/python-pillow/Pillow/security/advisories/GHSA-wqgw-9xfh-qf2g

## From the sources

```
============================================================================
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 `eXIf` chunk costs 754 MiB on `getexif()` or `ImageOps.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.

```python
# 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 inside `Image.open()`, before `load()` and before any size check (`_decompression_bomb_check` only looks at `width × height` and is satisfied by the 1×1 image), and the resulting directory stays referenced as `im.tag_v2`. `_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()`/`n_frames`.
- EXIF in other formats: `Image.Exif.load()` / `load_from_fp()` / `_get_ifd_dict()` (`Image.py:4146-4196`, `:4119-4133`) wrap the same `ImageFileDirectory_v2.load()` around the EXIF blob. `PngImagePlugin.chunk_eXIf` (`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.exif_transpose()` and `_getexif()` then parse it and keep the values in `im._exif`. For JPEG no explicit call is needed: `JpegImageFile._open()` ends with `self._read_dpi_from_exif()` (`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 `_get_ifd_dict`), 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 `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

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
