GitHub Advisoryadvisory · PyPI

GHSA-pchr-8j38-3x4p Pillow: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read

HIGH Published 2026-10-20 Modified 2026-10-20 Official page ↗
Aliases
CVE-2026-112896
Ecosystem
PyPI
Packages
Pillow
Severity
High
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N
Weaknesses
CWE-125
Reviewed
2026-10-20

Affected packages

• PyPI Pillow: < 13.0.0 → fixed in 13.0.0

Description

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: 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: 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: `TIFFTAG_COLORMAP` computes the per-plane count as `1L 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, 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 group. These limits a…

Related

Alias · CVE (1)

Weakness · CWE (1)

  • CWE-125 Out-of-bounds Read weakness

References