---
id: "CVE-2026-112896"
kind: "vulnerability"
aliases: ["CVE-2026-112896", "GHSA-pchr-8j38-3x4p"]
title: "Pillow: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read"
severity: "high"
cvss: 8.8
epss: 0.0029100000392645597
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": "<= 12.3.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-125", "image-upload", "package", "pillow", "python", "self-hosted"]
not_affected_if: ["default-config", "metadata-not-retained", "no-untrusted-tiff-input"]
published_at: "2026-10-19T22:17:09+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:19+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-pchr-8j38-3x4p", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112896", "first_seen_at": "2026-10-23T21:57:19+00:00"}]
---

# Pillow: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read

Pillow's TIFF encoder passes a fixed 768-entry ColorMap to libtiff without checking it against the image's bit depth, so a crafted 16-bit TIFF saved with metadata retained causes a heap out-of-bounds read. The sources demonstrate an ASan-detected read crash during save; they do not demonstrate data disclosure or 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

pip show pillow (affected if version is 12.3.0 or lower)
Grep application code for Image.save(..., format="TIFF") on untrusted uploads where image.info or TIFF tags are preserved

## Fix

Upgrade Pillow to 13.0.0 or later
Until upgraded, do not re-save untrusted TIFFs with retained metadata

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-pchr-8j38-3x4p
- 2026-10-23T21:57:19+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112896

## From the sources

## Summary

A small attacker-supplied TIFF can be opened and loaded normally, then reach an out-of-bounds read when an application saves it back to TIFF with retained metadata. The fixed reproducer is a 1×1, 16-bit grayscale, PackBits-compressed TIFF containing tag 320 (ColorMap) with **768 SHORT values**: three zero-filled planes of 256 entries each. No Python object is forged and no metadata is injected after loading.

Pillow's TIFF encoder allocates **768 × 2 = 1536 bytes**, then passes three plane pointers separated by 256 SHORT entries to libtiff. With `BitsPerSample = 16`, libtiff instead expects **65536 SHORT values per plane** and attempts a **131072-byte read** from a plane pointer. Existing ASAN observations identify a heap-buffer-overflow READ crossing the 1536-byte allocation made in `PyImaging_LibTiffEncoderNew`.

The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required.

## Impact and prerequisites

An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, **not demonstrated by extracting arbitrary heap contents in this report**. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed.

The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output.

## Tested environment and limits of the evidence

The existing observations were recorded on August 30, 2026, with **CPython 3.11.8 on macOS ARM64**, using the unmodified official **Pillow 12.3.0** wheel and its bundled **libtiff 4.7.1**:

- Wheel: `pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl`
- Wheel size: `4785266` bytes
- Wheel SHA-256: `37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6`
- Pillow source revision: `bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d`
- Bundled libtiff SHA-256: `e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1`

Apple's ASAN runtime was injected at process startup. This is **not an ASAN-rebuilt Pillow wheel**; allocation/memcpy interception detects the reported boundary violation, but the wheel's native instructions are not comprehensively instrumented.

Only this concrete full-package environment is established here. No affected-version range, current development-branch result, or patched release is asserted. Severity is **Unknown**; no CVSS score is proposed.

Separate earlier evidence reached the original Pillow 8.0.1 encoder constructor compiled as a component against a modern Python/libtiff environment. **That was not a complete Pillow 8.0.1 installation** and does not establish the historical package's full runtime behavior. It is not part of this reproducer or an affected-version range.

## Observed evidence and controls

The earlier integration harness performed the natural sequence `Image.open(BytesIO(input))`, `image.load()`, then `image.save(BytesIO(), format="TIFF")`, without replacing the image's metadata. Each case was recorded three times:

| Original fixture | Bytes | Recorded result |
| --- | ---: | --- |
| `colormap16` | 1685 | Loaded as `I;16`; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3). |
| `colormap8` | 1684 | Same fixed ColorMap, but 8-bit samples; save succeeded and retained SHORT/count 768 (3/3). |
| `colormap_absent` | 137 | Same 16-bit geometry without ColorMap; save succeeded (3/3). |

Address-free summary of the recorded native diagnostic:

```text
AddressSanitizer: heap-buffer-overflow
READ of size 131072
0 bytes after 1536-byte region
Copy path: memcpy -> _TIFFmemcpy -> setByteArray -> _TIFFsetShortArrayExt
           -> _TIFFVSetField -> TIFFVSetField -> ImagingLibTiffSetField
           -> PyImaging_LibTiffEncoderNew
Allocation: calloc -> PyImaging_LibTiffEncoderNew
```

Input SHA-256 values (also enforced inside the script):

- `colormap16`: `5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d`
- `colormap8`: `fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c`
- `colormap_absent`: `bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556`

## Root cause and source references

- [Pillow `src/encode.c`, lines 870–899, pinned revision](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/encode.c#L870): the ColorMap tuple must contain exactly 768 entries; a 1536-byte allocation is passed with a fixed stride of 256. The check does not reconcile that extent with `BitsPerSample`.
- [Pillow `src/PIL/TiffImagePlugin.py`, lines 1944–1991, pinned revision](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/PIL/TiffImagePlugin.py#L1944): save carries supplied tags into the libtiff encoder and sorts the tags, so sample depth is established before ColorMap.
- [libtiff `tif_dir.c`, lines 441–448, pinned revision](https://gitlab.com/libtiff/libtiff/-/blob/5fe20d0e9aba49a6a350ed533459d1505203838f/libtiff/tif_dir.c#L441): `TIFFTAG_COLORMAP` computes the per-plane count as `1L << td->td_bitspersample` and copies that many SHORTs from each pointer.

The reported boundary is Pillow's construction of a fixed-sized array for a native API whose required size depends on metadata; this does not establish a standalone libtiff defect for a correctly sized caller buffer.

## Prior art and disclosure scope

[Pillow PR #5148](https://github.com/python-pillow/Pillow/pull/5148), merged December 28, 2020, is related TIFF metadata prior art, but its archived diff replaces a short-lived stack field-name pointer with the persistent literal `"CustomField"`. That is a **different field-name-lifetime fix**, not this plane-size mismatch. The reviewed material does not establish an exact prior public report or fix for this root, and **novelty is not asserted**. This specific report remains NOT_SENT; association with other observations is not a prior disclosure of it.

## Reproduction instructions

Use an **already reviewed, existing** installation of the exact wheel above, an existing compatible macOS ARM64 CPython 3.11 interpreter, and an existing compatible Apple ASAN runtime. `--package-path` is the absolute directory containing `PIL`, not the `PIL` directory itself. The script does not install, download, build, patch, or submit anything. It checks selected key package files against the original installation's hashes and checks the loaded package/version; these checks are not a full installation attestation.

Save the complete Python block below as `reproduce.py` and run:

```sh
python3.11 -I -S -B reproduce.py \
  --package-path /absolute/path/to/reviewed/site-packages \
  --asan-runtime /absolute/path/to/reviewed/libclang_rt.asan_osx_dynamic.dylib
```

Both arguments are required. The runtime must be loaded at child startup, not by late `dlopen`. Each child resolves `__asan_init`, `__asan_address_is_poisoned`, and `__asan_region_is_poisoned` in its actual process symbol table **before importing Pillow or opening a fixture**. Merely setting `DYLD_INSERT_LIBRARIES` is not accepted as proof of ASAN availability. A missing symbol or package mismatch prevents the operation.

Each of the three cases runs once in a separate child with a **30-second wall limit, 10-second hard CPU limit, core dumps disabled, and 1 MiB regular-file-size limit**. The parent supplies a clean allowlisted environment, isolated Python startup (`-I -S -B`), an empty temporary working directory, no stdin, and a 128 KiB combined pipe-output cap. Timeout/output-cap handling kills the child's process 

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
